Compare commits
192
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 | ||
|
|
3e4f466114 | ||
|
|
127ec36a55 | ||
|
|
e87df789b2 | ||
|
|
2ddaaeb56a | ||
|
|
3f51a124d6 | ||
|
|
db4bdb4b70 | ||
|
|
420bafeb4e | ||
|
|
e17a952bee | ||
|
|
a8a68f6dd4 | ||
|
|
71da73ef70 | ||
|
|
cd2fa800b9 | ||
|
|
532fdb4e6b | ||
|
|
bd7c8c99e9 | ||
|
|
bf4dc0af3d | ||
|
|
200d4593e8 | ||
|
|
890bf1d483 | ||
|
|
137a4cf7d6 | ||
|
|
920ee336e9 | ||
|
|
fc46c4272a | ||
|
|
8b9f229b80 | ||
|
|
918952f130 | ||
|
|
d67fb251d2 | ||
|
|
e473162d4c | ||
|
|
8a2d2ae190 | ||
|
|
7182f929e8 | ||
|
|
021ea63a2b | ||
|
|
f8af99ace1 | ||
|
|
01b606df30 | ||
|
|
331435e78a | ||
|
|
a219cddc0b | ||
|
|
1602e0dc51 | ||
|
|
84d1675292 | ||
|
|
f0f8f40c7a | ||
|
|
2120901909 | ||
|
|
a0855018d5 | ||
|
|
7c68caed27 | ||
|
|
65f6d6dd87 | ||
|
|
2c2952cff1 | ||
|
|
a6e1faa2d8 | ||
|
|
3951bc372c | ||
|
|
eaafe5eafc | ||
|
|
6953c864c4 | ||
|
|
958d813300 | ||
|
|
d4edf1ccb3 | ||
|
|
65cdd2284e | ||
|
|
bb4a71c612 | ||
|
|
c8de46fac2 | ||
|
|
8782f96baf | ||
|
|
0035243361 | ||
|
|
716a1c572a | ||
|
|
3d1de6d5d0 | ||
|
|
7cb050d235 | ||
|
|
3d572767d2 | ||
|
|
be1eec1348 | ||
|
|
cdd17d29c8 | ||
|
|
a52019c5e9 | ||
|
|
777f943660 | ||
|
|
65623fb1dd | ||
|
|
cf33d445a2 | ||
|
|
13f37bfd0f | ||
|
|
d02dacae0f | ||
|
|
7b9d28c0ee | ||
|
|
2622bb8c92 | ||
|
|
dd2efff487 | ||
|
|
acee83fe53 | ||
|
|
bdbac6fbb5 | ||
|
|
09fb3a5f72 | ||
|
|
2afd5eeb11 | ||
|
|
4242057bd8 | ||
|
|
492562a517 | ||
|
|
a1bddfcd98 | ||
|
|
8e1263833b | ||
|
|
b44bbbd110 | ||
|
|
a09a8c8daf | ||
|
|
8639b42574 | ||
|
|
f74b414b24 | ||
|
|
8c8b4f26e9 | ||
|
|
62c8aabeab | ||
|
|
95ba5a5e81 | ||
|
|
853ae3043d | ||
|
|
43e238034a | ||
|
|
8af0a27e4c | ||
|
|
fafce0af87 | ||
|
|
a071896834 | ||
|
|
7569917924 | ||
|
|
5c3ad50090 | ||
|
|
78013b25c9 | ||
|
|
568be2e632 | ||
|
|
c4c1b35dee | ||
|
|
1193b2dcee | ||
|
|
2d33a8f4bc | ||
|
|
c0f28520b4 | ||
|
|
95ebca89f6 | ||
|
|
2cea81739e | ||
|
|
314b65872e | ||
|
|
94badfb56f | ||
|
|
a1870f2789 | ||
|
|
0cd4359964 | ||
|
|
bef9dce55e | ||
|
|
3ac83d9f00 | ||
|
|
999d840615 | ||
|
|
b3feb5c7e0 | ||
|
|
77e2f10cd5 | ||
|
|
d35e85e066 | ||
|
|
6ccbf7eb5e | ||
|
|
9eb7d293f7 | ||
|
|
143097dc28 | ||
|
|
c84a809f77 | ||
|
|
f6871aefac | ||
|
|
6f9e7084dd | ||
|
|
7c820cbada | ||
|
|
c7b92f99c6 | ||
|
|
2199f1c11e | ||
|
|
f6dda509a2 | ||
|
|
a63fda313b | ||
|
|
6f34ac16bf | ||
|
|
8f14a3840f | ||
|
|
03a6db10ad | ||
|
|
3818b011d5 | ||
|
|
33c8bfae3d | ||
|
|
1f47171d07 | ||
|
|
796433c275 | ||
|
|
a598fcd5c3 | ||
|
|
cdead78977 | ||
|
|
056b71fead | ||
|
|
c745fd2a5c | ||
|
|
2c38da16b5 |
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
|
||||
@@ -2,36 +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/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)。
|
||||
+654
-367
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
-345
@@ -1,352 +1,19 @@
|
||||
# 仿真后端
|
||||
|
||||
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
||||
当前采用 Python 编排、C 数值执行。输入 XML 经校验后,Python 根据模型参数和连接生成系统专用 C;GCC 编译成 EXE,EXE 内执行初始化、物性、流量、机械、RK45/CVODE BDF、事件和采样。求解循环不调用 Python。
|
||||
|
||||
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
|
||||
## 目录
|
||||
|
||||
当前状态不是“只有骨架”,而是“`Testmodel` 已有一版可运行的 ODE 近似实现,并具备基础结果导出与对比能力”。
|
||||
- `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/`: 三通等连接节点。
|
||||
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
||||
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
|
||||
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
|
||||
- `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/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
|
||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
||||
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
||||
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
|
||||
|
||||
## 当前阶段进度
|
||||
|
||||
这一阶段原先有 4 件重点工作,现在的状态如下:
|
||||
|
||||
1. `mytee1` 的 stream/焓传播语义:已完成当前阶段收紧
|
||||
现在如果只有一条支路发生倒流,下游来流焓统一按 `tank.h` 处理,不再临时借另一条支路的焓来凑。
|
||||
2. 下游初始化/约束处理:已完成当前阶段收口
|
||||
之前是“直接改对象状态再开始积分”,现在已经收成显式的 `consistent_initial_state_vector()` 初始化入口。当前这一步会在不改下游总质量、总内能的前提下,把几段直接相连的体积拉回同一个连接压力。
|
||||
3. 自动校验:已完成当前阶段首版
|
||||
已经补了标准库 `unittest` 回归测试,先把初始化投影是否守恒、是否污染原始状态,以及 4 个主变量的提交基线锁住。
|
||||
4. 更严格介质模型:已完成当前阶段首版
|
||||
已经从固定 `cp/cv` 的理想气体近似,推进到随温度变化的空气近似,并接上了内能反解和初始化求根。
|
||||
|
||||
如果只看结果,可以把这一阶段理解成:
|
||||
|
||||
- 连接器语义:首轮收紧已完成
|
||||
- 初始化入口:首轮收口已完成
|
||||
- 基线验证:首轮保护已完成
|
||||
- 介质精化:首轮近似已完成
|
||||
|
||||
## 当前阶段收口
|
||||
|
||||
上一轮 `N0-N3` 已全部完成首版,当前可以简单理解为:
|
||||
|
||||
1. `N0`:系统层里最明显的流向/焓判断已经继续下沉到组件 helper。
|
||||
2. `N1`:模型参数和运行参数已经收口到配置对象。
|
||||
3. `N2`:运行接口已经分成“准备请求”和“执行请求”两层。
|
||||
4. `N3`:结果导出和命令行报告格式化已经统一收口到 `reporting/`。
|
||||
|
||||
这一轮结束后,项目已经不缺“能不能跑”的能力,下一步更重要的是把后续开发最容易卡住的地方先处理掉。
|
||||
|
||||
## 本次推送更新
|
||||
|
||||
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
|
||||
|
||||
1. `M2`:已完成当前阶段首版
|
||||
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
|
||||
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
|
||||
|
||||
2. `M3`:已完成当前阶段首版
|
||||
- 已给两条支路入口流量固定点求解、下游公共压力投影补了显式诊断
|
||||
- 诊断内容至少包含 `converged / iterations / residual`
|
||||
- 已支持严格模式;内部求解不收敛时可以直接抛错,而不是静默返回最后一个近似值
|
||||
- `run_testmodel()` 的结构化结果和 `testmodel_run_report.txt` 已能带出最后一次内部闭合求解诊断
|
||||
|
||||
3. `M4`:已完成当前阶段首版
|
||||
- 自动测试已不再只盯最终主变量结果
|
||||
- 现在已经覆盖:
|
||||
- 改支路参数后,初始支路入口流量是否按预期变化
|
||||
- 更偏激配置下,初始化和内部闭合是否仍然收敛
|
||||
- 有无 Modelica 参考两种运行路径下,程序接口与产物行为是否一致
|
||||
|
||||
4. `M5`:已启动
|
||||
- 当前已经明确选择优先走“更容易扩展”的方向,而不是先追求更贴近 Modelica
|
||||
- 已完成第一步:把闭合器内部原来大量写死的 `upper/lower` 双支路逻辑,收成可复用的 `BranchClosureComponents / BranchClosureState` 结构
|
||||
- 当前已继续推进到 `G1-G5` 的首轮兼容层改造:`snapshot` 已提供通用分支集合,系统层结果生成已拆成“通用键生成 + 旧键别名派生”两层,报告层已开始优先消费通用分支键,旧导出列名仍通过兼容映射保留,兼容测试已显式保护分支顺序和旧导出语义
|
||||
|
||||
## 下一阶段接手建议
|
||||
|
||||
如果继续往前推进,建议按下面顺序做,而不是再零散补功能:
|
||||
|
||||
1. `G1`:已完成当前阶段首轮兼容接入
|
||||
- `TestModelSnapshot` 已新增 `branches` 集合
|
||||
- 每个分支当前至少带 `name / pipe / inlet_flow / outlet_flow / inlet_h / inlet_flow_diagnostics`
|
||||
- `pipe_upper / pipe_lower / branch_inlet_flows / branch_outlet_flows` 目前仍保留为兼容属性,供旧调用方继续使用
|
||||
|
||||
2. `G2`:已完成当前阶段首轮内部迁移
|
||||
- `evaluate_solution()` 已改成从 `snapshot.branches` 读取数据,再通过显式分支名映射写回当前旧列名
|
||||
- `rhs()` 里的分支导数计算已改成通过通用 helper 按分支循环生成,再按当前状态向量顺序拼回
|
||||
- 当前外部导出列名仍保持兼容:
|
||||
- `mypipe.p`
|
||||
- `mypipe1.p`
|
||||
- `branch_upper.in/out`
|
||||
- `branch_lower.in/out`
|
||||
|
||||
3. `G3`:已完成当前阶段首轮兼容测试
|
||||
- 当前测试已经显式保护:
|
||||
- `branches` 顺序是否稳定
|
||||
- `snapshot` 新字段和兼容字段是否一致
|
||||
- 旧导出列名是否仍映射到正确分支语义
|
||||
- 参数变化后 `upper/lower` 的名字和顺序是否不会被打乱
|
||||
|
||||
4. `G4`:已完成当前阶段首轮兼容拆层
|
||||
- `evaluate_solution()` 现在会同时产出:
|
||||
- 通用分支键:`branch.<branch_name>.p/in/out`
|
||||
- 旧兼容键:`mypipe.p`、`mypipe1.p`、`branch_upper.*`、`branch_lower.*`
|
||||
- 报告层当前已开始优先读取通用分支键,旧键只作为兼容后备
|
||||
- 当前已经把“内部统一表达”和“旧接口兼容导出”拆成两层,但还没有把所有报告/导出逻辑都迁干净
|
||||
|
||||
5. `G5`:已完成当前阶段首轮兼容收口
|
||||
- `evaluate_solution()` 当前会先生成通用分支键,再统一派生旧兼容键
|
||||
- 报告层当前已支持“通用键优先、旧键兼容后备”
|
||||
- 当前已经把系统层和 reporting 层的主要旧专名读取入口收口到少量 helper 上,后续继续迁移不会再到处散改
|
||||
|
||||
6. `P1`:下一阶段建议从这里接手
|
||||
当前更合适的下一步,不是继续深挖内核通用化,而是切回结果导向主线:
|
||||
- 定义一份稳定的外部输入参数 schema
|
||||
- 明确这些结构化参数如何映射到 `TestModelConfig / TestModelRunConfig`
|
||||
- 建立“结构化参数 -> 仿真执行 -> 结果产物/摘要”的稳定接口
|
||||
这样可以直接服务后续文档解析、网页入口和报告生成,而不是继续在 `Testmodel` 内部做边际收益越来越低的抽象整理
|
||||
|
||||
7. `P2`:在 `P1` 完成后,再推进文档解析或报告生成链路
|
||||
更现实的顺序应是:
|
||||
- 先把结构化输入跑通
|
||||
- 再把结果摘要/产物组织成更接近最终产品的输出包
|
||||
- 最后再接 Word 解析或页面入口
|
||||
|
||||
如果后续继续推进,这个 README 也要一起更新,不要长期保留已经失效的路线描述。
|
||||
|
||||
## 当前实现了什么
|
||||
|
||||
当前代码已经实现:
|
||||
|
||||
1. `m`、`U` 作为动态元件主状态,`p`、`T`、`rho`、`u`、`h` 作为派生量。
|
||||
2. `Cylinder`、`Tank`、`Pipe` 的刚性绝热容腔近似。
|
||||
3. `Orifice` 的压差开方流量关系。
|
||||
4. `Tee` 的简化混合焓处理。
|
||||
5. `Testmodel` 的系统级拓扑映射和一版可运行的 `rhs(t, x)`。
|
||||
6. 基于 `solve_ivp` 的积分入口,以及 SciPy 不可用时的 RK4 回退。
|
||||
7. 温度相关空气近似介质,包括 `cp(T)`、`h(T)`、`u(T)` 以及 `u -> T` 反解。
|
||||
8. 显式一致初值入口 `consistent_initial_state_vector()`,以及可迭代初始化器 `initialize_consistent_state()`。
|
||||
9. Python 主变量结果导出:
|
||||
`mytank.p`、`mytank.T`、`mycylinder.p`、`mycylinder.T`
|
||||
10. 贮箱温度曲线导出:
|
||||
`testmodel_tank_temperature.csv`
|
||||
`testmodel_tank_temperature.svg`
|
||||
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
||||
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
||||
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
|
||||
|
||||
当前没有实现:
|
||||
|
||||
- 通用 DAE 初始化器
|
||||
- `Modelica.Media.Air.SimpleAir` 的严格复刻
|
||||
- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器
|
||||
|
||||
## 当前怎么运行
|
||||
|
||||
最小运行方式:
|
||||
|
||||
```bash
|
||||
python -m app.simulation.examples.testmodel.run
|
||||
```
|
||||
|
||||
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
|
||||
|
||||
```python
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
TestModelRunConfig,
|
||||
TestModelSamplingConfig,
|
||||
run_testmodel,
|
||||
)
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
BranchConfig,
|
||||
CylinderConfig,
|
||||
OrificeConfig,
|
||||
PipeConfig,
|
||||
TankConfig,
|
||||
TestModelConfig,
|
||||
)
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
run_config = TestModelRunConfig(
|
||||
model=TestModelConfig(
|
||||
cylinder=CylinderConfig(p0=30e6),
|
||||
upper_branch=BranchConfig(
|
||||
orifice=OrificeConfig(K=8e-6),
|
||||
pipe=PipeConfig(length=6.0, diameter=0.03),
|
||||
),
|
||||
tank=TankConfig(volume=0.12),
|
||||
),
|
||||
solver=SolveIVPConfig(t_start=0.0, t_stop=10.0, method="BDF"),
|
||||
sampling=TestModelSamplingConfig(step=0.05),
|
||||
)
|
||||
|
||||
result = run_testmodel(run_config=run_config)
|
||||
```
|
||||
|
||||
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
|
||||
|
||||
```python
|
||||
from app.simulation.examples.testmodel.run import (
|
||||
prepare_testmodel_run,
|
||||
run_prepared_testmodel,
|
||||
TestModelRunConfig,
|
||||
)
|
||||
|
||||
prepared = prepare_testmodel_run(run_config=TestModelRunConfig())
|
||||
print(prepared.output_dir)
|
||||
print(prepared.t_eval)
|
||||
|
||||
result = run_prepared_testmodel(prepared)
|
||||
print(result.artifacts.primary_csv_path)
|
||||
print(result.used_modelica_reference)
|
||||
```
|
||||
|
||||
当前脚本会:
|
||||
|
||||
1. 构建 `TestModelSystem`
|
||||
2. 打印原始初值向量与约束一致后的初值向量
|
||||
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
|
||||
4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
|
||||
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
|
||||
|
||||
当前脚本默认不会把运行结果直接写到提交基线目录,而是会在
|
||||
`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如:
|
||||
|
||||
- `app/data/simulation-runs/testmodel_20260512_103000_123456/`
|
||||
|
||||
该目录里通常会包含:
|
||||
|
||||
- `testmodel_primary_series.csv`
|
||||
- `testmodel_tank_temperature.csv`
|
||||
- `testmodel_tank_temperature.svg`
|
||||
- `testmodel_run_report.txt`
|
||||
- `testmodel_modelica_comparison.csv`
|
||||
- `testmodel_modelica_comparison_summary.txt`
|
||||
|
||||
## 基线结果
|
||||
|
||||
当前基线对比摘要来自:
|
||||
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||
|
||||
当前四个主变量的最大误差为:
|
||||
|
||||
- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%`
|
||||
- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%`
|
||||
- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%`
|
||||
- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%`
|
||||
|
||||
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
|
||||
|
||||
## 当前架构判断
|
||||
|
||||
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
|
||||
|
||||
- 组件级:已完成首版
|
||||
- 系统闭合:已完成首版
|
||||
- 积分入口:已完成首版
|
||||
- 基线结果对齐:已具备初步能力
|
||||
- 去近似:仍在进行中
|
||||
|
||||
所以当前最准确的说法不是“已完成移植”,而是:
|
||||
|
||||
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
|
||||
|
||||
## 已知限制
|
||||
|
||||
当前最主要的限制可以直接理解成下面几条:
|
||||
|
||||
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
|
||||
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
|
||||
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
||||
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。
|
||||
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
|
||||
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
|
||||
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
|
||||
- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。
|
||||
|
||||
所以,当前版本适合:
|
||||
|
||||
- 架构验证
|
||||
- 组件接口验证
|
||||
- 基线工况对比
|
||||
- 结果导出与误差定位
|
||||
|
||||
但当前版本还不适合:
|
||||
|
||||
- 直接宣称与 OpenModelica 严格等价
|
||||
- 作为最终工程结论的唯一依据
|
||||
- 直接扩展到更复杂拓扑而不补通用连接器语义
|
||||
|
||||
## 文件级现状
|
||||
|
||||
按代码现状逐项看:
|
||||
|
||||
- `core/base.py`: 正常
|
||||
只提供最小抽象层,没有明显冗余。
|
||||
- `core/ports.py`: 正常
|
||||
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
|
||||
- `core/state.py`: 正常
|
||||
`VolumeState` 只负责 `[m, U]` 状态打包。
|
||||
- `systems/network.py`: 正常
|
||||
负责状态向量拼装和连接摘要,不参与物理求解。
|
||||
- `solvers/solver.py`: 正常
|
||||
已支持 SciPy、RK4 回退和零时长仿真。
|
||||
- `components/experimental/**/*.py`: 正常
|
||||
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
|
||||
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
|
||||
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
|
||||
- `examples/testmodel/run.py`: 正常
|
||||
已不是“最小打印脚本”,而是当前结果导出和对比入口。
|
||||
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
||||
|
||||
## 当前主技术债
|
||||
|
||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
||||
|
||||
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
|
||||
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
||||
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
|
||||
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
|
||||
运行、支持范围与依赖见 [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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1 @@
|
||||
"""AMESim pneumatic boundary components."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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.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.
|
||||
|
||||
The component behaves as a sealed pneumatic boundary in the current acausal
|
||||
solver: it does not prescribe pressure, and only constrains its port mass
|
||||
flow to zero.
|
||||
"""
|
||||
MODEL_TYPE = 'amesim_pnpl01'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=ZERO_FLOW_SUPPLY),)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
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')
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPnpl01:
|
||||
return cls(name=name)
|
||||
EQUATIONS = ({'id': '__MODEL__:zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'},)
|
||||
@@ -0,0 +1 @@
|
||||
"""AMESim pneumatic flow components."""
|
||||
@@ -0,0 +1,172 @@
|
||||
"""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, 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
|
||||
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,))
|
||||
|
||||
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.medium = medium
|
||||
self.cq = float(cq)
|
||||
self.area = float(area)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
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.')
|
||||
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=1e-12):
|
||||
raise ValueError(f'PNOR001 parameter {name} must be an integer value.')
|
||||
return int(rounded)
|
||||
|
||||
@classmethod
|
||||
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:
|
||||
return self.cq if self.flowset == 1 else 0.72
|
||||
|
||||
@property
|
||||
def effective_area(self) -> float:
|
||||
if self.flowset == 1:
|
||||
return self.area
|
||||
if self.flowset == 2:
|
||||
return self._area_from_cv(self.Cv, self.effective_cq)
|
||||
return self._area_from_kv(self.Kv, self.effective_cq)
|
||||
|
||||
@staticmethod
|
||||
def _area_from_cv(Cv: float, cq: float) -> float:
|
||||
water_density = 999.0
|
||||
reference_flow_m3_s = Cv * 6.30901964e-05
|
||||
reference_dp_pa = 6894.75729
|
||||
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
|
||||
|
||||
@staticmethod
|
||||
def _area_from_kv(Kv: float, cq: float) -> float:
|
||||
water_density = 999.0
|
||||
reference_flow_m3_s = Kv / 3600.0
|
||||
reference_dp_pa = 100000.0
|
||||
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
|
||||
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. 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,))
|
||||
|
||||
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.medium = medium
|
||||
self.cq = float(cq)
|
||||
self.area0 = float(area0)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
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.')
|
||||
self.opening = min(1.0, max(0.0, float(opening)))
|
||||
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=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: 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:
|
||||
return self.cq if self.flowset == 1 else 0.72
|
||||
|
||||
@property
|
||||
def maximum_area(self) -> float:
|
||||
if self.flowset == 1:
|
||||
return self.area0
|
||||
if self.flowset == 2:
|
||||
return AmesimPnor001._area_from_cv(self.Cv, self.effective_cq)
|
||||
return AmesimPnor001._area_from_kv(self.Kv, self.effective_cq)
|
||||
|
||||
@property
|
||||
def effective_area(self) -> float:
|
||||
return self.opening * self.maximum_area
|
||||
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.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='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: 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.medium = medium
|
||||
self.cq = float(cq)
|
||||
self.area0 = float(area0)
|
||||
self.Cv = float(Cv)
|
||||
self.Kv = float(Kv)
|
||||
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.')
|
||||
self.opening0 = min(1.0, max(0.0, float(opening0)))
|
||||
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: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnvo001SignalOpening':
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
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'})
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from math import isclose, pi
|
||||
from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index
|
||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent, ThermodynamicVolumeComponent
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
|
||||
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
from app.simulation.core.medium import GasMedium
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.port_computation import FLOW_SUPPLY, THERMODYNAMIC_SUPPLY
|
||||
_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition('mode', (1.0,))
|
||||
_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition('mode', (2.0,))
|
||||
_DYNAMIC_PIPE_PARAMETER_GROUPS = (ParameterGroupDisplaySpec(id='thermodynamics', label='热力学', parameters=('k', 'kth', 'extemp'), order=10),)
|
||||
|
||||
class AmesimPnl00r(AlgebraicComponent):
|
||||
"""AMESim PNL00R pneumatic pipe friction resistance.
|
||||
|
||||
The public model exposes the AMESim PNL00R catalog/XML contract and uses
|
||||
the `pn2pipefr` compressible gas relation with a Reynolds/roughness-
|
||||
dependent equivalent flow coefficient.
|
||||
"""
|
||||
MODEL_TYPE = 'amesim_pnl00r'
|
||||
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('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='参与摩擦压降计算的管路有效长度。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=30), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=40))
|
||||
DISPLAY = ComponentDisplaySpec(label='PNL00R 气动管路阻力', library_id='amesim', category_id='flow', symbol='amesim_pnl00r', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20)
|
||||
|
||||
def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, gi: float=1.0) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'gi': gi})
|
||||
self.medium = medium
|
||||
self.diam = float(diam)
|
||||
self.le = float(le)
|
||||
self.rr = float(rr)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.area = pi * self.diam * self.diam / 4.0
|
||||
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=1e-12):
|
||||
raise ValueError(f'PNL00R parameter {name} must be an integer value.')
|
||||
return int(rounded)
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnl00r:
|
||||
return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], gi=parameters['gi'])
|
||||
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 AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
|
||||
MODEL_TYPE = 'amesim_pnl0001'
|
||||
MODEL_VERSION = '0.4.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
|
||||
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积、储气容积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效长度,用于计算储气容积、换热面积和摩擦压降。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'), ParameterDefinition('k', 1.35, label='多方指数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='mode=1 多方过程指数。采用理想气体储气关系,按质量变化计算温度与压力变化;k=1 为等温。流阻和端口焓仍使用选定介质。', visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,)), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='mode=2 带换热过程使用的气体与外部环境对流换热系数。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='mode=2 带换热过程使用的外部环境绝对温度。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('mode', 2.0, label='热模型', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '多方过程'), ParameterOption(2.0, '带换热')), description='AMESim 原始编码:1 为多方过程,2 为带换热。多方模式按多方指数计算状态变化,不使用流入焓和环境换热决定温度;带换热模式采用质量与能量守恒。'), 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 + (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=100), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=110), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=120), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=130))
|
||||
DISPLAY = ComponentDisplaySpec(label='PNL0001 C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=30, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
|
||||
|
||||
def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, k: float=1.35, kth: float=0.0, extemp: float=293.15, gi: float=1.0, mode: float=2.0, p0: float=100000.0, T0: float=293.15) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'k': k, 'kth': kth, 'extemp': extemp, 'gi': gi, 'mode': mode, 'p0': p0, 'T0': T0})
|
||||
self.medium = medium
|
||||
self.diam = float(diam)
|
||||
self.le = float(le)
|
||||
self.rr = float(rr)
|
||||
self.k = float(k)
|
||||
self.kth = float(kth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.mode = self._integer_parameter('mode', mode)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
self.area = pi * self.diam * self.diam / 4.0
|
||||
self.volume = self.area * self.le
|
||||
self.exchange_area = pi * self.diam * self.le
|
||||
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=1e-12):
|
||||
raise ValueError(f'PNL0001 parameter {name} must be an integer value.')
|
||||
integer = int(rounded)
|
||||
if name == 'mode' and integer not in {1, 2}:
|
||||
raise ValueError('PNL0001 parameter mode must be one of 1, 2.')
|
||||
return integer
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0001':
|
||||
return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], k=parameters['k'], kth=parameters['kth'], extemp=parameters['extemp'], gi=parameters['gi'], mode=parameters['mode'], p0=parameters['p0'], T0=parameters['T0'])
|
||||
EQUATIONS = ({'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_1_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 AmesimPnl0002(AmesimPnl0001):
|
||||
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
|
||||
MODEL_TYPE = 'amesim_pnl0002'
|
||||
MODEL_VERSION = '0.6.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
|
||||
PARAMETERS = AmesimPnl0001.PARAMETERS
|
||||
RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES
|
||||
DISPLAY = ComponentDisplaySpec(label='PNL0002 R-C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0002', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0002':
|
||||
return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], k=parameters['k'], kth=parameters['kth'], extemp=parameters['extemp'], gi=parameters['gi'], mode=parameters['mode'], p0=parameters['p0'], T0=parameters['T0'])
|
||||
|
||||
@property
|
||||
def resistance_length(self) -> float:
|
||||
return self.le / 2.0
|
||||
EQUATIONS = ({'id': '__MODEL__:port_1_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state', '__MODEL__.port_1.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:port_2_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state', '__MODEL__.port_2.m_flow'], 'role': 'flow'})
|
||||
|
||||
class AmesimPnl0003(DynamicComponent):
|
||||
"""AMESim PNL0003 C-R-C pneumatic pipe with two end compliances."""
|
||||
state_size = 4
|
||||
MODEL_TYPE = 'amesim_pnl0003'
|
||||
MODEL_VERSION = '0.4.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
|
||||
PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + (ParameterDefinition('p1_0', 100000.0, label='端口 1 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T1_0', 293.15, label='端口 1 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p2_0', 100000.0, label='端口 2 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T2_0', 293.15, label='端口 2 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
|
||||
RESULT_VARIABLES = (ResultVariableDefinition('m1', '端口 1 侧质量', 'mass', 'kg', 'state', 10), ResultVariableDefinition('U1', '端口 1 侧内能', 'internal_energy', 'J', 'state', 20), ResultVariableDefinition('p1', '端口 1 侧压力', 'pressure', 'Pa', 'thermodynamic', 30), ResultVariableDefinition('T1', '端口 1 侧温度', 'temperature', 'K', 'thermodynamic', 40), ResultVariableDefinition('rho1', '端口 1 侧密度', 'density', 'kg/m³', 'thermodynamic', 50), ResultVariableDefinition('u1', '端口 1 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 60), ResultVariableDefinition('h1', '端口 1 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 70), ResultVariableDefinition('m2', '端口 2 侧质量', 'mass', 'kg', 'state', 80), ResultVariableDefinition('U2', '端口 2 侧内能', 'internal_energy', 'J', 'state', 90), ResultVariableDefinition('p2', '端口 2 侧压力', 'pressure', 'Pa', 'thermodynamic', 100), ResultVariableDefinition('T2', '端口 2 侧温度', 'temperature', 'K', 'thermodynamic', 110), ResultVariableDefinition('rho2', '端口 2 侧密度', 'density', 'kg/m³', 'thermodynamic', 120), ResultVariableDefinition('u2', '端口 2 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 130), ResultVariableDefinition('h2', '端口 2 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 140), ResultVariableDefinition('dmctr', '中心质量流量', 'mass_flow', 'kg/s', 'derived', 150), ResultVariableDefinition('re', 'Reynolds 数', 'dimensionless', '', 'derived', 160), ResultVariableDefinition('cm', '质量流量参数', 'dimensionless', '', 'derived', 170), ResultVariableDefinition('v', '平均气体速度', 'velocity', 'm/s', 'derived', 180), ResultVariableDefinition('ff', '摩擦因子', 'dimensionless', '', 'derived', 190))
|
||||
DISPLAY = ComponentDisplaySpec(label='PNL0003 C-R-C 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0003', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=50, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
|
||||
|
||||
def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, k: float=1.35, kth: float=0.0, extemp: float=293.15, gi: float=1.0, mode: float=2.0, p1_0: float=100000.0, T1_0: float=293.15, p2_0: float=100000.0, T2_0: float=293.15) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'k': k, 'kth': kth, 'extemp': extemp, 'gi': gi, 'mode': mode, 'p1_0': p1_0, 'T1_0': T1_0, 'p2_0': p2_0, 'T2_0': T2_0})
|
||||
self.medium = medium
|
||||
self.diam = float(diam)
|
||||
self.le = float(le)
|
||||
self.rr = float(rr)
|
||||
self.k = float(k)
|
||||
self.kth = float(kth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.mode = AmesimPnl0001._integer_parameter('mode', mode)
|
||||
self.area = pi * self.diam * self.diam / 4.0
|
||||
self.volume = self.area * self.le
|
||||
self.compliance_volume = self.volume / 2.0
|
||||
self.exchange_area = pi * self.diam * self.le
|
||||
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: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0003':
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
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'})
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.simulation.components.amesim.junctions.nodes import AmesimP4Node2, AmesimPn3Node2
|
||||
|
||||
__all__ = ["AmesimP4Node2", "AmesimPn3Node2"]
|
||||
@@ -0,0 +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.medium import IdealGasMedium
|
||||
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.
|
||||
|
||||
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))
|
||||
|
||||
class AmesimPn3Node2(_AmesimPneumaticNode):
|
||||
"""AMESim PN3NODE2 pneumatic three-port junction."""
|
||||
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='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:
|
||||
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.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='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:
|
||||
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'})
|
||||
@@ -0,0 +1,49 @@
|
||||
"""AMESim-compatible public component library."""
|
||||
|
||||
from app.simulation.core.catalog import (
|
||||
ComponentCategorySpec,
|
||||
ComponentLibrarySpec,
|
||||
)
|
||||
|
||||
|
||||
LIBRARY = ComponentLibrarySpec(
|
||||
id="amesim",
|
||||
label="AMESim 组件库",
|
||||
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),
|
||||
ComponentCategorySpec(id="boundary", label="边界元件", order=40),
|
||||
ComponentCategorySpec(id="signals", label="信号元件", order=50),
|
||||
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",
|
||||
"app.simulation.components.amesim.mechanical.translational:AmesimF000",
|
||||
"app.simulation.components.amesim.mechanical.translational:AmesimForc",
|
||||
"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",
|
||||
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001FixedOpening",
|
||||
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001SignalOpening",
|
||||
"app.simulation.components.amesim.flow.pipes:AmesimPnl00r",
|
||||
"app.simulation.components.amesim.flow.pipes:AmesimPnl0001",
|
||||
"app.simulation.components.amesim.flow.pipes:AmesimPnl0002",
|
||||
"app.simulation.components.amesim.flow.pipes:AmesimPnl0003",
|
||||
"app.simulation.components.amesim.junctions.nodes:AmesimPn3Node2",
|
||||
"app.simulation.components.amesim.junctions.nodes:AmesimP4Node2",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""AMESim mechanical components."""
|
||||
@@ -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'})
|
||||
@@ -0,0 +1,181 @@
|
||||
"""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, 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'),)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
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')
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimF000':
|
||||
return cls(name=name)
|
||||
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)
|
||||
|
||||
def __init__(self, name: str, *, direction: float=1.0) -> None:
|
||||
super().__init__(name=name)
|
||||
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, 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.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:
|
||||
super().__init__(name=name)
|
||||
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.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':
|
||||
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))
|
||||
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.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}
|
||||
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')
|
||||
|
||||
@classmethod
|
||||
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))
|
||||
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)
|
||||
|
||||
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.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'):
|
||||
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'])
|
||||
|
||||
@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((definition.name for definition in self.active_port_definitions))
|
||||
|
||||
@property
|
||||
def reference_port_name(self) -> str:
|
||||
return f'port_{self.v1 + 1}'
|
||||
|
||||
@property
|
||||
def required_connection_ports(self) -> tuple[str, ...]:
|
||||
return self.active_ports
|
||||
|
||||
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)
|
||||
Whitespace-only changes.
@@ -0,0 +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, 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)
|
||||
|
||||
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.initial = float(initial)
|
||||
self.final = float(final)
|
||||
self.time = float(time)
|
||||
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'])
|
||||
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),))
|
||||
|
||||
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.')
|
||||
if nstages < 1 or nstages > 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.nstages = int(nstages)
|
||||
self.iscyclic = bool(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]
|
||||
self.set_parameter_values(values)
|
||||
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']
|
||||
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 = ()
|
||||
@@ -0,0 +1 @@
|
||||
"""AMESim pneumatic storage components."""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||
from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
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.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.port_computation import THERMODYNAMIC_SUPPLY
|
||||
|
||||
class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
||||
|
||||
The AMESim submodel owns pressure and temperature states and exposes two
|
||||
pneumatic flow ports. This public component maps those states onto the
|
||||
framework's mass/internal-energy volume state and keeps the AMESim
|
||||
heat-transfer contract `kth * sth * (extemp - T)`.
|
||||
"""
|
||||
MODEL_TYPE = 'amesim_pnch023'
|
||||
MODEL_VERSION = '0.1.0'
|
||||
PORTS = (PortDefinition.pneumatic('port_1', 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='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10)
|
||||
|
||||
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.medium = medium
|
||||
self.cvol = float(cvol)
|
||||
self.kth = float(kth)
|
||||
self.sth = float(sth)
|
||||
self.extemp = float(extemp)
|
||||
self.gi = normalize_amesim_gas_index(gi)
|
||||
self.p0 = float(p0)
|
||||
self.T0 = float(T0)
|
||||
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: 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 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)
|
||||
|
||||
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.medium = medium
|
||||
self.cvol0 = float(cvol0)
|
||||
self.kth = float(kth)
|
||||
self.sth = float(sth)
|
||||
self.extemp = float(extemp)
|
||||
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)}
|
||||
for port_name in ('port_1', 'port_2', 'port_3', 'port_4'):
|
||||
port = self.register_declared_port(port_name)
|
||||
setattr(self, port_name, port)
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnch012':
|
||||
return cls(name=name, medium=medium, **dict(parameters))
|
||||
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,12 +6,21 @@ from typing import Literal
|
||||
|
||||
|
||||
ResultVariableScope = Literal["component", "port"]
|
||||
ParameterEditor = Literal[
|
||||
"amesimGasReference",
|
||||
"amesimGasPropertyModel",
|
||||
"choice",
|
||||
]
|
||||
|
||||
|
||||
SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
"acceleration": "m/s2",
|
||||
"area": "m2",
|
||||
"dimensionless": "",
|
||||
"density": "kg/m³",
|
||||
"flow_coefficient": "kg/(s*Pa^0.5)",
|
||||
"force": "N",
|
||||
"heat_transfer_coefficient": "W/(m2*K)",
|
||||
"internal_energy": "J",
|
||||
"length": "m",
|
||||
"mass": "kg",
|
||||
@@ -20,13 +29,51 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||
"specific_enthalpy": "J/kg",
|
||||
"specific_internal_energy": "J/kg",
|
||||
"temperature": "K",
|
||||
"translational_damping": "N/(m/s)",
|
||||
"translational_stiffness": "N/m",
|
||||
"time": "s",
|
||||
"velocity": "m/s",
|
||||
"volume": "m3",
|
||||
"volume_flow": "m3/s",
|
||||
"windage": "N/(m/s)^2",
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
@@ -36,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):
|
||||
@@ -47,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]:
|
||||
@@ -62,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
|
||||
|
||||
@@ -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
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from .port_computation import IMPLICIT_PNEUMATIC, PortComputation
|
||||
|
||||
|
||||
PortKind = Literal["physical", "signal"]
|
||||
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
|
||||
@@ -45,6 +47,7 @@ class PortDefinition:
|
||||
nominal_role: PortNominalRole
|
||||
positive_flow_direction: Literal["intoComponent"] | None = None
|
||||
variables: tuple[PortVariableDefinition, ...] = ()
|
||||
computation: PortComputation | None = None
|
||||
|
||||
@classmethod
|
||||
def pneumatic(
|
||||
@@ -52,6 +55,7 @@ class PortDefinition:
|
||||
name: str,
|
||||
*,
|
||||
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||
computation: PortComputation = IMPLICIT_PNEUMATIC,
|
||||
) -> PortDefinition:
|
||||
return cls(
|
||||
name=name,
|
||||
@@ -59,6 +63,7 @@ class PortDefinition:
|
||||
domain="pneumatic",
|
||||
nominal_role=nominal_role,
|
||||
positive_flow_direction="intoComponent",
|
||||
computation=computation,
|
||||
variables=(
|
||||
PortVariableDefinition(
|
||||
"p",
|
||||
@@ -87,6 +92,96 @@ class PortDefinition:
|
||||
unit="J/kg",
|
||||
order=30,
|
||||
),
|
||||
PortVariableDefinition(
|
||||
"volume",
|
||||
"signal",
|
||||
"directed",
|
||||
label="外部容积",
|
||||
quantity="volume",
|
||||
unit="m3",
|
||||
result_visible=False,
|
||||
order=40,
|
||||
),
|
||||
PortVariableDefinition(
|
||||
"volume_flow",
|
||||
"signal",
|
||||
"directed",
|
||||
label="外部容积变化率",
|
||||
quantity="volume_flow",
|
||||
unit="m3/s",
|
||||
result_visible=False,
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def mechanical_translational(
|
||||
cls,
|
||||
name: str,
|
||||
*,
|
||||
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||
) -> PortDefinition:
|
||||
return cls(
|
||||
name=name,
|
||||
kind="physical",
|
||||
domain="mechanical",
|
||||
nominal_role=nominal_role,
|
||||
positive_flow_direction="intoComponent",
|
||||
variables=(
|
||||
PortVariableDefinition(
|
||||
"x",
|
||||
"effort",
|
||||
"equal",
|
||||
label="位移",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
order=10,
|
||||
),
|
||||
PortVariableDefinition(
|
||||
"v",
|
||||
"effort",
|
||||
"equal",
|
||||
label="速度",
|
||||
quantity="velocity",
|
||||
unit="m/s",
|
||||
order=20,
|
||||
),
|
||||
PortVariableDefinition(
|
||||
"f",
|
||||
"flow",
|
||||
"sumToZero",
|
||||
label="力",
|
||||
quantity="force",
|
||||
unit="N",
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def signal(
|
||||
cls,
|
||||
name: str,
|
||||
*,
|
||||
nominal_role: Literal["input", "output"],
|
||||
domain: str = "signal",
|
||||
) -> PortDefinition:
|
||||
return cls(
|
||||
name=name,
|
||||
kind="signal",
|
||||
domain=domain,
|
||||
nominal_role=nominal_role,
|
||||
variables=(
|
||||
PortVariableDefinition(
|
||||
"signal",
|
||||
"signal",
|
||||
"directed",
|
||||
label="信号值",
|
||||
quantity="dimensionless",
|
||||
unit="",
|
||||
order=10,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -98,6 +193,7 @@ class PortDefinition:
|
||||
"nominalRole": self.nominal_role,
|
||||
"positiveFlowDirection": self.positive_flow_direction,
|
||||
"variables": [variable.as_interface_dict() for variable in self.variables],
|
||||
**({"computation": self.computation.as_dict()} if self.computation else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +204,12 @@ class PortState:
|
||||
p: float = 0.0
|
||||
m_flow: float = 0.0
|
||||
h_outflow: float = 0.0
|
||||
volume: float = 0.0
|
||||
volume_flow: float = 0.0
|
||||
signal: float = 0.0
|
||||
x: float = 0.0
|
||||
v: float = 0.0
|
||||
f: float = 0.0
|
||||
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
"""Reference systems and regression examples."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Legacy TestModel reference system."""
|
||||
@@ -1,668 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchInletFlowDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
residual: float
|
||||
m_flow: float
|
||||
inlet_pressure: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownstreamPressureDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
residual: float
|
||||
pressure: float
|
||||
target_total_internal_energy: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelSolveDiagnostics:
|
||||
upper_branch_inlet: BranchInletFlowDiagnostics
|
||||
lower_branch_inlet: BranchInletFlowDiagnostics
|
||||
downstream_pressure_projection: DownstreamPressureDiagnostics | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchClosureComponents:
|
||||
name: str
|
||||
orifice: Orifice
|
||||
pipe: Pipe
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchClosureState:
|
||||
name: str
|
||||
pipe: ThermodynamicProperties
|
||||
inlet_flow: float
|
||||
outlet_flow: float
|
||||
inlet_h: float
|
||||
inlet_flow_diagnostics: BranchInletFlowDiagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchSnapshot:
|
||||
name: str
|
||||
pipe: ThermodynamicProperties
|
||||
inlet_flow: float
|
||||
outlet_flow: float
|
||||
inlet_h: float
|
||||
inlet_flow_diagnostics: BranchInletFlowDiagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelSnapshot:
|
||||
cylinder: ThermodynamicProperties
|
||||
tank: ThermodynamicProperties
|
||||
tee_upstream_h: float
|
||||
tee_downstream_h: float
|
||||
branches: tuple[BranchSnapshot, ...] = field(default_factory=tuple)
|
||||
solve_diagnostics: TestModelSolveDiagnostics | None = None
|
||||
|
||||
@property
|
||||
def pipe_upper(self) -> ThermodynamicProperties:
|
||||
return self.branches[0].pipe
|
||||
|
||||
@property
|
||||
def pipe_lower(self) -> ThermodynamicProperties:
|
||||
return self.branches[1].pipe
|
||||
|
||||
@property
|
||||
def branch_inlet_flows(self) -> tuple[float, ...]:
|
||||
return tuple(branch.inlet_flow for branch in self.branches)
|
||||
|
||||
@property
|
||||
def branch_outlet_flows(self) -> tuple[float, ...]:
|
||||
return tuple(branch.outlet_flow for branch in self.branches)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InitializationDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
max_state_delta: float
|
||||
max_flow_delta: float
|
||||
max_enthalpy_delta: float
|
||||
downstream_pressure_spread: float
|
||||
state_vector: tuple[float, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelClosureComponents:
|
||||
cylinder: Cylinder
|
||||
upstream_tee: Tee
|
||||
upper_branch: BranchClosureComponents
|
||||
lower_branch: BranchClosureComponents
|
||||
downstream_tee: Tee
|
||||
tank: Tank
|
||||
|
||||
def branches(self) -> tuple[BranchClosureComponents, BranchClosureComponents]:
|
||||
return (self.upper_branch, self.lower_branch)
|
||||
|
||||
|
||||
class TestModelClosure:
|
||||
"""Owns Testmodel-specific closure, projection and port-writeback logic."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
medium: IdealGasMedium,
|
||||
components: TestModelClosureComponents,
|
||||
initial_state_vector: Callable[[], list[float]],
|
||||
apply_state_vector: Callable[[list[float]], None],
|
||||
) -> None:
|
||||
self.medium = medium
|
||||
self.components = components
|
||||
self._initial_state_vector = initial_state_vector
|
||||
self._apply_state_vector = apply_state_vector
|
||||
self.last_solve_diagnostics: TestModelSolveDiagnostics | None = None
|
||||
self.last_downstream_pressure_diagnostics: DownstreamPressureDiagnostics | None = None
|
||||
|
||||
@staticmethod
|
||||
def _downstream_pressure_spread(snapshot: TestModelSnapshot) -> float:
|
||||
downstream_pressures = tuple(branch.pipe.p for branch in snapshot.branches) + (
|
||||
snapshot.tank.p,
|
||||
)
|
||||
return max(downstream_pressures) - min(downstream_pressures)
|
||||
|
||||
@staticmethod
|
||||
def _initialization_flow_delta(
|
||||
previous_snapshot: TestModelSnapshot | None,
|
||||
current_snapshot: TestModelSnapshot,
|
||||
) -> float:
|
||||
if previous_snapshot is None:
|
||||
return max(abs(branch.outlet_flow) for branch in current_snapshot.branches)
|
||||
return max(
|
||||
abs(curr - prev)
|
||||
for curr, prev in zip(
|
||||
current_snapshot.branch_outlet_flows,
|
||||
previous_snapshot.branch_outlet_flows,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _initialization_enthalpy_delta(
|
||||
previous_snapshot: TestModelSnapshot | None,
|
||||
current_snapshot: TestModelSnapshot,
|
||||
) -> float:
|
||||
if previous_snapshot is None:
|
||||
return abs(current_snapshot.tee_downstream_h - current_snapshot.tank.h)
|
||||
return max(
|
||||
abs(current_snapshot.tee_upstream_h - previous_snapshot.tee_upstream_h),
|
||||
abs(current_snapshot.tee_downstream_h - previous_snapshot.tee_downstream_h),
|
||||
)
|
||||
|
||||
def consistent_initial_state_vector(self) -> list[float]:
|
||||
return list(self.initialize_consistent_state().state_vector)
|
||||
|
||||
def initialize_consistent_state(
|
||||
self,
|
||||
max_iterations: int = 12,
|
||||
state_tolerance: float = 1e-9,
|
||||
flow_tolerance: float = 1e-9,
|
||||
enthalpy_tolerance: float = 1e-6,
|
||||
pressure_tolerance: float = 1e-6,
|
||||
strict_internal_solvers: bool = False,
|
||||
) -> InitializationDiagnostics:
|
||||
raw_state = self._initial_state_vector()
|
||||
previous_snapshot: TestModelSnapshot | None = None
|
||||
diagnostics: InitializationDiagnostics | None = None
|
||||
|
||||
for iteration in range(1, max_iterations + 1):
|
||||
state_before_projection = self._initial_state_vector()
|
||||
self.snapshot(state_before_projection, strict=strict_internal_solvers)
|
||||
|
||||
self.project_downstream_pressure_constraints(strict=strict_internal_solvers)
|
||||
|
||||
state_after_projection = self._initial_state_vector()
|
||||
snapshot_after_projection = self.snapshot(
|
||||
state_after_projection,
|
||||
strict=strict_internal_solvers,
|
||||
)
|
||||
|
||||
max_state_delta = max(
|
||||
abs(after - before)
|
||||
for before, after in zip(state_before_projection, state_after_projection)
|
||||
)
|
||||
max_flow_delta = self._initialization_flow_delta(
|
||||
previous_snapshot,
|
||||
snapshot_after_projection,
|
||||
)
|
||||
max_enthalpy_delta = self._initialization_enthalpy_delta(
|
||||
previous_snapshot,
|
||||
snapshot_after_projection,
|
||||
)
|
||||
downstream_pressure_spread = self._downstream_pressure_spread(
|
||||
snapshot_after_projection,
|
||||
)
|
||||
|
||||
diagnostics = InitializationDiagnostics(
|
||||
converged=(
|
||||
max_state_delta <= state_tolerance
|
||||
and max_flow_delta <= flow_tolerance
|
||||
and max_enthalpy_delta <= enthalpy_tolerance
|
||||
and downstream_pressure_spread <= pressure_tolerance
|
||||
),
|
||||
iterations=iteration,
|
||||
max_state_delta=max_state_delta,
|
||||
max_flow_delta=max_flow_delta,
|
||||
max_enthalpy_delta=max_enthalpy_delta,
|
||||
downstream_pressure_spread=downstream_pressure_spread,
|
||||
state_vector=tuple(state_after_projection),
|
||||
)
|
||||
previous_snapshot = snapshot_after_projection
|
||||
|
||||
if diagnostics.converged:
|
||||
self._apply_state_vector(raw_state)
|
||||
return diagnostics
|
||||
|
||||
assert diagnostics is not None
|
||||
self._apply_state_vector(raw_state)
|
||||
return diagnostics
|
||||
|
||||
def _solve_branch_inlet_flow(
|
||||
self,
|
||||
orifice: Orifice,
|
||||
pipe: Pipe,
|
||||
p_upstream: float,
|
||||
pipe_props: ThermodynamicProperties,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> tuple[float, BranchInletFlowDiagnostics]:
|
||||
m_flow = orifice.mass_flow(p_upstream, pipe_props.p)
|
||||
rho = max(pipe_props.rho, 1e-9)
|
||||
p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p)
|
||||
residual = abs(orifice.mass_flow(p_upstream, p_inlet) - m_flow)
|
||||
converged = False
|
||||
iterations = 0
|
||||
for iteration in range(1, 9):
|
||||
p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p)
|
||||
next_m_flow = orifice.mass_flow(p_upstream, p_inlet)
|
||||
residual = abs(next_m_flow - m_flow)
|
||||
iterations = iteration
|
||||
if residual <= 1e-9 * max(1.0, abs(next_m_flow)):
|
||||
m_flow = next_m_flow
|
||||
converged = True
|
||||
break
|
||||
m_flow = next_m_flow
|
||||
diagnostics = BranchInletFlowDiagnostics(
|
||||
converged=converged,
|
||||
iterations=iterations,
|
||||
residual=residual,
|
||||
m_flow=m_flow,
|
||||
inlet_pressure=p_inlet,
|
||||
)
|
||||
if strict and not diagnostics.converged:
|
||||
raise RuntimeError(
|
||||
f"Branch inlet flow solve did not converge for {pipe.name}: residual={residual:.6e}"
|
||||
)
|
||||
return m_flow, diagnostics
|
||||
|
||||
def _solve_downstream_branch_flows(
|
||||
self,
|
||||
cylinder: ThermodynamicProperties,
|
||||
tank: ThermodynamicProperties,
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
) -> tuple[float, float]:
|
||||
return self._solve_downstream_branch_flows_from_state(
|
||||
inlet_h_upper=branch_states[0].inlet_h,
|
||||
inlet_h_lower=branch_states[1].inlet_h,
|
||||
pipe_upper_h=max(branch_states[0].pipe.h, 1e-9),
|
||||
pipe_lower_h=max(branch_states[1].pipe.h, 1e-9),
|
||||
tank_h=max(tank.h, 1e-9),
|
||||
q_in_upper=branch_states[0].inlet_flow,
|
||||
q_in_lower=branch_states[1].inlet_flow,
|
||||
)
|
||||
|
||||
def _project_volume_energy_to_pressure(
|
||||
self,
|
||||
component: Pipe | Tank,
|
||||
target_pressure: float,
|
||||
) -> None:
|
||||
target_temperature = target_pressure * component.V / (
|
||||
max(component.state.m, 1e-12) * self.medium.R_gas
|
||||
)
|
||||
target_internal_energy = (
|
||||
component.state.m * self.medium.specific_internal_energy(target_temperature)
|
||||
)
|
||||
component.state = VolumeState(m=component.state.m, U=target_internal_energy)
|
||||
|
||||
def _downstream_total_internal_energy_for_pressure(
|
||||
self,
|
||||
target_pressure: float,
|
||||
downstream_components: tuple[Pipe | Tank, ...],
|
||||
) -> float:
|
||||
total_internal_energy = 0.0
|
||||
for component in downstream_components:
|
||||
target_temperature = target_pressure * component.V / (
|
||||
max(component.state.m, 1e-12) * self.medium.R_gas
|
||||
)
|
||||
total_internal_energy += (
|
||||
component.state.m * self.medium.specific_internal_energy(target_temperature)
|
||||
)
|
||||
return total_internal_energy
|
||||
|
||||
def _solve_downstream_common_pressure(
|
||||
self,
|
||||
downstream_components: tuple[Pipe | Tank, ...],
|
||||
target_total_internal_energy: float,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> tuple[float, DownstreamPressureDiagnostics]:
|
||||
lower_pressure = 1.0
|
||||
upper_pressure = max(component.properties().p for component in downstream_components)
|
||||
upper_pressure = max(upper_pressure, 1e5)
|
||||
|
||||
def residual(pressure: float) -> float:
|
||||
return (
|
||||
self._downstream_total_internal_energy_for_pressure(
|
||||
pressure,
|
||||
downstream_components,
|
||||
)
|
||||
- target_total_internal_energy
|
||||
)
|
||||
|
||||
upper_residual = residual(upper_pressure)
|
||||
iteration_count = 0
|
||||
|
||||
while upper_residual < 0.0:
|
||||
upper_pressure *= 2.0
|
||||
upper_residual = residual(upper_pressure)
|
||||
|
||||
final_pressure = 0.5 * (lower_pressure + upper_pressure)
|
||||
final_residual = residual(final_pressure)
|
||||
converged = False
|
||||
for iteration in range(1, 81):
|
||||
middle_pressure = 0.5 * (lower_pressure + upper_pressure)
|
||||
middle_residual = residual(middle_pressure)
|
||||
iteration_count = iteration
|
||||
final_pressure = middle_pressure
|
||||
final_residual = middle_residual
|
||||
if abs(middle_residual) <= 1e-12 * max(1.0, target_total_internal_energy):
|
||||
converged = True
|
||||
break
|
||||
if middle_residual > 0.0:
|
||||
upper_pressure = middle_pressure
|
||||
else:
|
||||
lower_pressure = middle_pressure
|
||||
|
||||
diagnostics = DownstreamPressureDiagnostics(
|
||||
converged=converged,
|
||||
iterations=iteration_count,
|
||||
residual=final_residual,
|
||||
pressure=final_pressure,
|
||||
target_total_internal_energy=target_total_internal_energy,
|
||||
)
|
||||
if strict and not diagnostics.converged:
|
||||
raise RuntimeError(
|
||||
"Downstream common-pressure solve did not converge: "
|
||||
f"residual={final_residual:.6e}"
|
||||
)
|
||||
return final_pressure, diagnostics
|
||||
|
||||
def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None:
|
||||
downstream_components = (
|
||||
self.components.upper_branch.pipe,
|
||||
self.components.lower_branch.pipe,
|
||||
self.components.tank,
|
||||
)
|
||||
total_internal_energy = sum(component.state.U for component in downstream_components)
|
||||
common_pressure, diagnostics = self._solve_downstream_common_pressure(
|
||||
downstream_components,
|
||||
total_internal_energy,
|
||||
strict=strict,
|
||||
)
|
||||
self.last_downstream_pressure_diagnostics = diagnostics
|
||||
|
||||
for component in downstream_components:
|
||||
self._project_volume_energy_to_pressure(component, common_pressure)
|
||||
|
||||
def _downstream_connection_enthalpy(
|
||||
self,
|
||||
q_out_upper: float,
|
||||
q_out_lower: float,
|
||||
pipe_upper_h: float,
|
||||
pipe_lower_h: float,
|
||||
tank_h: float,
|
||||
) -> float:
|
||||
return self.components.downstream_tee.inlet_stream_enthalpy(
|
||||
q_out_lower,
|
||||
pipe_lower_h,
|
||||
q_out_upper,
|
||||
pipe_upper_h,
|
||||
fallback_h=tank_h,
|
||||
)
|
||||
|
||||
def _solve_downstream_branch_flows_from_state(
|
||||
self,
|
||||
*,
|
||||
inlet_h_upper: float,
|
||||
inlet_h_lower: float,
|
||||
pipe_upper_h: float,
|
||||
pipe_lower_h: float,
|
||||
tank_h: float,
|
||||
q_in_upper: float,
|
||||
q_in_lower: float,
|
||||
) -> tuple[float, float]:
|
||||
return self.components.downstream_tee.solve_branch_outlet_flows_from_energy_balance(
|
||||
ratio_branch1=self.components.upper_branch.pipe.V / self.components.tank.V,
|
||||
ratio_branch2=self.components.lower_branch.pipe.V / self.components.tank.V,
|
||||
inlet_h_branch1=inlet_h_upper,
|
||||
inlet_h_branch2=inlet_h_lower,
|
||||
branch1_h=pipe_upper_h,
|
||||
branch2_h=pipe_lower_h,
|
||||
inlet_h=tank_h,
|
||||
q_in_branch1=q_in_upper,
|
||||
q_in_branch2=q_in_lower,
|
||||
)
|
||||
|
||||
def _evaluate_branch_states(
|
||||
self,
|
||||
cylinder: ThermodynamicProperties,
|
||||
) -> tuple[BranchClosureState, BranchClosureState]:
|
||||
states: list[BranchClosureState] = []
|
||||
for branch in self.components.branches():
|
||||
pipe_properties = branch.pipe.properties()
|
||||
inlet_flow, inlet_flow_diagnostics = self._solve_branch_inlet_flow(
|
||||
branch.orifice,
|
||||
branch.pipe,
|
||||
cylinder.p,
|
||||
pipe_properties,
|
||||
)
|
||||
inlet_h = branch.pipe.port_a_inlet_enthalpy(
|
||||
port_a_m_flow=inlet_flow,
|
||||
connected_h=cylinder.h,
|
||||
internal_h=pipe_properties.h,
|
||||
)
|
||||
states.append(
|
||||
BranchClosureState(
|
||||
name=branch.name,
|
||||
pipe=pipe_properties,
|
||||
inlet_flow=inlet_flow,
|
||||
outlet_flow=0.0,
|
||||
inlet_h=inlet_h,
|
||||
inlet_flow_diagnostics=inlet_flow_diagnostics,
|
||||
)
|
||||
)
|
||||
return (states[0], states[1])
|
||||
|
||||
@staticmethod
|
||||
def _with_branch_outlet_flows(
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
outlet_flows: tuple[float, float],
|
||||
) -> tuple[BranchClosureState, BranchClosureState]:
|
||||
return (
|
||||
BranchClosureState(
|
||||
name=branch_states[0].name,
|
||||
pipe=branch_states[0].pipe,
|
||||
inlet_flow=branch_states[0].inlet_flow,
|
||||
outlet_flow=outlet_flows[0],
|
||||
inlet_h=branch_states[0].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics,
|
||||
),
|
||||
BranchClosureState(
|
||||
name=branch_states[1].name,
|
||||
pipe=branch_states[1].pipe,
|
||||
inlet_flow=branch_states[1].inlet_flow,
|
||||
outlet_flow=outlet_flows[1],
|
||||
inlet_h=branch_states[1].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _branch_snapshots(
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
) -> tuple[BranchSnapshot, BranchSnapshot]:
|
||||
return (
|
||||
BranchSnapshot(
|
||||
name=branch_states[0].name,
|
||||
pipe=branch_states[0].pipe,
|
||||
inlet_flow=branch_states[0].inlet_flow,
|
||||
outlet_flow=branch_states[0].outlet_flow,
|
||||
inlet_h=branch_states[0].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics,
|
||||
),
|
||||
BranchSnapshot(
|
||||
name=branch_states[1].name,
|
||||
pipe=branch_states[1].pipe,
|
||||
inlet_flow=branch_states[1].inlet_flow,
|
||||
outlet_flow=branch_states[1].outlet_flow,
|
||||
inlet_h=branch_states[1].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics,
|
||||
),
|
||||
)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
state_vector: list[float] | None = None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> TestModelSnapshot:
|
||||
if state_vector is not None:
|
||||
self._apply_state_vector(list(state_vector))
|
||||
|
||||
cylinder = self.components.cylinder.properties()
|
||||
tank = self.components.tank.properties()
|
||||
branch_states = self._evaluate_branch_states(cylinder)
|
||||
if strict:
|
||||
for branch_state in branch_states:
|
||||
if not branch_state.inlet_flow_diagnostics.converged:
|
||||
raise RuntimeError(
|
||||
"Branch inlet flow solve did not converge for "
|
||||
f"{branch_state.name}: residual="
|
||||
f"{branch_state.inlet_flow_diagnostics.residual:.6e}"
|
||||
)
|
||||
outlet_flows = self._solve_downstream_branch_flows(cylinder, tank, branch_states)
|
||||
branch_states = self._with_branch_outlet_flows(branch_states, outlet_flows)
|
||||
|
||||
tee_upstream_h = self.components.upstream_tee.inlet_stream_enthalpy(
|
||||
-branch_states[0].inlet_flow,
|
||||
branch_states[0].pipe.h,
|
||||
-branch_states[1].inlet_flow,
|
||||
branch_states[1].pipe.h,
|
||||
fallback_h=cylinder.h,
|
||||
)
|
||||
tee_downstream_h = self._downstream_connection_enthalpy(
|
||||
branch_states[0].outlet_flow,
|
||||
branch_states[1].outlet_flow,
|
||||
branch_states[0].pipe.h,
|
||||
branch_states[1].pipe.h,
|
||||
tank.h,
|
||||
)
|
||||
|
||||
self._write_port_states(
|
||||
cylinder,
|
||||
tank,
|
||||
branch_states,
|
||||
tee_upstream_h,
|
||||
tee_downstream_h,
|
||||
)
|
||||
|
||||
solve_diagnostics = TestModelSolveDiagnostics(
|
||||
upper_branch_inlet=branch_states[0].inlet_flow_diagnostics,
|
||||
lower_branch_inlet=branch_states[1].inlet_flow_diagnostics,
|
||||
downstream_pressure_projection=self.last_downstream_pressure_diagnostics,
|
||||
)
|
||||
self.last_solve_diagnostics = solve_diagnostics
|
||||
branch_snapshots = self._branch_snapshots(branch_states)
|
||||
|
||||
return TestModelSnapshot(
|
||||
cylinder=cylinder,
|
||||
tank=tank,
|
||||
tee_upstream_h=tee_upstream_h,
|
||||
tee_downstream_h=tee_downstream_h,
|
||||
branches=branch_snapshots,
|
||||
solve_diagnostics=solve_diagnostics,
|
||||
)
|
||||
|
||||
def _write_port_states(
|
||||
self,
|
||||
cylinder: ThermodynamicProperties,
|
||||
tank: ThermodynamicProperties,
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
tee_upstream_h: float,
|
||||
tee_downstream_h: float,
|
||||
) -> None:
|
||||
cylinder_m_flow = -sum(branch_state.inlet_flow for branch_state in branch_states)
|
||||
tank_m_flow = sum(branch_state.outlet_flow for branch_state in branch_states)
|
||||
|
||||
self.components.cylinder.port_b.m_flow = cylinder_m_flow
|
||||
|
||||
self.components.upstream_tee.port_in.p = cylinder.p
|
||||
self.components.upstream_tee.port_out1.p = cylinder.p
|
||||
self.components.upstream_tee.port_out2.p = cylinder.p
|
||||
self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow
|
||||
self.components.upstream_tee.port_in.h_outflow = tee_upstream_h
|
||||
self.components.upstream_tee.port_out1.h_outflow = cylinder.h
|
||||
self.components.upstream_tee.port_out2.h_outflow = cylinder.h
|
||||
self.components.upstream_tee.port_out1.m_flow = -branch_states[0].inlet_flow
|
||||
self.components.upstream_tee.port_out2.m_flow = -branch_states[1].inlet_flow
|
||||
|
||||
for branch_components, branch_state in zip(self.components.branches(), branch_states):
|
||||
branch_components.orifice.port_a.p = cylinder.p
|
||||
branch_components.orifice.port_b.p = branch_components.pipe.inlet_pressure(
|
||||
branch_state.inlet_flow,
|
||||
max(branch_state.pipe.rho, 1e-9),
|
||||
branch_state.pipe.p,
|
||||
)
|
||||
branch_components.orifice.port_a.m_flow = branch_state.inlet_flow
|
||||
branch_components.orifice.port_b.m_flow = -branch_state.inlet_flow
|
||||
branch_components.orifice.port_a.h_outflow = cylinder.h
|
||||
branch_components.orifice.port_b.h_outflow = branch_state.pipe.h
|
||||
|
||||
branch_components.pipe.port_a.p = branch_components.orifice.port_b.p
|
||||
branch_components.pipe.port_a.m_flow = branch_state.inlet_flow
|
||||
branch_components.pipe.port_b.m_flow = -branch_state.outlet_flow
|
||||
branch_components.pipe.port_b.p = branch_state.pipe.p
|
||||
|
||||
self.components.downstream_tee.port_in.p = tank.p
|
||||
self.components.downstream_tee.port_out1.p = tank.p
|
||||
self.components.downstream_tee.port_out2.p = tank.p
|
||||
self.components.downstream_tee.port_in.m_flow = -tank_m_flow
|
||||
self.components.downstream_tee.port_out1.m_flow = branch_states[1].outlet_flow
|
||||
self.components.downstream_tee.port_out2.m_flow = branch_states[0].outlet_flow
|
||||
self.components.downstream_tee.port_in.h_outflow = tee_downstream_h
|
||||
self.components.downstream_tee.port_out1.h_outflow = tank.h
|
||||
self.components.downstream_tee.port_out2.h_outflow = tank.h
|
||||
|
||||
self.components.tank.port_a.m_flow = tank_m_flow
|
||||
|
||||
def _branch_derivative_states(
|
||||
self,
|
||||
snapshot: TestModelSnapshot,
|
||||
) -> tuple[VolumeState, VolumeState]:
|
||||
derivative_states: list[VolumeState] = []
|
||||
for branch_components, branch_snapshot in zip(self.components.branches(), snapshot.branches):
|
||||
derivative_states.append(
|
||||
branch_components.pipe.derivatives_from_connections(
|
||||
port_a_m_flow=branch_snapshot.inlet_flow,
|
||||
connected_h_a=snapshot.cylinder.h,
|
||||
port_b_m_flow=-branch_snapshot.outlet_flow,
|
||||
connected_h_b=snapshot.tank.h,
|
||||
internal_h=branch_snapshot.pipe.h,
|
||||
)
|
||||
)
|
||||
return (derivative_states[0], derivative_states[1])
|
||||
|
||||
def rhs(self, state_vector: list[float]) -> list[float]:
|
||||
snapshot = self.snapshot(state_vector)
|
||||
|
||||
cylinder_m_flow = -sum(branch.inlet_flow for branch in snapshot.branches)
|
||||
tank_m_flow = sum(branch.outlet_flow for branch in snapshot.branches)
|
||||
d_cylinder = self.components.cylinder.derivatives_from_connection(
|
||||
connected_h=snapshot.tee_upstream_h,
|
||||
port_m_flow=cylinder_m_flow,
|
||||
internal_h=snapshot.cylinder.h,
|
||||
)
|
||||
branch_derivatives = self._branch_derivative_states(snapshot)
|
||||
d_tank = self.components.tank.derivatives_from_connection(
|
||||
connected_h=snapshot.tee_downstream_h,
|
||||
port_m_flow=tank_m_flow,
|
||||
internal_h=snapshot.tank.h,
|
||||
)
|
||||
|
||||
return [
|
||||
d_cylinder.m,
|
||||
d_cylinder.U,
|
||||
branch_derivatives[0].m,
|
||||
branch_derivatives[0].U,
|
||||
branch_derivatives[1].m,
|
||||
branch_derivatives[1].U,
|
||||
d_tank.m,
|
||||
d_tank.U,
|
||||
]
|
||||
@@ -1,272 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class Pipe(ThermodynamicVolumeComponent):
|
||||
"""Dynamic pipe retained for the fixed TestModel compatibility example."""
|
||||
|
||||
MODEL_TYPE = "pipe"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"length",
|
||||
5.0,
|
||||
label="长度",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"diameter",
|
||||
0.02,
|
||||
label="直径",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"lambda_darcy",
|
||||
0.02,
|
||||
label="摩阻系数",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
1e5,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
300.0,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
L: float = 5.0,
|
||||
D: float = 0.02,
|
||||
lambda_darcy: float = 0.02,
|
||||
p0: float = 1e5,
|
||||
T0: float = 300.0,
|
||||
) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"length": L,
|
||||
"diameter": D,
|
||||
"lambda_darcy": lambda_darcy,
|
||||
"p0": p0,
|
||||
"T0": T0,
|
||||
}
|
||||
)
|
||||
self.medium = medium
|
||||
self.L = L
|
||||
self.D = D
|
||||
self.lambda_darcy = lambda_darcy
|
||||
self.area = 3.141592653589793 * D * D / 4.0
|
||||
self.V = self.area * L
|
||||
m0 = p0 * self.V / (medium.R_gas * T0)
|
||||
U0 = m0 * medium.specific_internal_energy(T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Pipe:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
L=parameters["length"],
|
||||
D=parameters["diameter"],
|
||||
lambda_darcy=parameters["lambda_darcy"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
self.state = VolumeState.from_vector(values)
|
||||
|
||||
def properties(self) -> ThermodynamicProperties:
|
||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
|
||||
self.port_b.p = props.p
|
||||
self.port_a.h_outflow = props.h
|
||||
self.port_b.h_outflow = props.h
|
||||
return props
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
return self.properties()
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
properties = self.properties()
|
||||
derivative = self.derivatives_from_connections(
|
||||
port_a_m_flow=self.port_a.m_flow,
|
||||
connected_h_a=connected_h["port_a"],
|
||||
port_b_m_flow=self.port_b.m_flow,
|
||||
connected_h_b=connected_h["port_b"],
|
||||
internal_h=properties.h,
|
||||
)
|
||||
return derivative.as_vector()
|
||||
|
||||
def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float:
|
||||
resistance = self.lambda_darcy * (self.L / self.D)
|
||||
dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area)
|
||||
return core_pressure + resistance * dynamic_term
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
properties = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.V,
|
||||
)
|
||||
expected_inlet_pressure = self.inlet_pressure(
|
||||
self.port_a.m_flow,
|
||||
max(properties.rho, 1e-12),
|
||||
properties.p,
|
||||
)
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:darcy_pressure_loss",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.port_a.p",
|
||||
f"{self.name}.port_a.m_flow",
|
||||
f"{self.name}.state",
|
||||
),
|
||||
role="effort",
|
||||
value=self.port_a.p - expected_inlet_pressure,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_b_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_b.p - properties.p,
|
||||
),
|
||||
)
|
||||
|
||||
def port_a_inlet_enthalpy(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h: float,
|
||||
internal_h: float,
|
||||
) -> float:
|
||||
return self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_a_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
|
||||
def port_b_inlet_enthalpy(
|
||||
self,
|
||||
*,
|
||||
port_b_m_flow: float,
|
||||
connected_h: float,
|
||||
internal_h: float,
|
||||
) -> float:
|
||||
return self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_b_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
|
||||
def connection_inlet_enthalpies(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h_a: float,
|
||||
port_b_m_flow: float,
|
||||
connected_h_b: float,
|
||||
internal_h: float,
|
||||
) -> tuple[float, float]:
|
||||
return (
|
||||
self.port_a_inlet_enthalpy(
|
||||
port_a_m_flow=port_a_m_flow,
|
||||
connected_h=connected_h_a,
|
||||
internal_h=internal_h,
|
||||
),
|
||||
self.port_b_inlet_enthalpy(
|
||||
port_b_m_flow=port_b_m_flow,
|
||||
connected_h=connected_h_b,
|
||||
internal_h=internal_h,
|
||||
),
|
||||
)
|
||||
|
||||
def derivatives_from_connections(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h_a: float,
|
||||
port_b_m_flow: float,
|
||||
connected_h_b: float,
|
||||
internal_h: float,
|
||||
) -> VolumeState:
|
||||
inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies(
|
||||
port_a_m_flow=port_a_m_flow,
|
||||
connected_h_a=connected_h_a,
|
||||
port_b_m_flow=port_b_m_flow,
|
||||
connected_h_b=connected_h_b,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
return self.derivatives(
|
||||
inlet_h_a=inlet_h_a,
|
||||
inlet_h_b=inlet_h_b,
|
||||
m_flow_a=port_a_m_flow,
|
||||
m_flow_b=port_b_m_flow,
|
||||
)
|
||||
|
||||
def derivatives(
|
||||
self,
|
||||
inlet_h_a: float,
|
||||
inlet_h_b: float,
|
||||
m_flow_a: float,
|
||||
m_flow_b: float,
|
||||
) -> VolumeState:
|
||||
dm_dt = m_flow_a + m_flow_b
|
||||
dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b
|
||||
return VolumeState(m=dm_dt, U=dU_dt)
|
||||
@@ -1,222 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
InitializationDiagnostics,
|
||||
TestModelConfig,
|
||||
TestModelSystem,
|
||||
)
|
||||
from app.simulation.paths import (
|
||||
MODELICA_TESTMODEL_RESULT_PATH,
|
||||
PROJECT_ROOT,
|
||||
SIMULATION_RUNS_DIR,
|
||||
)
|
||||
from app.simulation.reporting import (
|
||||
COMPARISON_KEYS,
|
||||
PRIMARY_KEYS,
|
||||
TestModelArtifacts,
|
||||
export_testmodel_artifacts,
|
||||
format_testmodel_run_report,
|
||||
load_modelica_series,
|
||||
write_testmodel_run_report,
|
||||
)
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelSamplingConfig:
|
||||
step: float = 0.1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelPathConfig:
|
||||
output_dir: Path | None = None
|
||||
modelica_result_path: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelExecutionConfig:
|
||||
use_modelica_reference_if_available: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelRunConfig:
|
||||
model: TestModelConfig = field(default_factory=TestModelConfig)
|
||||
solver: SolveIVPConfig = field(default_factory=SolveIVPConfig)
|
||||
sampling: TestModelSamplingConfig = field(default_factory=TestModelSamplingConfig)
|
||||
paths: TestModelPathConfig = field(default_factory=TestModelPathConfig)
|
||||
execution: TestModelExecutionConfig = field(default_factory=TestModelExecutionConfig)
|
||||
|
||||
@property
|
||||
def sample_step(self) -> float:
|
||||
return self.sampling.step
|
||||
|
||||
def sample_times(self) -> list[float]:
|
||||
return _sample_times(
|
||||
self.solver.t_start,
|
||||
self.solver.t_stop,
|
||||
step=self.sampling.step,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedTestModelRun:
|
||||
run_config: TestModelRunConfig
|
||||
repo_root: Path
|
||||
output_dir: Path
|
||||
modelica_result_path: Path
|
||||
t_eval: tuple[float, ...]
|
||||
use_modelica_reference_if_available: bool
|
||||
modelica_reference_exists: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelRunResult:
|
||||
run_config: TestModelRunConfig
|
||||
prepared_run: PreparedTestModelRun
|
||||
system: TestModelSystem
|
||||
initialization: InitializationDiagnostics
|
||||
raw_initial_state: tuple[float, ...]
|
||||
consistent_initial_state: tuple[float, ...]
|
||||
solution: object
|
||||
series: dict[str, list[float]]
|
||||
solve_diagnostics: TestModelSolveDiagnostics | None
|
||||
artifacts: TestModelArtifacts
|
||||
comparison_summary: dict[str, tuple[float, float]] | None
|
||||
used_modelica_reference: bool
|
||||
|
||||
|
||||
def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]:
|
||||
point_count = int(round((t_stop - t_start) / step))
|
||||
return [t_start + index * step for index in range(point_count + 1)]
|
||||
|
||||
|
||||
def _default_run_output_dir() -> Path:
|
||||
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
|
||||
return SIMULATION_RUNS_DIR / timestamp
|
||||
|
||||
|
||||
def prepare_testmodel_run(
|
||||
*,
|
||||
run_config: TestModelRunConfig | None = None,
|
||||
output_dir: Path | None = None,
|
||||
modelica_result_path: Path | None = None,
|
||||
) -> PreparedTestModelRun:
|
||||
run_config = run_config or TestModelRunConfig()
|
||||
resolved_output_dir = (
|
||||
output_dir
|
||||
or run_config.paths.output_dir
|
||||
or _default_run_output_dir()
|
||||
)
|
||||
resolved_modelica_result_path = (
|
||||
modelica_result_path
|
||||
or run_config.paths.modelica_result_path
|
||||
or MODELICA_TESTMODEL_RESULT_PATH
|
||||
)
|
||||
t_eval = tuple(run_config.sample_times())
|
||||
return PreparedTestModelRun(
|
||||
run_config=run_config,
|
||||
repo_root=PROJECT_ROOT,
|
||||
output_dir=resolved_output_dir,
|
||||
modelica_result_path=resolved_modelica_result_path,
|
||||
t_eval=t_eval,
|
||||
use_modelica_reference_if_available=run_config.execution.use_modelica_reference_if_available,
|
||||
modelica_reference_exists=resolved_modelica_result_path.exists(),
|
||||
)
|
||||
|
||||
|
||||
def run_prepared_testmodel(prepared_run: PreparedTestModelRun) -> TestModelRunResult:
|
||||
run_config = prepared_run.run_config
|
||||
system = TestModelSystem(config=run_config.model)
|
||||
raw_initial_state = tuple(system.initial_state_vector())
|
||||
initialization = system.initialize_consistent_state()
|
||||
consistent_initial_state = tuple(initialization.state_vector)
|
||||
solution = system.simulate(config=run_config.solver, t_eval=list(prepared_run.t_eval))
|
||||
series = system.evaluate_solution(solution)
|
||||
solve_diagnostics = system.last_solve_diagnostics
|
||||
|
||||
modelica_series = None
|
||||
used_modelica_reference = False
|
||||
if (
|
||||
prepared_run.use_modelica_reference_if_available
|
||||
and prepared_run.modelica_reference_exists
|
||||
):
|
||||
modelica_series = load_modelica_series(
|
||||
prepared_run.modelica_result_path,
|
||||
COMPARISON_KEYS,
|
||||
)
|
||||
used_modelica_reference = True
|
||||
|
||||
artifacts, comparison_summary = export_testmodel_artifacts(
|
||||
output_dir=prepared_run.output_dir,
|
||||
series=series,
|
||||
modelica_series=modelica_series,
|
||||
)
|
||||
report_text = format_testmodel_run_report(
|
||||
network_summary=system.network.summary(),
|
||||
initialization=initialization,
|
||||
raw_initial_state=raw_initial_state,
|
||||
consistent_initial_state=consistent_initial_state,
|
||||
solution=solution,
|
||||
series=series,
|
||||
solve_diagnostics=solve_diagnostics,
|
||||
artifacts=artifacts,
|
||||
comparison_summary=comparison_summary,
|
||||
)
|
||||
write_testmodel_run_report(prepared_run.output_dir, report_text)
|
||||
|
||||
return TestModelRunResult(
|
||||
run_config=run_config,
|
||||
prepared_run=prepared_run,
|
||||
system=system,
|
||||
initialization=initialization,
|
||||
raw_initial_state=raw_initial_state,
|
||||
consistent_initial_state=consistent_initial_state,
|
||||
solution=solution,
|
||||
series=series,
|
||||
solve_diagnostics=solve_diagnostics,
|
||||
artifacts=artifacts,
|
||||
comparison_summary=comparison_summary,
|
||||
used_modelica_reference=used_modelica_reference,
|
||||
)
|
||||
|
||||
|
||||
def run_testmodel(
|
||||
*,
|
||||
run_config: TestModelRunConfig | None = None,
|
||||
output_dir: Path | None = None,
|
||||
modelica_result_path: Path | None = None,
|
||||
) -> TestModelRunResult:
|
||||
prepared_run = prepare_testmodel_run(
|
||||
run_config=run_config,
|
||||
output_dir=output_dir,
|
||||
modelica_result_path=modelica_result_path,
|
||||
)
|
||||
return run_prepared_testmodel(prepared_run)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run_config = TestModelRunConfig()
|
||||
result = run_testmodel(run_config=run_config)
|
||||
print(
|
||||
format_testmodel_run_report(
|
||||
network_summary=result.system.network.summary(),
|
||||
initialization=result.initialization,
|
||||
raw_initial_state=result.raw_initial_state,
|
||||
consistent_initial_state=result.consistent_initial_state,
|
||||
solution=result.solution,
|
||||
series=result.series,
|
||||
solve_diagnostics=result.solve_diagnostics,
|
||||
artifacts=result.artifacts,
|
||||
comparison_summary=result.comparison_summary,
|
||||
),
|
||||
end="",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,303 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.examples.testmodel.closure import (
|
||||
BranchClosureComponents,
|
||||
InitializationDiagnostics,
|
||||
TestModelClosure,
|
||||
TestModelClosureComponents,
|
||||
TestModelSnapshot,
|
||||
)
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CylinderConfig:
|
||||
volume: float = 0.01
|
||||
p0: float = 35e6
|
||||
T0: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrificeConfig:
|
||||
K: float = 1e-5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TankConfig:
|
||||
volume: float = 0.1
|
||||
p0: float = 1e5
|
||||
T0: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipeConfig:
|
||||
length: float = 5.0
|
||||
diameter: float = 0.02
|
||||
lambda_darcy: float = 0.02
|
||||
p0: float = 1e5
|
||||
T0: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchConfig:
|
||||
orifice: OrificeConfig = field(default_factory=OrificeConfig)
|
||||
pipe: PipeConfig = field(default_factory=PipeConfig)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelConfig:
|
||||
cylinder: CylinderConfig = field(default_factory=CylinderConfig)
|
||||
upper_branch: BranchConfig = field(default_factory=BranchConfig)
|
||||
lower_branch: BranchConfig = field(default_factory=BranchConfig)
|
||||
tank: TankConfig = field(default_factory=TankConfig)
|
||||
|
||||
|
||||
class TestModelSystem:
|
||||
"""Runnable first-pass Python system for the current Testmodel topology.
|
||||
|
||||
This version keeps the component split from the Modelica model while keeping
|
||||
the downstream tee-tank pressure coupling in the ODE framework. The original
|
||||
Modelica system is a tighter DAE because both pipe outlets discharge into an
|
||||
ideal lossless junction directly connected to the tank. Here the branch
|
||||
outlet flows are solved from a pressure-consistent energy balance so the
|
||||
outlet is no longer driven by an arbitrary conductance parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
medium: IdealGasMedium | None = None,
|
||||
config: TestModelConfig | None = None,
|
||||
) -> None:
|
||||
self.medium = medium or IdealGasMedium()
|
||||
self.config = config or TestModelConfig()
|
||||
|
||||
self.mycylinder = Cylinder(
|
||||
name="mycylinder",
|
||||
medium=self.medium,
|
||||
V=self.config.cylinder.volume,
|
||||
p0=self.config.cylinder.p0,
|
||||
T0=self.config.cylinder.T0,
|
||||
)
|
||||
self.mytee = Tee(name="mytee")
|
||||
self.myorifice = Orifice(name="myorifice", K=self.config.upper_branch.orifice.K)
|
||||
self.mypipe = Pipe(
|
||||
name="mypipe",
|
||||
medium=self.medium,
|
||||
L=self.config.upper_branch.pipe.length,
|
||||
D=self.config.upper_branch.pipe.diameter,
|
||||
lambda_darcy=self.config.upper_branch.pipe.lambda_darcy,
|
||||
p0=self.config.upper_branch.pipe.p0,
|
||||
T0=self.config.upper_branch.pipe.T0,
|
||||
)
|
||||
self.myorifice1 = Orifice(name="myorifice1", K=self.config.lower_branch.orifice.K)
|
||||
self.mypipe1 = Pipe(
|
||||
name="mypipe1",
|
||||
medium=self.medium,
|
||||
L=self.config.lower_branch.pipe.length,
|
||||
D=self.config.lower_branch.pipe.diameter,
|
||||
lambda_darcy=self.config.lower_branch.pipe.lambda_darcy,
|
||||
p0=self.config.lower_branch.pipe.p0,
|
||||
T0=self.config.lower_branch.pipe.T0,
|
||||
)
|
||||
self.mytee1 = Tee(name="mytee1")
|
||||
self.mytank = Tank(
|
||||
name="mytank",
|
||||
medium=self.medium,
|
||||
V=self.config.tank.volume,
|
||||
p0=self.config.tank.p0,
|
||||
T0=self.config.tank.T0,
|
||||
)
|
||||
|
||||
self.network = SimulationNetwork(name="Testmodel")
|
||||
for component in (
|
||||
self.mycylinder,
|
||||
self.mytee,
|
||||
self.myorifice,
|
||||
self.mypipe,
|
||||
self.myorifice1,
|
||||
self.mypipe1,
|
||||
self.mytee1,
|
||||
self.mytank,
|
||||
):
|
||||
self.network.add_component(component)
|
||||
|
||||
self.network.connect("mycylinder", "port_b", "mytee", "port_in")
|
||||
self.network.connect("mytee", "port_out1", "myorifice", "port_a")
|
||||
self.network.connect("myorifice", "port_b", "mypipe", "port_a")
|
||||
self.network.connect("mypipe", "port_b", "mytee1", "port_out2")
|
||||
self.network.connect("mytee", "port_out2", "myorifice1", "port_a")
|
||||
self.network.connect("myorifice1", "port_b", "mypipe1", "port_a")
|
||||
self.network.connect("mypipe1", "port_b", "mytee1", "port_out1")
|
||||
self.network.connect("mytee1", "port_in", "mytank", "port_a")
|
||||
|
||||
self.closure = TestModelClosure(
|
||||
medium=self.medium,
|
||||
components=TestModelClosureComponents(
|
||||
cylinder=self.mycylinder,
|
||||
upstream_tee=self.mytee,
|
||||
upper_branch=BranchClosureComponents(
|
||||
name="upper_branch",
|
||||
orifice=self.myorifice,
|
||||
pipe=self.mypipe,
|
||||
),
|
||||
lower_branch=BranchClosureComponents(
|
||||
name="lower_branch",
|
||||
orifice=self.myorifice1,
|
||||
pipe=self.mypipe1,
|
||||
),
|
||||
downstream_tee=self.mytee1,
|
||||
tank=self.mytank,
|
||||
),
|
||||
initial_state_vector=self.initial_state_vector,
|
||||
apply_state_vector=self.apply_state_vector,
|
||||
)
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
return self.network.initial_state_vector()
|
||||
|
||||
def apply_state_vector(self, values: list[float]) -> None:
|
||||
self.network.apply_state_vector(values)
|
||||
|
||||
def consistent_initial_state_vector(self) -> list[float]:
|
||||
return self.closure.consistent_initial_state_vector()
|
||||
|
||||
@property
|
||||
def last_solve_diagnostics(self):
|
||||
return self.closure.last_solve_diagnostics
|
||||
|
||||
def initialize_consistent_state(
|
||||
self,
|
||||
max_iterations: int = 12,
|
||||
state_tolerance: float = 1e-9,
|
||||
flow_tolerance: float = 1e-9,
|
||||
enthalpy_tolerance: float = 1e-6,
|
||||
pressure_tolerance: float = 1e-6,
|
||||
strict_internal_solvers: bool = False,
|
||||
) -> InitializationDiagnostics:
|
||||
return self.closure.initialize_consistent_state(
|
||||
max_iterations=max_iterations,
|
||||
state_tolerance=state_tolerance,
|
||||
flow_tolerance=flow_tolerance,
|
||||
enthalpy_tolerance=enthalpy_tolerance,
|
||||
pressure_tolerance=pressure_tolerance,
|
||||
strict_internal_solvers=strict_internal_solvers,
|
||||
)
|
||||
|
||||
def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None:
|
||||
self.closure.project_downstream_pressure_constraints(strict=strict)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
state_vector: list[float] | None = None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> TestModelSnapshot:
|
||||
return self.closure.snapshot(state_vector, strict=strict)
|
||||
|
||||
def rhs(self, _t: float, state_vector: list[float]) -> list[float]:
|
||||
return self.closure.rhs(state_vector)
|
||||
|
||||
@staticmethod
|
||||
def _legacy_branch_series_key_map() -> tuple[tuple[str, str, str], tuple[str, str, str]]:
|
||||
return (
|
||||
("upper_branch", "branch_upper.in", "branch_upper.out"),
|
||||
("lower_branch", "branch_lower.in", "branch_lower.out"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _legacy_branch_series_keys_by_name(cls) -> dict[str, tuple[str, str]]:
|
||||
return {
|
||||
branch_name: (inlet_key, outlet_key)
|
||||
for branch_name, inlet_key, outlet_key in cls._legacy_branch_series_key_map()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _generic_branch_series_keys(branch_name: str) -> tuple[str, str, str]:
|
||||
return (
|
||||
f"branch.{branch_name}.p",
|
||||
f"branch.{branch_name}.in",
|
||||
f"branch.{branch_name}.out",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _legacy_branch_pressure_keys_by_name() -> dict[str, str]:
|
||||
return {
|
||||
"upper_branch": "mypipe.p",
|
||||
"lower_branch": "mypipe1.p",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _append_legacy_branch_series_aliases(
|
||||
cls,
|
||||
series: dict[str, list[float]],
|
||||
) -> dict[str, list[float]]:
|
||||
legacy_branch_series_keys = cls._legacy_branch_series_keys_by_name()
|
||||
legacy_branch_pressure_keys = cls._legacy_branch_pressure_keys_by_name()
|
||||
for branch_name, (legacy_inlet_key, legacy_outlet_key) in legacy_branch_series_keys.items():
|
||||
pressure_key, generic_inlet_key, generic_outlet_key = cls._generic_branch_series_keys(
|
||||
branch_name
|
||||
)
|
||||
series[legacy_branch_pressure_keys[branch_name]] = list(series[pressure_key])
|
||||
series[legacy_inlet_key] = list(series[generic_inlet_key])
|
||||
series[legacy_outlet_key] = list(series[generic_outlet_key])
|
||||
return series
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
config: SolveIVPConfig | None = None,
|
||||
t_eval: list[float] | None = None,
|
||||
) -> Any:
|
||||
return integrate_ode(
|
||||
rhs=self.rhs,
|
||||
initial_state=self.consistent_initial_state_vector(),
|
||||
config=config or SolveIVPConfig(),
|
||||
t_eval=t_eval,
|
||||
)
|
||||
|
||||
def evaluate_solution(self, solution: Any) -> dict[str, list[float]]:
|
||||
series = {
|
||||
"time": [],
|
||||
"mycylinder.p": [],
|
||||
"mycylinder.T": [],
|
||||
"mytank.p": [],
|
||||
"mytank.T": [],
|
||||
}
|
||||
for branch_name, _, _ in self._legacy_branch_series_key_map():
|
||||
pressure_key, inlet_key, outlet_key = self._generic_branch_series_keys(branch_name)
|
||||
series[pressure_key] = []
|
||||
series[inlet_key] = []
|
||||
series[outlet_key] = []
|
||||
|
||||
for index, time_value in enumerate(solution.t):
|
||||
state_vector = [row[index] for row in solution.y]
|
||||
snapshot = self.snapshot(state_vector)
|
||||
series["time"].append(float(time_value))
|
||||
series["mycylinder.p"].append(snapshot.cylinder.p)
|
||||
series["mycylinder.T"].append(snapshot.cylinder.T)
|
||||
series["mytank.p"].append(snapshot.tank.p)
|
||||
series["mytank.T"].append(snapshot.tank.T)
|
||||
for branch in snapshot.branches:
|
||||
pressure_key, generic_inlet_key, generic_outlet_key = self._generic_branch_series_keys(
|
||||
branch.name
|
||||
)
|
||||
series[pressure_key].append(branch.pipe.p)
|
||||
series[generic_inlet_key].append(branch.inlet_flow)
|
||||
series[generic_outlet_key].append(branch.outlet_flow)
|
||||
|
||||
return self._append_legacy_branch_series_aliases(series)
|
||||
|
||||
|
||||
def build_testmodel() -> SimulationNetwork:
|
||||
"""Compatibility helper for callers that only need the topology."""
|
||||
|
||||
return TestModelSystem().network
|
||||
@@ -0,0 +1 @@
|
||||
"""Compilation of supported XML networks to independent native executables."""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""python -m app.simulation.native_codegen INPUT.xml|json --output-dir DIR"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import time
|
||||
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.backends import simulation_config
|
||||
from .build import build_native
|
||||
from .compiler import compile_native_program
|
||||
from .input import load_input
|
||||
from .runner import execute_native
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--method", choices=("RK45", "BDF"))
|
||||
parser.add_argument("--max-step", type=float)
|
||||
parser.add_argument("--rtol", type=float)
|
||||
parser.add_argument("--runs", type=int, default=3)
|
||||
parser.add_argument("--timeout", type=float, default=300)
|
||||
parser.add_argument("--solve-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.runs < 1:
|
||||
parser.error("--runs must be positive")
|
||||
out = args.output_dir.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
if (out / "summary.json").exists():
|
||||
parser.error("Output directory already contains a completed run; choose a new directory.")
|
||||
started = time.perf_counter()
|
||||
xml, document = load_input(args.input)
|
||||
program = compile_native_program(compile_system_xml_network(document))
|
||||
config = simulation_config(document.simulation)
|
||||
for name in ("method", "max_step", "rtol"):
|
||||
if getattr(args, name) is not None:
|
||||
config = replace(config, **{name: getattr(args, name)})
|
||||
preparation = time.perf_counter()-started
|
||||
(out / "input.xml").write_bytes(xml)
|
||||
build = build_native(program)
|
||||
package = out / "program"
|
||||
package.mkdir(exist_ok=True)
|
||||
for name in (*build.manifest["artifacts"], "manifest.json"):
|
||||
shutil.copy2(build.executable.parent / name, package / name)
|
||||
(out / "model-manifest.json").write_text(json.dumps(build.manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
rows = []
|
||||
for i in range(args.runs+1):
|
||||
data = execute_native(build, config, document.simulation.sample_step,
|
||||
run_dir=out / ("warmup" if i == 0 else f"run-{i}"),
|
||||
record_samples=not args.solve_only, timeout=args.timeout)
|
||||
row = {k: v for k, v in data.items() if k not in ("series", "final", "finalState")}
|
||||
row["run"] = "warmup" if i == 0 else i
|
||||
rows.append(row)
|
||||
print(json.dumps(row, ensure_ascii=False), flush=True)
|
||||
if not data["success"]:
|
||||
break
|
||||
measured = rows[1:]
|
||||
summary = {
|
||||
"source": str(args.input.resolve()), "sourceSha256": sha256(args.input.read_bytes()).hexdigest(),
|
||||
"xmlSha256": sha256(xml).hexdigest(), "executable": str(package / build.executable.name),
|
||||
"cachedExecutable": str(build.executable),
|
||||
"buildKey": build.manifest["buildKey"], "buildSeconds": build.seconds,
|
||||
"preparationSeconds": preparation, "cacheHit": build.cache_hit,
|
||||
"settings": vars(config), "sampleStep": document.simulation.sample_step,
|
||||
"recordSamples": not args.solve_only, "runs": rows,
|
||||
"success": len(measured) == args.runs and all(row["success"] for row in rows),
|
||||
"medianSolveSeconds": statistics.median(row["solveSeconds"] for row in measured) if measured else None,
|
||||
}
|
||||
(out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({k: v for k, v in summary.items() if k != "runs"}, ensure_ascii=False))
|
||||
return 0 if summary["success"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,403 @@
|
||||
"""On-demand native modules, checked object reuse and bounded executable caches."""
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from .compiler import NativeProgram
|
||||
from .modules import component_modules
|
||||
from .processes import command_environment, is_driver_launch_failure, is_transient_process_error, LAUNCH_RETRY_DELAYS
|
||||
from .cache_storage import acquire_cache_lease, touch_cache_entry, prune_cache
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
WINDOWS = os.name == 'nt'
|
||||
|
||||
|
||||
class NativeCommandError(RuntimeError):
|
||||
def __init__(self, message, command, *, exit_code=None, stdout=b'', stderr='', error_type='CalledProcessError', attempts=1):
|
||||
super().__init__(message)
|
||||
self.details = {'command': list(command), 'cwd': str(Path.cwd()), 'exitCode': exit_code,
|
||||
'stdout': stdout.decode('utf-8',errors='replace')[-8000:], 'stderr': stderr[-8000:],
|
||||
'errorType': error_type, 'attempts': attempts}
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
NATIVE = ROOT / "native"
|
||||
CACHE = ROOT / "app/data/native-builds"
|
||||
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
|
||||
COMPILER_FLAGS = ("-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math")
|
||||
RUNTIME_SOURCES = ("runtime/main.c", "runtime/common.c", "runtime/sample_storage.c", "runtime/rk45.c",
|
||||
"runtime/cvode_solver.c", "runtime/json_numbers.c", "encoding/ryu/d2s.c")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeBuild:
|
||||
executable: Path
|
||||
manifest: dict
|
||||
cache_hit: bool
|
||||
seconds: float
|
||||
details: dict = field(default_factory=dict)
|
||||
_lease: Any = field(default=None, repr=False, compare=False)
|
||||
_cache: Path | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release this caller's executable pin after its last use."""
|
||||
if self._lease is not None:
|
||||
self._lease.close()
|
||||
if self._cache is not None:
|
||||
_prune(self._cache)
|
||||
|
||||
|
||||
def _hash(path: Path) -> str:
|
||||
return sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _key(value: object) -> str:
|
||||
return sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _runtime_sources(program: NativeProgram) -> list[Path]:
|
||||
return [NATIVE / name for name in RUNTIME_SOURCES] + [
|
||||
NATIVE / "components/modules" / f"{name}.c"
|
||||
for name in component_modules(program.source + "\n" + program.header)
|
||||
]
|
||||
|
||||
|
||||
def _budget(name: str, default: int) -> int:
|
||||
value = os.environ.get(name, str(default))
|
||||
if not re.fullmatch(r"[0-9]+", value):
|
||||
raise ValueError(f"{name} must be a nonnegative integer in MiB.")
|
||||
return int(value) * 1024**2
|
||||
|
||||
|
||||
def _prune(cache: Path) -> dict:
|
||||
return prune_cache(cache,
|
||||
model_limit_bytes=_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256),
|
||||
object_limit_bytes=_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128))
|
||||
|
||||
|
||||
def _checked_manifest(target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict | None:
|
||||
if not target.exists() and not target.is_symlink():
|
||||
return None
|
||||
message = f"Native cache integrity check failed: {target}"
|
||||
if target.is_symlink() or not target.is_dir():
|
||||
raise RuntimeError(message)
|
||||
try:
|
||||
path = target / "manifest.json"
|
||||
if path.is_symlink():
|
||||
raise ValueError("linked manifest")
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(manifest, dict) or manifest.get(key_name) != key or manifest.get("cacheVersion") != version:
|
||||
raise ValueError("identity mismatch")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, dict) or set(artifacts) != required:
|
||||
raise ValueError("incomplete artifacts")
|
||||
if key_name == "objectKey":
|
||||
recipe = {name: manifest[name] for name in ("cacheVersion", "sourceName", "preprocessedSha256", "compiler")}
|
||||
if _key(recipe) != key:
|
||||
raise ValueError("object identity mismatch")
|
||||
else:
|
||||
recipe = manifest.get("buildIdentity")
|
||||
if not isinstance(recipe, dict) or _key(recipe) != key or manifest.get("objectKeys") != recipe["objectKeys"]:
|
||||
raise ValueError("model identity mismatch")
|
||||
if artifacts["model.c"] != recipe["sourceSha256"] or artifacts["model.h"] != recipe["headerSha256"]:
|
||||
raise ValueError("model source identity mismatch")
|
||||
if _key({name: manifest[name] for name in recipe["contractKeys"]}) != recipe["contractSha256"]:
|
||||
raise ValueError("model contract mismatch")
|
||||
if artifacts["THIRD_PARTY_NOTICES.txt"] != recipe["notice"]:
|
||||
raise ValueError("license artifact mismatch")
|
||||
for name in required:
|
||||
if name.lower().endswith(".dll") and artifacts[name] != recipe["dependencies"][name]:
|
||||
raise ValueError("runtime dependency artifact mismatch")
|
||||
for name, digest in artifacts.items():
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in (".", ".."):
|
||||
raise ValueError("invalid artifact name")
|
||||
path = target / name
|
||||
if path.is_symlink() or not path.is_file() or not isinstance(digest, str) or _hash(path) != digest:
|
||||
raise ValueError("artifact mismatch")
|
||||
return manifest
|
||||
except (OSError, ValueError, TypeError, KeyError) as exc:
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
|
||||
def _publish(stage: Path, target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict:
|
||||
try:
|
||||
stage.rename(target)
|
||||
except OSError:
|
||||
# Another reader/build may publish the same content while we compile.
|
||||
# Its artifacts need not be byte-identical (e.g. PE timestamps), but
|
||||
# must satisfy the same complete identity and its own recorded hashes.
|
||||
winner = _checked_manifest(target, key_name, key, version, required)
|
||||
if winner is None:
|
||||
raise
|
||||
shutil.rmtree(stage)
|
||||
return winner
|
||||
manifest = _checked_manifest(target, key_name, key, version, required)
|
||||
assert manifest is not None
|
||||
return manifest
|
||||
|
||||
|
||||
def _command(command: list[str], *, log: list[str], timeout: float = 120,
|
||||
require_stdout: bool = False) -> bytes:
|
||||
environment = command_environment(command[0])
|
||||
for attempt in range(len(LAUNCH_RETRY_DELAYS) + 1):
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, timeout=timeout,
|
||||
stdin=subprocess.DEVNULL, close_fds=True, env=environment,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if WINDOWS else 0)
|
||||
except OSError as exc:
|
||||
if not is_transient_process_error(exc) or attempt == len(LAUNCH_RETRY_DELAYS):
|
||||
raise
|
||||
reason = f'{type(exc).__name__}: {exc}'
|
||||
else:
|
||||
stderr = result.stderr.decode('utf-8', errors='replace')
|
||||
log.append(' '.join(command) + '\n' + stderr)
|
||||
empty = result.returncode == 0 and require_stdout and not result.stdout.strip()
|
||||
transient = is_driver_launch_failure(stderr) or (empty and WINDOWS)
|
||||
if not result.returncode and not empty:
|
||||
return result.stdout
|
||||
reason = 'Compiler returned no identification output.' if empty else stderr[-3000:]
|
||||
if not transient or attempt == len(LAUNCH_RETRY_DELAYS):
|
||||
prefix = ('GCC 已启动,但编译子进程启动失败(已完成短间隔重试);'
|
||||
if is_driver_launch_failure(stderr) else 'Native compilation failed: ')
|
||||
raise NativeCommandError(prefix + reason, command, exit_code=result.returncode,
|
||||
stdout=result.stdout, stderr=stderr, error_type='EmptyCompilerOutput' if empty else 'CalledProcessError',
|
||||
attempts=attempt+1)
|
||||
delay = LAUNCH_RETRY_DELAYS[attempt]
|
||||
log.append(f'Compiler launch retry {attempt+1}/{len(LAUNCH_RETRY_DELAYS)} after {delay:g}s: {reason}')
|
||||
LOGGER.warning('Compiler process launch failed; retry %d/%d in %.2fs: %s',
|
||||
attempt+1, len(LAUNCH_RETRY_DELAYS), delay, reason.strip())
|
||||
time.sleep(delay)
|
||||
raise AssertionError('Unreachable compiler retry state')
|
||||
|
||||
|
||||
def toolchain() -> tuple[str, Path, str]:
|
||||
if os.name != "nt" and not sys.platform.startswith("linux"):
|
||||
raise RuntimeError("Native builds support Windows and Linux; this platform is not supported.")
|
||||
compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
|
||||
if not compiler:
|
||||
raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.")
|
||||
configured = os.environ.get("SUNDIALS_ROOT")
|
||||
candidates = ([Path(configured)] if configured else
|
||||
[Path(sys.base_prefix) / "Library"] if os.name == "nt" else
|
||||
[Path(sys.prefix) / "native/sundials-7.4.0", Path("/usr/local"), Path("/usr")])
|
||||
base = next((path for path in candidates if (path / "include/cvode/cvode.h").is_file()), candidates[0])
|
||||
if not (base / "include/cvode/cvode.h").is_file():
|
||||
raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.")
|
||||
if os.name == 'nt':
|
||||
compiler = str(Path(shutil.which(compiler) or compiler).resolve())
|
||||
version = _command([compiler, '--version'], log=[], timeout=15, require_stdout=True).decode('utf-8', errors='replace').strip().splitlines()[0]
|
||||
return compiler, base, version
|
||||
|
||||
|
||||
def platform_build_inputs(sundials: Path) -> tuple[list[str], list[Path], list[Path], str]:
|
||||
"""Share production flags and dependencies with the startup smoke check."""
|
||||
flags = list(COMPILER_FLAGS)
|
||||
executable_name = "model.exe" if os.name == "nt" else "model"
|
||||
if os.name == "nt":
|
||||
flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"]
|
||||
libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES]
|
||||
dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES]
|
||||
if (sundials / "bin/vcruntime140.dll").is_file():
|
||||
dlls.append(sundials / "bin/vcruntime140.dll")
|
||||
else:
|
||||
flags += ["-D_POSIX_C_SOURCE=200809L"]
|
||||
candidates = [sundials / "lib", sundials / "lib64", sundials / "lib/x86_64-linux-gnu"]
|
||||
directory = next((p for p in candidates if all((p / f"libsundials_{name}.a").is_file() for name in LIBRARIES)), None)
|
||||
if directory is None:
|
||||
raise RuntimeError("SUNDIALS static libraries not found; set SUNDIALS_ROOT.")
|
||||
libraries = [directory / f"libsundials_{name}.a" for name in LIBRARIES]
|
||||
dlls = []
|
||||
return flags, libraries, dlls, executable_name
|
||||
|
||||
|
||||
def link_library_arguments(libraries: list[Path]) -> list[str]:
|
||||
arguments = list(map(str, libraries))
|
||||
if sys.platform.startswith("linux"):
|
||||
arguments = ["-Wl,--start-group", *arguments, "-Wl,--end-group"]
|
||||
return arguments
|
||||
|
||||
|
||||
def build_native(program: NativeProgram, *, cache_dir: Path | None = None,
|
||||
progress_callback: Callable[[str], None] | None = None) -> NativeBuild:
|
||||
started = time.perf_counter()
|
||||
if progress_callback:
|
||||
progress_callback("native-cache-check")
|
||||
compiler, sundials, compiler_version = toolchain()
|
||||
flags, libraries, dlls, executable_name = platform_build_inputs(sundials)
|
||||
cache = (cache_dir or CACHE).resolve()
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
for kind in ("models", "objects"):
|
||||
path = cache / kind
|
||||
if path.is_symlink():
|
||||
raise RuntimeError(f"Native cache directory must not be a symbolic link: {path}")
|
||||
path.mkdir(exist_ok=True)
|
||||
_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256)
|
||||
_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128)
|
||||
work_key = sha256(os.urandom(32)).hexdigest()
|
||||
work_lease = acquire_cache_lease(cache, "models", work_key)
|
||||
try:
|
||||
stage = Path(tempfile.mkdtemp(prefix=f"building-{work_key}-", dir=cache))
|
||||
except BaseException:
|
||||
work_lease.close()
|
||||
raise
|
||||
logs: list[str] = []
|
||||
model_lease = None
|
||||
try:
|
||||
(stage / "model.c").write_text(program.source, encoding="utf-8", newline="\n")
|
||||
(stage / "model.h").write_text(program.header, encoding="utf-8", newline="\n")
|
||||
compiler_path = Path(shutil.which(compiler) or compiler).resolve()
|
||||
compiler_identity = {"version": compiler_version, "binarySha256": _hash(compiler_path),
|
||||
"target": _command([compiler, "-dumpmachine"], log=logs, require_stdout=True).decode().strip(),
|
||||
"flags": flags, "platform": sys.platform}
|
||||
runtime = _runtime_sources(program)
|
||||
sources = [stage / "model.c", *runtime]
|
||||
native_headers = {p: _hash(p) for p in (NATIVE / "include").rglob("*.h")}
|
||||
dependency_headers = {p: _hash(p) for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol")
|
||||
for p in (sundials / "include" / directory).glob("*.h")}
|
||||
preprocessing_start = time.perf_counter()
|
||||
# Preprocessed bytes include the actual transitive headers and all
|
||||
# effective macros/ABI switches. Compile precisely these bytes, so a
|
||||
# header edit cannot race the cache identity and the compiler input.
|
||||
def preprocess(source: Path) -> dict:
|
||||
local_log: list[str] = []
|
||||
source_hash = _hash(source)
|
||||
data = _command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={stage}=/generated",
|
||||
"-I", str(stage), "-I", str(NATIVE / "include"), "-I", str(sundials / "include"),
|
||||
str(source)], log=local_log)
|
||||
name = "model.c" if source == stage / "model.c" else "native/" + source.relative_to(NATIVE).as_posix()
|
||||
if _hash(source) != source_hash:
|
||||
raise RuntimeError(f"Native source changed during preprocessing: {source}")
|
||||
digest = sha256(data).hexdigest()
|
||||
identity = {"cacheVersion": 1, "sourceName": name, "preprocessedSha256": digest, "compiler": compiler_identity}
|
||||
return {"sourceName": name, "sourceSha256": source_hash, "data": data,
|
||||
"objectKey": _key(identity), "identity": identity, "log": local_log}
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(sources))) as pool:
|
||||
units = list(pool.map(preprocess, sources))
|
||||
for unit in units:
|
||||
logs.extend(unit.pop("log"))
|
||||
preprocessing_seconds = time.perf_counter() - preprocessing_start
|
||||
if any(_hash(path) != digest for path, digest in {**native_headers, **dependency_headers}.items()):
|
||||
raise RuntimeError("Native headers changed during preprocessing; retry with stable sources.")
|
||||
dependencies = {p.name: _hash(p) for p in libraries + dlls}
|
||||
notice = (NATIVE / "THIRD_PARTY_NOTICES.txt").read_bytes()
|
||||
identity = {"cacheVersion": 2, "sourceSha256": sha256(program.source.encode()).hexdigest(),
|
||||
"headerSha256": sha256(program.header.encode()).hexdigest(),
|
||||
"contractSha256": _key(program.manifest()), "contractKeys": sorted(program.manifest()),
|
||||
"objectKeys": [unit["objectKey"] for unit in units],
|
||||
"compiler": compiler_identity, "dependencies": dependencies, "notice": sha256(notice).hexdigest()}
|
||||
signature = _key(identity)
|
||||
target = cache / "models" / signature
|
||||
required = {"model.c", "model.h", executable_name, "THIRD_PARTY_NOTICES.txt", *(p.name for p in dlls)}
|
||||
model_lease = acquire_cache_lease(cache, "models", signature)
|
||||
manifest = _checked_manifest(target, "buildKey", signature, 2, required)
|
||||
details = {"selectedModules": list(component_modules(program.source + "\n" + program.header)),
|
||||
"unitCount": len(units), "objectCacheHits": 0, "objectCompilations": 0,
|
||||
"preprocessSeconds": preprocessing_seconds, "compileSeconds": 0.0,
|
||||
"compileWallSeconds": 0.0, "linkSeconds": 0.0, "modelCacheHit": manifest is not None}
|
||||
details['compilerLaunchRetries'] = sum(line.startswith('Compiler launch retry ') for line in logs)
|
||||
if manifest is not None:
|
||||
if progress_callback:
|
||||
progress_callback("native-cache-hit")
|
||||
touch_cache_entry(cache, "models", signature)
|
||||
details["pruning"] = _prune(cache)
|
||||
result = NativeBuild(target / executable_name, manifest, True, time.perf_counter() - started,
|
||||
details, model_lease, cache)
|
||||
model_lease = None
|
||||
return result
|
||||
if progress_callback:
|
||||
progress_callback("native-compilation")
|
||||
compile_start = time.perf_counter()
|
||||
def compile_unit(index_unit: tuple[int, dict]) -> dict:
|
||||
index, unit = index_unit
|
||||
key = unit["objectKey"]
|
||||
destination = cache / "objects" / key
|
||||
with acquire_cache_lease(cache, "objects", key):
|
||||
object_manifest = _checked_manifest(destination, "objectKey", key, 1, {"unit.o"})
|
||||
hit = object_manifest is not None
|
||||
elapsed = 0.0
|
||||
local_log: list[str] = []
|
||||
if not hit:
|
||||
temporary = Path(tempfile.mkdtemp(prefix=f"building-{key}-", dir=cache / "objects"))
|
||||
try:
|
||||
(temporary / "unit.i").write_bytes(unit["data"])
|
||||
before = time.perf_counter()
|
||||
_command([compiler, *flags, "-x", "cpp-output", "-c", str(temporary / "unit.i"),
|
||||
"-o", str(temporary / "unit.o")], log=local_log)
|
||||
elapsed = time.perf_counter() - before
|
||||
(temporary / "unit.i").unlink()
|
||||
object_manifest = {**unit["identity"], "objectKey": key, "artifacts": {"unit.o": _hash(temporary / "unit.o")}}
|
||||
(temporary / "manifest.json").write_text(json.dumps(object_manifest, indent=2) + "\n")
|
||||
_publish(temporary, destination, "objectKey", key, 1, {"unit.o"})
|
||||
finally:
|
||||
if temporary.exists():
|
||||
shutil.rmtree(temporary)
|
||||
# Linking uses private copies, so another model's LRU pass can
|
||||
# safely reclaim the shared object after this lease is released.
|
||||
local_object = stage / f"object-{index}.o"
|
||||
shutil.copyfile(destination / "unit.o", local_object)
|
||||
touch_cache_entry(cache, "objects", key)
|
||||
return {"path": local_object, "hit": hit, "seconds": elapsed, "log": local_log}
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(units))) as pool:
|
||||
objects = list(pool.map(compile_unit, enumerate(units)))
|
||||
details["compileWallSeconds"] = time.perf_counter() - compile_start
|
||||
details["compileSeconds"] = sum(item["seconds"] for item in objects)
|
||||
details["objectCacheHits"] = sum(item["hit"] for item in objects)
|
||||
details["objectCompilations"] = len(objects) - details["objectCacheHits"]
|
||||
for item in objects:
|
||||
logs.extend(item["log"])
|
||||
link_libraries = link_library_arguments(libraries)
|
||||
if progress_callback:
|
||||
progress_callback("native-linking")
|
||||
before_link = time.perf_counter()
|
||||
_command([compiler, *flags, *[str(item["path"]) for item in objects], *link_libraries,
|
||||
"-lm", "-o", str(stage / executable_name)], log=logs)
|
||||
details['compilerLaunchRetries'] = sum(line.startswith('Compiler launch retry ') for line in logs)
|
||||
details["linkSeconds"] = time.perf_counter() - before_link
|
||||
if {p.name: _hash(p) for p in libraries + dlls} != dependencies:
|
||||
raise RuntimeError("Native dependencies changed during linking; retry with a stable toolchain.")
|
||||
for item in objects:
|
||||
item["path"].unlink()
|
||||
for library in dlls:
|
||||
shutil.copyfile(library, stage / library.name)
|
||||
if _hash(stage / library.name) != dependencies[library.name]:
|
||||
raise RuntimeError("Native runtime dependency changed during copying; retry with a stable toolchain.")
|
||||
(stage / "THIRD_PARTY_NOTICES.txt").write_bytes(notice)
|
||||
(stage / "build.log").write_text("\n".join(logs), encoding="utf-8")
|
||||
manifest = {**program.manifest(), "cacheVersion": 2, "buildKey": signature, "buildIdentity": identity,
|
||||
"compiler": compiler_version, "compilerFlags": flags,
|
||||
"sourceHashes": {unit["sourceName"]: unit["sourceSha256"] for unit in units},
|
||||
"nativeHeaderHashes": {"native/" + p.relative_to(NATIVE).as_posix(): digest
|
||||
for p, digest in native_headers.items()},
|
||||
"dependencyHashes": dependencies, "selectedModules": details["selectedModules"],
|
||||
"objectKeys": [unit["objectKey"] for unit in units],
|
||||
"artifacts": {name: _hash(stage / name) for name in sorted(required)}}
|
||||
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
manifest = _publish(stage, target, "buildKey", signature, 2, required)
|
||||
touch_cache_entry(cache, "models", signature)
|
||||
details["pruning"] = _prune(cache)
|
||||
result = NativeBuild(target / executable_name, manifest, False, time.perf_counter() - started,
|
||||
details, model_lease, cache)
|
||||
model_lease = None
|
||||
return result
|
||||
finally:
|
||||
if model_lease is not None:
|
||||
model_lease.close()
|
||||
# Failed compilations never publish partial entries or retain unlimited
|
||||
# model sources/preprocessed files. Error stderr is included in the exception.
|
||||
try:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
finally:
|
||||
work_lease.close()
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Leases and bounded LRU eviction for immutable native cache entries.
|
||||
|
||||
Builds publish and validate their own artifacts. Readers hold a shared lease
|
||||
before checking an entry and until they finish using it. Eviction only removes
|
||||
an entry while holding the corresponding nonblocking exclusive lease.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import weakref
|
||||
|
||||
|
||||
MODEL_LIMIT_BYTES = 256 * 1024**2
|
||||
OBJECT_LIMIT_BYTES = 128 * 1024**2
|
||||
LOCK_SHARDS = 128
|
||||
_KINDS = frozenset(("models", "objects"))
|
||||
_KEY = re.compile(r"[0-9a-f]{64}\Z")
|
||||
# tempfile.mkdtemp uses eight lowercase letters, digits or underscores. Older
|
||||
# stages lacking a lease key are deliberately outside automatic cleanup.
|
||||
_STAGE = re.compile(r"building-([0-9a-f]{64})-[a-z0-9_]{8}\Z")
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import msvcrt
|
||||
|
||||
class _Offset(ctypes.Structure):
|
||||
_fields_ = [("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD)]
|
||||
|
||||
class _OffsetUnion(ctypes.Union):
|
||||
_fields_ = [("offset", _Offset), ("Pointer", ctypes.c_void_p)]
|
||||
|
||||
class _Overlapped(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
|
||||
("offset", _OffsetUnion), ("hEvent", wintypes.HANDLE),
|
||||
]
|
||||
|
||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_lock_file = _kernel32.LockFileEx
|
||||
_lock_file.argtypes = [
|
||||
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_Overlapped),
|
||||
]
|
||||
_lock_file.restype = wintypes.BOOL
|
||||
_unlock_file = _kernel32.UnlockFileEx
|
||||
_unlock_file.argtypes = [
|
||||
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.DWORD, ctypes.POINTER(_Overlapped),
|
||||
]
|
||||
_unlock_file.restype = wintypes.BOOL
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
def _validate(kind: str, key: str) -> None:
|
||||
if kind not in _KINDS or not isinstance(key, str) or _KEY.fullmatch(key) is None:
|
||||
raise ValueError("Native cache leases require models/objects and a lowercase SHA-256 key.")
|
||||
|
||||
|
||||
def _reparse(info: os.stat_result) -> bool:
|
||||
# Junctions as well as symbolic links are excluded on Windows.
|
||||
return stat.S_ISLNK(info.st_mode) or bool(
|
||||
getattr(info, "st_file_attributes", 0)
|
||||
& getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
||||
)
|
||||
|
||||
|
||||
def _directory(path: Path) -> os.stat_result | None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
|
||||
return None
|
||||
return info
|
||||
|
||||
|
||||
def _require_directory(path: Path, *, create: bool = False) -> None:
|
||||
if create:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if _directory(path) is None:
|
||||
raise RuntimeError(f"Native cache directory must be a real directory: {path}")
|
||||
|
||||
|
||||
def _lock(fd: int, *, exclusive: bool, blocking: bool) -> bool:
|
||||
if os.name == "nt":
|
||||
# os.open supplies a synchronous handle. LockFileEx therefore really
|
||||
# waits without FAIL_IMMEDIATELY; unlike msvcrt.locking it also supports
|
||||
# overlapping shared leases, including multiple builds in one process.
|
||||
# The one-byte lock may extend beyond EOF, so lock files remain empty.
|
||||
overlapped = _Overlapped()
|
||||
flags = (2 if exclusive else 0) | (0 if blocking else 1)
|
||||
if _lock_file(msvcrt.get_osfhandle(fd), flags, 0, 1, 0, ctypes.byref(overlapped)):
|
||||
return True
|
||||
code = ctypes.get_last_error()
|
||||
if not blocking and code == 33: # ERROR_LOCK_VIOLATION
|
||||
return False
|
||||
raise ctypes.WinError(code)
|
||||
operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
||||
if not blocking:
|
||||
operation |= fcntl.LOCK_NB
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, operation)
|
||||
return True
|
||||
except InterruptedError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if not blocking and exc.errno in (errno.EACCES, errno.EAGAIN):
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def _release(fd: int, owner_pid: int) -> None:
|
||||
# A forked child's finalizer must not explicitly unlock its parent's flock.
|
||||
# Closing the inherited descriptor alone leaves the parent's lease intact.
|
||||
with suppress(OSError):
|
||||
if os.getpid() == owner_pid:
|
||||
if os.name == "nt":
|
||||
overlapped = _Overlapped()
|
||||
_unlock_file(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped))
|
||||
else:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
with suppress(OSError):
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class CacheLease:
|
||||
"""An acquired cache-use lock; close explicitly or use as a context manager."""
|
||||
|
||||
__slots__ = ("path", "kind", "key", "exclusive", "_finalizer", "__weakref__")
|
||||
|
||||
def __init__(self, fd: int, path: Path, kind: str, key: str, exclusive: bool):
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.key = key
|
||||
self.exclusive = exclusive
|
||||
self._finalizer = weakref.finalize(self, _release, fd, os.getpid())
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return not self._finalizer.alive
|
||||
|
||||
def close(self) -> None:
|
||||
self._finalizer()
|
||||
|
||||
def __enter__(self) -> CacheLease:
|
||||
if self.closed:
|
||||
raise RuntimeError("Cannot reuse a closed native cache lease.")
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def acquire_cache_lease(
|
||||
cache_dir: Path, kind: str, key: str, *, exclusive: bool = False,
|
||||
blocking: bool = True,
|
||||
) -> CacheLease | None:
|
||||
"""Acquire a shared-use/exclusive-eviction lease; return None only if busy.
|
||||
|
||||
Locks use 128 stable shards per namespace, bounding metadata as model keys
|
||||
accumulate. A collision can defer eviction but cannot admit unsafe deletion.
|
||||
Lock files must never be unlinked while the application is running: waiters
|
||||
may already hold the old inode. There are at most 256 empty lock files.
|
||||
"""
|
||||
_validate(kind, key)
|
||||
shard = int(key[:8], 16) % LOCK_SHARDS
|
||||
return _acquire_lock_file(
|
||||
Path(cache_dir).absolute(), kind, key, f"{kind}-{shard:03d}.lock",
|
||||
exclusive=exclusive, blocking=blocking,
|
||||
)
|
||||
|
||||
|
||||
def _acquire_lock_file(
|
||||
cache: Path, kind: str, key: str, lock_name: str, *, exclusive: bool,
|
||||
blocking: bool,
|
||||
) -> CacheLease | None:
|
||||
_require_directory(cache, create=True)
|
||||
lock_dir = cache / ".locks"
|
||||
_require_directory(lock_dir, create=True)
|
||||
lock_path = lock_dir / lock_name
|
||||
try:
|
||||
info = lock_path.lstat()
|
||||
except FileNotFoundError:
|
||||
info = None
|
||||
if info is not None and (_reparse(info) or not stat.S_ISREG(info.st_mode)):
|
||||
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
|
||||
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
||||
fd = os.open(lock_path, flags, 0o600)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
|
||||
os.set_inheritable(fd, False)
|
||||
if not _lock(fd, exclusive=exclusive, blocking=blocking):
|
||||
os.close(fd)
|
||||
return None
|
||||
return CacheLease(fd, cache / kind / key, kind, key, exclusive)
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def touch_cache_entry(cache_dir: Path, kind: str, key: str) -> None:
|
||||
"""Mark an immutable entry as recently used while its caller holds a lease."""
|
||||
_validate(kind, key)
|
||||
cache = Path(cache_dir).absolute()
|
||||
_require_directory(cache)
|
||||
_require_directory(cache / kind)
|
||||
entry = cache / kind / key
|
||||
_require_directory(entry)
|
||||
# Directory mtime is separate from the checksummed artifact/manifest bytes.
|
||||
# Windows Python may expose utime without the no-follow operation. The
|
||||
# checks above reject symlinks and reparse points before either call, and
|
||||
# the caller's use lease protects the entry from application eviction.
|
||||
if os.utime in os.supports_follow_symlinks:
|
||||
os.utime(entry, None, follow_symlinks=False)
|
||||
else:
|
||||
os.utime(entry, None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Entry:
|
||||
path: Path
|
||||
size: int
|
||||
mtime_ns: int
|
||||
device: int
|
||||
inode: int
|
||||
|
||||
|
||||
def _tree_size(path: Path) -> int | None:
|
||||
"""Count regular file bytes without reading files or following any links."""
|
||||
size = 0
|
||||
try:
|
||||
with os.scandir(path) as children:
|
||||
for child in children:
|
||||
info = child.stat(follow_symlinks=False)
|
||||
if _reparse(info):
|
||||
return None
|
||||
if stat.S_ISREG(info.st_mode):
|
||||
size += info.st_size
|
||||
elif stat.S_ISDIR(info.st_mode):
|
||||
nested = _tree_size(Path(child.path))
|
||||
if nested is None:
|
||||
return None
|
||||
size += nested
|
||||
else:
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
return None # A concurrent sweep may already have removed this entry.
|
||||
return size
|
||||
|
||||
|
||||
def _scan(namespace: Path) -> tuple[list[_Entry], int]:
|
||||
entries = []
|
||||
skipped = 0
|
||||
if _directory(namespace) is None:
|
||||
try:
|
||||
namespace.lstat()
|
||||
except FileNotFoundError:
|
||||
return entries, 0
|
||||
return entries, 1
|
||||
with os.scandir(namespace) as children:
|
||||
for child in children:
|
||||
if _KEY.fullmatch(child.name) is None:
|
||||
skipped += 1
|
||||
continue
|
||||
path = Path(child.path)
|
||||
info = _directory(path)
|
||||
if info is None:
|
||||
skipped += 1
|
||||
continue
|
||||
size = _tree_size(path)
|
||||
if size is None:
|
||||
skipped += 1
|
||||
continue
|
||||
entries.append(_Entry(path, size, info.st_mtime_ns, info.st_dev, info.st_ino))
|
||||
return entries, skipped
|
||||
|
||||
|
||||
def _empty_report(limit: int) -> dict:
|
||||
return {
|
||||
"limitBytes": limit, "beforeBytes": 0, "afterBytes": 0,
|
||||
"removedBytes": 0, "removedEntries": 0, "skippedInUse": 0,
|
||||
"skippedChanged": 0, "skippedUnmanaged": 0, "oversizedEntries": 0,
|
||||
"overLimitBytes": 0, "skippedConcurrentSweep": False, "errors": [],
|
||||
}
|
||||
|
||||
|
||||
def _prune_kind(cache: Path, kind: str, limit: int) -> dict:
|
||||
report = _empty_report(limit)
|
||||
namespace = cache / kind
|
||||
try:
|
||||
entries, skipped = _scan(namespace)
|
||||
except OSError as exc:
|
||||
report["errors"].append(str(exc))
|
||||
return report
|
||||
report["skippedUnmanaged"] = skipped
|
||||
remaining = report["beforeBytes"] = sum(entry.size for entry in entries)
|
||||
remaining_count = len(entries)
|
||||
for entry in sorted(entries, key=lambda item: (item.mtime_ns, item.path.name)):
|
||||
if remaining <= limit:
|
||||
break
|
||||
# Keep the last entry if it alone exceeds the budget. Older oversized
|
||||
# entries are still evictable, so they cannot accumulate unboundedly.
|
||||
if remaining_count == 1 and entry.size > limit:
|
||||
continue
|
||||
try:
|
||||
lease = acquire_cache_lease(cache, kind, entry.path.name, exclusive=True, blocking=False)
|
||||
if lease is None:
|
||||
report["skippedInUse"] += 1
|
||||
continue
|
||||
with lease:
|
||||
info = _directory(entry.path)
|
||||
if info is None:
|
||||
report["skippedChanged"] += 1
|
||||
continue
|
||||
if (info.st_dev, info.st_ino, info.st_mtime_ns) != (
|
||||
entry.device, entry.inode, entry.mtime_ns,
|
||||
):
|
||||
# It was republished or reused after sorting; defer it to
|
||||
# the next sweep instead of evicting a freshly used build.
|
||||
report["skippedChanged"] += 1
|
||||
continue
|
||||
size = _tree_size(entry.path)
|
||||
if size is None:
|
||||
report["skippedUnmanaged"] += 1
|
||||
continue
|
||||
shutil.rmtree(entry.path)
|
||||
remaining -= entry.size
|
||||
remaining_count -= 1
|
||||
report["removedBytes"] += size
|
||||
report["removedEntries"] += 1
|
||||
except (OSError, RuntimeError) as exc:
|
||||
# Cleanup is best effort. A locked executable, permissions or a
|
||||
# damaged unrelated cache entry must not fail a valid simulation.
|
||||
report["errors"].append(str(exc))
|
||||
try:
|
||||
current, _ = _scan(namespace)
|
||||
report["afterBytes"] = sum(entry.size for entry in current)
|
||||
report["oversizedEntries"] = sum(entry.size > limit for entry in current)
|
||||
except OSError as exc:
|
||||
report["errors"].append(str(exc))
|
||||
report["afterBytes"] = remaining
|
||||
report["overLimitBytes"] = max(0, report["afterBytes"] - limit)
|
||||
return report
|
||||
|
||||
|
||||
|
||||
def _empty_orphan_report() -> dict:
|
||||
return {
|
||||
"orphanStagesRemoved": 0, "orphanStagesBytes": 0,
|
||||
"skippedInUse": 0, "skippedUnmanaged": 0,
|
||||
"skippedConcurrentSweep": False, "errors": [],
|
||||
}
|
||||
|
||||
|
||||
def _prune_orphan_stages(cache: Path) -> dict:
|
||||
"""Remove only named stages whose builder's lease is no longer held.
|
||||
|
||||
Callers acquire the embedded key's shared lease BEFORE creating the stage
|
||||
and retain it until publication or cleanup. The OS releases that protection
|
||||
if the builder is killed; no age threshold or unreliable PID test is needed.
|
||||
Root/models stages use models leases; objects stages use objects leases.
|
||||
"""
|
||||
report = _empty_orphan_report()
|
||||
for namespace, kind in ((cache, "models"), (cache / "models", "models"),
|
||||
(cache / "objects", "objects")):
|
||||
try:
|
||||
if _directory(namespace) is None:
|
||||
continue
|
||||
with os.scandir(namespace) as children:
|
||||
candidates = []
|
||||
for child in children:
|
||||
match = _STAGE.fullmatch(child.name)
|
||||
if match is not None:
|
||||
candidates.append((Path(child.path), match.group(1)))
|
||||
elif child.name.startswith("building-"):
|
||||
report["skippedUnmanaged"] += 1
|
||||
except OSError as exc:
|
||||
report["errors"].append(f"{namespace}: {exc}")
|
||||
continue
|
||||
for stage, key in candidates:
|
||||
try:
|
||||
lease = acquire_cache_lease(cache, kind, key, exclusive=True, blocking=False)
|
||||
if lease is None:
|
||||
report["skippedInUse"] += 1
|
||||
continue
|
||||
with lease:
|
||||
if _directory(stage) is None:
|
||||
report["skippedUnmanaged"] += 1
|
||||
continue
|
||||
size = _tree_size(stage)
|
||||
if size is None:
|
||||
report["skippedUnmanaged"] += 1
|
||||
continue
|
||||
shutil.rmtree(stage)
|
||||
report["orphanStagesRemoved"] += 1
|
||||
report["orphanStagesBytes"] += size
|
||||
except (OSError, RuntimeError) as exc:
|
||||
report["errors"].append(f"{stage}: {exc}")
|
||||
return report
|
||||
|
||||
|
||||
def prune_cache(
|
||||
cache_dir: Path, *, model_limit_bytes: int = MODEL_LIMIT_BYTES,
|
||||
object_limit_bytes: int = OBJECT_LIMIT_BYTES,
|
||||
) -> dict:
|
||||
"""Apply separate soft LRU byte budgets to managed models and objects.
|
||||
|
||||
In-use entries (including conservative shard collisions), the final entry
|
||||
when it alone exceeds the budget, unknown names, links and legacy root-level
|
||||
builds are retained. Returned counts explain any remaining overage. The
|
||||
cache may transiently exceed its budgets while builds or solvers are active.
|
||||
Strictly named stages abandoned by killed builders are removed under their
|
||||
matching lease and counted separately in the orphanStages report.
|
||||
"""
|
||||
for limit in (model_limit_bytes, object_limit_bytes):
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
|
||||
raise ValueError("Native cache byte limits must be nonnegative integers.")
|
||||
cache = Path(cache_dir).absolute()
|
||||
empty = {
|
||||
"models": _empty_report(model_limit_bytes),
|
||||
"objects": _empty_report(object_limit_bytes),
|
||||
"orphanStages": _empty_orphan_report(),
|
||||
}
|
||||
try:
|
||||
if _directory(cache) is None:
|
||||
# Do not create a missing cache or inspect an alias to a directory.
|
||||
return empty
|
||||
# One additional stable lock file (257 total) serializes sweep decisions
|
||||
# without serializing readers/builds. Otherwise simultaneous sweeps can
|
||||
# each remove a different entry and incorrectly discard the final one.
|
||||
sweep = _acquire_lock_file(
|
||||
cache, "models", "0" * 64, "prune.lock", exclusive=True, blocking=False,
|
||||
)
|
||||
if sweep is None:
|
||||
for report in empty.values():
|
||||
report["skippedConcurrentSweep"] = True
|
||||
return empty
|
||||
with sweep:
|
||||
orphan_stages = _prune_orphan_stages(cache)
|
||||
return {
|
||||
"models": _prune_kind(cache, "models", model_limit_bytes),
|
||||
"objects": _prune_kind(cache, "objects", object_limit_bytes),
|
||||
"orphanStages": orphan_stages,
|
||||
}
|
||||
except (OSError, RuntimeError) as exc:
|
||||
for report in empty.values():
|
||||
report["errors"].append(str(exc))
|
||||
return empty
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Lower reviewed component contracts to a static C evaluation schedule.
|
||||
|
||||
The compact storage-anchored schedule and the extended catalog schedule both
|
||||
produce standalone C numerics, without Python callbacks or numerical fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from math import isfinite
|
||||
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS
|
||||
from .tolerances import state_absolute_tolerance
|
||||
from .jacobian import JacobianStructure
|
||||
|
||||
|
||||
class NativeCapabilityError(ValueError):
|
||||
"""The complete model cannot be represented by this native backend."""
|
||||
|
||||
|
||||
_STORAGE_ANCHORED_TYPES = frozenset(
|
||||
"amesim_" + name for name in (
|
||||
"pnch023", "pnch012", "pnvo001", "pnpl01", "step0", "ud00",
|
||||
"forc", "pnrp17", "mecmas21", "f000", "lstp00a",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeProgram:
|
||||
source: str
|
||||
header: str
|
||||
state_keys: tuple[str, ...]
|
||||
variables: tuple[ResultVariableMetadata, ...]
|
||||
component_types: tuple[str, ...]
|
||||
evaluation_schedule: dict | None = None
|
||||
jacobian_structure: dict | None = None
|
||||
|
||||
def manifest(self) -> dict:
|
||||
return {
|
||||
"abiVersion": 1, "stateKeys": self.state_keys,
|
||||
"variables": [v.as_dict() for v in self.variables],
|
||||
"componentTypes": self.component_types,
|
||||
"componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types},
|
||||
"jacobianPolicy": (self.jacobian_structure or {}).get("policy", "CVODE default; no custom Jacobian"),
|
||||
"jacobianStructure": self.jacobian_structure,
|
||||
"evaluationSchedule": self.evaluation_schedule or {"strategy": "storage-anchored", "cyclicBlockCount": 0},
|
||||
}
|
||||
|
||||
|
||||
class _Groups:
|
||||
def __init__(self, items):
|
||||
self.parent = {x: x for x in items}
|
||||
|
||||
def find(self, x):
|
||||
if self.parent[x] != x:
|
||||
self.parent[x] = self.find(self.parent[x])
|
||||
return self.parent[x]
|
||||
|
||||
def union(self, a, b):
|
||||
self.parent[self.find(b)] = self.find(a)
|
||||
|
||||
|
||||
def _number(value):
|
||||
value = float(value)
|
||||
if not isfinite(value):
|
||||
raise NativeCapabilityError("Native constants must be finite.")
|
||||
return repr(value)
|
||||
|
||||
|
||||
def compile_native_program(network: SimulationNetwork) -> NativeProgram:
|
||||
network.validate_port_supplies()
|
||||
from .extended import catalog_contracts
|
||||
contracts = catalog_contracts()
|
||||
from app.simulation.components.amesim.semantics import validate_numerical_semantics
|
||||
for component in network.components.values():
|
||||
if type(component) is not contracts.get(component.model_type):
|
||||
raise NativeCapabilityError(f'{component.name}: no native contract for {component.model_type}')
|
||||
if component.MODEL_VERSION != SUPPORTED_VERSIONS.get(component.model_type):
|
||||
raise NativeCapabilityError(f'{component.name}: native kernel version does not match the component contract')
|
||||
try:
|
||||
validate_numerical_semantics(component)
|
||||
except ValueError as exc:
|
||||
raise NativeCapabilityError(str(exc)) from exc
|
||||
# Preserve the compact, verified schedule for its supported topology.
|
||||
# Both paths emit C; this never falls back to Python numerical execution.
|
||||
try:
|
||||
return _compile_storage_anchored_program(network)
|
||||
except NativeCapabilityError:
|
||||
from .extended import compile_extended_program
|
||||
return compile_extended_program(network)
|
||||
|
||||
|
||||
def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgram:
|
||||
components = list(network.components.values())
|
||||
if not components:
|
||||
raise NativeCapabilityError("Native simulation requires a dynamic model.")
|
||||
for c in components:
|
||||
if c.model_type not in _STORAGE_ANCHORED_TYPES:
|
||||
raise NativeCapabilityError(f"{c.name}: unsupported native component {c.model_type}.")
|
||||
if c.MODEL_VERSION != SUPPORTED_VERSIONS[c.model_type]:
|
||||
raise NativeCapabilityError(f"{c.name}: native kernel version does not match the component contract.")
|
||||
if c.model_type == "amesim_pnch023" and c.kth*c.sth != 0:
|
||||
raise NativeCapabilityError(f"{c.name}: native v1 supports adiabatic PNCH023 only.")
|
||||
medium = getattr(c, "medium", None)
|
||||
if medium is not None and (
|
||||
getattr(medium, "SUBSTANCE_ID", None),
|
||||
getattr(medium, "PROPERTY_METHOD_ID", None),
|
||||
) != ("helium", "peng_robinson"):
|
||||
raise NativeCapabilityError(f"{c.name}: native v1 requires helium / Peng-Robinson.")
|
||||
if c.model_type == "amesim_mecmas21":
|
||||
if int(c.stoptype) not in (1, 4) or any(
|
||||
getattr(c, key) != 0 for key in ("fcoul", "fstick", "rvisc", "wind", "theta")
|
||||
):
|
||||
raise NativeCapabilityError(f"{c.name}: native v1 supports friction-free masses with stoptype 1 or 4.")
|
||||
if c.model_type == "amesim_lstp00a" and int(c.stiffmode) != 1:
|
||||
raise NativeCapabilityError(f"{c.name}: native v1 requires explicit contact stiffness.")
|
||||
ports = {
|
||||
(c.name, p.name): p for c in components for p in c.active_port_definitions
|
||||
}
|
||||
adjacent = {}
|
||||
groups = _Groups(ports)
|
||||
for edge in network.connections:
|
||||
a, b = (p.key for p in edge.endpoints)
|
||||
# Signal fan-out is allowed; physical ports have one external connection.
|
||||
if edge.kind == "physical":
|
||||
if a in adjacent or b in adjacent:
|
||||
raise NativeCapabilityError("Native physical ports require one connection each.")
|
||||
adjacent[a], adjacent[b] = b, a
|
||||
groups.union(a, b)
|
||||
else:
|
||||
source, target = (a, b) if ports[a].nominal_role == "output" else (b, a)
|
||||
adjacent[target] = source
|
||||
for c in components:
|
||||
for name in c.required_connection_ports:
|
||||
if (c.name, name) not in adjacent:
|
||||
raise NativeCapabilityError(f"{c.name}.{name}: unconnected physical port.")
|
||||
names = [p.name for p in c.active_port_definitions if p.domain == "pneumatic"]
|
||||
if c.model_type in ("amesim_pnch023", "amesim_pnch012"):
|
||||
for name in names[1:]:
|
||||
groups.union((c.name, names[0]), (c.name, name))
|
||||
if c.model_type == "amesim_mecmas21":
|
||||
groups.union((c.name, "port_1"), (c.name, "port_2"))
|
||||
if c.model_type == "amesim_pnrp17":
|
||||
groups.union((c.name, "port_2"), (c.name, "port_5"))
|
||||
groups.union((c.name, "port_3"), (c.name, "port_4"))
|
||||
|
||||
chambers = [c for c in components if c.model_type in ("amesim_pnch023", "amesim_pnch012")]
|
||||
masses = [c for c in components if c.model_type == "amesim_mecmas21"]
|
||||
chamber_by_group, mass_by_group = {}, {}
|
||||
for items, mapping in ((chambers, chamber_by_group), (masses, mass_by_group)):
|
||||
for c in items:
|
||||
root = groups.find((c.name, "port_1"))
|
||||
if root in mapping:
|
||||
raise NativeCapabilityError(f"{c.name}: coupled storage/mass reduction is outside native v1.")
|
||||
mapping[root] = c
|
||||
for ep, port in ports.items():
|
||||
mapping = chamber_by_group if port.domain == "pneumatic" else mass_by_group
|
||||
if port.kind == "physical" and groups.find(ep) not in mapping:
|
||||
raise NativeCapabilityError(f"{ep}: no unique storage/mass anchor; native v1 cannot close this block.")
|
||||
|
||||
variables = tuple(v for c in components for v in c.result_variable_metadata())
|
||||
slots = {v.key: i for i, v in enumerate(variables)}
|
||||
assigned = set()
|
||||
lines, init, declarations = [], [], []
|
||||
state_keys, state_index = [], {}
|
||||
for c in components:
|
||||
fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ())
|
||||
if c.model_type == "amesim_pnch012" and sum(c.external_volume_rates.values()) != 0:
|
||||
fields += ("_volume_displacement",)
|
||||
for field in fields:
|
||||
key = f"{c.name}.{field}"
|
||||
state_index[key] = len(state_keys)
|
||||
state_keys.append(key)
|
||||
if not state_keys:
|
||||
raise NativeCapabilityError("Native v1 requires continuous states.")
|
||||
if len(state_keys) > 256 or len(variables) > 8192:
|
||||
raise NativeCapabilityError("Native v1 supports at most 256 states and 8192 outputs per model.")
|
||||
|
||||
def w(key):
|
||||
return f"w[{slots[key]}]"
|
||||
|
||||
def key(c, field):
|
||||
return f"{c.name}.{field}"
|
||||
|
||||
def get(c, field):
|
||||
return w(key(c, field))
|
||||
|
||||
def put(c, field, expression):
|
||||
k = key(c, field)
|
||||
lines.append(f"{w(k)} = {expression};")
|
||||
assigned.add(k)
|
||||
|
||||
def si(c, field):
|
||||
return state_index[key(c, field)]
|
||||
|
||||
def mass_at(c, port):
|
||||
return mass_by_group[groups.find((c.name, port))]
|
||||
|
||||
def chamber_at(c, port):
|
||||
return chamber_by_group[groups.find((c.name, port))]
|
||||
|
||||
for c in masses:
|
||||
init.extend([f"y[{si(c, 'v')}] = {_number(c.v0)};", f"y[{si(c, 'x')}] = {_number(c.x0)};"])
|
||||
for field in ("v", "x"):
|
||||
put(c, field, f"y[{si(c, field)}]")
|
||||
for (cid, pname), port in ports.items():
|
||||
if port.domain == "mechanical":
|
||||
m = mass_by_group[groups.find((cid, pname))]
|
||||
for field in ("v", "x"):
|
||||
k = f"{cid}.{pname}.{field}"
|
||||
lines.append(f"{w(k)} = y[{si(m, field)}];")
|
||||
assigned.add(k)
|
||||
|
||||
signal_specs = []
|
||||
for c in components:
|
||||
if c.model_type == "amesim_step0":
|
||||
put(c, "y", f"t < {_number(c.time)} ? {_number(c.initial)} : {_number(c.final)}")
|
||||
signal_specs.append(("step", c))
|
||||
elif c.model_type == "amesim_ud00":
|
||||
index = len(signal_specs)
|
||||
declarations.append(f"static const double signal_{index}[24] = {{" + ",".join(
|
||||
_number(v) for values in (c.starts, c.ends, c.durations) for v in values
|
||||
) + "};")
|
||||
put(c, "y", f"native_signal(t, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index})")
|
||||
signal_specs.append((index, c))
|
||||
else:
|
||||
continue
|
||||
put(c, "out.signal", get(c, "y"))
|
||||
for c in components:
|
||||
for port in c.active_port_definitions:
|
||||
if port.kind == "signal" and port.nominal_role == "input":
|
||||
ep = (c.name, port.name)
|
||||
if ep not in adjacent:
|
||||
# PNVO's explicit unconnected opening is a supported default.
|
||||
if c.model_type == "amesim_pnvo001":
|
||||
put(c, port.name + ".signal", _number(c.opening0))
|
||||
continue
|
||||
raise NativeCapabilityError(f"{ep}: missing signal input.")
|
||||
source = adjacent[ep]
|
||||
source_key = f"{source[0]}.{source[1]}.signal"
|
||||
if source_key not in assigned:
|
||||
raise NativeCapabilityError(f"{ep}: unsupported signal dependency.")
|
||||
put(c, port.name + ".signal", w(source_key))
|
||||
|
||||
pistons = [c for c in components if c.model_type == "amesim_pnrp17"]
|
||||
for c in pistons:
|
||||
put(c, "length", f"{_number(c.x0)} + {get(c, 'port_5.x')} - {get(c, 'port_4.x')}")
|
||||
put(c, "volume", f"{_number(c.effective_area)} * {get(c, 'length')}")
|
||||
put(c, "volume_flow", f"{_number(c.effective_area)} * ({get(c, 'port_5.v')} - {get(c, 'port_4.v')})")
|
||||
if chamber_at(c, "port_1").model_type != "amesim_pnch012":
|
||||
raise NativeCapabilityError(f"{c.name}: moving volume requires PNCH012.")
|
||||
volume_expressions = {}
|
||||
for gi, c in enumerate(chambers):
|
||||
connected = [p for p in pistons if chamber_at(p, "port_1") is c]
|
||||
base = c.cvol if c.model_type == "amesim_pnch023" else c.cvol0 + sum(c.external_volumes.values())
|
||||
volume = _number(base)
|
||||
rate = "0.0"
|
||||
if c.model_type == "amesim_pnch012":
|
||||
volume += "".join(" + " + get(p, "volume") for p in connected)
|
||||
if key(c, '_volume_displacement') in state_index:
|
||||
index = si(c, '_volume_displacement')
|
||||
init.append(f"y[{index}] = 0.0;")
|
||||
lines.append(f"dy[{index}] = {_number(sum(c.external_volume_rates.values()))};")
|
||||
volume += f" + y[{index}]"
|
||||
put(c, "vol", f"fmax({_number(c.cvol0 / 100)}, {volume})")
|
||||
rate = _number(sum(c.external_volume_rates.values())) + "".join(" + " + get(p, "volume_flow") for p in connected)
|
||||
limit = _number(c.cvol0 / 100)
|
||||
put(c, "dvol", f"({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})")
|
||||
volume, rate = get(c, "vol"), get(c, "dvol")
|
||||
volume_expressions[c.name] = (volume, rate)
|
||||
# p0/T0 refer to the complete connected volume at the initial positions.
|
||||
initial_volume = base
|
||||
if c.model_type == "amesim_pnch012":
|
||||
initial_volume += sum(p.effective_area * (p.x0 + mass_at(p, "port_5").x0 - mass_at(p, "port_4").x0) for p in connected)
|
||||
initial_volume = max(initial_volume, c.cvol0 / 100)
|
||||
init.append(f"if (!native_gas_init({_number(c.p0)}, {_number(c.T0)}, {_number(initial_volume)}, &y[{si(c, 'm')}])) return 0;")
|
||||
lines.append(f"NativeGas gas_{gi};")
|
||||
lines.append(f"if (!native_gas_context(properties, y[{si(c, 'm')}], y[{si(c, 'U')}], {volume}, &gas_{gi})) return 0;")
|
||||
for field in ("m", "U"):
|
||||
put(c, field, f"y[{si(c, field)}]")
|
||||
for field in ("p", "T", "rho", "u", "h"):
|
||||
put(c, field, f"gas_{gi}.{field}")
|
||||
for (cid, pname), port in ports.items():
|
||||
if port.domain == "pneumatic":
|
||||
c = chamber_by_group[groups.find((cid, pname))]
|
||||
for field, expr in (("p", get(c, "p")), ("h_outflow", get(c, "h")), ("m_flow", "0.0")):
|
||||
k = f"{cid}.{pname}.{field}"
|
||||
lines.append(f"{w(k)} = {expr};")
|
||||
assigned.add(k)
|
||||
for c in components:
|
||||
if c.model_type != "amesim_pnvo001":
|
||||
continue
|
||||
a, b = chamber_at(c, "port_2"), chamber_at(c, "port_3")
|
||||
put(c, "xv", f"fmax(0.0, fmin(1.0, {get(c, 'res.signal')}))")
|
||||
lines.append(f"if (!native_orifice_context(properties, {get(a, 'p')}, {get(b, 'p')}, {get(a, 'h')}, {get(b, 'h')}, {_number(c.effective_cq * c.maximum_area)}, {get(c, 'xv')}, &{get(c, 'port_2.m_flow')}, &{get(c, 'cm')}, &{get(c, 'gasvel')})) return 0;")
|
||||
assigned.update((key(c, "cm"), key(c, "gasvel")))
|
||||
put(c, "port_3.m_flow", f"-{get(c, 'port_2.m_flow')}")
|
||||
put(c, "port_2.h_outflow", get(b, "h"))
|
||||
put(c, "port_3.h_outflow", get(a, "h"))
|
||||
for pname in ("port_2", "port_3"):
|
||||
other = adjacent[c.name, pname]
|
||||
# Resistance-to-resistance streams need the extended pressure/enthalpy closure.
|
||||
if network.components[other[0]] not in chambers:
|
||||
raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.")
|
||||
lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};")
|
||||
|
||||
force_known = set()
|
||||
def force(c, port, expr):
|
||||
put(c, port + ".f", expr)
|
||||
force_known.add((c.name, port))
|
||||
equations = []
|
||||
for c in components:
|
||||
if c.model_type == "amesim_forc":
|
||||
put(c, "force", f"{_number(c.direction)} * {get(c, 'res.signal')}")
|
||||
force(c, "port_2", f"-{get(c, 'force')}")
|
||||
elif c.model_type == "amesim_f000":
|
||||
force(c, "port_1", "0.0")
|
||||
elif c.model_type == "amesim_lstp00a":
|
||||
put(c, "gap", f"{_number(c.gap0)} + ({get(c, 'port_2.x')} - {get(c, 'port_1.x')})")
|
||||
put(c, "penetration", f"fmax(-{get(c, 'gap')}, 0.0)")
|
||||
put(c, "force", f"native_contact({get(c, 'penetration')}, {get(c, 'port_1.v')} - {get(c, 'port_2.v')}, {_number(c.kcont)}, {_number(c.rcont)}, {_number(c.Pdis)}, {int(c.discContactOption)})")
|
||||
force(c, "port_1", get(c, "force"))
|
||||
force(c, "port_2", f"-{get(c, 'force')}")
|
||||
elif c.model_type == "amesim_pnrp17":
|
||||
put(c, "pressure_force", f"({get(c, 'port_1.p')} - 101300.0) * {_number(c.effective_area)}")
|
||||
equations.extend([
|
||||
((c.name, "port_2"), (c.name, "port_5"), f"-{get(c, 'pressure_force')}"),
|
||||
((c.name, "port_3"), (c.name, "port_4"), get(c, "pressure_force")),
|
||||
])
|
||||
for edge in network.connections:
|
||||
if edge.domain == "mechanical":
|
||||
equations.append((*[p.key for p in edge.endpoints], "0.0"))
|
||||
pending = equations
|
||||
while pending:
|
||||
remaining = []
|
||||
for a, b, total in pending:
|
||||
if a in force_known and b in force_known:
|
||||
raise NativeCapabilityError("Overconstrained native force balance.")
|
||||
if a not in force_known and b not in force_known:
|
||||
remaining.append((a, b, total))
|
||||
continue
|
||||
target, source = (b, a) if a in force_known else (a, b)
|
||||
c = network.components[target[0]]
|
||||
force(c, target[1], f"({total}) - {w(f'{source[0]}.{source[1]}.f')}")
|
||||
if len(remaining) == len(pending):
|
||||
raise NativeCapabilityError("Native v1 cannot resolve this mechanical force loop.")
|
||||
pending = remaining
|
||||
for c in masses:
|
||||
expression = f"({get(c, 'port_1.f')} + {get(c, 'port_2.f')}) / {_number(c.mass)}"
|
||||
put(c, "a", expression)
|
||||
lines.append(f"dy[{si(c, 'v')}] = {get(c, 'a')}; dy[{si(c, 'x')}] = {get(c, 'v')};")
|
||||
if int(c.stoptype) == 1:
|
||||
lines.append(f"native_stop_motion({get(c, 'x')}, {get(c, 'v')}, {_number(c.xmin)}, {_number(c.xmax)}, &dy[{si(c, 'v')}], &dy[{si(c, 'x')}]);")
|
||||
put(c, "a", f"dy[{si(c, 'v')}]")
|
||||
for field in ("Fvisc", "Ffric", "Fmin", "Fmax"):
|
||||
put(c, field, "0.0")
|
||||
for c in chambers:
|
||||
mass_terms, energy_terms = [], []
|
||||
for port in c.active_port_definitions:
|
||||
q = get(c, port.name + ".m_flow")
|
||||
other = adjacent[c.name, port.name]
|
||||
inlet_h = w(f"{other[0]}.{other[1]}.h_outflow")
|
||||
mass_terms.append(q)
|
||||
energy_terms.append(f"{q} * ({q} > 0.0 ? {inlet_h} : {get(c, 'h')})")
|
||||
volume, rate = volume_expressions[c.name]
|
||||
energy_terms.extend([f"{_number(c.kth*c.sth)} * ({_number(c.extemp)} - {get(c, 'T')})", f"-{get(c, 'p')} * ({rate})"])
|
||||
lines.extend([f"dy[{si(c, 'm')}] = " + " + ".join(mass_terms) + ";", f"dy[{si(c, 'U')}] = " + " + ".join(energy_terms) + ";"])
|
||||
missing = set(slots) - assigned
|
||||
if missing:
|
||||
raise NativeCapabilityError(f"Native output coverage is incomplete: {sorted(missing)}")
|
||||
|
||||
stops = [(si(c, "v"), c.xmin, c.xmax) for c in masses if int(c.stoptype) == 1]
|
||||
from .contacts import contact_table
|
||||
contact_source, contact_header = contact_table([
|
||||
(si(mass_at(c, 'port_1'), 'v'), si(mass_at(c, 'port_2'), 'v'),
|
||||
c.gap0, c.kcont, c.rcont, c.Pdis, int(c.discContactOption), 1)
|
||||
for c in components if c.model_type == 'amesim_lstp00a'])
|
||||
stop_c = ",".join(f"{{{v},{_number(lo)},{_number(hi)},0,0,0,0}}" for v, lo, hi in stops) or "{0,0,0,0,0,0,0}"
|
||||
next_event = []
|
||||
for index, c in signal_specs:
|
||||
if index == "step":
|
||||
next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});")
|
||||
else:
|
||||
next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));")
|
||||
jacobian = JacobianStructure.dense(len(state_keys), 'Compact storage-anchored lowering retains default differences')
|
||||
source = '\n'.join([
|
||||
'#include "model.h"', '#include <math.h>', *declarations,
|
||||
f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};",
|
||||
contact_source,
|
||||
f"const double model_atol[NSTATES] = {{{','.join(map(state_absolute_tolerance, state_keys))}}};",
|
||||
"const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};",
|
||||
"int model_init(double *y) {", *init, "return 1; }",
|
||||
"static int model_eval_internal(double t, const double *y, double *dy, double *w, NativePropertyTemperatures *temperatures) {", "(void)t;",
|
||||
f"NativePropertyState property_states[{min(256,max(16,4*len(components)))}];",
|
||||
"NativePropertyCache property_cache, *properties=&property_cache;",
|
||||
"native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));",
|
||||
"properties->temperatures=temperatures;", *lines,
|
||||
"for (int i=0;i<NSTATES;i++) if (!isfinite(dy[i])) return 0;",
|
||||
"for (int i=0;i<NOUTPUTS;i++) if (!isfinite(w[i])) return 0;",
|
||||
"return 1; }",
|
||||
"int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,NULL);}",
|
||||
"int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,temperatures);}",
|
||||
"double model_next_break(double t, double end) { (void)t; double result=end;", *next_event,
|
||||
"return result; }", "",
|
||||
])
|
||||
header = f'''#ifndef GENERATED_NATIVE_MODEL_H
|
||||
#define GENERATED_NATIVE_MODEL_H
|
||||
#include "kernels.h"
|
||||
#define MODEL_PROPERTY_TEMPERATURES 1
|
||||
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
|
||||
#define NSTATES {len(state_keys)}
|
||||
#define NOUTPUTS {len(variables)}
|
||||
#define NSTOPS {len(stops)}
|
||||
{contact_header}
|
||||
extern const NativeStop model_stops[{max(1,len(stops))}];
|
||||
extern const double model_atol[NSTATES];
|
||||
extern const char *const model_output_keys[NOUTPUTS];
|
||||
{chr(10).join(jacobian.header_lines())}
|
||||
int model_init(double *y);
|
||||
int model_eval(double t, const double *y, double *dy, double *w);
|
||||
double model_next_break(double t, double end);
|
||||
#endif
|
||||
'''
|
||||
return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components})),jacobian_structure=jacobian.manifest())
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Emit state-only LSTP event descriptors for both native lowering paths."""
|
||||
from .compiler import _number
|
||||
|
||||
|
||||
def contact_table(contacts):
|
||||
"""Rows: velocity indices, gap, stiffness, damping, depth, force option,
|
||||
subtract-first flag. Keep the force expression's floating operation order.
|
||||
Position follows velocity in each reviewed mechanical state pair.
|
||||
"""
|
||||
rows = []
|
||||
for v1, v2, gap, stiffness, damping, depth, option, subtract_first in contacts:
|
||||
rows.append('{' + ','.join((str(v1), str(v2), _number(gap),
|
||||
_number(stiffness), _number(damping), _number(depth),
|
||||
str(option), str(subtract_first))) + '}')
|
||||
count = len(rows)
|
||||
header = f'''#define NCONTACTS {count}
|
||||
typedef struct {{
|
||||
int velocity1, velocity2;
|
||||
double gap0, stiffness, damping, depth;
|
||||
int signed_force, subtract_first;
|
||||
}} NativeContact;
|
||||
extern const NativeContact model_contacts[{max(1, count)}];'''
|
||||
source = f'const NativeContact model_contacts[{max(1, count)}] = {{' + (','.join(rows) or '{0,0,0,0,0,0,0,0}') + '};'
|
||||
return source, header
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Reviewed model contract versions implemented by the C kernels.
|
||||
|
||||
A new registration/version must be explicitly ported and tested; it does not
|
||||
gain native support just by inheriting a supported Python component class.
|
||||
"""
|
||||
SUPPORTED_VERSIONS = {
|
||||
'amesim_ideal_air_medium': '0.2.0',
|
||||
'amesim_helium_medium': '0.1.0',
|
||||
'amesim_pnpl01': '0.1.0',
|
||||
'amesim_step0': '0.1.0',
|
||||
'amesim_ud00': '0.2.0',
|
||||
'amesim_f000': '0.1.0',
|
||||
'amesim_forc': '0.2.0',
|
||||
'amesim_mecmas21': '0.2.0',
|
||||
'amesim_lstp00a': '0.2.0',
|
||||
'amesim_lmechn1': '0.2.0',
|
||||
'amesim_pnrp17': '0.1.0',
|
||||
'amesim_pnch023': '0.1.0',
|
||||
'amesim_pnch012': '0.1.0',
|
||||
'amesim_pnor001': '0.3.0',
|
||||
'amesim_pnvo001_fixed': '0.2.0',
|
||||
'amesim_pnvo001': '0.2.0',
|
||||
'amesim_pnl00r': '0.3.0',
|
||||
'amesim_pnl0001': '0.4.0',
|
||||
'amesim_pnl0002': '0.6.0',
|
||||
'amesim_pnl0003': '0.4.0',
|
||||
'amesim_pn3node2': '0.3.0',
|
||||
'amesim_p4node2': '0.3.0',
|
||||
'cylinder': '1.0.0', 'tank': '1.0.0', 'pipe': '1.0.0',
|
||||
'orifice': '1.0.0', 'tee': '1.0.0',
|
||||
}
|
||||
SUPPORTED_TYPES = frozenset(SUPPORTED_VERSIONS)
|
||||
@@ -0,0 +1,796 @@
|
||||
"""Static C lowering for the complete built-in component catalog.
|
||||
|
||||
Python constructs the graph and eliminates constant linear constraints once.
|
||||
All thermodynamics, flow/stream closure and derivatives execute in the EXE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from importlib import import_module
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
|
||||
from .contracts import SUPPORTED_VERSIONS
|
||||
from .schedule import Computation, EvaluationSchedule, references
|
||||
from .tolerances import state_absolute_tolerance
|
||||
from .jacobian import StateDependencies, expression_inputs
|
||||
from app.simulation.components.amesim.semantics import contact_stiffness, validate_numerical_semantics
|
||||
|
||||
|
||||
GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001',
|
||||
'amesim_pnl0002', 'amesim_pnl0003', 'cylinder', 'tank'}
|
||||
NODES = {'amesim_pn3node2', 'amesim_p4node2', 'tee'}
|
||||
RESISTORS = {'amesim_pnor001', 'amesim_pnvo001_fixed', 'amesim_pnvo001',
|
||||
'amesim_pnl00r', 'pipe', 'orifice'}
|
||||
|
||||
|
||||
def is_polytropic(c):
|
||||
return c.model_type in {'amesim_pnl0001', 'amesim_pnl0002', 'amesim_pnl0003'} and int(c.mode) == 1
|
||||
|
||||
|
||||
def catalog_contracts():
|
||||
from app.simulation.components.amesim.library import LIBRARY as a
|
||||
from app.simulation.components.experimental.library import LIBRARY as e
|
||||
result = {}
|
||||
for entry in (*a.models, *e.models):
|
||||
module, name = entry.split(':')
|
||||
cls = getattr(import_module(module), name)
|
||||
result[cls.MODEL_TYPE] = cls
|
||||
return result
|
||||
|
||||
|
||||
def linear_assignments(equations, unknowns, free_flows=()):
|
||||
"""Eliminate constant coefficients; retain RHS expressions as C temporaries.
|
||||
|
||||
Exact pivot elimination avoids a numeric pseudoinverse and its tiny spurious
|
||||
dependencies. Redundant rows are left to the nodal pressure closure.
|
||||
"""
|
||||
rows = [[dict(a), {i: 1.0}] for i, (a, _) in enumerate(equations)]
|
||||
pivots = []
|
||||
for key in unknowns:
|
||||
found = next((i for i in range(len(pivots), len(rows)) if abs(rows[i][0].get(key, 0)) > 1e-14), None)
|
||||
if found is None and key in free_flows:
|
||||
# Ideal coupled pipe compliances have a redundant internal flow.
|
||||
# Their total m/U derivative is distributed by physical volume.
|
||||
index = len(equations)
|
||||
equations.append(({key: 1.0}, '0.0'))
|
||||
rows.append([{key: 1.0}, {index: 1.0}])
|
||||
found = len(rows)-1
|
||||
if found is None:
|
||||
raise NativeCapabilityError(f'Underdetermined native connection constraint: {key}')
|
||||
j = len(pivots)
|
||||
rows[j], rows[found] = rows[found], rows[j]
|
||||
a, b = rows[j]
|
||||
pivot = a[key]
|
||||
rows[j] = [{k: v/pivot for k, v in a.items()}, {k: v/pivot for k, v in b.items()}]
|
||||
for i, (a, b) in enumerate(rows):
|
||||
if i == j:
|
||||
continue
|
||||
scale = a.get(key, 0)
|
||||
if not scale:
|
||||
continue
|
||||
for target, source in zip((a, b), rows[j]):
|
||||
for k, v in source.items():
|
||||
value = target.get(k, 0) - scale*v
|
||||
if abs(value) < 1e-14:
|
||||
target.pop(k, None)
|
||||
else:
|
||||
target[k] = value
|
||||
pivots.append(key)
|
||||
lines = [(f'b{i}', expr) for i, (_, expr) in enumerate(equations)
|
||||
if any(i in rows[j][1] for j in range(len(pivots)))]
|
||||
for j, key in enumerate(pivots):
|
||||
expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0'
|
||||
lines.append((key, expr))
|
||||
return lines
|
||||
|
||||
|
||||
def linear_schedule(equations, unknowns, free_flows=()):
|
||||
return [f'{"double " if key.startswith("b") else ""}{key} = {expr};'
|
||||
for key, expr in linear_assignments(equations, unknowns, free_flows)]
|
||||
|
||||
|
||||
def compile_extended_program(network):
|
||||
network.validate_port_supplies()
|
||||
components = list(network.components.values())
|
||||
contracts = catalog_contracts()
|
||||
for c in components:
|
||||
if type(c) is not contracts.get(c.model_type):
|
||||
raise NativeCapabilityError(f'{c.name}: no native contract for {c.model_type} / {type(c).__name__}')
|
||||
if c.MODEL_VERSION != SUPPORTED_VERSIONS.get(c.model_type):
|
||||
raise NativeCapabilityError(f'{c.name}: native kernel version does not match the component contract')
|
||||
try:
|
||||
validate_numerical_semantics(c)
|
||||
except ValueError as exc:
|
||||
raise NativeCapabilityError(str(exc)) from exc
|
||||
variables = tuple(v for c in components for v in c.result_variable_metadata())
|
||||
if not variables:
|
||||
raise NativeCapabilityError('Simulation requires a runtime component; medium definitions alone have no outputs')
|
||||
slots = {v.key: i for i, v in enumerate(variables)}
|
||||
ports = {(c.name, p.name): p for c in components for p in c.active_port_definitions}
|
||||
groups = _Groups(ports)
|
||||
adjacent = {}
|
||||
for edge in network.connections:
|
||||
a, b = (p.key for p in edge.endpoints)
|
||||
if edge.kind == 'physical':
|
||||
adjacent[a], adjacent[b] = b, a
|
||||
groups.union(a, b)
|
||||
else:
|
||||
source, target = (a, b) if ports[a].nominal_role == 'output' else (b, a)
|
||||
adjacent[target] = source
|
||||
for c in components:
|
||||
for port in c.required_connection_ports:
|
||||
if (c.name, port) not in adjacent:
|
||||
raise NativeCapabilityError(f'{c.name}.{port}: unconnected required port')
|
||||
names = [p.name for p in c.active_port_definitions]
|
||||
if c.model_type in NODES | {'amesim_pnch023', 'amesim_pnch012', 'amesim_mecmas21', 'amesim_lmechn1'} or (c.model_type=='pipe' and c.lambda_darcy==0):
|
||||
for p in names[1:]:
|
||||
groups.union((c.name, names[0]), (c.name, p))
|
||||
if c.model_type == 'amesim_pnrp17':
|
||||
for a, b in [('port_2', 'port_5'), ('port_3', 'port_4')]:
|
||||
groups.union((c.name, a), (c.name, b))
|
||||
|
||||
state_keys, initial, gas_initializers = [], [], []
|
||||
states = {}
|
||||
def add_states(c, fields, values):
|
||||
for field, value in zip(fields, values):
|
||||
states[c.name, field] = len(state_keys)
|
||||
state_keys.append(f'{c.name}.{field}')
|
||||
initial.append(float(value))
|
||||
mass_groups = {}
|
||||
for c in components:
|
||||
if c.model_type in GAS_TYPES:
|
||||
thermal = 'T' if is_polytropic(c) else 'U'
|
||||
fields = ('m1', thermal+'1', 'm2', thermal+'2') if c.model_type == 'amesim_pnl0003' else ('m', thermal)
|
||||
add_states(c, fields, [0.0]*len(fields))
|
||||
if c.model_type == 'amesim_pnch012' and sum(c.external_volume_rates.values()) != 0:
|
||||
add_states(c, ('_volume_displacement',), (0.0,))
|
||||
elif c.model_type == 'amesim_mecmas21':
|
||||
root = groups.find((c.name, 'port_1'))
|
||||
mass_groups.setdefault(root, []).append(c)
|
||||
if len(mass_groups[root]) == 1:
|
||||
add_states(c, ('v', 'x'), (c.v0, c.x0))
|
||||
else:
|
||||
ref = mass_groups[root][0]
|
||||
if abs(c.v0-ref.v0)>1e-10*max(abs(c.v0),1) or abs(c.x0-ref.x0)>1e-10*max(abs(c.x0),1):
|
||||
raise NativeCapabilityError('Rigidly connected masses require consistent initial x/v')
|
||||
for field in ('v', 'x'):
|
||||
states[c.name, field] = states[ref.name, field]
|
||||
friction_groups = []
|
||||
for group in mass_groups.values():
|
||||
dry = [c for c in group if c.use_friction and int(c.stoptype) != 3 and c.fstick > 0]
|
||||
if dry:
|
||||
ref = group[0]
|
||||
threshold = min(c.dvel if int(c.frictionType) == 2 else 0 for c in dry)
|
||||
mode = (1 if ref.v0 > 0 else -1) if abs(ref.v0) > threshold else 0
|
||||
if mode == 0:
|
||||
initial[states[ref.name, 'v']] = 0.0
|
||||
add_states(ref, ('_friction_mode',), (mode,))
|
||||
friction_groups.append((group, dry, threshold))
|
||||
if len(state_keys)>1024 or len(variables)>16384:
|
||||
raise NativeCapabilityError('Native model exceeds the 1024-state / 16384-output resource limit')
|
||||
# Algebraic models use an internal constant state; the public state map stays empty.
|
||||
nstates = max(len(state_keys), 1)
|
||||
if not initial:
|
||||
initial = [0.0]
|
||||
dependencies = StateDependencies(len(state_keys))
|
||||
lines, declarations, breaks = [], [], []
|
||||
assigned = set()
|
||||
def w(c, field):
|
||||
name = c if isinstance(c, str) else c.name
|
||||
return f'w[{slots[name+"."+field]}]'
|
||||
def put(c, field, expr, dest=None):
|
||||
(lines if dest is None else dest).append(f'{w(c,field)} = {expr};')
|
||||
dependencies.expression(w(c,field), expr)
|
||||
assigned.add((c if isinstance(c,str) else c.name)+'.'+field)
|
||||
def y(c, field):
|
||||
return f'y[{states[c.name,field]}]'
|
||||
def ep(c, port):
|
||||
return c.name, port
|
||||
def pnames(c):
|
||||
return [p.name for p in c.active_port_definitions if p.domain == 'pneumatic']
|
||||
def mnames(c):
|
||||
return [p.name for p in c.active_port_definitions if p.domain == 'mechanical']
|
||||
for c in components:
|
||||
if c.model_type == 'amesim_mecmas21':
|
||||
for f in ('v', 'x'):
|
||||
put(c, f, y(c, f))
|
||||
for name in mnames(c):
|
||||
root = groups.find(ep(c, name))
|
||||
if root not in mass_groups:
|
||||
raise NativeCapabilityError(f'{c.name}.{name}: mechanical group has no inertia anchor')
|
||||
mass = mass_groups[root][0]
|
||||
for f in ('v', 'x'):
|
||||
put(c, name+'.'+f, y(mass, f))
|
||||
if c.model_type == 'amesim_step0':
|
||||
put(c, 'y', f't < {num(c.time)} ? {num(c.initial)} : {num(c.final)}')
|
||||
breaks.append(f'if(t < {num(c.time)}) result=fmin(result,{num(c.time)});')
|
||||
elif c.model_type == 'amesim_ud00':
|
||||
ident = 'signal_'+str(len(declarations))
|
||||
declarations.append(f'static const double {ident}[24] = {{'+','.join(num(v) for vs in (c.starts,c.ends,c.durations) for v in vs)+'};')
|
||||
args=f'{num(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, {ident}'
|
||||
put(c, 'y', f'native_signal(t, {args})')
|
||||
breaks.append(f'result=fmin(result,native_signal_break(t,end,{args}));')
|
||||
else:
|
||||
continue
|
||||
put(c, 'out.signal', w(c, 'y'))
|
||||
for c in components:
|
||||
for port in c.active_port_definitions:
|
||||
if port.kind == 'signal' and port.nominal_role == 'input':
|
||||
target = adjacent.get(ep(c, port.name))
|
||||
if target:
|
||||
expr = w(target[0], target[1]+'.signal')
|
||||
elif c.model_type == 'amesim_pnvo001':
|
||||
expr = num(c.opening0)
|
||||
else:
|
||||
raise NativeCapabilityError(f'{c.name}.{port.name}: signal input missing')
|
||||
put(c, port.name+'.signal', expr)
|
||||
|
||||
media = {}
|
||||
def medium(c):
|
||||
m = c.medium
|
||||
key = (getattr(m, 'SUBSTANCE_ID', None), getattr(m, 'PROPERTY_METHOD_ID', None),
|
||||
m.R_gas, m.cp_ref, m.T_ref, m.cp_slope, m.viscosity_ref, m.viscosity_T_ref, m.sutherland_constant)
|
||||
if key not in media:
|
||||
if key[1] not in (None, 'ideal_gas', 'peng_robinson') or (key[1]=='peng_robinson' and key[0]!='helium'):
|
||||
raise NativeCapabilityError(f'{c.name}: unsupported medium contract {key[:2]}')
|
||||
ident = f'medium_{len(media)}'
|
||||
declarations.append(f'static const NativeMedium {ident} = {{'+str(int(key[1]=='peng_robinson'))+','+','.join(num(v) for v in key[2:])+'};')
|
||||
media[key]=ident
|
||||
return '&'+media[key]
|
||||
|
||||
pistons = [c for c in components if c.model_type == 'amesim_pnrp17']
|
||||
for c in pistons:
|
||||
put(c, 'length', f'{num(c.x0)}+{w(c,"port_5.x")}-{w(c,"port_4.x")}')
|
||||
put(c, 'volume', f'{num(c.effective_area)}*{w(c,"length")}')
|
||||
put(c, 'volume_flow', f'{num(c.effective_area)}*({w(c,"port_5.v")}-{w(c,"port_4.v")})')
|
||||
|
||||
pneu = [endpoint for endpoint, port in ports.items() if port.domain == 'pneumatic']
|
||||
pi = {endpoint: i for i, endpoint in enumerate(pneu)}
|
||||
pgroups = list(dict.fromkeys(groups.find(endpoint) for endpoint in pneu))
|
||||
pgi = {root: i for i, root in enumerate(pgroups)}
|
||||
def p(c, name): return f'p[{pgi[groups.find(ep(c,name))]}]'
|
||||
def q(c, name): return f'q[{pi[ep(c,name)]}]'
|
||||
def h(c, name): return f'h[{pi[ep(c,name)]}]'
|
||||
def hin(c, name, temperature=False):
|
||||
other = adjacent[ep(c,name)]
|
||||
obj = network.components[other[0]]
|
||||
if temperature and obj.model_type in NODES-{'tee'}:
|
||||
other = adjacent[(obj.name, 'port_2')]
|
||||
return f'h[{pi[other]}]'
|
||||
|
||||
# Reference nodes aggregate branch quantities towards port 2. Follow this
|
||||
# directed contract, not the undirected pressure group (which can span
|
||||
# unrelated volumes). Port-supply validation has already rejected cycles.
|
||||
reference_nodes = {c.name: c for c in components if c.model_type in NODES-{'tee'}}
|
||||
node_graph = {c.name: [other[0] for name in pnames(c) if name != 'port_2'
|
||||
for other in [adjacent[ep(c, name)]]
|
||||
if other[0] in reference_nodes and other[1] == 'port_2']
|
||||
for c in reference_nodes.values()}
|
||||
node_order = [reference_nodes[name] for name in TopologicalSorter(node_graph).static_order()]
|
||||
volume_sources = {ep(c, 'port_1'): [c] for c in pistons}
|
||||
node_energy = {ep(c, 'port_2'): f'node_energy[{i}]' for i, c in enumerate(node_order)}
|
||||
for c in node_order:
|
||||
volume_sources[ep(c, 'port_2')] = [source for name in pnames(c) if name != 'port_2'
|
||||
for source in volume_sources.get(adjacent[ep(c, name)], ())]
|
||||
|
||||
def energy_into(c, name, outflow_h):
|
||||
other = adjacent[ep(c, name)]
|
||||
# Net mass flow can be zero while the sum of branch enthalpy flows
|
||||
# remains nonzero. Carry that signed energy directly, without H/q.
|
||||
if other in node_energy:
|
||||
return node_energy[other]
|
||||
return f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{outflow_h})'
|
||||
|
||||
gases, anchor, port_gas, volume_rate = {}, {}, {}, {}
|
||||
anchor_partitions = {}
|
||||
gas_count = 0
|
||||
for c in components:
|
||||
if c.model_type not in GAS_TYPES:
|
||||
continue
|
||||
kind = c.model_type
|
||||
if kind == 'amesim_pnch023':
|
||||
volume, rate = num(c.cvol), '0.0'
|
||||
elif kind == 'amesim_pnch012':
|
||||
attached = [source for name in pnames(c)
|
||||
for source in volume_sources.get(adjacent[ep(c, name)], ())]
|
||||
volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached)
|
||||
if (c.name, '_volume_displacement') in states:
|
||||
volume += '+'+y(c, '_volume_displacement')
|
||||
target = f'dy[{states[c.name,"_volume_displacement"]}]'
|
||||
expr = num(sum(c.external_volume_rates.values()))
|
||||
lines.append(f'{target}={expr};')
|
||||
dependencies.expression(target, expr)
|
||||
put(c, 'vol', f'fmax({num(c.cvol0/100)}, {volume})')
|
||||
rate = num(sum(c.external_volume_rates.values()))+''.join('+'+w(d,'volume_flow') for d in attached)
|
||||
limit = num(c.cvol0/100)
|
||||
put(c, 'dvol', f'({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})')
|
||||
volume, rate = w(c,'vol'), w(c,'dvol')
|
||||
elif kind in ('cylinder', 'tank'):
|
||||
volume, rate = num(c.V), '0.0'
|
||||
else:
|
||||
volume, rate = num(c.volume), '0.0'
|
||||
volume_rate[c.name] = rate
|
||||
halves = (1,2) if kind == 'amesim_pnl0003' else (0,)
|
||||
for half in halves:
|
||||
suffix = str(half) if half else ''
|
||||
gas = f'g[{gas_count}]';gas_count += 1
|
||||
gases[c.name,half] = gas
|
||||
V = num(c.compliance_volume) if half else volume
|
||||
if half:
|
||||
p0,T0=c.parameter_values[f'p{half}_0'],c.parameter_values[f'T{half}_0']
|
||||
initial_volume=c.compliance_volume
|
||||
else:
|
||||
p0,T0=c.parameter_values['p0'],c.parameter_values['T0']
|
||||
initial_volume=(max(c.cvol0+sum(c.external_volumes.values()),c.cvol0/100)
|
||||
if kind=='amesim_pnch012' else
|
||||
c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume)
|
||||
if kind == 'amesim_pnch012':
|
||||
def initial_x(d, port):
|
||||
return mass_groups[groups.find(ep(d, port))][0].x0
|
||||
initial_volume = max(c.cvol0 + sum(c.external_volumes.values()) + sum(
|
||||
d.effective_area * (d.x0 + initial_x(d, 'port_5') - initial_x(d, 'port_4'))
|
||||
for d in attached), c.cvol0 / 100)
|
||||
thermal = ('T' if is_polytropic(c) else 'U') + suffix
|
||||
if is_polytropic(c):
|
||||
gas_initializers += [f'{y(c,"m"+suffix)}={num(p0*initial_volume/(c.medium.R_gas*T0))};', f'{y(c,thermal)}={num(T0)};']
|
||||
gas_function = 'native_polytropic_gas_context'
|
||||
else:
|
||||
gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;')
|
||||
gas_function = 'native_medium_gas_context'
|
||||
gas_arguments = f'{medium(c)}, {y(c,"m"+suffix)}, {y(c,thermal)}, {V}, &{gas}'
|
||||
lines.append(f'if(!(jacobian ? native_jacobian_gas(&jacobian->gases[{gas_count-1}],{gas_function},{gas_arguments},&jacobian->stats) : '
|
||||
f'{gas_function}(gas_properties,{gas_arguments}))) return 0;')
|
||||
gas_inputs = expression_inputs(f'{y(c,"m"+suffix)}+{y(c,thermal)}+({V})')
|
||||
for field in ('p','T','rho','u','h'):
|
||||
dependencies.assign(gas+'.'+field, gas_inputs)
|
||||
put(c, 'm'+suffix, y(c, 'm'+suffix))
|
||||
put(c, 'U'+suffix, f'{y(c,"m"+suffix)}*{gas}.u' if is_polytropic(c) else y(c,'U'+suffix))
|
||||
for field in ('p','T','rho','u','h'):
|
||||
put(c, field+suffix, gas+'.'+field)
|
||||
anchored = pnames(c)
|
||||
if kind == 'amesim_pnl0001': anchored=['port_2']
|
||||
if kind == 'amesim_pnl0002': anchored=[]
|
||||
if half: anchored=['port_'+suffix]
|
||||
for name in anchored:
|
||||
root=groups.find(ep(c,name))
|
||||
anchor[root]=gas
|
||||
partition=(c.name,half)
|
||||
anchor_partitions.setdefault(root, {})[partition]=(c, float(c.compliance_volume if half else c.volume) if kind in ('amesim_pnl0001','amesim_pnl0003') else None)
|
||||
for name in (['port_'+suffix] if half else pnames(c)):
|
||||
port_gas[ep(c,name)]=gas
|
||||
coupled=[]
|
||||
project=[]
|
||||
for root,partitions in anchor_partitions.items():
|
||||
if len(partitions)<2: continue
|
||||
if any(volume is None for _,volume in partitions.values()) or len({id(c.medium) for c,_ in partitions.values()})!=1:
|
||||
raise NativeCapabilityError('Direct gas-storage coupling requires compatible fixed pipe compliances; insert a resistance between independent chambers')
|
||||
laws = {(is_polytropic(c), c.k if is_polytropic(c) else None) for c,_ in partitions.values()}
|
||||
if len(laws) != 1:
|
||||
raise NativeCapabilityError('Directly coupled pipe compliances require the same thermodynamic mode and polytropic exponent')
|
||||
poly = next(iter(laws))[0]
|
||||
offsets=[];volumes=[]
|
||||
for (name,half),(c,volume) in partitions.items():
|
||||
offsets.append(states[name,'m'+(str(half) if half else '')]);volumes.append(volume)
|
||||
for prefix in ('p','T'):
|
||||
values=[c.parameter_values[f'{prefix}{half}_0'] if half else getattr(c,prefix+'0')
|
||||
for (_,half),(c,_) in partitions.items()]
|
||||
if max(values)-min(values)>1e-9*max(1,*map(abs,values)):
|
||||
raise NativeCapabilityError('Ideally coupled pipe compliances require consistent initial pressure and temperature')
|
||||
total=sum(volumes)
|
||||
thermal_sum = '+'.join(f'y[{i+1}]*{num(v/total)}' if poly else f'y[{i+1}]' for i,v in zip(offsets,volumes))
|
||||
project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',thermal='+thermal_sum+';')
|
||||
for i,volume in zip(offsets,volumes):
|
||||
project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=thermal*{num(1 if poly else volume/total)};']
|
||||
project.append('}')
|
||||
coupled.append((root,offsets,volumes,poly))
|
||||
dependencies.project_states(offsets)
|
||||
dependencies.project_states([i+1 for i in offsets])
|
||||
for root, gas in anchor.items():
|
||||
lines.append(f'p[{pgi[root]}]={gas}.p;')
|
||||
dependencies.assign(f'p[{pgi[root]}]', (gas+'.p',))
|
||||
h_initial = {f'h[{pi[endpoint]}]': gas+'.h' for endpoint,gas in port_gas.items()}
|
||||
|
||||
operations, flow_known, flow_eq = [], set(), []
|
||||
pipe_cache_count = 0
|
||||
def pipe_flow(expression):
|
||||
nonlocal pipe_cache_count
|
||||
result = f'native_pipe_flow_cached_context(properties,&pipe_cache[{pipe_cache_count}],{expression})'
|
||||
pipe_cache_count += 1
|
||||
return result
|
||||
def flow(c, name, expr):
|
||||
target=q(c,name)
|
||||
operations.append(Computation.assignment(f'flow:{c.name}.{name}',target,expr))
|
||||
flow_known.add(target)
|
||||
def flow_equation(terms):
|
||||
flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0'))
|
||||
for c in components:
|
||||
kind=c.model_type
|
||||
names=pnames(c)
|
||||
if kind in RESISTORS or kind in NODES:
|
||||
flow_equation([(c,name,1) for name in names])
|
||||
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||
flow(c,'port_1','0.0')
|
||||
if kind in RESISTORS:
|
||||
a,b=names
|
||||
pa,pb=p(c,a),p(c,b)
|
||||
if kind=='pipe' and c.lambda_darcy==0:
|
||||
# A zero-loss pipe is an ideal connection, whose flow is
|
||||
# determined by the neighbouring constitutive equations.
|
||||
continue
|
||||
if kind=='orifice':
|
||||
flow(c,a,f'{num(c.K_eff)}*copysign(sqrt(fabs({pa}-{pb})),{pa}-{pb})')
|
||||
elif kind=='pipe':
|
||||
resistance=c.lambda_darcy*c.L/c.D
|
||||
flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density_context(properties,{medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})')
|
||||
elif kind=='amesim_pnl00r':
|
||||
T=f'native_temperature_ph_context(properties,{medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})'
|
||||
flow(c,a,pipe_flow(f'{medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0'))
|
||||
else:
|
||||
opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0'
|
||||
if kind!='amesim_pnor001':
|
||||
value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening)
|
||||
put(c,'xv',value)
|
||||
area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area)
|
||||
inputs=f'{medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening}'
|
||||
outputs=(q(c,a),w(c,'cm'),w(c,'gasvel'))
|
||||
code=f'if(!native_medium_orifice_context(properties,{inputs},&{outputs[0]},&{outputs[1]},&{outputs[2]})) return 0;'
|
||||
operations.append(Computation(f'flow:{c.name}.{a}',outputs,references(inputs),(code,)))
|
||||
flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel'))
|
||||
flow(c,b,f'-{q(c,a)}')
|
||||
elif kind in ('amesim_pnl0001','amesim_pnl0002'):
|
||||
gas=gases[c.name,0]
|
||||
for name in (['port_1'] if kind.endswith('1') else names):
|
||||
# Resistance uses the gas arriving from the upstream side,
|
||||
# including inflow into a PNL0001 storage volume.
|
||||
T=f'({p(c,name)}>{gas}.p?native_temperature_ph_context(properties,{medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)'
|
||||
flow(c,name,pipe_flow(f'{medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1'))
|
||||
for edge in network.connections:
|
||||
if edge.domain=='pneumatic':
|
||||
flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0'))
|
||||
unknownq=[f'q[{i}]' for i in range(len(pneu)) if f'q[{i}]' not in flow_known]
|
||||
reduced=[]
|
||||
for terms,rhs in flow_eq:
|
||||
known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known)
|
||||
reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known))
|
||||
free_flows={f'q[{pi[e]}]' for root,_,_,_ in coupled for e in pneu if groups.find(e)==root}
|
||||
flow_bindings=linear_assignments(reduced,unknownq,free_flows)
|
||||
flow_temporary_count=max((int(key[1:])+1 for key,_ in flow_bindings if key.startswith('b')),default=0)
|
||||
for key,expr in flow_bindings:
|
||||
target=re.sub(r'\bb(\d+)\b',r'fb[\1]',key)
|
||||
value=re.sub(r'\bb(\d+)\b',r'fb[\1]',expr)
|
||||
operations.append(Computation.assignment('connection:'+key,target,value,'linear'))
|
||||
|
||||
unknownp=[root for root in pgroups if root not in anchor]
|
||||
for root in unknownp:
|
||||
terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known]
|
||||
if not terms:
|
||||
raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation')
|
||||
expr=' + '.join(terms)
|
||||
operations.append(Computation('pressure:'+str(root),(f'p[{pgi[root]}]',),references(expr),(),'pressure',expr))
|
||||
|
||||
for c in components:
|
||||
names=pnames(c);kind=c.model_type
|
||||
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||
operations.append(Computation.assignment(f'alias:{c.name}.port_1',h(c,'port_1'),hin(c,'port_1'),'alias'))
|
||||
elif kind in RESISTORS:
|
||||
a,b=names
|
||||
for name,other in ((a,b),(b,a)):
|
||||
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,other),'alias'))
|
||||
elif kind in NODES:
|
||||
if kind!='tee':
|
||||
# Reference copies have no flow dependency. Keep them separate
|
||||
# from the reference port's finite h_outflow diagnostic below;
|
||||
# actual reference energy is carried by node_energy, not q*h.
|
||||
for name in names:
|
||||
if name!='port_2':
|
||||
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,'port_2'),'alias'))
|
||||
stream_lines = ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]]
|
||||
if kind=='tee':
|
||||
stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]]
|
||||
else:
|
||||
stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;',
|
||||
*[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'],
|
||||
f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);',
|
||||
'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);',
|
||||
f'{h(c,"port_2")}=mixed-(e+flow*mixed)*inv;','}']
|
||||
# Nodes expose their reference temperature as an output too.
|
||||
if c.name+'.T' in slots:
|
||||
raise NativeCapabilityError('Unexpected node temperature output contract')
|
||||
stream_lines += ['}']
|
||||
outputs=tuple(h(c,name) for name in (names if kind=='tee' else ['port_2']))
|
||||
inputs=frozenset(q(c,name) for name in names)|frozenset(hin(c,name) for name in names)
|
||||
operations.append(Computation(f'stream:{c.name}',outputs,inputs,tuple(stream_lines),'stream'))
|
||||
|
||||
# Current state/parameter/signal values are available before this phase.
|
||||
# Default h guesses are deliberately absent from the known-source map.
|
||||
labels={f'w[{i}]': key for key,i in slots.items()}
|
||||
known={f'w[{slots[key]}]': 'prepared:'+key for key in assigned}
|
||||
gas_origins={gas: 'state:'+','.join(f'{name}.{field}{str(half) if half else ""}' for field in ('m','U'))
|
||||
for (name,half),gas in gases.items()}
|
||||
for gas,origin in gas_origins.items():
|
||||
for field in ('p','T','rho','u','h'):
|
||||
known[gas+'.'+field]=origin
|
||||
for root,gas in anchor.items():
|
||||
known[f'p[{pgi[root]}]']=gas_origins[gas]
|
||||
for endpoint,gas in port_gas.items():
|
||||
known[f'h[{pi[endpoint]}]']=gas_origins[gas]
|
||||
for endpoint,index in pi.items():
|
||||
labels[f'h[{index}]']='.'.join(endpoint)+'.h_outflow'
|
||||
labels[f'q[{index}]']='.'.join(endpoint)+'.m_flow'
|
||||
for root,index in pgi.items():
|
||||
labels[f'p[{index}]']='pressure:'+','.join('.'.join(ep) for ep in pneu if groups.find(ep)==root)
|
||||
schedule=EvaluationSchedule(operations,known,labels)
|
||||
for operation in operations:
|
||||
dependencies.computation(operation)
|
||||
for target,expr in h_initial.items():
|
||||
if target not in schedule.producers:
|
||||
lines.append(f'{target}={expr};')
|
||||
dependencies.expression(target, expr)
|
||||
schedule_helpers, scheduled_lines=schedule.emit()
|
||||
lines += scheduled_lines
|
||||
if node_order:
|
||||
lines.append(f'double node_energy[{len(node_order)}];')
|
||||
for c in node_order:
|
||||
target = node_energy[ep(c, 'port_2')]
|
||||
expr = ' + '.join(energy_into(c, name, h(c, name)) for name in pnames(c) if name != 'port_2')
|
||||
lines.append(f'{target}={expr};')
|
||||
dependencies.expression(target, expr)
|
||||
for c in components:
|
||||
for name in pnames(c):
|
||||
for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]:
|
||||
put(c,name+'.'+field,expr)
|
||||
|
||||
# Mechanical force balance includes shared accelerations for rigid groups.
|
||||
feq=[]
|
||||
for edge in network.connections:
|
||||
if edge.domain=='mechanical':
|
||||
feq.append(({w(e.component,e.port+'.f'):1 for e in edge.endpoints},'0.0'))
|
||||
for c in components:
|
||||
kind=c.model_type
|
||||
if kind=='amesim_forc':
|
||||
put(c,'force',f'{num(c.direction)}*{w(c,"res.signal")}')
|
||||
feq.append(({w(c,'port_2.f'):1},'-'+w(c,'force')))
|
||||
elif kind=='amesim_f000':
|
||||
feq.append(({w(c,'port_1.f'):1},'0.0'))
|
||||
elif kind=='amesim_lstp00a':
|
||||
put(c,'gap',f'{num(c.gap0)}+{w(c,"port_2.x")}-{w(c,"port_1.x")}')
|
||||
put(c,'penetration',f'fmax(-{w(c,"gap")},0)')
|
||||
put(c,'force',f'native_contact({w(c,"penetration")},{w(c,"port_1.v")}-{w(c,"port_2.v")},{num(contact_stiffness(c))},{num(c.rcont)},{num(c.Pdis)},{int(c.discContactOption)})')
|
||||
feq += [({w(c,'port_1.f'):1},w(c,'force')),({w(c,'port_2.f'):1},'-'+w(c,'force'))]
|
||||
elif kind=='amesim_pnrp17':
|
||||
put(c,'pressure_force',f'({w(c,"port_1.p")}-101300)*{num(c.effective_area)}')
|
||||
feq += [({w(c,'port_2.f'):1,w(c,'port_5.f'):1},'-'+w(c,'pressure_force')),({w(c,'port_3.f'):1,w(c,'port_4.f'):1},w(c,'pressure_force'))]
|
||||
elif kind=='amesim_lmechn1':
|
||||
feq.append(({w(c,name+'.f'):1 for name in mnames(c)},'0.0'))
|
||||
elif kind=='amesim_mecmas21':
|
||||
v,x=w(c,'v'),w(c,'x')
|
||||
put(c,'Fvisc',f'-{num(c.rvisc)}*{v}' if c.use_friction else '0.0')
|
||||
put(c,'Ffric','0.0')
|
||||
for field,penetration,velocity,suffix in [('Fmin',num(c.xmin)+'-'+x,'-'+v,'min'),('Fmax',x+'-'+num(c.xmax),v,'max')]:
|
||||
expr=f'native_limit_force({penetration},{velocity},{num(getattr(c,"Kb"+suffix))},{num(getattr(c,"Db"+suffix))},{num(getattr(c,"Pd"+suffix))},{int(c.discContactOption)})' if int(c.stoptype)==2 else '0.0'
|
||||
put(c,field,expr)
|
||||
ref=mass_groups[groups.find(ep(c,'port_1'))][0]
|
||||
extra=f'{w(c,"Fvisc")}+{w(c,"Ffric")}+{w(c,"Fmin")}-{w(c,"Fmax")}'
|
||||
if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})'
|
||||
extra += f'+({num(c.mass * 9.80665 * math.sin(math.radians(c.theta)))})'
|
||||
feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})'))
|
||||
unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()]
|
||||
# Keep the same elimination/order while registering its structured bindings.
|
||||
mechanical_assignments = linear_assignments(feq, unknownf)
|
||||
for target, expression in mechanical_assignments:
|
||||
lines.append(f'{"double " if target.startswith("b") else ""}{target} = {expression};')
|
||||
dependencies.expression(target, expression)
|
||||
for index, (group, dry, threshold) in enumerate(friction_groups):
|
||||
ref = group[0]
|
||||
drive = f'friction_drive_{index}'
|
||||
expression = f'{num(sum(c.mass for c in group))}*{w(ref,"a")}'
|
||||
lines.append(f'double {drive}={expression};')
|
||||
dependencies.expression(drive, expression)
|
||||
# Amesim ideal stops take priority over dry friction. A blocked mass
|
||||
# carries its load through the stop, not through an extra friction force.
|
||||
blocked = f'friction_blocked_{index}'
|
||||
ideal = [c for c in group if int(c.stoptype) == 1]
|
||||
conditions = []
|
||||
for c in ideal:
|
||||
x, v = y(ref, 'x'), y(ref, 'v')
|
||||
conditions += [f'({x}<={num(c.xmin+1e-12*max(abs(c.xmin),1))} && {v}<=1e-12 && {drive}<=0)',
|
||||
f'({x}>={num(c.xmax-1e-12*max(abs(c.xmax),1))} && {v}>=-1e-12 && {drive}>=0)']
|
||||
blocked_expression = ' || '.join(conditions) or '0'
|
||||
lines.append(f'double {blocked}=({blocked_expression});')
|
||||
dependencies.expression(blocked, blocked_expression)
|
||||
lines.append(f'{drive}={blocked}?0:{drive};')
|
||||
dependencies.expression(drive, f'{blocked}?0:{drive}')
|
||||
lines.append(f'if(friction_drives) friction_drives[{index}]={drive};')
|
||||
capacity = sum(c.fstick for c in dry)
|
||||
for c in dry:
|
||||
put(c, 'Ffric', f'{blocked}?0:native_dry_friction({y(ref,"v")},{drive}*{num(c.fstick/capacity)},'
|
||||
f'{num(c.fcoul)},{num(c.fstick)},{num(c.astrib)},'
|
||||
f'{int(int(c.frictionType)==2 and int(c.strib)==2)},{y(ref,"_friction_mode")})')
|
||||
lines.append(f'dy[{states[ref.name,"_friction_mode"]}]=0;')
|
||||
dependencies.expression(f'dy[{states[ref.name,"_friction_mode"]}]', '0')
|
||||
if friction_groups:
|
||||
# The first linear solve supplies the drive without dry friction. The
|
||||
# second redistributes actual friction forces through the same network.
|
||||
# No pressure/flow iteration or mutable RHS mode is introduced.
|
||||
for target, expression in mechanical_assignments:
|
||||
lines.append(f'{target}={expression};')
|
||||
dependencies.expression(target, expression)
|
||||
for c in components:
|
||||
for name in mnames(c): assigned.add(c.name+'.'+name+'.f')
|
||||
if c.model_type=='amesim_lmechn1':
|
||||
put(c,'tforce',' + '.join(w(c,name+'.f') for name in mnames(c)[:-1]))
|
||||
stops=[]
|
||||
for group in mass_groups.values():
|
||||
ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x']
|
||||
limits=[c for c in group if int(c.stoptype) in (1,3)]
|
||||
lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};']
|
||||
dependencies.expression(f'dy[{vi}]', w(ref,'a'))
|
||||
dependencies.expression(f'dy[{xi}]', y(ref,'v'))
|
||||
if limits:
|
||||
lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits)
|
||||
if lower>upper or ref.x0<lower-1e-12 or ref.x0>upper+1e-12:
|
||||
raise NativeCapabilityError('Inconsistent discrete endstop bounds or initial position')
|
||||
restitution, thresholds = [], []
|
||||
for parameter, bound in [('xmin',lower),('xmax',upper)]:
|
||||
active=[c for c in limits if abs(getattr(c,parameter)-bound)<=1e-12*max(abs(bound),1)]
|
||||
restitution.append(0 if any(int(c.stoptype)==1 for c in active) else min(c.restcoeff for c in active))
|
||||
thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0))
|
||||
stops.append((vi,lower,upper,*restitution,*thresholds))
|
||||
lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);')
|
||||
dependencies.stop_motion(vi, xi)
|
||||
for c in group: put(c,'a',f'dy[{vi}]')
|
||||
|
||||
for c in components:
|
||||
kind=c.model_type
|
||||
if kind not in GAS_TYPES and kind!='amesim_pnl00r': continue
|
||||
if kind in GAS_TYPES:
|
||||
halves=(1,2) if kind=='amesim_pnl0003' else (0,)
|
||||
center='0.0'
|
||||
if kind=='amesim_pnl0003':
|
||||
a,b=gases[c.name,1],gases[c.name,2]
|
||||
center=w(c,'dmctr')
|
||||
put(c,'dmctr',f'native_pipe_flow_context(properties,{medium(c)},{a}.p,{b}.p,{a}.p>={b}.p?{a}.T:{b}.T,{num(c.diam)},{num(c.le)},{num(c.rr)},3)')
|
||||
for half in halves:
|
||||
gas=gases[c.name,half];suffix=str(half) if half else ''
|
||||
names=['port_'+suffix] if half else pnames(c)
|
||||
mass=' + '.join(q(c,name) for name in names)
|
||||
energy=' + '.join(energy_into(c, name, gas+'.h') for name in names)
|
||||
if half:
|
||||
sign='-' if half==1 else '+'
|
||||
mass+=sign+center
|
||||
energy+=f'{sign}{center}*({center}>0?{gases[c.name,1]}.h:{gases[c.name,2]}.h)'
|
||||
heat='0.0'
|
||||
if kind.startswith('amesim_pnch'):
|
||||
heat=f'{num(c.kth*c.sth)}*({num(c.extemp)}-{gas}.T)-{gas}.p*({volume_rate[c.name]})'
|
||||
elif kind.startswith('amesim_pnl') and int(c.mode)!=1:
|
||||
temp=gas+'.T'
|
||||
heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))'
|
||||
thermal = ('T' if is_polytropic(c) else 'U') + suffix
|
||||
thermal_rhs = f'{num(c.k-1)}*{gas}.T/({y(c,"m"+suffix)})*({mass})' if is_polytropic(c) else f'{energy}+({heat})'
|
||||
lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,thermal]}]={thermal_rhs};']
|
||||
dependencies.expression(f'dy[{states[c.name,"m"+suffix]}]', mass)
|
||||
dependencies.expression(f'dy[{states[c.name,thermal]}]', thermal_rhs)
|
||||
if kind.startswith('amesim_pnl'):
|
||||
diag=[]
|
||||
if kind=='amesim_pnl0002':
|
||||
gas=gases[c.name,0]
|
||||
for name in pnames(c):
|
||||
flow=q(c,name);pp=f'({flow}>=0?fmax({p(c,name)},1):fmax({gas}.p,1))'
|
||||
temp=f'({flow}>=0?fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{hin(c,name,True)}),1):{gas}.T)'
|
||||
diag.append((flow,pp,temp,c.le/2,0))
|
||||
elif kind=='amesim_pnl0003':
|
||||
a,b=gases[c.name,1],gases[c.name,2];flow=w(c,'dmctr')
|
||||
diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)]
|
||||
else:
|
||||
pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)'
|
||||
if kind=='amesim_pnl0001':
|
||||
gas=gases[c.name,0]
|
||||
temp=f'({pa}>{gas}.p?fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{hin(c,"port_1",True)}),1):{gas}.T)'
|
||||
else:
|
||||
temp=f'fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)'
|
||||
diag=[(q(c,'port_1'),pp,temp,c.le,0)]
|
||||
lines.append('{ double d[4],acc[4]={0};')
|
||||
for flow,pp,temp,length,diagnostic in diag:
|
||||
lines.append(f'native_pipe_diagnostics_context(properties,{medium(c)},{flow},{pp},{temp},{num(c.diam)},{num(length)},{num(c.rr)},{diagnostic},d);')
|
||||
if len(diag)>1: lines.append('d[2]=fabs(d[2]);')
|
||||
lines.append('for(int i=0;i<4;i++) acc[i]+=d[i];')
|
||||
for i,field in enumerate(('re','cm','v','ff')):
|
||||
expr=f'acc[{i}]/{len(diag)}'
|
||||
put(c,field,f'fmin({expr},64000000)' if field=='ff' else expr)
|
||||
lines.append('}')
|
||||
for _,offsets,volumes,poly in coupled:
|
||||
thermal_sum = '+'.join(f'dy[{i+1}]*{num(v/sum(volumes))}' if poly else f'dy[{i+1}]' for i,v in zip(offsets,volumes))
|
||||
lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',thermal='+thermal_sum+';')
|
||||
for i,volume in zip(offsets,volumes):
|
||||
lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=thermal*{num(1 if poly else volume/sum(volumes))};')
|
||||
dependencies.assign(f'dy[{i}]', (f'dy[{j}]' for j in offsets))
|
||||
dependencies.assign(f'dy[{i+1}]', (f'dy[{j+1}]' for j in offsets))
|
||||
lines.append('}')
|
||||
missing=set(slots)-assigned
|
||||
if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}')
|
||||
if not state_keys: lines.append('dy[0]=0;')
|
||||
jacobian = dependencies.build()
|
||||
memo_kernels = ('native_temperature_ph_context(', 'native_density_context(',
|
||||
'native_pipe_flow_context(', 'native_pipe_flow_cached_context(')
|
||||
has_memo_work = gas_count > 0 or any(name in '\n'.join([*lines,*schedule_helpers]) for name in memo_kernels)
|
||||
reuse_enabled = jacobian.enabled and 0 < jacobian.color_count < nstates and has_memo_work
|
||||
jacobian_info = jacobian.manifest(canonical_rhs=True)
|
||||
jacobian_info['reuse'] = dict(enabled=reuse_enabled,gasSlots=gas_count,scalarSlots=1024,
|
||||
scope='one Jacobian construction',key='kernel and bit-exact complete physical inputs',
|
||||
policy='immutable canonical baseline for gas, PH inversion, density and pipe roots; changed inputs recomputed',
|
||||
verification='uncached canonical dense differences')
|
||||
np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu))
|
||||
from .contacts import contact_table
|
||||
contact_source, contact_header = contact_table([
|
||||
(states[mass_groups[groups.find(ep(c, 'port_1'))][0].name, 'v'],
|
||||
states[mass_groups[groups.find(ep(c, 'port_2'))][0].name, 'v'],
|
||||
c.gap0, contact_stiffness(c), c.rcont, c.Pdis, int(c.discContactOption), 0)
|
||||
for c in components if c.model_type == 'amesim_lstp00a'])
|
||||
# Advanced-friction reference runs at 5 N and 7 N confirm Amesim 2404's
|
||||
# 0.1% breakaway hysteresis. Simple friction uses the exact static threshold.
|
||||
source='\n'.join(['#include "model.h"','#include <math.h>','#include <string.h>',*declarations,
|
||||
f'const NativeFriction model_frictions[{max(1,len(friction_groups))}] = {{'+(','.join(
|
||||
'{'+str(states[group[0].name,'v'])+','+str(states[group[0].name,'_friction_mode'])+','+num(threshold)+','+
|
||||
num(sum(c.fstick*(1.001 if int(c.frictionType)==2 else 1) for c in dry))+'}'
|
||||
for group,dry,threshold in friction_groups) or '{0,0,0,0}')+'};',
|
||||
f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};',
|
||||
contact_source,
|
||||
'const double model_atol[NSTATES] = {'+','.join(map(state_absolute_tolerance, state_keys or ['dummy']))+'};',
|
||||
'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};',
|
||||
*jacobian.source_lines(),
|
||||
*schedule_helpers,
|
||||
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
|
||||
'static int model_eval_internal(double t,const double *y,double *dy,double *w,int canonical,double *friction_drives,NativePropertyTemperatures *temperatures,ModelJacobianWorkspace *jacobian) {',
|
||||
'(void)friction_drives;(void)jacobian;',
|
||||
f'NativePropertyState property_states[{min(256,max(16,4*gas_count+2*len(components)))}];',
|
||||
'NativePropertyCache property_cache, *properties=&property_cache;',
|
||||
'native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));',
|
||||
'properties->temperatures=temperatures;',
|
||||
'properties->jacobian=jacobian?&jacobian->scalars:NULL;',
|
||||
'NativePropertyCache *gas_properties=canonical?NULL:properties;(void)gas_properties;',
|
||||
*(['double projected[NSTATES];for(int i=0;i<NSTATES;i++) projected[i]=y[i];',*project,'y=projected;'] if project else []),
|
||||
f'double p[{np}]={{0}},h[{nq}]={{0}},q[{nq}]={{0}};NativeGas g[{ng}];',
|
||||
f'double fb[{max(1,flow_temporary_count)}]={{0}};(void)fb;',
|
||||
*([f'NativePipeCache pipe_cache[{max(1,pipe_cache_count)}]={{0}};(void)pipe_cache;'] if pneu else []),
|
||||
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;',*lines,
|
||||
'for(int i=0;i<NSTATES;i++) if(!isfinite(dy[i])) return 0;',
|
||||
'for(int i=0;i<NOUTPUTS;i++) if(!isfinite(w[i])) return 0;','return 1;}',
|
||||
'int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,0,NULL,NULL,NULL);}',
|
||||
'int model_eval_jacobian(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,1,NULL,NULL,NULL);}',
|
||||
'void model_jacobian_begin(ModelJacobianWorkspace *workspace) {memset(workspace,0,sizeof(*workspace));workspace->scalars.entries=workspace->scalar_entries;workspace->scalars.capacity=MODEL_JACOBIAN_SCALAR_COUNT;workspace->scalars.recording=1;}',
|
||||
'int model_eval_jacobian_reuse(double t,const double *y,double *dy,double *w,ModelJacobianWorkspace *workspace) {int ok=model_eval_internal(t,y,dy,w,1,NULL,NULL,workspace);if(workspace)workspace->scalars.recording=0;return ok;}',
|
||||
'int model_friction_drives(double t,const double *y,double *drives) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,drives,NULL,NULL);}',
|
||||
'int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,NULL,temperatures,NULL);}',
|
||||
'double model_next_break(double t,double end) { double result=end;(void)t;',*breaks,'return result;}',''])
|
||||
header=f'''#ifndef GENERATED_NATIVE_MODEL_H
|
||||
#define GENERATED_NATIVE_MODEL_H
|
||||
#include "kernels.h"
|
||||
#define MODEL_JACOBIAN_GAS_REUSE {int(reuse_enabled)}
|
||||
#define MODEL_JACOBIAN_GAS_COUNT {gas_count}
|
||||
#define MODEL_JACOBIAN_SCALAR_COUNT 1024
|
||||
typedef struct {{ NativeJacobianGasMemo gases[{ng}]; NativeJacobianGasStats stats;
|
||||
NativeJacobianScalars scalars; NativeJacobianScalarEntry scalar_entries[MODEL_JACOBIAN_SCALAR_COUNT]; }} ModelJacobianWorkspace;
|
||||
void model_jacobian_begin(ModelJacobianWorkspace *workspace);
|
||||
int model_eval_jacobian_reuse(double t,const double *y,double *dy,double *w,ModelJacobianWorkspace *workspace);
|
||||
#define MODEL_PROPERTY_TEMPERATURES {int(any(key[1] == 'peng_robinson' for key in media))}
|
||||
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
|
||||
#define NSTATES {nstates}
|
||||
#define NOUTPUTS {max(1,len(variables))}
|
||||
#define NSTOPS {len(stops)}
|
||||
#define NFRICTIONS {len(friction_groups)}
|
||||
{contact_header}
|
||||
extern const NativeFriction model_frictions[{max(1,len(friction_groups))}];
|
||||
int model_friction_drives(double t,const double *y,double *drives);
|
||||
extern const NativeStop model_stops[{max(1,len(stops))}];
|
||||
extern const double model_atol[NSTATES];
|
||||
extern const char *const model_output_keys[NOUTPUTS];
|
||||
{chr(10).join(jacobian.header_lines(canonical_rhs=True))}
|
||||
int model_init(double *y);
|
||||
int model_eval(double t,const double *y,double *dy,double *w);
|
||||
double model_next_break(double t,double end);
|
||||
#endif
|
||||
'''
|
||||
return NativeProgram(source,header,tuple(state_keys),variables,tuple(sorted({c.model_type for c in components})),schedule.report(),jacobian_info)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""CLI input adapter shared with HTTP; numerical execution accepts SI XML only."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
|
||||
from app.project_parameters import expression_value as arithmetic_value, prepare_project, version_warning
|
||||
from app.system_xml import validate_system_xml_document
|
||||
|
||||
|
||||
def project_xml(data: dict) -> bytes:
|
||||
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
|
||||
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(data))
|
||||
if notices:
|
||||
warnings.warn(json.dumps(version_warning(notices), ensure_ascii=False), UserWarning, stacklevel=2)
|
||||
return build_reactflow_system_xml(normalized)
|
||||
|
||||
|
||||
def load_input(path: Path):
|
||||
xml = project_xml(json.loads(path.read_text(encoding="utf-8"))) if path.suffix.lower() == ".json" else path.read_bytes()
|
||||
report = validate_system_xml_document(xml)
|
||||
if not report.valid or report.document is None:
|
||||
raise ValueError(f"Invalid simulation XML: {report.as_dict()}")
|
||||
return xml, report.document
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Conservative state dependencies for compiler-owned expressions.
|
||||
|
||||
This is structural bookkeeping, not C parsing or numerical Jacobian evaluation.
|
||||
Native multi-output operations, projections and mode-dependent writes must be
|
||||
registered explicitly by the lowering path. Unresolved reachable inputs disable
|
||||
coloring for the complete model.
|
||||
"""
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
# Reviewed expression leaves. Unknown arrays/scalars remain unresolved instead
|
||||
# of silently becoming constants; the flow schedule supplies its own explicit IR.
|
||||
_ARRAY = re.compile(r'\b[A-Za-z_]\w*\[\d+\](?:\.[A-Za-z_]\w*)?|\bgas_\d+\.[A-Za-z_]\w*')
|
||||
_IDENTIFIER = re.compile(r'\b[A-Za-z_]\w*\b')
|
||||
_NUMBER = re.compile(r'(?<![\w.])(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?')
|
||||
_FUNCTIONS = frozenset({
|
||||
'fmax', 'fmin', 'fabs', 'sqrt', 'copysign', 'pow',
|
||||
'native_signal', 'native_contact', 'native_limit_force', 'native_dry_friction',
|
||||
'native_pipe_flow_context', 'native_pipe_flow_cached_context',
|
||||
'native_temperature_ph_context', 'native_density_context',
|
||||
})
|
||||
_CONSTANTS = frozenset({'t', 'properties', 'NAN', 'INFINITY', 'NULL', 'true', 'false'})
|
||||
|
||||
|
||||
def expression_inputs(expression):
|
||||
"""Extract leaves from a reviewed expression; unfamiliar names fail closed."""
|
||||
inputs = set()
|
||||
def leaf(match):
|
||||
key = match.group()
|
||||
# Per-evaluation cache scratch is an implementation detail of reviewed
|
||||
# kernels; each value is determined by that kernel's explicit arguments.
|
||||
if not re.fullmatch(r'pipe_cache\[\d+\]', key):
|
||||
inputs.add(key)
|
||||
return ' '
|
||||
remainder = _ARRAY.sub(leaf, expression)
|
||||
remainder = _NUMBER.sub(' ', remainder)
|
||||
for name in _IDENTIFIER.findall(remainder):
|
||||
if name in _FUNCTIONS or name in _CONSTANTS or re.fullmatch(r'(?:medium|signal)_\d+', name):
|
||||
continue
|
||||
inputs.add(name)
|
||||
return frozenset(inputs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JacobianStructure:
|
||||
state_count: int
|
||||
rows: tuple[tuple[int, ...], ...] = ()
|
||||
colors: tuple[int, ...] = ()
|
||||
reason: str | None = None
|
||||
|
||||
@classmethod
|
||||
def dense(cls, state_count, reason):
|
||||
return cls(state_count, reason=reason)
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return self.reason is None
|
||||
|
||||
@property
|
||||
def color_count(self):
|
||||
return max(self.colors, default=-1) + 1 if self.enabled else self.state_count
|
||||
|
||||
@property
|
||||
def nonzeros(self):
|
||||
return sum(map(len, self.rows)) if self.enabled else self.state_count ** 2
|
||||
|
||||
def manifest(self, *, canonical_rhs=False):
|
||||
# Use proven coloring automatically when it reduces finite-difference
|
||||
# groups; unsupported or unhelpful patterns retain dense differences.
|
||||
runtime_eligible = self.enabled and 0 < self.color_count < self.state_count
|
||||
fallback_reason = None if runtime_eligible else (
|
||||
self.reason or 'Coloring does not reduce finite-difference groups')
|
||||
pattern = {'rows': self.rows, 'columnColors': self.colors}
|
||||
policy = ('CVODE colored forward differences; canonical-property-cache RHS' if canonical_rhs else 'CVODE colored forward differences') if runtime_eligible else 'CVODE default dense differences'
|
||||
return {
|
||||
'policyScope': 'default-runtime',
|
||||
'defaultRuntimePolicy': policy,
|
||||
'verification': '--verify-jacobian',
|
||||
'policy': policy,
|
||||
'rhsPolicy': 'canonical-property-cache finite differences' if canonical_rhs and runtime_eligible else 'ordinary model_eval',
|
||||
'runtimeEligible': runtime_eligible, 'runtimeFallbackReason': fallback_reason,
|
||||
'canonicalRhs': canonical_rhs, 'ordinaryRhsUnchanged': True,
|
||||
'enabled': self.enabled, 'reason': self.reason, 'stateCount': self.state_count,
|
||||
'nonzeros': self.nonzeros, 'density': self.nonzeros / self.state_count ** 2 if self.state_count else 0,
|
||||
'colorCount': self.color_count,
|
||||
'patternSha256': hashlib.sha256(json.dumps(pattern, separators=(',', ':')).encode()).hexdigest() if self.enabled else None,
|
||||
'columnColors': list(self.colors) if self.enabled else None,
|
||||
}
|
||||
|
||||
def header_lines(self, *, canonical_rhs=False):
|
||||
lines = [f'#define MODEL_JACOBIAN_COLORED {int(self.enabled)}',
|
||||
f'#define MODEL_JACOBIAN_CANONICAL_RHS {int(canonical_rhs)}',
|
||||
f'#define MODEL_JACOBIAN_COLOR_COUNT {self.color_count}',
|
||||
f'#define MODEL_JACOBIAN_NNZ {self.nonzeros}']
|
||||
if canonical_rhs:
|
||||
lines += ['int model_eval_jacobian(double t, const double *y, double *dy, double *w);']
|
||||
if self.enabled:
|
||||
lines += ['extern const int model_jacobian_column_color[NSTATES];',
|
||||
'extern const int model_jacobian_col_ptr[NSTATES+1];',
|
||||
'extern const int model_jacobian_row_index[MODEL_JACOBIAN_NNZ];']
|
||||
return lines
|
||||
|
||||
def source_lines(self):
|
||||
if not self.enabled:
|
||||
return []
|
||||
columns = [[] for _ in range(self.state_count)]
|
||||
for row, entries in enumerate(self.rows):
|
||||
for column in entries:
|
||||
columns[column].append(row)
|
||||
pointers = [0]
|
||||
indices = []
|
||||
for column in columns:
|
||||
indices.extend(column)
|
||||
pointers.append(len(indices))
|
||||
return [f'const int {name}[{len(values)}] = {{'+','.join(map(str, values))+'};'
|
||||
for name, values in [('model_jacobian_column_color', self.colors),
|
||||
('model_jacobian_col_ptr', pointers),
|
||||
('model_jacobian_row_index', indices)]]
|
||||
|
||||
|
||||
class StateDependencies:
|
||||
def __init__(self, state_count):
|
||||
self.state_count = state_count
|
||||
self.seeds = {f'y[{i}]': 1 << i for i in range(state_count)}
|
||||
self.inputs = {}
|
||||
|
||||
def assign(self, target, inputs):
|
||||
# Union repeated writes, rather than dropping dependencies from another
|
||||
# branch or an earlier in-place value (stop motion is mode dependent).
|
||||
self.inputs.setdefault(target, set()).update(inputs)
|
||||
|
||||
def expression(self, target, expression):
|
||||
self.assign(target, expression_inputs(expression))
|
||||
|
||||
def project_states(self, offsets):
|
||||
refs = {f'y[{i}]' for i in offsets}
|
||||
for target in refs:
|
||||
self.assign(target, refs)
|
||||
|
||||
def stop_motion(self, velocity_index, position_index):
|
||||
refs = {f'y[{velocity_index}]', f'y[{position_index}]',
|
||||
f'dy[{velocity_index}]', f'dy[{position_index}]'}
|
||||
for target in (f'dy[{velocity_index}]', f'dy[{position_index}]'):
|
||||
self.assign(target, refs)
|
||||
|
||||
def computation(self, operation):
|
||||
# A schedule SCC reaches a fixed point in build(), so every member gains
|
||||
# every external state dependency even through pressure/stream loops.
|
||||
for output in operation.outputs:
|
||||
self.assign(output, operation.inputs)
|
||||
|
||||
def build(self):
|
||||
if self.state_count == 0:
|
||||
return JacobianStructure.dense(1, 'Algebraic-only internal state uses default differences')
|
||||
required = {f'dy[{i}]' for i in range(self.state_count)}
|
||||
pending = list(required)
|
||||
reachable = set()
|
||||
missing = set()
|
||||
while pending:
|
||||
key = pending.pop()
|
||||
if key in reachable:
|
||||
continue
|
||||
reachable.add(key)
|
||||
if key not in self.inputs and key not in self.seeds:
|
||||
missing.add(key)
|
||||
pending.extend(self.inputs.get(key, ()))
|
||||
if missing:
|
||||
return JacobianStructure.dense(self.state_count,
|
||||
'Unresolved structural inputs: '+', '.join(sorted(missing)[:16]))
|
||||
consumers = {key: set() for key in reachable}
|
||||
for target in reachable:
|
||||
for source in self.inputs.get(target, ()):
|
||||
consumers[source].add(target)
|
||||
masks = dict(self.seeds)
|
||||
queue = deque(key for key in self.seeds if key in reachable)
|
||||
queued = set(queue)
|
||||
while queue:
|
||||
source = queue.popleft()
|
||||
queued.remove(source)
|
||||
for target in consumers[source]:
|
||||
merged = masks.get(target, 0) | masks[source]
|
||||
if merged != masks.get(target, 0):
|
||||
masks[target] = merged
|
||||
if target not in queued:
|
||||
queue.append(target)
|
||||
queued.add(target)
|
||||
# A diagonal overestimate is safe and keeps isolated/dummy rows valid
|
||||
# without introducing zero-length C arrays or uncolored state columns.
|
||||
rows = tuple(tuple(column for column in range(self.state_count)
|
||||
if (masks.get(f'dy[{row}]', 0) | (1 << row)) >> column & 1)
|
||||
for row in range(self.state_count))
|
||||
conflicts = [set() for _ in range(self.state_count)]
|
||||
for entries in rows:
|
||||
for column in entries:
|
||||
conflicts[column].update(set(entries) - {column})
|
||||
colors = {}
|
||||
while len(colors) < self.state_count:
|
||||
column = max((i for i in range(self.state_count) if i not in colors),
|
||||
key=lambda i: (len({colors[j] for j in conflicts[i] if j in colors}),
|
||||
len(conflicts[i]), -i))
|
||||
used = {colors[j] for j in conflicts[column] if j in colors}
|
||||
color = 0
|
||||
while color in used:
|
||||
color += 1
|
||||
colors[column] = color
|
||||
ordered = tuple(colors[i] for i in range(self.state_count))
|
||||
for entries in rows:
|
||||
if len({ordered[column] for column in entries}) != len(entries):
|
||||
raise AssertionError('Jacobian coloring contains a row conflict')
|
||||
return JacobianStructure(self.state_count, rows, ordered)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Reviewed native component module exports and their compilation dependencies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
EXPORTS = {
|
||||
"properties": frozenset({
|
||||
"native_properties_init", "native_temperature_ph_context", "native_density_context",
|
||||
"native_gas_init", "native_gas", "native_gas_context", "native_medium_init",
|
||||
"native_density", "native_temperature_ph", "native_viscosity",
|
||||
"native_medium_gas", "native_medium_gas_context", "native_polytropic_gas_context",
|
||||
"native_jacobian_gas",
|
||||
"native_jacobian_scalar_get", "native_jacobian_scalar_put",
|
||||
}),
|
||||
"orifice": frozenset({"native_orifice", "native_orifice_context", "native_medium_orifice", "native_medium_orifice_context"}),
|
||||
"pipe": frozenset({"native_pipe_resistance", "native_pipe_flow", "native_pipe_flow_context",
|
||||
"native_pipe_flow_cached", "native_pipe_flow_cached_context",
|
||||
"native_pipe_diagnostics", "native_pipe_diagnostics_context"}),
|
||||
"mechanics": frozenset({"native_contact", "native_stop_motion", "native_limit_force", "native_dry_friction"}),
|
||||
"signal": frozenset({"native_signal", "native_signal_break"}),
|
||||
}
|
||||
DEPENDENCIES = {"orifice": ("properties",), "pipe": ("properties",)}
|
||||
|
||||
|
||||
def component_modules(source: str) -> tuple[str, ...]:
|
||||
# Generated C refers to these functions directly. Include identifier uses
|
||||
# (also function pointers), conservatively including any comments/strings.
|
||||
identifiers = set(re.findall(r"\bnative_[A-Za-z0-9_]+\b", source))
|
||||
selected = {name for name, exports in EXPORTS.items() if identifiers & exports}
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
for dependency in DEPENDENCIES.get(pending.pop(), ()):
|
||||
if dependency not in selected:
|
||||
selected.add(dependency)
|
||||
pending.append(dependency)
|
||||
return tuple(name for name in EXPORTS if name in selected)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Portable process setup and classification for the native compiler driver.
|
||||
|
||||
MinGW maps different CreateProcess failures to ENOENT. That text does not prove
|
||||
the compiler/subprogram is missing; never treat a C diagnostic as a launch retry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
LAUNCH_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.35, 0.5)
|
||||
|
||||
|
||||
def command_environment(executable: str) -> dict[str, str] | None:
|
||||
if not IS_WINDOWS:
|
||||
return None # Preserve the Linux toolchain and environment unchanged.
|
||||
env = os.environ.copy()
|
||||
path_key = next((key for key in env if key.upper() == 'PATH'), 'PATH')
|
||||
directory = str(Path(shutil.which(executable) or executable).resolve().parent)
|
||||
env[path_key] = directory + os.pathsep + env.get(path_key, '')
|
||||
return env
|
||||
|
||||
|
||||
def is_driver_launch_failure(stderr: str) -> bool:
|
||||
# GCC 8 emits "gcc.exe: error: CreateProcess: ..."; other releases add
|
||||
# "cannot execute 'cc1':". Anchor the driver diagnostic, not source text.
|
||||
return IS_WINDOWS and bool(re.search(
|
||||
r'(?im)^(?:[^\r\n]*[/\\])?[\w.+-]*gcc(?:\.exe)?: (?:fatal )?error: (?:cannot execute [^\r\n]*: )?CreateProcess:', stderr))
|
||||
|
||||
|
||||
def is_transient_process_error(exc: OSError) -> bool:
|
||||
return IS_WINDOWS and getattr(exc, 'winerror', None) in (5, 32, 1450, 1455)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""User-facing wording shared by live events and saved result diagnostics."""
|
||||
|
||||
|
||||
def format_property_warning(warning: dict) -> dict:
|
||||
domains = {
|
||||
"equation-of-state": "状态方程及热力性质的真实气体修正项",
|
||||
"ideal-caloric": "理想气体热容、焓和内能",
|
||||
"dynamic-viscosity": "动力黏度",
|
||||
}
|
||||
high = warning["direction"] == "high"
|
||||
message = (
|
||||
f"氦气物性有效温度越界:{domains[warning['property']]},"
|
||||
f"t = {warning['time']:.9g} s,T = {warning['temperature']:.9g} K "
|
||||
f"{'>' if high else '<'} {'Tmax' if high else 'Tmin'} = {warning['limit']:.9g} K。"
|
||||
"仿真继续计算,超范围物性不保证有效性。"
|
||||
)
|
||||
return {**warning, "message": message}
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Quota-controlled, append-only result archives in PROJECT/simresults.
|
||||
|
||||
Reservations cover state AND future output blocks before the worker may write.
|
||||
A global process lock serializes grants; per-run leases protect active writers.
|
||||
Grants are persisted in bounded windows; individual blocks consume local credit
|
||||
without scanning history or synchronously rewriting the manifest.
|
||||
Only recognized archives are evicted, oldest first. Unknown user files count
|
||||
toward the quota but are never deleted. File sizes are logical bytes, not the
|
||||
filesystem's allocation-unit overhead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from uuid import uuid4
|
||||
import zlib
|
||||
|
||||
from .cache_storage import acquire_cache_lease, _acquire_lock_file, _reparse, _require_directory
|
||||
|
||||
RESULT_ROOT = Path(__file__).resolve().parents[3] / "simresults"
|
||||
DEFAULT_LIMIT_BYTES = 1024**3
|
||||
_RESERVATION_WINDOW_BYTES = 16 * 1024**2
|
||||
_RUN = re.compile(r"run-([0-9a-f]{64})\Z")
|
||||
_FILES = frozenset(("manifest.json", "manifest.tmp", "states.bin", "outputs.bin"))
|
||||
_HEADER = struct.Struct("<8sQQQQ")
|
||||
|
||||
|
||||
class ResultQuotaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def result_archive_path(result_id: str, *, root: Path | None = None) -> Path:
|
||||
"""Locate an archive by ID under this installation's result root.
|
||||
|
||||
Never use a saved absolute directory as a locator: older manifests may
|
||||
contain one from a different machine. Moving a stopped project and starting
|
||||
it again establishes its new root through __file__ above.
|
||||
"""
|
||||
if not isinstance(result_id, str) or _RUN.fullmatch(result_id) is None:
|
||||
raise ValueError("Invalid result archive ID.")
|
||||
base = Path(root or RESULT_ROOT).absolute()
|
||||
_require_directory(base)
|
||||
path = base / result_id
|
||||
_require_directory(path)
|
||||
return path
|
||||
|
||||
|
||||
def result_limit_bytes() -> int:
|
||||
raw = os.environ.get("SIMULATION_RESULT_STORAGE_MB", str(DEFAULT_LIMIT_BYTES // 1024**2))
|
||||
if not re.fullmatch(r"[0-9]+", raw) or int(raw) < 1:
|
||||
raise ValueError("SIMULATION_RESULT_STORAGE_MB must be a positive integer in MiB.")
|
||||
return int(raw) * 1024**2
|
||||
|
||||
|
||||
def _alive(pid: int | None) -> bool:
|
||||
if not pid:
|
||||
return False
|
||||
if os.name == "nt":
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
|
||||
kernel.OpenProcess.restype = ctypes.c_void_p
|
||||
kernel.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
handle = kernel.OpenProcess(0x100000, False, pid) # SYNCHRONIZE
|
||||
if not handle:
|
||||
return ctypes.get_last_error() == 5 # Access denied: conservatively pin.
|
||||
try:
|
||||
return kernel.WaitForSingleObject(handle, 0) != 0
|
||||
finally:
|
||||
kernel.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
|
||||
def _size(path: Path) -> int:
|
||||
info = path.lstat()
|
||||
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
|
||||
return info.st_size
|
||||
return sum(_size(child) for child in path.iterdir())
|
||||
|
||||
|
||||
def _record(path: Path) -> dict | None:
|
||||
try:
|
||||
info = (path / "manifest.json").lstat()
|
||||
if _reparse(info) or not stat.S_ISREG(info.st_mode):
|
||||
return None
|
||||
data = json.loads((path / "manifest.json").read_bytes())
|
||||
if (data.get("format") != "simulation-blocks-v1" or
|
||||
data.get("id") != path.name or type(data.get("reservedBytes")) is not int or
|
||||
data["reservedBytes"] < 0 or type(data.get("createdAt")) is not int or
|
||||
type(data.get("finishedAt", data["createdAt"])) is not int or
|
||||
data.get("status") not in ("running", "completed", "cancelled", "failed", "interrupted") or
|
||||
(data.get("workerPid") is not None and
|
||||
(type(data["workerPid"]) is not int or data["workerPid"] <= 0))):
|
||||
return None
|
||||
return data
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def scan_blocks(path: Path, *, expected_columns: int | None = None) -> dict:
|
||||
"""Validate a committed prefix using bounded reads; ignore an incomplete tail.
|
||||
|
||||
A CRC mismatch is reported as corruption, never a valid sample. This reader
|
||||
works after process termination without loading the trajectory into RAM.
|
||||
"""
|
||||
result = {"blocks": 0, "samples": 0, "validBytes": 0, "storedUntil": None,
|
||||
"incompleteTail": False, "corrupt": False}
|
||||
if not path.exists():
|
||||
return result
|
||||
with path.open("rb") as stream:
|
||||
size = os.fstat(stream.fileno()).st_size
|
||||
while stream.tell() < size:
|
||||
raw = stream.read(_HEADER.size)
|
||||
if len(raw) != _HEADER.size:
|
||||
result["incompleteTail"] = True
|
||||
break
|
||||
magic, sequence, rows, columns, expected_crc = _HEADER.unpack(raw)
|
||||
if (magic != b"SIMBLK01" or sequence != result["blocks"] or not 0 < rows <= 1024 or
|
||||
not columns or (expected_columns is not None and columns != expected_columns)):
|
||||
result["corrupt"] = True
|
||||
break
|
||||
length = rows * columns * 8
|
||||
if length + 8 > size - stream.tell():
|
||||
result["incompleteTail"] = True
|
||||
break
|
||||
# The first column is always time; at most 1024 timestamps.
|
||||
times = stream.read(rows * 8)
|
||||
crc = zlib.crc32(times)
|
||||
remaining = length - len(times)
|
||||
while remaining:
|
||||
block = stream.read(min(65536, remaining))
|
||||
if not block:
|
||||
raise OSError("Result block changed during inspection.")
|
||||
crc = zlib.crc32(block, crc)
|
||||
remaining -= len(block)
|
||||
if stream.read(8) != b"COMMIT01" or crc != expected_crc:
|
||||
result["corrupt"] = True
|
||||
break
|
||||
result["blocks"] += 1
|
||||
result["samples"] += rows
|
||||
result["validBytes"] = stream.tell()
|
||||
result["storedUntil"] = struct.unpack_from("<d", times, len(times) - 8)[0]
|
||||
return result
|
||||
|
||||
|
||||
class ResultArchive:
|
||||
def __init__(self, metadata: dict, *, root: Path | None = None, limit_bytes: int | None = None):
|
||||
self.root = Path(root or RESULT_ROOT).absolute()
|
||||
_require_directory(self.root, create=True)
|
||||
self.limit = result_limit_bytes() if limit_bytes is None else limit_bytes
|
||||
if self.limit <= 0:
|
||||
raise ValueError("Result quota must be positive.")
|
||||
self.key = uuid4().hex + uuid4().hex
|
||||
self.path = self.root / ("run-" + self.key)
|
||||
self.lease = acquire_cache_lease(self.root, "models", self.key)
|
||||
self.closed = False
|
||||
self._mutex = threading.RLock()
|
||||
self._credit = 0
|
||||
# This is accounting credit, not an allocated memory/disk buffer. Bound
|
||||
# speculative grants for small quotas and concurrent simulations.
|
||||
self._reservation_window = min(_RESERVATION_WINDOW_BYTES, self.limit // 16)
|
||||
self.data = {"format": "simulation-blocks-v1", "id": self.path.name,
|
||||
"createdAt": time.time_ns(), "status": "running", "workerPid": None,
|
||||
"metadata": metadata, "reservedBytes": 0}
|
||||
# Covers final scalars, JSON framing and both copies during atomic update.
|
||||
self.metadata_budget = max(65536, len(json.dumps(self.data).encode()) * 8)
|
||||
self.data["reservedBytes"] = self.metadata_budget
|
||||
try:
|
||||
with self._locked():
|
||||
self._make_room(self.metadata_budget)
|
||||
self.path.mkdir()
|
||||
self._publish()
|
||||
except BaseException:
|
||||
self.lease.close()
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _locked(self):
|
||||
with _acquire_lock_file(self.root, "models", "0" * 64, "results-quota.lock",
|
||||
exclusive=True, blocking=True):
|
||||
yield
|
||||
|
||||
def _publish(self):
|
||||
encoded = json.dumps(self.data, ensure_ascii=False, allow_nan=False,
|
||||
separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) * 2 > self.metadata_budget:
|
||||
raise ResultQuotaError("Result metadata exceeded its reserved space.")
|
||||
temporary = self.path / "manifest.tmp"
|
||||
with temporary.open("wb") as stream:
|
||||
stream.write(encoded)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, self.path / "manifest.json")
|
||||
|
||||
def _make_room(self, extra: int) -> int:
|
||||
"""Make room for required bytes and return free bytes BEFORE this grant.
|
||||
|
||||
Caller holds the global quota lock through publishing the reservation.
|
||||
Optional ahead-of-write credit must never trigger extra eviction.
|
||||
"""
|
||||
usage = 0
|
||||
candidates = []
|
||||
for path in self.root.iterdir():
|
||||
info = path.lstat()
|
||||
actual = _size(path)
|
||||
match = _RUN.fullmatch(path.name)
|
||||
record = (_record(path) if match and stat.S_ISDIR(info.st_mode) and not _reparse(info) else None)
|
||||
if record is None:
|
||||
usage += actual
|
||||
continue
|
||||
lease = acquire_cache_lease(self.root, "models", match[1], exclusive=True, blocking=False)
|
||||
active = lease is None or (record.get("status") == "running" and _alive(record.get("workerPid")))
|
||||
if lease is not None:
|
||||
lease.close()
|
||||
usage += max(actual, record["reservedBytes"]) if active else actual
|
||||
if not active and path != self.path:
|
||||
candidates.append((record.get("finishedAt", record["createdAt"]), path, match[1], actual))
|
||||
if usage + extra <= self.limit:
|
||||
return self.limit - usage
|
||||
for _, path, key, actual in sorted(candidates):
|
||||
lease = acquire_cache_lease(self.root, "models", key, exclusive=True, blocking=False)
|
||||
if lease is None:
|
||||
continue
|
||||
with lease:
|
||||
# No recursive deletion, links, unknown files, or paths outside
|
||||
# the configured root. User-owned additions prevent eviction.
|
||||
if path.resolve().parent != self.root.resolve():
|
||||
continue
|
||||
files = list(path.iterdir())
|
||||
if any(p.name not in _FILES or _reparse(p.lstat()) or
|
||||
not stat.S_ISREG(p.lstat().st_mode) for p in files):
|
||||
continue
|
||||
for item in files:
|
||||
item.unlink()
|
||||
path.rmdir()
|
||||
usage -= actual
|
||||
if usage + extra <= self.limit:
|
||||
return self.limit - usage
|
||||
raise ResultQuotaError("Result storage quota exhausted; active runs and unrelated files were retained.")
|
||||
|
||||
def worker_started(self, pid: int):
|
||||
with self._mutex, self._locked():
|
||||
self._ensure_open()
|
||||
self.data["workerPid"] = pid
|
||||
self._publish()
|
||||
|
||||
def _ensure_open(self):
|
||||
if self.closed:
|
||||
raise OSError("Result archive is closed.")
|
||||
|
||||
def reserve(self, amount: int):
|
||||
if type(amount) is not int or not 0 < amount <= self.limit:
|
||||
raise ResultQuotaError("Invalid or oversized result block reservation.")
|
||||
with self._mutex:
|
||||
self._ensure_open()
|
||||
if amount <= self._credit:
|
||||
self._credit -= amount
|
||||
return
|
||||
needed = amount - self._credit
|
||||
with self._locked():
|
||||
available = self._make_room(needed)
|
||||
grant = min(available, max(needed, self._reservation_window))
|
||||
previous = self.data["reservedBytes"]
|
||||
self.data["reservedBytes"] += grant
|
||||
try:
|
||||
# Publish BEFORE granting permission to write, so another
|
||||
# process (or an orphan worker) cannot reuse our quota.
|
||||
self._publish()
|
||||
except BaseException:
|
||||
self.data["reservedBytes"] = previous
|
||||
raise
|
||||
self._credit += grant - amount
|
||||
|
||||
def finish(self, payload: dict | None = None, error: str | None = None) -> dict:
|
||||
with self._mutex:
|
||||
self._ensure_open()
|
||||
return self._finish(payload, error)
|
||||
|
||||
def _finish(self, payload: dict | None, error: str | None) -> dict:
|
||||
try:
|
||||
metadata = self.data["metadata"]
|
||||
states = scan_blocks(self.path / "states.bin",
|
||||
expected_columns=len(metadata["stateColumns"]) if "stateColumns" in metadata
|
||||
else max(1, len(metadata["stateKeys"])) + 1 if "stateKeys" in metadata else None)
|
||||
outputs = scan_blocks(self.path / "outputs.bin",
|
||||
expected_columns=len(metadata["outputColumns"]) if "outputColumns" in metadata
|
||||
else max(1, len(metadata["variables"])) + 1 if "variables" in metadata else None)
|
||||
status = payload.get("status", "failed") if payload is not None else "interrupted"
|
||||
if states["corrupt"] or outputs["corrupt"]:
|
||||
status = "failed"
|
||||
summary = {"id": self.path.name, "directory": f"simresults/{self.path.name}",
|
||||
"directoryBase": "project", "limitBytes": self.limit,
|
||||
"status": status, "states": states, "outputs": outputs}
|
||||
with self._locked():
|
||||
# Release unused ahead-of-write credit even if a result reader
|
||||
# subsequently pins this completed archive with a shared lease.
|
||||
self.data["reservedBytes"] -= self._credit
|
||||
self._credit = 0
|
||||
self.data.update(status=status, finishedAt=time.time_ns(), blocks=summary,
|
||||
error=error, workerPid=None)
|
||||
if payload is not None:
|
||||
self.data["result"] = {k: v for k, v in payload.items()
|
||||
if k not in ("series", "buildDetails", "resultStorage")}
|
||||
self._publish()
|
||||
return summary
|
||||
finally:
|
||||
self.closed = True
|
||||
self.lease.close()
|
||||
|
||||
def close(self):
|
||||
with self._mutex:
|
||||
if not self.closed:
|
||||
self.finish(error="Execution ended before a result was returned.")
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Isolated native numerical execution; Python only handles process I/O."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.simulation.config import SolveIVPConfig
|
||||
from app.simulation.results import GenericSimulationResult
|
||||
from .transport import NativeSeriesJson, read_indexed_result
|
||||
from .build import NativeBuild, build_native
|
||||
from .compiler import NativeCapabilityError, compile_native_program
|
||||
from .result_storage import ResultArchive, ResultQuotaError
|
||||
from .property_warnings import format_property_warning
|
||||
|
||||
_WORKER_SHUTDOWN_GRACE_SECONDS = 5.0
|
||||
|
||||
|
||||
def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
|
||||
run_dir: Path, record_samples=True, cancel_check=None,
|
||||
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False) -> dict:
|
||||
# Reject unsupported calls before allocating quota or evicting any history.
|
||||
if config.method not in ("RK45", "BDF"):
|
||||
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
|
||||
if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None:
|
||||
raise NativeCapabilityError("Native uses generated per-state absolute tolerances and automatic initial step; custom config.atol/first_step are not supported.")
|
||||
if any((run_dir / name).exists() for name in ("result.json", "cancel.request", "result-index.json")):
|
||||
raise ValueError("Native execution requires a fresh run directory.")
|
||||
archive = None
|
||||
if record_samples:
|
||||
metadata = {key: build.manifest[key] for key in ("buildKey", "stateKeys", "variables")
|
||||
if key in build.manifest}
|
||||
if "stateKeys" in metadata:
|
||||
# Algebraic-only models carry one private constant integrator state.
|
||||
metadata["stateColumns"] = ["time", *(metadata["stateKeys"] or [None])]
|
||||
if "variables" in metadata:
|
||||
metadata["outputColumns"] = ["time", *([v["key"] for v in metadata["variables"]] or [""])]
|
||||
metadata["simulation"] = {"method": config.method, "start": config.t_start,
|
||||
"stop": config.t_stop, "sampleStep": sample_step,
|
||||
"maxStep": config.max_step, "rtol": config.rtol}
|
||||
archive = ResultArchive(metadata)
|
||||
try:
|
||||
payload = _execute_native(build, config, sample_step, run_dir=run_dir,
|
||||
record_samples=record_samples, cancel_check=cancel_check,
|
||||
progress_callback=progress_callback, warning_callback=warning_callback, activity_tracker=activity_tracker,
|
||||
timeout=timeout, raw_series=raw_series, archive=archive)
|
||||
if archive is not None:
|
||||
payload["resultStorage"] = archive.finish(payload)
|
||||
return payload
|
||||
except BaseException as exc:
|
||||
if archive is not None and not archive.closed:
|
||||
try:
|
||||
archive.finish(error=str(exc))
|
||||
except Exception as storage_error:
|
||||
exc.add_note(f"Could not finalize result archive: {storage_error}")
|
||||
raise
|
||||
finally:
|
||||
if archive is not None:
|
||||
archive.close()
|
||||
|
||||
|
||||
def _execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
|
||||
run_dir: Path, record_samples=True, cancel_check=None,
|
||||
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False,
|
||||
archive: ResultArchive | None = None) -> dict:
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = run_dir / "result.json"
|
||||
cancel_path = run_dir / "cancel.request"
|
||||
index_path = run_dir / "result-index.json"
|
||||
command = [str(build.executable), "--method", config.method,
|
||||
"--start", str(config.t_start), "--stop", str(config.t_stop),
|
||||
"--sample-step", str(sample_step), "--max-step", str(config.max_step),
|
||||
"--rtol", str(config.rtol), "--timeout", str(timeout),
|
||||
"--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())]
|
||||
if raw_series:
|
||||
command.extend(["--result-index", str(index_path.resolve())])
|
||||
if not record_samples:
|
||||
command.append("--solve-only")
|
||||
if archive is not None:
|
||||
command.extend(["--sample-file", str(archive.path / "states.bin"),
|
||||
"--output-block-file", str(archive.path / "outputs.bin"), "--storage-control"])
|
||||
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
||||
started = time.perf_counter()
|
||||
# A fast worker may finish before the monitor's first iteration. Honor a
|
||||
# cancellation already requested during preparation before spawning it.
|
||||
cancelled_at = started if cancel_check is not None and cancel_check() else None
|
||||
if cancelled_at is not None:
|
||||
cancel_path.write_text("cancel\n", encoding="ascii")
|
||||
process = subprocess.Popen(command, cwd=build.executable.parent,
|
||||
stdin=subprocess.PIPE if archive is not None else subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
|
||||
messages: queue.Queue[str] = queue.Queue(maxsize=128)
|
||||
reader_stop = threading.Event()
|
||||
def read_stderr():
|
||||
assert process.stderr is not None
|
||||
for line in process.stderr:
|
||||
while not reader_stop.is_set():
|
||||
try:
|
||||
messages.put(line, timeout=.1)
|
||||
break
|
||||
except queue.Full:
|
||||
continue
|
||||
if reader_stop.is_set():
|
||||
break
|
||||
reader = threading.Thread(target=read_stderr, daemon=True)
|
||||
reader.start()
|
||||
last_time = config.t_start
|
||||
last_nfev = last_accepted = 0
|
||||
forced_cancel = False
|
||||
property_warnings = []
|
||||
try:
|
||||
if archive is not None:
|
||||
archive.worker_started(process.pid)
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.start_integration(config.t_start)
|
||||
with (run_dir / "worker.log").open("w", encoding="utf-8") as log:
|
||||
while process.poll() is None or not messages.empty() or reader.is_alive():
|
||||
now = time.perf_counter()
|
||||
if cancel_check is not None and cancel_check() and cancelled_at is None:
|
||||
cancel_path.write_text("cancel\n", encoding="ascii")
|
||||
cancelled_at = now
|
||||
cancellation_expired = (cancelled_at is not None and
|
||||
now-cancelled_at > _WORKER_SHUTDOWN_GRACE_SECONDS)
|
||||
deadline_expired = timeout > 0 and now-started > timeout+_WORKER_SHUTDOWN_GRACE_SECONDS
|
||||
if process.poll() is None and (cancellation_expired or deadline_expired):
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if cancelled_at is not None:
|
||||
forced_cancel = True
|
||||
break
|
||||
raise RuntimeError("Native worker time limit expired; the unresponsive process was terminated before it returned results.")
|
||||
try:
|
||||
line = messages.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
continue
|
||||
log.write(line)
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("phase") == "storage-reserve" and archive is not None:
|
||||
reply = "ok\n"
|
||||
try:
|
||||
archive.reserve(event.get("bytes"))
|
||||
except ResultQuotaError as exc:
|
||||
reply = "full\n"
|
||||
log.write(f"Result storage reservation failed: {exc}\n")
|
||||
except OSError as exc:
|
||||
reply = "io\n"
|
||||
log.write(f"Result storage I/O failed: {exc}\n")
|
||||
try:
|
||||
process.stdin.write(reply)
|
||||
process.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
if process.poll() is None:
|
||||
raise
|
||||
continue
|
||||
if event.get("phase") == "property-warning":
|
||||
warning = format_property_warning(event["warning"])
|
||||
property_warnings.append(warning)
|
||||
if warning_callback:
|
||||
warning_callback(warning)
|
||||
continue
|
||||
if event.get("phase") == "integrating":
|
||||
last_time = max(last_time, min(config.t_stop, float(event["time"])))
|
||||
last_nfev = int(event["nfev"])
|
||||
last_accepted = int(event["acceptedSteps"])
|
||||
if progress_callback:
|
||||
progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating")
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.record_native_progress(last_time, int(event["nfev"]), int(event["acceptedSteps"]))
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
reader_stop.set()
|
||||
reader.join(timeout=2)
|
||||
if process.stdin is not None:
|
||||
process.stdin.close()
|
||||
if process.stderr is not None:
|
||||
process.stderr.close()
|
||||
if forced_cancel:
|
||||
# A killed worker cannot publish trustworthy result artifacts. Retain
|
||||
# only its last progress report; do not invent a trajectory or totals.
|
||||
payload = {
|
||||
"success": False, "status": "cancelled",
|
||||
"message": "Simulation cancelled; the unresponsive worker was terminated. No complete result artifact is available; time and counters are the last reported progress.",
|
||||
"backend": "native-c", "method": config.method,
|
||||
"simulatedUntil": last_time, "nfev": last_nfev, "acceptedSteps": last_accepted,
|
||||
"series": {}, "final": {}, "finalState": [],
|
||||
"solverControl": {"reason": "cancelled", "operation": "worker-termination",
|
||||
"forcedTermination": True, "workerExitCode": process.returncode,
|
||||
"resultAvailable": False, "statisticsComplete": False},
|
||||
}
|
||||
else:
|
||||
if not output.is_file():
|
||||
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
|
||||
if process.returncode not in (0, 2):
|
||||
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
|
||||
try:
|
||||
payload = (read_indexed_result(output, index_path) if raw_series
|
||||
else json.loads(output.read_text(encoding="utf-8")))
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Cannot read native worker result artifacts: {exc}") from exc
|
||||
payload["propertyWarnings"] = (
|
||||
property_warnings if forced_cancel else
|
||||
[format_property_warning(w) for w in payload.get("propertyWarnings", [])])
|
||||
payload["processWallSeconds"] = time.perf_counter()-started
|
||||
payload["buildKey"] = build.manifest["buildKey"]
|
||||
payload["cacheHit"] = build.cache_hit
|
||||
payload["buildSeconds"] = build.seconds
|
||||
payload["buildDetails"] = build.details
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"])
|
||||
return payload
|
||||
|
||||
|
||||
def simulate_native(network, config, *, sample_step, progress_callback=None,
|
||||
warning_callback=None, cancel_check=None, activity_tracker=None, raw_series=False):
|
||||
if config.method not in ("RK45", "BDF"):
|
||||
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
|
||||
if progress_callback:
|
||||
progress_callback(0.0, "native-generation")
|
||||
program = compile_native_program(network)
|
||||
build = build_native(program, progress_callback=(
|
||||
(lambda phase: progress_callback(0.0, phase)) if progress_callback else None
|
||||
))
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
|
||||
data = execute_native(build, config, sample_step, run_dir=Path(directory),
|
||||
cancel_check=cancel_check, progress_callback=progress_callback,
|
||||
warning_callback=warning_callback,
|
||||
activity_tracker=activity_tracker, raw_series=raw_series)
|
||||
finally:
|
||||
# Keep the executable pinned through process exit and result reading;
|
||||
# only then may cache eviction reclaim this completed model version.
|
||||
build.close()
|
||||
totals = {target: data[source] for source, target in (
|
||||
("nfev", "nfev"), ("njev", "njev"), ("nlu", "nlu"),
|
||||
("acceptedSteps", "acceptedStepCount"), ("rejectedSteps", "rejectedStepCount"),
|
||||
("stateTransitions", "stateTransitionCount"), ("solverStarts", "solverStartCount"),
|
||||
) if source in data}
|
||||
if progress_callback:
|
||||
fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start)
|
||||
progress_callback(fraction, "complete" if data["success"] else data["status"])
|
||||
return GenericSimulationResult(
|
||||
success=data["success"], status=data["status"], message=data["message"],
|
||||
simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop,
|
||||
variables=program.variables, series=data["series"], final=data["final"],
|
||||
diagnostics={"backend": "native-c", "propertyWarnings": data.get("propertyWarnings", []), "native": {k: v for k, v in data.items()
|
||||
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "rtol": config.rtol, "totals": totals},
|
||||
"stateCount": len(program.state_keys), "sampleCount": (data["series"].sample_count if isinstance(data["series"], NativeSeriesJson)
|
||||
else len(data["series"].get("time", [])))},
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Dependency ordering and local algebraic blocks for generated C expressions.
|
||||
|
||||
This module only arranges reviewed C computations. It never evaluates a model
|
||||
numerically and has no dependency on the retired Python numerical backend.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import heapq
|
||||
import re
|
||||
|
||||
from .compiler import NativeCapabilityError
|
||||
|
||||
|
||||
# Only compiler-owned array expressions are inspected, never arbitrary user C.
|
||||
_REFERENCE = re.compile(r"\b(?:[phqw]|fb)\[\d+\]|\bg\[\d+\]\.[A-Za-z_]\w*")
|
||||
|
||||
|
||||
def references(expression: str) -> frozenset[str]:
|
||||
return frozenset(_REFERENCE.findall(expression))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Computation:
|
||||
key: str
|
||||
outputs: tuple[str, ...]
|
||||
inputs: frozenset[str]
|
||||
code: tuple[str, ...]
|
||||
kind: str = "flow"
|
||||
residual: str | None = None
|
||||
|
||||
@classmethod
|
||||
def assignment(cls, key, target, expression, kind="flow"):
|
||||
return cls(key, (target,), references(expression), (f'{target}={expression};',), kind)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Block:
|
||||
members: tuple[int, ...]
|
||||
cyclic: bool
|
||||
|
||||
|
||||
class EvaluationSchedule:
|
||||
def __init__(self, computations, known, labels=None):
|
||||
self.computations = tuple(computations)
|
||||
self.labels = labels or {}
|
||||
self.producers = {}
|
||||
for i, op in enumerate(self.computations):
|
||||
for output in op.outputs:
|
||||
if output in self.producers:
|
||||
raise NativeCapabilityError(f'Multiple native producers for {self.label(output)}')
|
||||
self.producers[output] = i
|
||||
# An iterative initial guess is not a known source if an equation owns it.
|
||||
used = set().union(*(op.inputs for op in self.computations)) if self.computations else set()
|
||||
self.known = {key: value for key, value in known.items() if key not in self.producers and key in used}
|
||||
self.dependencies = []
|
||||
for op in self.computations:
|
||||
missing = op.inputs - self.producers.keys() - self.known.keys()
|
||||
if missing:
|
||||
raise NativeCapabilityError(f'{op.key}: missing native input sources: {sorted(map(self.label, missing))}')
|
||||
self.dependencies.append({self.producers[key] for key in op.inputs if key in self.producers})
|
||||
self.blocks = self._blocks()
|
||||
|
||||
def label(self, key):
|
||||
return self.labels.get(key, key)
|
||||
|
||||
def _blocks(self):
|
||||
"""Iterative SCC discovery followed by deterministic topological order."""
|
||||
count = len(self.computations)
|
||||
consumers = [set() for _ in range(count)]
|
||||
for target, sources in enumerate(self.dependencies):
|
||||
for source in sources:
|
||||
consumers[source].add(target)
|
||||
visited, finish = set(), []
|
||||
for start in range(count):
|
||||
if start in visited:
|
||||
continue
|
||||
visited.add(start)
|
||||
stack = [(start, iter(sorted(consumers[start])))]
|
||||
while stack:
|
||||
node, edges = stack[-1]
|
||||
child = next(edges, None)
|
||||
if child is None:
|
||||
finish.append(node)
|
||||
stack.pop()
|
||||
elif child not in visited:
|
||||
visited.add(child)
|
||||
stack.append((child, iter(sorted(consumers[child]))))
|
||||
groups, owner = [], {}
|
||||
for start in reversed(finish):
|
||||
if start in owner:
|
||||
continue
|
||||
index = len(groups)
|
||||
owner[start] = index
|
||||
members, stack = [], [start]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
members.append(node)
|
||||
for child in sorted(self.dependencies[node]):
|
||||
if child not in owner:
|
||||
owner[child] = index
|
||||
stack.append(child)
|
||||
groups.append(tuple(sorted(members)))
|
||||
incoming = [set() for _ in groups]
|
||||
outgoing = [set() for _ in groups]
|
||||
for target, sources in enumerate(self.dependencies):
|
||||
for source in sources:
|
||||
a, b = owner[source], owner[target]
|
||||
if a != b:
|
||||
incoming[b].add(a)
|
||||
outgoing[a].add(b)
|
||||
ready = [(min(groups[i]), i) for i, inputs in enumerate(incoming) if not inputs]
|
||||
heapq.heapify(ready)
|
||||
blocks = []
|
||||
while ready:
|
||||
_, index = heapq.heappop(ready)
|
||||
members = groups[index]
|
||||
cyclic = len(members) > 1 or members[0] in self.dependencies[members[0]]
|
||||
blocks.append(Block(members, cyclic))
|
||||
for child in sorted(outgoing[index]):
|
||||
incoming[child].remove(index)
|
||||
if not incoming[child]:
|
||||
heapq.heappush(ready, (min(groups[child]), child))
|
||||
return tuple(blocks)
|
||||
|
||||
def ordered_subset(self, members):
|
||||
"""Order a trial's computations while pressure/stream guesses are fixed."""
|
||||
pending = set(members)
|
||||
result = []
|
||||
while pending:
|
||||
ready = sorted(i for i in pending if not self.dependencies[i] & pending)
|
||||
if not ready:
|
||||
raise NativeCapabilityError('Unsupported cycle within native flow expressions')
|
||||
result.extend(ready)
|
||||
pending.difference_update(ready)
|
||||
return result
|
||||
|
||||
def ancestors(self, inputs, allowed):
|
||||
pending = [self.producers[key] for key in inputs if key in self.producers]
|
||||
found = set()
|
||||
while pending:
|
||||
index = pending.pop()
|
||||
if index in found or index not in allowed:
|
||||
continue
|
||||
found.add(index)
|
||||
pending.extend(self.dependencies[index])
|
||||
return self.ordered_subset(found)
|
||||
|
||||
def report(self):
|
||||
result = []
|
||||
sources = {key: {str(origin)} for key, origin in self.known.items()}
|
||||
for block in self.blocks:
|
||||
outputs = {key for i in block.members for key in self.computations[i].outputs}
|
||||
inputs = {key for i in block.members for key in self.computations[i].inputs} - outputs
|
||||
origins = set().union(*(sources[key] for key in inputs)) if inputs else set()
|
||||
for key in outputs:
|
||||
sources[key] = origins
|
||||
result.append({
|
||||
'cyclic': block.cyclic,
|
||||
'inputs': sorted(map(self.label, inputs)),
|
||||
'outputs': sorted(map(self.label, outputs)),
|
||||
'origins': sorted(origins),
|
||||
'operations': [self.computations[i].key for i in block.members],
|
||||
'pressureUnknowns': [self.label(key) for i in block.members
|
||||
if self.computations[i].kind == 'pressure'
|
||||
for key in self.computations[i].outputs],
|
||||
})
|
||||
return {
|
||||
'strategy': 'dependency-blocks',
|
||||
'operationCount': len(self.computations),
|
||||
'cyclicBlockCount': sum(b.cyclic for b in self.blocks),
|
||||
'knownSources': {self.label(key): value for key, value in self.known.items()},
|
||||
'blocks': result,
|
||||
'operations': [{'key': op.key, 'kind': op.kind,
|
||||
'inputs': sorted(map(self.label, op.inputs)),
|
||||
'outputs': list(map(self.label, op.outputs))}
|
||||
for op in self.computations],
|
||||
}
|
||||
|
||||
def emit(self):
|
||||
"""Return C helper definitions and a straight-line/local-block schedule.
|
||||
|
||||
Existing scalar pressure bisection and stream convergence tolerances are
|
||||
retained. Only the computations in the relevant SCC participate in each
|
||||
closure; a pressure trial further restricts work to its residual inputs.
|
||||
"""
|
||||
helpers, lines = [], []
|
||||
args = 'p,h,g,w,q,fb,pipe_cache,properties'
|
||||
signature = ('double *p,double *h,const NativeGas *g,double *w,double *q,'
|
||||
'double *fb,NativePipeCache *pipe_cache,NativePropertyCache *properties')
|
||||
|
||||
def code(indices):
|
||||
return [line for i in indices for line in
|
||||
(f'/* schedule operation {i}: {self.computations[i].kind} */', *self.computations[i].code)]
|
||||
|
||||
def helper(name, indices):
|
||||
helpers.extend([f'static int {name}({signature}) {{',
|
||||
'(void)p;(void)h;(void)g;(void)w;(void)q;(void)fb;(void)pipe_cache;(void)properties;',
|
||||
*code(indices), 'return 1;', '}'])
|
||||
return f'if(!{name}({args})) return 0;'
|
||||
|
||||
for number, block in enumerate(self.blocks):
|
||||
if not block.cyclic:
|
||||
if self.computations[block.members[0]].kind == 'pressure':
|
||||
raise NativeCapabilityError('Pressure balance has no pressure-dependent flow relation')
|
||||
lines.extend(code(block.members))
|
||||
continue
|
||||
pressures = [i for i in block.members if self.computations[i].kind == 'pressure']
|
||||
streams = [i for i in block.members if self.computations[i].kind in ('stream', 'alias')]
|
||||
flows = set(block.members) - set(pressures) - set(streams)
|
||||
if all(self.computations[i].kind == 'alias' for i in block.members):
|
||||
raise NativeCapabilityError('Enthalpy reference cycle has no thermodynamic source: ' +
|
||||
', '.join(self.computations[i].key for i in block.members))
|
||||
refresh = helper(f'model_block_{number}_flows', self.ordered_subset(flows)) if flows else ''
|
||||
lines.append(f'{{ /* local algebraic block {number} */')
|
||||
hvars = [key for i in streams for key in self.computations[i].outputs]
|
||||
outputs = {key for i in block.members for key in self.computations[i].outputs}
|
||||
inputs = sorted({key for i in block.members for key in self.computations[i].inputs} - outputs)
|
||||
if hvars:
|
||||
seeds = [key for key in inputs if key.startswith('h[') or key.endswith('.h')]
|
||||
if not seeds:
|
||||
raise NativeCapabilityError('Local stream loop has no supplied thermodynamic state')
|
||||
lines.extend([*[f'{key}={seeds[0]};' for key in hvars], 'int closure_ok=0;',
|
||||
f'for(int closure=0;closure<{max(64,4*len(hvars))};closure++) {{',
|
||||
'double previous[]={' + ','.join(hvars) + '};'])
|
||||
if pressures:
|
||||
pressure_bounds = [key for key in inputs if key.startswith('p[') or key.endswith('.p')]
|
||||
if not pressure_bounds:
|
||||
raise NativeCapabilityError('Local pressure block has no pressure boundary')
|
||||
lines.extend(['double plo=INFINITY,phi=0;',
|
||||
*[f'plo=fmin(plo,{p});phi=fmax(phi,{p});' for p in pressure_bounds],
|
||||
*[f'{self.computations[i].outputs[0]}=.5*(plo+phi);' for i in pressures],
|
||||
'int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {'])
|
||||
for i in pressures:
|
||||
op = self.computations[i]
|
||||
p = op.outputs[0]
|
||||
trial = helper(f'model_block_{number}_pressure_{i}', self.ancestors(op.inputs, flows))
|
||||
lines.extend(['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',
|
||||
f'{p}=.5*(lo+hi);', trial,
|
||||
f'double balance={op.residual};if(!isfinite(balance)) return 0;',
|
||||
f'if(balance>0) hi={p};else lo={p};', '}}'])
|
||||
lines.extend([refresh, 'double residual=0;',
|
||||
*[f'{{double balance={self.computations[i].residual};if(!isfinite(balance)) return 0;residual=fmax(residual,fabs(balance));}}' for i in pressures],
|
||||
'if(residual<1e-11) {pressure_ok=1;break;}', '}',
|
||||
'if(!pressure_ok) return 0;'])
|
||||
elif refresh:
|
||||
lines.append(refresh)
|
||||
if hvars:
|
||||
# Gauss-Seidel within a genuine stream loop, in stable emission order.
|
||||
lines.extend([*code(streams), 'double change=0;',
|
||||
*[line for j,key in enumerate(hvars) for line in
|
||||
(f'if(!isfinite({key})) return 0;',
|
||||
f'change=fmax(change,fabs({key}-previous[{j}])/fmax(1,fabs({key})));')],
|
||||
'if(change<1e-12) {closure_ok=1;break;}', '}',
|
||||
'if(!closure_ok) return 0;', refresh])
|
||||
lines.append('}')
|
||||
return helpers, lines
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Small, uncached compile/link/run check using the production native toolchain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
from threading import Event
|
||||
from time import monotonic
|
||||
from typing import Callable
|
||||
|
||||
from .build import CACHE, NativeCommandError, link_library_arguments, platform_build_inputs, toolchain
|
||||
from .processes import command_environment, is_driver_launch_failure
|
||||
|
||||
STAGE_MESSAGES = {
|
||||
"toolchain": "正在查找编译器与 SUNDIALS",
|
||||
"preparing": "正在准备最小自检程序",
|
||||
"preprocessing": "正在预处理最小自检程序",
|
||||
"compiling": "正在编译最小自检程序",
|
||||
"linking": "正在链接最小自检程序",
|
||||
"running": "正在运行最小自检程序",
|
||||
"schema": "正在检查模型 XML 解析环境",
|
||||
}
|
||||
OUTPUT_MARKER = "native-runtime-check-ok"
|
||||
# One smooth scalar equation exercises every library used by production BDF.
|
||||
# Its known answer is independent of the user's model and cannot hit model cache.
|
||||
SOURCE = r'''
|
||||
#include <cvode/cvode.h>
|
||||
#include <cvode/cvode_ls.h>
|
||||
#include <nvector/nvector_serial.h>
|
||||
#include <sunmatrix/sunmatrix_dense.h>
|
||||
#include <sunlinsol/sunlinsol_dense.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
static int rhs(sunrealtype t, N_Vector y, N_Vector dy, void *data) {
|
||||
(void)t; (void)data;
|
||||
NV_Ith_S(dy,0)=-NV_Ith_S(y,0);
|
||||
return 0;
|
||||
}
|
||||
int main(void) {
|
||||
SUNContext context=NULL;
|
||||
N_Vector y=NULL;
|
||||
SUNMatrix matrix=NULL;
|
||||
SUNLinearSolver linear=NULL;
|
||||
void *solver=NULL;
|
||||
int result=1;
|
||||
sunrealtype time=0;
|
||||
if (SUNContext_Create(SUN_COMM_NULL,&context)) goto done;
|
||||
y=N_VNew_Serial(1,context);
|
||||
if (!y) goto done;
|
||||
NV_Ith_S(y,0)=1;
|
||||
matrix=SUNDenseMatrix(1,1,context);
|
||||
if (!matrix) goto done;
|
||||
linear=SUNLinSol_Dense(y,matrix,context);
|
||||
solver=CVodeCreate(CV_BDF,context);
|
||||
if (!linear || !solver) goto done;
|
||||
if (CVodeInit(solver,rhs,0,y)<0 ||
|
||||
CVodeSStolerances(solver,1e-8,1e-10)<0 ||
|
||||
CVodeSetLinearSolver(solver,linear,matrix)<0 ||
|
||||
CVode(solver,0.1,y,&time,CV_NORMAL)<0) goto done;
|
||||
if (!isfinite(NV_Ith_S(y,0)) || fabs(NV_Ith_S(y,0)-exp(-0.1))>1e-6) goto done;
|
||||
puts("native-runtime-check-ok");
|
||||
result=0;
|
||||
done:
|
||||
if (result) fputs("Minimal BDF self-check failed.\n",stderr);
|
||||
if (solver) CVodeFree(&solver);
|
||||
if (linear) SUNLinSolFree(linear);
|
||||
if (matrix) SUNMatDestroy(matrix);
|
||||
if (y) N_VDestroy(y);
|
||||
if (context) SUNContext_Free(&context);
|
||||
return result;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class CheckCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CheckFailure(RuntimeError):
|
||||
def __init__(self, message: str, *, command=(), cwd=None, exit_code=None,
|
||||
stdout="", stderr="", error_type="RuntimeError"):
|
||||
super().__init__(message)
|
||||
self.details = {"command": list(command), "cwd": str(cwd) if cwd else None,
|
||||
"exitCode": exit_code, "stdout": stdout[-8000:],
|
||||
"stderr": stderr[-8000:], "errorType": error_type}
|
||||
|
||||
|
||||
def _stop_process_tree(process: subprocess.Popen) -> None:
|
||||
# GCC starts cc1/as/ld. Killing only its driver can leave inherited pipes open.
|
||||
if os.name == "nt":
|
||||
try:
|
||||
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=5,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
else:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def _command(command: list[str], directory: Path, stop: Event, timeout: float = 30) -> str:
|
||||
if stop.is_set():
|
||||
raise CheckCancelled()
|
||||
try:
|
||||
with subprocess.Popen(command, cwd=directory, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
env=command_environment(command[0]), close_fds=True,
|
||||
start_new_session=os.name != "nt",
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) as process:
|
||||
deadline = monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=0.2)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
if stop.is_set() or monotonic() >= deadline:
|
||||
_stop_process_tree(process)
|
||||
stdout, stderr = process.communicate()
|
||||
if stop.is_set():
|
||||
raise CheckCancelled()
|
||||
raise CheckFailure(f"命令执行超过 {timeout:g} 秒", command=command,
|
||||
cwd=directory, exit_code=process.returncode,
|
||||
stdout=stdout.decode("utf-8", errors="replace"),
|
||||
stderr=stderr.decode("utf-8", errors="replace"),
|
||||
error_type="TimeoutExpired")
|
||||
except OSError as exc:
|
||||
raise CheckFailure(str(exc), command=command, cwd=directory,
|
||||
error_type=type(exc).__name__) from exc
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
if process.returncode:
|
||||
stderr_text = stderr.decode('utf-8', errors='replace')
|
||||
message = ('GCC 已启动,但无法启动编译子进程;该错误不能直接判定为编译器不存在'
|
||||
if is_driver_launch_failure(stderr_text) else '命令执行失败')
|
||||
# Whole-check retries remain owned by warmup (initial check + 5 rounds).
|
||||
raise CheckFailure(message, command=command, cwd=directory,
|
||||
exit_code=process.returncode, stdout=output,
|
||||
stderr=stderr_text,
|
||||
error_type="CalledProcessError")
|
||||
return output
|
||||
|
||||
|
||||
def check_native_runtime(progress: Callable[..., None], stop: Event) -> None:
|
||||
def stage(name: str, **details):
|
||||
if stop.is_set():
|
||||
raise CheckCancelled()
|
||||
progress(stage=name, message=STAGE_MESSAGES[name], **details)
|
||||
|
||||
candidate = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
|
||||
stage("toolchain", compiler=candidate)
|
||||
try:
|
||||
compiler, sundials, version = toolchain()
|
||||
except NativeCommandError as exc:
|
||||
details = exc.details
|
||||
failure = CheckFailure(str(exc), command=details['command'], cwd=details['cwd'],
|
||||
exit_code=details['exitCode'], stdout=details['stdout'], stderr=details['stderr'],
|
||||
error_type=details['errorType'])
|
||||
failure.details['attempts'] = details['attempts']
|
||||
raise failure from exc
|
||||
except subprocess.SubprocessError as exc:
|
||||
def decoded(value):
|
||||
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else (value or "")
|
||||
raise CheckFailure(str(exc), command=getattr(exc, "cmd", ()), cwd=Path.cwd(),
|
||||
exit_code=getattr(exc, "returncode", None),
|
||||
stdout=decoded(getattr(exc, "stdout", "")),
|
||||
stderr=decoded(getattr(exc, "stderr", "")),
|
||||
error_type=type(exc).__name__) from exc
|
||||
except OSError as exc:
|
||||
raise CheckFailure(str(exc), command=[candidate, "--version"] if candidate else [],
|
||||
cwd=Path.cwd(), error_type=type(exc).__name__) from exc
|
||||
compiler = str(Path(shutil.which(compiler) or compiler).resolve())
|
||||
stage("preparing", compiler=compiler, compilerVersion=version, sundialsRoot=str(sundials))
|
||||
flags, libraries, dlls, executable_name = platform_build_inputs(sundials)
|
||||
# Keep the probe on the build filesystem: Linux /tmp may be mounted noexec.
|
||||
# Disposable probes never populate or evict the model/object caches.
|
||||
directory_root = CACHE.parent / "native-runtime-checks"
|
||||
directory_root.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="check-", dir=directory_root) as temporary:
|
||||
directory = Path(temporary).resolve()
|
||||
source, preprocessed, obj = directory / "check.c", directory / "check.i", directory / "check.o"
|
||||
executable = directory / executable_name
|
||||
source.write_text(SOURCE, encoding="utf-8", newline="\n")
|
||||
stage("preprocessing")
|
||||
_command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={directory}=/generated",
|
||||
"-I", str(sundials / "include"), str(source), "-o", str(preprocessed)], directory, stop)
|
||||
stage("compiling")
|
||||
_command([compiler, *flags, "-x", "cpp-output", "-c", str(preprocessed), "-o", str(obj)], directory, stop)
|
||||
stage("linking")
|
||||
_command([compiler, *flags, str(obj), *link_library_arguments(libraries),
|
||||
"-lm", "-o", str(executable)], directory, stop)
|
||||
stage("running")
|
||||
for dll in dlls:
|
||||
shutil.copyfile(dll, directory / dll.name)
|
||||
output = _command([str(executable)], directory, stop, timeout=10)
|
||||
if output.strip() != OUTPUT_MARKER:
|
||||
raise CheckFailure("最小程序未返回预期的自检成功标记", command=[str(executable)],
|
||||
cwd=directory, exit_code=0, stdout=output, error_type="UnexpectedOutput")
|
||||
stage("schema")
|
||||
from app.system_xml import validate_system_xml_document
|
||||
validate_system_xml_document(b"<System/>")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""SI absolute error floors shared by both native code generators.
|
||||
|
||||
Mass must not inherit the energy floor: 1e-8 kg overwhelmed relative error
|
||||
control in small pipe volumes. These conservative defaults were validated on
|
||||
the pipe and chamber regressions; model-scale/user tolerance settings remain
|
||||
a separate protocol change.
|
||||
"""
|
||||
|
||||
|
||||
def state_absolute_tolerance(key: str) -> str:
|
||||
field = key.rsplit('.', 1)[-1]
|
||||
if field in ('m', 'm1', 'm2'):
|
||||
return '1e-14' # kg
|
||||
if field in ('v', 'x'):
|
||||
return '1e-12' # m/s or m
|
||||
if field == '_volume_displacement':
|
||||
return '1e-14' # m3, prescribed external volume change
|
||||
return '1e-8' # J (or the internal constant state of an algebraic model)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Carry trusted C-generated series JSON without a Python float-array round trip.
|
||||
|
||||
Only native output plus its byte index may construct this transport object. Public
|
||||
JSON still has the ordinary object/array schema; synchronous callers materialize
|
||||
it explicitly. Bytes own their lifetime independently of the worker directory.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeSeriesJson:
|
||||
data: bytes
|
||||
sample_count: int
|
||||
|
||||
def materialize(self) -> dict[str, list[float]]:
|
||||
return json.loads(self.data)
|
||||
|
||||
|
||||
def read_indexed_result(output: Path, index_path: Path) -> dict:
|
||||
index = json.loads(index_path.read_bytes())
|
||||
if not isinstance(index, dict) or type(index.get('version')) is not int or index['version'] != 1:
|
||||
raise ValueError('Unsupported native result index.')
|
||||
names = ('seriesStart', 'seriesEnd', 'resultBytes', 'sampleCount')
|
||||
if any(type(index.get(key)) is not int for key in names):
|
||||
raise ValueError('Invalid native result index integers.')
|
||||
start, end, length, count = (index[key] for key in names)
|
||||
if not (0 < start < end < length and count >= 0):
|
||||
raise ValueError('Invalid native result index bounds.')
|
||||
if output.stat().st_size != length:
|
||||
raise ValueError('Incomplete native result file.')
|
||||
raw = output.read_bytes()
|
||||
if (len(raw) != length or not raw[:start].endswith(b'"series":')
|
||||
or raw[start:start+1] != b'{' or raw[end-1:end] != b'}'
|
||||
or not raw[end:].startswith(b',"final":')):
|
||||
raise ValueError('Native result index does not match the output layout.')
|
||||
# The C writer supplies exact boundaries. No search through strings or numeric
|
||||
# arrays, and no large json.loads call: only diagnostics/final values are read.
|
||||
metadata = json.loads(raw[:start] + b'{}' + raw[end:])
|
||||
if not isinstance(metadata, dict) or metadata.get('series') != {}:
|
||||
raise ValueError('Invalid native result metadata.')
|
||||
metadata['series'] = NativeSeriesJson(raw[start:end], count)
|
||||
return metadata
|
||||
|
||||
|
||||
def serialize_result_parts(payload: dict) -> tuple[bytes, ...]:
|
||||
"""Return one JSON object in three byte segments, without copying its series.
|
||||
|
||||
The only raw position is payload.result.series, produced by our C writer;
|
||||
all model-provided labels, messages and keys use the standard JSON encoder.
|
||||
"""
|
||||
result = payload.get('result')
|
||||
series = result.get('series') if isinstance(result, dict) else None
|
||||
if not isinstance(series, NativeSeriesJson):
|
||||
return (json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8'),)
|
||||
metadata = {key: value for key, value in result.items() if key != 'series'}
|
||||
outer = {key: value for key, value in payload.items() if key != 'result'}
|
||||
encoded = json.dumps(metadata, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
|
||||
remainder = json.dumps(outer, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
|
||||
prefix = b'{"result":' + encoded[:-1] + (b',' if metadata else b'') + b'"series":'
|
||||
suffix = b'}' + (b',' + remainder[1:] if outer else b'}')
|
||||
return prefix, series.data, suffix
|
||||
+10
-1
@@ -15,7 +15,16 @@ DATA_DIR = (
|
||||
)
|
||||
|
||||
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
|
||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines"
|
||||
# Current browser/solver input and the matching AME drawing (September 2026).
|
||||
CURRENT_MQL8_PROJECT_PATH = PROJECT_ROOT / "tests" / "data" / "test-mql-8-corrected.json"
|
||||
CURRENT_MQL8_AME_PATH = PROJECT_ROOT / "tests" / "data" / "test_mql.ame"
|
||||
|
||||
# Frozen August reference: its AME bytes differ from the current drawing.
|
||||
HISTORICAL_MQL8_AME_PATH = PROJECT_ROOT / "tests" / "data" / "AmesimModels" / "test_mql.ame"
|
||||
HISTORICAL_MQL8_PROJECT_PATH = SIMULATION_BASELINES_DIR / "simulation" / "test_mql_8" / "sources" / "test-mql-8.json"
|
||||
HISTORICAL_MQL8_XML_PATH = HISTORICAL_MQL8_PROJECT_PATH.with_suffix(".xml")
|
||||
AMESIM_TEST_MQL_ARCHIVE_PATH = HISTORICAL_MQL8_AME_PATH
|
||||
MODELICA_TESTMODEL_RESULT_PATH = (
|
||||
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Run-local timing of Python orchestration; numeric timings come from C."""
|
||||
from __future__ import annotations
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
import inspect
|
||||
import os
|
||||
from time import perf_counter_ns
|
||||
from typing import Any, Literal, ParamSpec, TypeVar, cast
|
||||
|
||||
ProfileMode = Literal['off', 'standard', 'audit']
|
||||
_P = ParamSpec('_P')
|
||||
_R = TypeVar('_R')
|
||||
_MODE_RANK: Mapping[ProfileMode, int] = {'off': 0, 'standard': 1, 'audit': 2}
|
||||
|
||||
def _read_startup_mode() -> ProfileMode:
|
||||
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
|
||||
aliases: dict[str, ProfileMode] = {
|
||||
"": "off",
|
||||
"0": "off",
|
||||
"false": "off",
|
||||
"no": "off",
|
||||
"off": "off",
|
||||
"1": "standard",
|
||||
"true": "standard",
|
||||
"yes": "standard",
|
||||
"on": "standard",
|
||||
"standard": "standard",
|
||||
"audit": "audit",
|
||||
}
|
||||
try:
|
||||
return aliases[raw_mode]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
|
||||
) from exc
|
||||
|
||||
def _minimum_mode(value: str) -> ProfileMode:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in _MODE_RANK:
|
||||
raise ValueError("minimum_mode must be one of: off, standard, audit.")
|
||||
return cast(ProfileMode, normalized)
|
||||
|
||||
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
||||
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
|
||||
# ``minimum_mode="off"`` by mistake.
|
||||
return (
|
||||
PROFILE_MODE != "off"
|
||||
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class _TimingStats:
|
||||
calls: int = 0
|
||||
inclusive_ns: int = 0
|
||||
self_ns: int = 0
|
||||
max_ns: int = 0
|
||||
errors: int = 0
|
||||
|
||||
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
|
||||
self.calls += 1
|
||||
self.inclusive_ns += inclusive_ns
|
||||
self.self_ns += self_ns
|
||||
self.max_ns = max(self.max_ns, inclusive_ns)
|
||||
if error:
|
||||
self.errors += 1
|
||||
|
||||
def snapshot(self) -> dict[str, int]:
|
||||
return {
|
||||
"calls": self.calls,
|
||||
"inclusiveNs": self.inclusive_ns,
|
||||
"selfNs": self.self_ns,
|
||||
"maxNs": self.max_ns,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
def profile_phase(
|
||||
name: str,
|
||||
minimum_mode: str = "standard",
|
||||
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||
"""Decorate a simulation phase while preserving the off-mode callable."""
|
||||
|
||||
minimum = _minimum_mode(minimum_mode)
|
||||
|
||||
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
if not _mode_enabled(minimum):
|
||||
return function
|
||||
|
||||
if inspect.iscoroutinefunction(function):
|
||||
|
||||
@wraps(function)
|
||||
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None:
|
||||
return await function(*args, **kwargs)
|
||||
with _tracked_span(
|
||||
trace,
|
||||
name,
|
||||
|
||||
):
|
||||
return await function(*args, **kwargs)
|
||||
|
||||
return cast(Callable[_P, _R], async_wrapper)
|
||||
|
||||
@wraps(function)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None:
|
||||
return function(*args, **kwargs)
|
||||
with _tracked_span(
|
||||
trace,
|
||||
name,
|
||||
|
||||
):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
PROFILE_MODE = _read_startup_mode()
|
||||
|
||||
@dataclass
|
||||
class _ActiveSpan:
|
||||
name: str
|
||||
started_ns: int
|
||||
child_ns: int = 0
|
||||
|
||||
@dataclass
|
||||
class PerformanceTrace:
|
||||
mode: ProfileMode
|
||||
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return self.mode != 'off'
|
||||
|
||||
def snapshot(self):
|
||||
return {'mode': self.mode, 'phases': {key: value.snapshot() for key, value in sorted(self._phases.items())}}
|
||||
|
||||
_CURRENT_TRACE = ContextVar('simulation_trace', default=None)
|
||||
_ACTIVE_SPANS = ContextVar('simulation_spans', default=())
|
||||
|
||||
@contextmanager
|
||||
def _tracked_span(trace, name):
|
||||
stack = _ACTIVE_SPANS.get()
|
||||
frame = _ActiveSpan(name, perf_counter_ns())
|
||||
token = _ACTIVE_SPANS.set((*stack, frame))
|
||||
error = False
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
error = True
|
||||
raise
|
||||
finally:
|
||||
elapsed = max(0, perf_counter_ns()-frame.started_ns)
|
||||
_ACTIVE_SPANS.reset(token)
|
||||
if stack:
|
||||
stack[-1].child_ns += elapsed
|
||||
trace._phases.setdefault(name, _TimingStats()).record(elapsed, max(0, elapsed-frame.child_ns), error)
|
||||
|
||||
@contextmanager
|
||||
def profile_run():
|
||||
trace = PerformanceTrace(PROFILE_MODE)
|
||||
token = _CURRENT_TRACE.set(trace)
|
||||
spans = _ACTIVE_SPANS.set(())
|
||||
try:
|
||||
if trace.enabled:
|
||||
with _tracked_span(trace, 'simulation.total'):
|
||||
yield trace
|
||||
else:
|
||||
yield trace
|
||||
finally:
|
||||
_ACTIVE_SPANS.reset(spans)
|
||||
_CURRENT_TRACE.reset(token)
|
||||
|
||||
@contextmanager
|
||||
def performance_span(name: str, minimum_mode: str = 'standard'):
|
||||
minimum = _minimum_mode(minimum_mode)
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None or not _mode_enabled(minimum):
|
||||
yield
|
||||
return
|
||||
with _tracked_span(trace, name):
|
||||
yield
|
||||
+341
-3
@@ -12,21 +12,26 @@ from app.simulation.core.catalog import (
|
||||
ComponentCategorySpec,
|
||||
ComponentDisplaySpec,
|
||||
ComponentLibrarySpec,
|
||||
ParameterGroupDisplaySpec,
|
||||
PortDisplaySpec,
|
||||
)
|
||||
from app.simulation.core.metadata import (
|
||||
SI_UNIT_BY_QUANTITY,
|
||||
ParameterCondition,
|
||||
ParameterDefinition,
|
||||
ParameterOption,
|
||||
ResultVariableDefinition,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.core.medium import GasMedium, IdealGasMedium
|
||||
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
|
||||
from app.simulation.core.port_computation import PortComputation
|
||||
|
||||
|
||||
ParameterSpec = ParameterDefinition
|
||||
|
||||
ENABLED_COMPONENT_LIBRARIES = (
|
||||
"app.simulation.components.experimental.library:LIBRARY",
|
||||
"app.simulation.components.amesim.library:LIBRARY",
|
||||
)
|
||||
|
||||
_MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*")
|
||||
@@ -69,6 +74,16 @@ class ComponentModelSpec:
|
||||
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
|
||||
return {parameter.name: parameter for parameter in self.parameters}
|
||||
|
||||
def active_ports(
|
||||
self,
|
||||
values: Mapping[str, float],
|
||||
) -> tuple[PortDefinition, ...]:
|
||||
resolved = {
|
||||
parameter.name: values.get(parameter.name, parameter.default)
|
||||
for parameter in self.parameters
|
||||
}
|
||||
return self.component_class.active_port_definitions_for_parameters(resolved)
|
||||
|
||||
def as_catalog_dict(self) -> dict[str, object]:
|
||||
category = self.library.category_by_id[self.display.category_id]
|
||||
display_ports = self.display.port_by_name
|
||||
@@ -80,7 +95,7 @@ class ComponentModelSpec:
|
||||
ports.append(payload)
|
||||
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
|
||||
|
||||
return {
|
||||
payload: dict[str, object] = {
|
||||
"type": self.model_type,
|
||||
"modelType": self.model_type,
|
||||
"modelVersion": self.model_version,
|
||||
@@ -93,11 +108,22 @@ class ComponentModelSpec:
|
||||
parameter.as_interface_dict() for parameter in self.parameters
|
||||
],
|
||||
}
|
||||
if self.display.role is not None:
|
||||
payload["role"] = self.display.role
|
||||
if self.display.parameter_groups:
|
||||
payload["parameterGroups"] = [
|
||||
group.as_catalog_dict()
|
||||
for group in sorted(
|
||||
self.display.parameter_groups,
|
||||
key=lambda item: (item.order, item.id),
|
||||
)
|
||||
]
|
||||
return payload
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
medium: GasMedium,
|
||||
values: Mapping[str, float],
|
||||
) -> Component:
|
||||
unknown = sorted(set(values) - set(self.parameter_by_name))
|
||||
@@ -275,6 +301,81 @@ def _validate_category(
|
||||
)
|
||||
|
||||
|
||||
def _validate_parameter_groups(
|
||||
parameter_groups: object,
|
||||
*,
|
||||
model_type: str,
|
||||
parameter_names: Sequence[str],
|
||||
) -> None:
|
||||
field = f"Component '{model_type}' display parameter groups"
|
||||
if not isinstance(parameter_groups, tuple):
|
||||
raise ValueError(f"{field} must be a tuple.")
|
||||
|
||||
group_ids: list[str] = []
|
||||
grouped_parameter_names: list[str] = []
|
||||
known_parameter_names = set(parameter_names)
|
||||
for group in parameter_groups:
|
||||
if not isinstance(group, ParameterGroupDisplaySpec):
|
||||
raise ValueError(
|
||||
f"{field} must use ParameterGroupDisplaySpec."
|
||||
)
|
||||
group_id = _validate_machine_id(
|
||||
group.id,
|
||||
field=f"{field} id",
|
||||
)
|
||||
_validate_label(
|
||||
group.label,
|
||||
field=f"{field} '{group_id}' label",
|
||||
)
|
||||
_validate_order(
|
||||
group.order,
|
||||
field=f"{field} '{group_id}' order",
|
||||
)
|
||||
if type(group.default_expanded) is not bool:
|
||||
raise ValueError(
|
||||
f"{field} '{group_id}' default_expanded must be a boolean."
|
||||
)
|
||||
if not isinstance(group.parameters, tuple) or not group.parameters:
|
||||
raise ValueError(
|
||||
f"{field} '{group_id}' parameters must use a non-empty tuple."
|
||||
)
|
||||
for parameter_name in group.parameters:
|
||||
_validate_member_id(
|
||||
parameter_name,
|
||||
field=f"{field} '{group_id}' parameter name",
|
||||
)
|
||||
_validate_unique_names(
|
||||
list(group.parameters),
|
||||
field=f"{field} '{group_id}' parameters",
|
||||
)
|
||||
unknown_parameters = sorted(
|
||||
set(group.parameters) - known_parameter_names
|
||||
)
|
||||
if unknown_parameters:
|
||||
raise ValueError(
|
||||
f"{field} '{group_id}' references unknown parameters: "
|
||||
+ ", ".join(unknown_parameters)
|
||||
+ "."
|
||||
)
|
||||
group_ids.append(group_id)
|
||||
grouped_parameter_names.extend(group.parameters)
|
||||
|
||||
_validate_unique_names(group_ids, field=field)
|
||||
duplicate_memberships = sorted(
|
||||
{
|
||||
name
|
||||
for name in grouped_parameter_names
|
||||
if grouped_parameter_names.count(name) > 1
|
||||
}
|
||||
)
|
||||
if duplicate_memberships:
|
||||
raise ValueError(
|
||||
f"{field} assign parameters to multiple groups: "
|
||||
+ ", ".join(duplicate_memberships)
|
||||
+ "."
|
||||
)
|
||||
|
||||
|
||||
def validate_component_library(library: ComponentLibrarySpec) -> None:
|
||||
if not isinstance(library, ComponentLibrarySpec):
|
||||
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
|
||||
@@ -409,6 +510,14 @@ def _validate_parameter(
|
||||
raise ValueError(f"{field} declarations must use ParameterDefinition.")
|
||||
_validate_member_id(parameter.name, field=f"{field} name")
|
||||
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
|
||||
if not isinstance(parameter.description, str):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' description must be a string."
|
||||
)
|
||||
if parameter.description and not parameter.description.strip():
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' description must not be blank."
|
||||
)
|
||||
_validate_quantity_unit(
|
||||
parameter.quantity,
|
||||
parameter.unit,
|
||||
@@ -432,6 +541,131 @@ def _validate_parameter(
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
|
||||
)
|
||||
if parameter.editor not in {
|
||||
None,
|
||||
"amesimGasReference",
|
||||
"amesimGasPropertyModel",
|
||||
"choice",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' uses unsupported editor "
|
||||
f"'{parameter.editor}'."
|
||||
)
|
||||
if not isinstance(parameter.options, tuple):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' options must use a tuple."
|
||||
)
|
||||
if parameter.editor == "amesimGasPropertyModel" and not parameter.options:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' property-model editor must declare options."
|
||||
)
|
||||
if parameter.editor == "choice" and not parameter.options:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' choice editor must declare options."
|
||||
)
|
||||
if parameter.options and parameter.editor not in {
|
||||
"amesimGasPropertyModel",
|
||||
"choice",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' options require the "
|
||||
"'choice' or 'amesimGasPropertyModel' editor."
|
||||
)
|
||||
option_values: list[float] = []
|
||||
for option in parameter.options:
|
||||
if not isinstance(option, ParameterOption):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' options must use ParameterOption."
|
||||
)
|
||||
if (
|
||||
isinstance(option.value, bool)
|
||||
or not isinstance(option.value, (int, float))
|
||||
or not isfinite(option.value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' option values must be finite numbers."
|
||||
)
|
||||
_validate_label(
|
||||
option.label,
|
||||
field=(
|
||||
f"{field} '{parameter.name}' option "
|
||||
f"'{option.value:g}' label"
|
||||
),
|
||||
)
|
||||
numeric_option = float(option.value)
|
||||
if (
|
||||
parameter.editor == "amesimGasPropertyModel"
|
||||
and not numeric_option.is_integer()
|
||||
):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' property-model option values "
|
||||
"must be integers."
|
||||
)
|
||||
option_message = parameter.validation_message(numeric_option)
|
||||
if option_message is not None:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' option {numeric_option:g} "
|
||||
f"{option_message}."
|
||||
)
|
||||
option_values.append(numeric_option)
|
||||
duplicate_option_values = sorted(
|
||||
{value for value in option_values if option_values.count(value) > 1}
|
||||
)
|
||||
if duplicate_option_values:
|
||||
duplicates = ", ".join(f"{value:g}" for value in duplicate_option_values)
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' contains duplicate option values: "
|
||||
f"{duplicates}."
|
||||
)
|
||||
if not isinstance(parameter.visible_when, tuple):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' visible_when must use a tuple."
|
||||
)
|
||||
for condition in parameter.visible_when:
|
||||
if not isinstance(condition, ParameterCondition):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' visible_when entries must use "
|
||||
"ParameterCondition."
|
||||
)
|
||||
_validate_member_id(
|
||||
condition.parameter,
|
||||
field=(
|
||||
f"{field} '{parameter.name}' visibility condition parameter"
|
||||
),
|
||||
)
|
||||
if not isinstance(condition.values, tuple) or not condition.values:
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' visibility condition values "
|
||||
"must use a non-empty tuple."
|
||||
)
|
||||
numeric_condition_values: list[float] = []
|
||||
for value in condition.values:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not isfinite(value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' visibility condition values "
|
||||
"must be finite numbers."
|
||||
)
|
||||
numeric_condition_values.append(float(value))
|
||||
duplicate_condition_values = sorted(
|
||||
{
|
||||
value
|
||||
for value in numeric_condition_values
|
||||
if numeric_condition_values.count(value) > 1
|
||||
}
|
||||
)
|
||||
if duplicate_condition_values:
|
||||
duplicates = ", ".join(
|
||||
f"{value:g}" for value in duplicate_condition_values
|
||||
)
|
||||
raise ValueError(
|
||||
f"{field} '{parameter.name}' visibility condition for "
|
||||
f"'{condition.parameter}' contains duplicate values: "
|
||||
f"{duplicates}."
|
||||
)
|
||||
if (
|
||||
parameter.minimum is not None
|
||||
and parameter.maximum is not None
|
||||
@@ -447,6 +681,89 @@ def _validate_parameter(
|
||||
)
|
||||
|
||||
|
||||
def _validate_parameter_visibility(
|
||||
parameters: tuple[ParameterDefinition, ...],
|
||||
*,
|
||||
model_type: str,
|
||||
) -> None:
|
||||
"""Validate cross-parameter references used by catalog visibility rules."""
|
||||
|
||||
parameter_by_name = {parameter.name: parameter for parameter in parameters}
|
||||
dependencies: dict[str, tuple[str, ...]] = {}
|
||||
|
||||
for parameter in parameters:
|
||||
controllers: list[str] = []
|
||||
for condition in parameter.visible_when:
|
||||
controller_name = condition.parameter
|
||||
controller = parameter_by_name.get(controller_name)
|
||||
if controller is None:
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||
"visibility condition references unknown parameter "
|
||||
f"'{controller_name}'."
|
||||
)
|
||||
if controller_name == parameter.name:
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||
"visibility condition cannot reference itself."
|
||||
)
|
||||
if controller_name in controllers:
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||
"contains duplicate visibility controller "
|
||||
f"'{controller_name}'."
|
||||
)
|
||||
if not controller.options:
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||
f"visibility controller '{controller_name}' must declare "
|
||||
"options."
|
||||
)
|
||||
controller_values = {
|
||||
float(option.value) for option in controller.options
|
||||
}
|
||||
unsupported_values = sorted(
|
||||
{
|
||||
float(value)
|
||||
for value in condition.values
|
||||
if float(value) not in controller_values
|
||||
}
|
||||
)
|
||||
if unsupported_values:
|
||||
values = ", ".join(f"{value:g}" for value in unsupported_values)
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' parameter '{parameter.name}' "
|
||||
f"visibility condition values for '{controller_name}' "
|
||||
f"must belong to its options; unsupported: {values}."
|
||||
)
|
||||
controllers.append(controller_name)
|
||||
dependencies[parameter.name] = tuple(controllers)
|
||||
|
||||
states: dict[str, int] = {}
|
||||
stack: list[str] = []
|
||||
|
||||
def visit(parameter_name: str) -> None:
|
||||
state = states.get(parameter_name, 0)
|
||||
if state == 2:
|
||||
return
|
||||
if state == 1:
|
||||
cycle_start = stack.index(parameter_name)
|
||||
cycle = stack[cycle_start:] + [parameter_name]
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' parameter visibility dependencies "
|
||||
f"contain a cycle: {' -> '.join(cycle)}."
|
||||
)
|
||||
states[parameter_name] = 1
|
||||
stack.append(parameter_name)
|
||||
for controller_name in dependencies[parameter_name]:
|
||||
visit(controller_name)
|
||||
stack.pop()
|
||||
states[parameter_name] = 2
|
||||
|
||||
for parameter in parameters:
|
||||
visit(parameter.name)
|
||||
|
||||
|
||||
def _validate_result_variable(
|
||||
variable: ResultVariableDefinition,
|
||||
*,
|
||||
@@ -531,6 +848,11 @@ def validate_component_model_class(
|
||||
field=f"Component '{model_type}' display symbol",
|
||||
)
|
||||
_validate_order(display.order, field=f"Component '{model_type}' display order")
|
||||
if display.role not in {None, "amesimGasMediumDefinition"}:
|
||||
raise ValueError(
|
||||
f"Component '{model_type}' DISPLAY uses unsupported role "
|
||||
f"'{display.role}'."
|
||||
)
|
||||
if not isinstance(display.ports, tuple):
|
||||
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
|
||||
if display.library_id != library.id:
|
||||
@@ -556,8 +878,18 @@ def validate_component_model_class(
|
||||
f"Component '{model_type}' RESULT_VARIABLES must be a tuple."
|
||||
)
|
||||
|
||||
port_by_name = {port.name: port for port in ports if isinstance(port, PortDefinition)}
|
||||
for port in ports:
|
||||
_validate_port(port, model_type=model_type)
|
||||
contract = port.computation
|
||||
if contract is not None:
|
||||
if port.domain != 'pneumatic' or not isinstance(contract, PortComputation):
|
||||
raise ValueError(f"Component '{model_type}' has an invalid pneumatic computation contract.")
|
||||
if contract.reference_port:
|
||||
reference = port_by_name.get(contract.reference_port)
|
||||
if (reference is None or reference.computation is None
|
||||
or not {'p', 'T'}.issubset(reference.computation.inputs)):
|
||||
raise ValueError(f"Component '{model_type}.{port.name}' must alias a pressure/temperature input port.")
|
||||
for parameter in parameters:
|
||||
_validate_parameter(parameter, model_type=model_type)
|
||||
for variable in result_variables:
|
||||
@@ -590,6 +922,12 @@ def validate_component_model_class(
|
||||
parameter_names,
|
||||
field=f"Component '{model_type}' parameters",
|
||||
)
|
||||
_validate_parameter_groups(
|
||||
display.parameter_groups,
|
||||
model_type=model_type,
|
||||
parameter_names=parameter_names,
|
||||
)
|
||||
_validate_parameter_visibility(parameters, model_type=model_type)
|
||||
_validate_unique_names(
|
||||
result_names,
|
||||
field=f"Component '{model_type}' result variables",
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
from app.simulation.reporting.testmodel_outputs import (
|
||||
COMPARISON_KEYS,
|
||||
MODELICA_COMPARISON_COLUMNS,
|
||||
PRIMARY_KEYS,
|
||||
TestModelArtifacts,
|
||||
export_testmodel_artifacts,
|
||||
format_testmodel_run_report,
|
||||
load_modelica_series,
|
||||
write_testmodel_run_report,
|
||||
write_modelica_comparison,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"COMPARISON_KEYS",
|
||||
"MODELICA_COMPARISON_COLUMNS",
|
||||
"PRIMARY_KEYS",
|
||||
"TestModelArtifacts",
|
||||
"export_testmodel_artifacts",
|
||||
"format_testmodel_run_report",
|
||||
"load_modelica_series",
|
||||
"write_testmodel_run_report",
|
||||
"write_modelica_comparison",
|
||||
]
|
||||
"""Readers for external simulation results."""
|
||||
@@ -0,0 +1,263 @@
|
||||
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,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,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Backend-neutral simulation result and preparation error contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.simulation.native_codegen.transport import NativeSeriesJson
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
|
||||
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationPreparationIssue:
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"code": self.code, "message": self.message}
|
||||
|
||||
|
||||
class SimulationPreparationError(ValueError):
|
||||
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
|
||||
super().__init__("The compiled model is not ready for simulation.")
|
||||
self.issues = issues
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenericSimulationResult:
|
||||
success: bool
|
||||
status: SimulationRunStatus
|
||||
message: str
|
||||
simulated_until: float
|
||||
requested_stop_time: float
|
||||
variables: tuple[ResultVariableMetadata, ...]
|
||||
series: dict[str, list[float]] | NativeSeriesJson
|
||||
final: dict[str, float]
|
||||
diagnostics: dict[str, object]
|
||||
|
||||
def as_dict(self, *, raw_series: bool = False) -> dict[str, object]:
|
||||
from app.simulation.native_codegen.transport import NativeSeriesJson
|
||||
series = self.series
|
||||
if isinstance(series, NativeSeriesJson) and not raw_series:
|
||||
series = series.materialize()
|
||||
return {
|
||||
"success": self.success,
|
||||
"status": self.status,
|
||||
"partial": self.status != "completed",
|
||||
"message": self.message,
|
||||
"simulatedUntil": self.simulated_until,
|
||||
"requestedStopTime": self.requested_stop_time,
|
||||
"variables": [variable.as_dict() for variable in self.variables],
|
||||
"series": series,
|
||||
"final": self.final,
|
||||
"diagnostics": self.diagnostics,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Validate the requested output time grid before starting the C worker."""
|
||||
from __future__ import annotations
|
||||
from math import floor, isfinite
|
||||
from sys import maxsize
|
||||
from app.simulation.config import SolveIVPConfig
|
||||
|
||||
class SimulationSampleTimeError(ValueError):
|
||||
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
def simulation_sample_times(
|
||||
config: SolveIVPConfig,
|
||||
step: float,
|
||||
) -> list[float]:
|
||||
t_start = float(config.t_start)
|
||||
t_stop = float(config.t_stop)
|
||||
if not isfinite(t_start) or not isfinite(t_stop):
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_VALUE_NOT_FINITE",
|
||||
"Simulation start and stop times must be finite.",
|
||||
)
|
||||
if step <= 0.0 or not isfinite(step):
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_STEP_INVALID",
|
||||
"Simulation sample step must be finite and greater than zero.",
|
||||
)
|
||||
duration = t_stop - t_start
|
||||
if not isfinite(duration):
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_TIME_SPAN_NOT_FINITE",
|
||||
"Simulation time span must be finite.",
|
||||
)
|
||||
if duration <= 0.0:
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_TIME_RANGE_INVALID",
|
||||
"Simulation stop time must be greater than start time.",
|
||||
)
|
||||
|
||||
ratio = duration / step
|
||||
if not isfinite(ratio):
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
||||
"Simulation sample count cannot be represented by this runtime; "
|
||||
"increase sampleStep.",
|
||||
)
|
||||
interval_count = floor(ratio)
|
||||
# There is no product-level point cap. Still reject a collection that the
|
||||
# Python runtime cannot index before multiplying by the potentially huge
|
||||
# interval count or allocating the output grid.
|
||||
if interval_count > maxsize - 2:
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
||||
"Simulation sample count cannot be represented by this runtime; "
|
||||
"increase sampleStep.",
|
||||
)
|
||||
last_regular_time = t_start + interval_count * step
|
||||
append_stop = last_regular_time < t_stop
|
||||
requested_point_count = interval_count + 1 + int(append_stop)
|
||||
if requested_point_count > maxsize:
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
||||
"Simulation sample count cannot be represented by this runtime; "
|
||||
"increase sampleStep.",
|
||||
)
|
||||
|
||||
times = [t_start]
|
||||
for index in range(1, interval_count + 1):
|
||||
candidate = t_start + index * step
|
||||
if not isfinite(candidate):
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
|
||||
"Simulation sampleStep cannot be represented over the requested "
|
||||
"absolute time range.",
|
||||
)
|
||||
if candidate >= t_stop:
|
||||
candidate = t_stop
|
||||
if candidate <= times[-1]:
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
|
||||
"Simulation sampleStep is too small to advance floating-point "
|
||||
"time over the requested absolute time range.",
|
||||
)
|
||||
times.append(candidate)
|
||||
if candidate == t_stop:
|
||||
break
|
||||
if times[-1] < t_stop:
|
||||
times.append(t_stop)
|
||||
|
||||
if len(times) < 2 or any(
|
||||
current >= following
|
||||
for current, following in zip(times, times[1:])
|
||||
):
|
||||
raise SimulationSampleTimeError(
|
||||
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
|
||||
"Simulation sample times must contain at least two strictly "
|
||||
"increasing values.",
|
||||
)
|
||||
return times
|
||||
@@ -1 +0,0 @@
|
||||
"""Numerical solvers used by simulation systems."""
|
||||
@@ -1,258 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import sqrt
|
||||
|
||||
from app.simulation.core.ports import PortState, VariableRole
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
class AlgebraicSolveError(RuntimeError):
|
||||
def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None:
|
||||
super().__init__(message)
|
||||
self.diagnostics = diagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlgebraicUnknown:
|
||||
component: str
|
||||
port: str
|
||||
variable: str
|
||||
role: VariableRole
|
||||
state: PortState
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return f"{self.component}.{self.port}.{self.variable}"
|
||||
|
||||
def read(self) -> float:
|
||||
return float(getattr(self.state, self.variable))
|
||||
|
||||
def write(self, value: float) -> None:
|
||||
setattr(self.state, self.variable, float(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlgebraicSolveDiagnostics:
|
||||
success: bool
|
||||
message: str
|
||||
evaluations: int
|
||||
pressure_scale: float
|
||||
flow_scale: float
|
||||
max_scaled_residual: float
|
||||
max_raw_residual: float
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"message": self.message,
|
||||
"evaluations": self.evaluations,
|
||||
"pressureScale": self.pressure_scale,
|
||||
"flowScale": self.flow_scale,
|
||||
"maxScaledResidual": self.max_scaled_residual,
|
||||
"maxRawResidual": self.max_raw_residual,
|
||||
}
|
||||
|
||||
|
||||
class PressureFlowSolver:
|
||||
"""Solve the acausal pressure-flow subsystem for a compiled network."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
network: SimulationNetwork,
|
||||
*,
|
||||
residual_tolerance: float = 1e-7,
|
||||
max_evaluations: int = 500,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.residual_tolerance = residual_tolerance
|
||||
self.max_evaluations = max_evaluations
|
||||
self.unknowns = self._build_unknowns()
|
||||
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||
|
||||
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
||||
unknowns: list[AlgebraicUnknown] = []
|
||||
for component in self.network.components.values():
|
||||
for definition in component.port_definitions:
|
||||
if definition.kind != "physical":
|
||||
continue
|
||||
state = component.get_port(definition.name)
|
||||
for variable in definition.variables:
|
||||
if variable.role not in {"effort", "flow"}:
|
||||
continue
|
||||
unknowns.append(
|
||||
AlgebraicUnknown(
|
||||
component=component.name,
|
||||
port=definition.name,
|
||||
variable=variable.name,
|
||||
role=variable.role,
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
return tuple(unknowns)
|
||||
|
||||
def _seed_equal_pressures(self) -> None:
|
||||
for _ in range(max(2, len(self.network.connections))):
|
||||
changed = False
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first = self.network.components[
|
||||
connection.endpoint_a.component
|
||||
].get_port(connection.endpoint_a.port)
|
||||
second = self.network.components[
|
||||
connection.endpoint_b.component
|
||||
].get_port(connection.endpoint_b.port)
|
||||
if first.p > 0.0 and second.p <= 0.0:
|
||||
second.p = first.p
|
||||
changed = True
|
||||
elif second.p > 0.0 and first.p <= 0.0:
|
||||
first.p = second.p
|
||||
changed = True
|
||||
|
||||
for component in self.network.components.values():
|
||||
equal_pressure_equations = [
|
||||
equation
|
||||
for equation in component.pressure_flow_equation_residuals()
|
||||
if equation.relation == "equal" and equation.role == "effort"
|
||||
]
|
||||
for equation in equal_pressure_equations:
|
||||
states = []
|
||||
for variable in equation.variables:
|
||||
_, port_name, variable_name = variable.rsplit(".", 2)
|
||||
if variable_name == "p":
|
||||
states.append(component.get_port(port_name))
|
||||
if len(states) != 2:
|
||||
continue
|
||||
first, second = states
|
||||
if first.p > 0.0 and second.p <= 0.0:
|
||||
second.p = first.p
|
||||
changed = True
|
||||
elif second.p > 0.0 and first.p <= 0.0:
|
||||
first.p = second.p
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
|
||||
def _scales(self) -> tuple[float, float]:
|
||||
pressure_scale = max(
|
||||
[
|
||||
abs(unknown.read())
|
||||
for unknown in self.unknowns
|
||||
if unknown.role == "effort" and unknown.read() > 0.0
|
||||
]
|
||||
+ [1e5]
|
||||
)
|
||||
estimated_flows = [
|
||||
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
|
||||
for component in self.network.components.values()
|
||||
if hasattr(component, "K_eff")
|
||||
]
|
||||
flow_scale = max(
|
||||
estimated_flows
|
||||
+ [
|
||||
abs(unknown.read())
|
||||
for unknown in self.unknowns
|
||||
if unknown.role == "flow"
|
||||
]
|
||||
+ [1e-3]
|
||||
)
|
||||
return pressure_scale, flow_scale
|
||||
|
||||
def solve(self) -> AlgebraicSolveDiagnostics:
|
||||
try:
|
||||
import numpy as np
|
||||
from scipy.optimize import least_squares
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||
) from exc
|
||||
|
||||
self._seed_equal_pressures()
|
||||
pressure_scale, flow_scale = self._scales()
|
||||
positive_pressures = [
|
||||
unknown.read()
|
||||
for unknown in self.unknowns
|
||||
if unknown.role == "effort" and unknown.read() > 0.0
|
||||
]
|
||||
fallback_pressure = (
|
||||
sum(positive_pressures) / len(positive_pressures)
|
||||
if positive_pressures
|
||||
else pressure_scale
|
||||
)
|
||||
|
||||
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
||||
return pressure_scale if unknown.role == "effort" else flow_scale
|
||||
|
||||
x0 = np.asarray(
|
||||
[
|
||||
(
|
||||
unknown.read()
|
||||
if unknown.role != "effort" or unknown.read() > 0.0
|
||||
else fallback_pressure
|
||||
)
|
||||
/ variable_scale(unknown)
|
||||
for unknown in self.unknowns
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
lower = np.asarray(
|
||||
[
|
||||
1.0 / pressure_scale if unknown.role == "effort" else -np.inf
|
||||
for unknown in self.unknowns
|
||||
]
|
||||
)
|
||||
upper = np.full(len(self.unknowns), np.inf)
|
||||
|
||||
def assign(values) -> None:
|
||||
for unknown, value in zip(self.unknowns, values):
|
||||
unknown.write(float(value) * variable_scale(unknown))
|
||||
|
||||
def scaled_residuals(values):
|
||||
assign(values)
|
||||
equations = self.network.pressure_flow_equation_residuals()
|
||||
return np.asarray(
|
||||
[
|
||||
equation.value
|
||||
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||
for equation in equations
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
result = least_squares(
|
||||
scaled_residuals,
|
||||
x0,
|
||||
bounds=(lower, upper),
|
||||
x_scale="jac",
|
||||
ftol=1e-10,
|
||||
xtol=1e-10,
|
||||
gtol=1e-10,
|
||||
max_nfev=self.max_evaluations,
|
||||
)
|
||||
assign(result.x)
|
||||
equations = self.network.pressure_flow_equation_residuals()
|
||||
scaled = [
|
||||
abs(
|
||||
equation.value
|
||||
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||
)
|
||||
for equation in equations
|
||||
]
|
||||
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
|
||||
diagnostics = AlgebraicSolveDiagnostics(
|
||||
success=success,
|
||||
message=str(result.message),
|
||||
evaluations=int(result.nfev),
|
||||
pressure_scale=pressure_scale,
|
||||
flow_scale=flow_scale,
|
||||
max_scaled_residual=max(scaled, default=0.0),
|
||||
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
if not success:
|
||||
raise AlgebraicSolveError(
|
||||
"Pressure-flow equations did not converge to the requested tolerance.",
|
||||
diagnostics,
|
||||
)
|
||||
return diagnostics
|
||||
@@ -1,308 +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-8
|
||||
max_step: float = 1e-3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ODESolution:
|
||||
t: list[float]
|
||||
y: list[list[float]]
|
||||
success: bool
|
||||
message: str
|
||||
status: IntegrationStatus = "completed"
|
||||
error: Exception | None = None
|
||||
|
||||
|
||||
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
|
||||
return [x + scale * y for x, y in zip(a, b)]
|
||||
|
||||
|
||||
def _append_solution_sample(
|
||||
times: list[float],
|
||||
states: list[list[float]],
|
||||
time: float,
|
||||
state: list[float],
|
||||
) -> None:
|
||||
if times and time <= times[-1] + 1e-12:
|
||||
return
|
||||
times.append(float(time))
|
||||
for index, value in enumerate(state):
|
||||
states[index].append(float(value))
|
||||
|
||||
|
||||
def _runge_kutta_4(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
config: SolveIVPConfig,
|
||||
t_eval: list[float] | None,
|
||||
cancel_check: CancellationCheck | None = None,
|
||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||
) -> ODESolution:
|
||||
if t_eval is None:
|
||||
point_count = max(
|
||||
2,
|
||||
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
|
||||
)
|
||||
step = (config.t_stop - config.t_start) / (point_count - 1)
|
||||
t_eval = [config.t_start + index * step for index in range(point_count)]
|
||||
|
||||
state = list(initial_state)
|
||||
states = [[value] for value in state]
|
||||
times = [float(t_eval[0])]
|
||||
current_time = float(t_eval[0])
|
||||
status: IntegrationStatus = "completed"
|
||||
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
||||
error: Exception | None = None
|
||||
|
||||
try:
|
||||
for target_time in t_eval[1:]:
|
||||
while current_time < target_time - 1e-15:
|
||||
if cancel_check is not None and cancel_check():
|
||||
raise _IntegrationCancelled
|
||||
dt = min(config.max_step, target_time - current_time)
|
||||
k1 = rhs(current_time, state)
|
||||
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
||||
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
|
||||
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
||||
state = [
|
||||
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
||||
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
||||
]
|
||||
current_time += dt
|
||||
if accepted_step_callback is not None:
|
||||
accepted_step_callback(current_time)
|
||||
|
||||
_append_solution_sample(times, states, target_time, state)
|
||||
except _IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = "Simulation was stopped before reaching the requested end time."
|
||||
_append_solution_sample(times, states, current_time, state)
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
_append_solution_sample(times, states, current_time, state)
|
||||
|
||||
return ODESolution(
|
||||
t=times,
|
||||
y=states,
|
||||
success=status == "completed",
|
||||
message=message,
|
||||
status=status,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _integrate_scipy_stepwise(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
config: SolveIVPConfig,
|
||||
t_eval: list[float] | None,
|
||||
cancel_check: CancellationCheck,
|
||||
accepted_step_callback: AcceptedStepCallback | None,
|
||||
) -> ODESolution:
|
||||
import numpy as np
|
||||
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
|
||||
|
||||
solver_types = {
|
||||
"BDF": BDF,
|
||||
"DOP853": DOP853,
|
||||
"LSODA": LSODA,
|
||||
"RK23": RK23,
|
||||
"RK45": RK45,
|
||||
"Radau": Radau,
|
||||
}
|
||||
solver_type = solver_types.get(config.method)
|
||||
if solver_type is None:
|
||||
raise ValueError(f"Unsupported integration method: {config.method}")
|
||||
|
||||
times = [float(config.t_start)]
|
||||
states = [[float(value)] for value in initial_state]
|
||||
last_accepted_time = float(config.t_start)
|
||||
last_accepted_state = [float(value) for value in initial_state]
|
||||
sample_times = list(t_eval or [])
|
||||
sample_index = 0
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= config.t_start + 1e-12
|
||||
):
|
||||
sample_index += 1
|
||||
|
||||
def cancellable_rhs(time, state):
|
||||
if cancel_check():
|
||||
raise _IntegrationCancelled
|
||||
return rhs(float(time), [float(value) for value in state])
|
||||
|
||||
if cancel_check():
|
||||
return ODESolution(
|
||||
t=times,
|
||||
y=states,
|
||||
success=False,
|
||||
message="Simulation was stopped before integration started.",
|
||||
status="cancelled",
|
||||
)
|
||||
|
||||
try:
|
||||
solver = solver_type(
|
||||
cancellable_rhs,
|
||||
config.t_start,
|
||||
np.asarray(initial_state, dtype=float),
|
||||
config.t_stop,
|
||||
rtol=config.rtol,
|
||||
atol=config.atol,
|
||||
max_step=config.max_step,
|
||||
)
|
||||
except _IntegrationCancelled:
|
||||
return ODESolution(
|
||||
t=times,
|
||||
y=states,
|
||||
success=False,
|
||||
message="Simulation was stopped before integration started.",
|
||||
status="cancelled",
|
||||
)
|
||||
except Exception as exc:
|
||||
return ODESolution(
|
||||
t=times,
|
||||
y=states,
|
||||
success=False,
|
||||
message=str(exc),
|
||||
status="failed",
|
||||
error=exc,
|
||||
)
|
||||
|
||||
status: IntegrationStatus = "completed"
|
||||
message = "The solver successfully reached the end of the integration interval."
|
||||
error: Exception | None = None
|
||||
|
||||
while solver.status == "running":
|
||||
if cancel_check():
|
||||
status = "cancelled"
|
||||
message = "Simulation was stopped before reaching the requested end time."
|
||||
break
|
||||
try:
|
||||
step_message = solver.step()
|
||||
except _IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = "Simulation was stopped before reaching the requested end time."
|
||||
break
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
break
|
||||
|
||||
if solver.status == "failed":
|
||||
status = "failed"
|
||||
message = str(step_message or "Integration step failed.")
|
||||
break
|
||||
|
||||
last_accepted_time = float(solver.t)
|
||||
last_accepted_state = [float(value) for value in solver.y]
|
||||
if sample_times:
|
||||
dense_output = solver.dense_output()
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= last_accepted_time + 1e-12
|
||||
):
|
||||
sample_time = float(sample_times[sample_index])
|
||||
sample_state = [float(value) for value in dense_output(sample_time)]
|
||||
_append_solution_sample(times, states, sample_time, sample_state)
|
||||
sample_index += 1
|
||||
else:
|
||||
_append_solution_sample(
|
||||
times,
|
||||
states,
|
||||
last_accepted_time,
|
||||
last_accepted_state,
|
||||
)
|
||||
if accepted_step_callback is not None:
|
||||
accepted_step_callback(last_accepted_time)
|
||||
|
||||
if status != "completed":
|
||||
_append_solution_sample(
|
||||
times,
|
||||
states,
|
||||
last_accepted_time,
|
||||
last_accepted_state,
|
||||
)
|
||||
|
||||
return ODESolution(
|
||||
t=times,
|
||||
y=states,
|
||||
success=status == "completed",
|
||||
message=message,
|
||||
status=status,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def integrate_ode(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
config: SolveIVPConfig,
|
||||
t_eval: list[float] | None = None,
|
||||
cancel_check: CancellationCheck | None = None,
|
||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||
):
|
||||
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
|
||||
|
||||
if abs(config.t_stop - config.t_start) <= 1e-15:
|
||||
return ODESolution(
|
||||
t=[float(config.t_start)],
|
||||
y=[[value] for value in initial_state],
|
||||
success=True,
|
||||
message="Skipped integration because t_start equals t_stop.",
|
||||
)
|
||||
|
||||
try:
|
||||
from scipy.integrate import solve_ivp
|
||||
except ImportError:
|
||||
return _runge_kutta_4(
|
||||
rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
cancel_check,
|
||||
accepted_step_callback,
|
||||
)
|
||||
|
||||
if cancel_check is not None:
|
||||
return _integrate_scipy_stepwise(
|
||||
rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
cancel_check,
|
||||
accepted_step_callback,
|
||||
)
|
||||
|
||||
return solve_ivp(
|
||||
fun=rhs,
|
||||
t_span=(config.t_start, config.t_stop),
|
||||
y0=initial_state,
|
||||
method=config.method,
|
||||
rtol=config.rtol,
|
||||
atol=config.atol,
|
||||
max_step=config.max_step,
|
||||
t_eval=t_eval,
|
||||
)
|
||||
@@ -1,119 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
class StreamSolveError(RuntimeError):
|
||||
def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None:
|
||||
super().__init__(message)
|
||||
self.diagnostics = diagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamSolveDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
max_delta: float
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"converged": self.converged,
|
||||
"iterations": self.iterations,
|
||||
"maxDelta": self.max_delta,
|
||||
}
|
||||
|
||||
|
||||
class StreamResolver:
|
||||
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
network: SimulationNetwork,
|
||||
*,
|
||||
relative_tolerance: float = 1e-9,
|
||||
max_iterations: int = 100,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.relative_tolerance = relative_tolerance
|
||||
self.max_iterations = max_iterations
|
||||
self._connected_endpoint = self._build_connection_map()
|
||||
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
||||
|
||||
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
||||
result: dict[Endpoint, Endpoint] = {}
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first, second = connection.endpoints
|
||||
result[first] = second
|
||||
result[second] = first
|
||||
return result
|
||||
|
||||
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
||||
values: dict[str, dict[str, float]] = {
|
||||
component.name: {} for component in self.network.components.values()
|
||||
}
|
||||
for endpoint, connected in self._connected_endpoint.items():
|
||||
connected_port = self.network.components[connected.component].get_port(
|
||||
connected.port
|
||||
)
|
||||
values[endpoint.component][endpoint.port] = connected_port.h_outflow
|
||||
return values
|
||||
|
||||
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||
dynamic_components = [
|
||||
component
|
||||
for component in self.network.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
]
|
||||
for component in dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
|
||||
max_delta = 0.0
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
previous = {
|
||||
(component.name, port_name): port.h_outflow
|
||||
for component in self.network.components.values()
|
||||
for port_name, port in component.ports.items()
|
||||
}
|
||||
connected = self.connected_enthalpies()
|
||||
for component in self.network.components.values():
|
||||
if isinstance(component, DynamicComponent):
|
||||
component.refresh_thermodynamic_ports()
|
||||
else:
|
||||
component.update_stream_outflows(connected[component.name])
|
||||
|
||||
deltas = [
|
||||
abs(port.h_outflow - previous[(component.name, port_name)])
|
||||
for component in self.network.components.values()
|
||||
for port_name, port in component.ports.items()
|
||||
]
|
||||
magnitudes = [
|
||||
abs(port.h_outflow)
|
||||
for component in self.network.components.values()
|
||||
for port in component.ports.values()
|
||||
]
|
||||
max_delta = max(deltas, default=0.0)
|
||||
scale = max(magnitudes + [1.0])
|
||||
if max_delta <= self.relative_tolerance * scale:
|
||||
diagnostics = StreamSolveDiagnostics(
|
||||
converged=True,
|
||||
iterations=iteration,
|
||||
max_delta=max_delta,
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
return diagnostics, self.connected_enthalpies()
|
||||
|
||||
diagnostics = StreamSolveDiagnostics(
|
||||
converged=False,
|
||||
iterations=self.max_iterations,
|
||||
max_delta=max_delta,
|
||||
)
|
||||
self.last_diagnostics = diagnostics
|
||||
raise StreamSolveError(
|
||||
"Stream enthalpy propagation did not converge.",
|
||||
diagnostics,
|
||||
)
|
||||
@@ -1,463 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from math import floor, isfinite
|
||||
from typing import Literal
|
||||
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
||||
from app.simulation.solvers.stream import StreamResolver
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
SimulationProgressCallback = Callable[[float, str], None]
|
||||
SimulationCancellationCheck = Callable[[], bool]
|
||||
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationPreparationIssue:
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"code": self.code, "message": self.message}
|
||||
|
||||
|
||||
class SimulationPreparationError(ValueError):
|
||||
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
|
||||
super().__init__("The compiled model is not ready for simulation.")
|
||||
self.issues = issues
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenericSimulationResult:
|
||||
success: bool
|
||||
status: SimulationRunStatus
|
||||
message: str
|
||||
simulated_until: float
|
||||
requested_stop_time: float
|
||||
variables: tuple[ResultVariableMetadata, ...]
|
||||
series: dict[str, list[float]]
|
||||
final: dict[str, float]
|
||||
diagnostics: dict[str, object]
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"status": self.status,
|
||||
"partial": self.status != "completed",
|
||||
"message": self.message,
|
||||
"simulatedUntil": self.simulated_until,
|
||||
"requestedStopTime": self.requested_stop_time,
|
||||
"variables": [variable.as_dict() for variable in self.variables],
|
||||
"series": self.series,
|
||||
"final": self.final,
|
||||
"diagnostics": self.diagnostics,
|
||||
}
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
def __init__(self, items: set[Endpoint]) -> None:
|
||||
self.parent = {item: item for item in items}
|
||||
|
||||
def find(self, item: Endpoint) -> Endpoint:
|
||||
parent = self.parent[item]
|
||||
if parent != item:
|
||||
self.parent[item] = self.find(parent)
|
||||
return self.parent[item]
|
||||
|
||||
def union(self, first: Endpoint, second: Endpoint) -> None:
|
||||
first_root = self.find(first)
|
||||
second_root = self.find(second)
|
||||
if first_root != second_root:
|
||||
self.parent[second_root] = first_root
|
||||
|
||||
|
||||
def _equation_port(component_name: str, variable: str) -> Endpoint | None:
|
||||
parts = variable.rsplit(".", 2)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
prefix, port_name, variable_name = parts
|
||||
if prefix != component_name or variable_name != "p":
|
||||
return None
|
||||
return Endpoint(component_name, port_name)
|
||||
|
||||
|
||||
def simulation_preparation_issues(
|
||||
network: SimulationNetwork,
|
||||
) -> tuple[SimulationPreparationIssue, ...]:
|
||||
issues: list[SimulationPreparationIssue] = []
|
||||
physical_endpoints = {
|
||||
Endpoint(component.name, definition.name)
|
||||
for component in network.components.values()
|
||||
for definition in component.port_definitions
|
||||
if definition.kind == "physical"
|
||||
}
|
||||
connected_endpoints = {
|
||||
endpoint
|
||||
for connection in network.connections
|
||||
if connection.kind == "physical"
|
||||
for endpoint in connection.endpoints
|
||||
}
|
||||
for endpoint in sorted(physical_endpoints - connected_endpoints, key=str):
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"PORT_UNCONNECTED",
|
||||
f"Physical port {endpoint} must be connected before simulation.",
|
||||
)
|
||||
)
|
||||
|
||||
if any(
|
||||
definition.kind == "signal"
|
||||
for component in network.components.values()
|
||||
for definition in component.port_definitions
|
||||
):
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"SIGNAL_PORT_UNSUPPORTED",
|
||||
"Signal-port simulation is not implemented in the current MVP solver.",
|
||||
)
|
||||
)
|
||||
|
||||
structure = network.pressure_flow_structure_dict()
|
||||
if not structure["isSquare"]:
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"PRESSURE_FLOW_SYSTEM_NOT_SQUARE",
|
||||
"Pressure-flow equation count does not match the unknown count: "
|
||||
f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.",
|
||||
)
|
||||
)
|
||||
|
||||
dynamic_names = {
|
||||
component.name
|
||||
for component in network.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
}
|
||||
if not dynamic_names:
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"DYNAMIC_STATE_MISSING",
|
||||
"Each simulated network requires at least one storage component.",
|
||||
)
|
||||
)
|
||||
|
||||
adjacency = {name: set() for name in network.components}
|
||||
for connection in network.connections:
|
||||
first, second = connection.endpoints
|
||||
adjacency[first.component].add(second.component)
|
||||
adjacency[second.component].add(first.component)
|
||||
remaining = set(adjacency)
|
||||
while remaining:
|
||||
start = remaining.pop()
|
||||
group = {start}
|
||||
stack = [start]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for neighbour in adjacency[current] - group:
|
||||
group.add(neighbour)
|
||||
remaining.discard(neighbour)
|
||||
stack.append(neighbour)
|
||||
if not (group & dynamic_names):
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
|
||||
"A connected physical network has no pressure/enthalpy storage anchor: "
|
||||
+ ", ".join(sorted(group))
|
||||
+ ".",
|
||||
)
|
||||
)
|
||||
|
||||
if physical_endpoints:
|
||||
effort_groups = _UnionFind(physical_endpoints)
|
||||
for connection in network.connections:
|
||||
if connection.kind == "physical":
|
||||
effort_groups.union(*connection.endpoints)
|
||||
storage_ports: dict[Endpoint, str] = {}
|
||||
for component in network.components.values():
|
||||
for equation in component.pressure_flow_equation_residuals():
|
||||
pressure_ports = [
|
||||
endpoint
|
||||
for variable in equation.variables
|
||||
if (endpoint := _equation_port(component.name, variable)) is not None
|
||||
]
|
||||
if equation.relation == "equal" and len(pressure_ports) == 2:
|
||||
effort_groups.union(pressure_ports[0], pressure_ports[1])
|
||||
if equation.relation == "state":
|
||||
for endpoint in pressure_ports:
|
||||
storage_ports[endpoint] = component.name
|
||||
|
||||
storages_by_group: dict[Endpoint, set[str]] = {}
|
||||
for endpoint, component_name in storage_ports.items():
|
||||
storages_by_group.setdefault(effort_groups.find(endpoint), set()).add(
|
||||
component_name
|
||||
)
|
||||
for storage_names in storages_by_group.values():
|
||||
if len(storage_names) > 1:
|
||||
issues.append(
|
||||
SimulationPreparationIssue(
|
||||
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||
"Storage components are connected without a resistance: "
|
||||
+ ", ".join(sorted(storage_names))
|
||||
+ ". Insert an orifice or pipe between them.",
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def simulation_sample_times(
|
||||
config: SolveIVPConfig,
|
||||
step: float,
|
||||
*,
|
||||
max_points: int = 10001,
|
||||
) -> list[float]:
|
||||
if step <= 0.0 or not isfinite(step):
|
||||
raise ValueError("Simulation sample step must be finite and greater than zero.")
|
||||
duration = config.t_stop - config.t_start
|
||||
if duration <= 0.0:
|
||||
raise ValueError("Simulation stop time must be greater than start time.")
|
||||
interval_count = int(floor(duration / step + 1e-12))
|
||||
times = [config.t_start + index * step for index in range(interval_count + 1)]
|
||||
if times[-1] < config.t_stop - 1e-12:
|
||||
times.append(config.t_stop)
|
||||
else:
|
||||
times[-1] = config.t_stop
|
||||
if len(times) > max_points:
|
||||
raise ValueError(
|
||||
f"Simulation requests {len(times)} samples; the limit is {max_points}."
|
||||
)
|
||||
return times
|
||||
|
||||
|
||||
class GenericFluidSystem:
|
||||
"""Topology-driven, semi-explicit fluid simulation for registered components."""
|
||||
|
||||
def __init__(self, network: SimulationNetwork) -> None:
|
||||
issues = simulation_preparation_issues(network)
|
||||
if issues:
|
||||
raise SimulationPreparationError(issues)
|
||||
self.network = network
|
||||
self.dynamic_components = network.dynamic_components()
|
||||
self.pressure_flow_solver = PressureFlowSolver(network)
|
||||
self.stream_resolver = StreamResolver(network)
|
||||
self.algebraic_solve_count = 0
|
||||
self.max_algebraic_residual = 0.0
|
||||
self.max_algebraic_evaluations = 0
|
||||
self.max_stream_iterations = 0
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
return self.network.initial_state_vector()
|
||||
|
||||
def apply_state_vector(self, values: list[float]) -> None:
|
||||
self.network.apply_state_vector(values)
|
||||
|
||||
def _close_current_state(self) -> dict[str, dict[str, float]]:
|
||||
for component in self.dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
algebraic = self.pressure_flow_solver.solve()
|
||||
stream, connected_h = self.stream_resolver.solve()
|
||||
self.algebraic_solve_count += 1
|
||||
self.max_algebraic_residual = max(
|
||||
self.max_algebraic_residual,
|
||||
algebraic.max_scaled_residual,
|
||||
)
|
||||
self.max_algebraic_evaluations = max(
|
||||
self.max_algebraic_evaluations,
|
||||
algebraic.evaluations,
|
||||
)
|
||||
self.max_stream_iterations = max(
|
||||
self.max_stream_iterations,
|
||||
stream.iterations,
|
||||
)
|
||||
return connected_h
|
||||
|
||||
def consistent_initial_state_vector(self) -> list[float]:
|
||||
state = self.initial_state_vector()
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state()
|
||||
return state
|
||||
|
||||
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||
self.apply_state_vector(state_vector)
|
||||
connected_h = self._close_current_state()
|
||||
derivatives: list[float] = []
|
||||
for component in self.dynamic_components:
|
||||
derivatives.extend(
|
||||
component.state_derivative_from_ports(connected_h[component.name])
|
||||
)
|
||||
return derivatives
|
||||
|
||||
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
||||
for component in self.network.components.values():
|
||||
for relative_key, value in component.result_values().items():
|
||||
series.setdefault(
|
||||
f"{component.name}.{relative_key}", []
|
||||
).append(value)
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
config: SolveIVPConfig,
|
||||
*,
|
||||
sample_step: float,
|
||||
progress_callback: SimulationProgressCallback | None = None,
|
||||
cancel_check: SimulationCancellationCheck | None = None,
|
||||
) -> GenericSimulationResult:
|
||||
last_reported_progress = -1.0
|
||||
last_reported_phase = ""
|
||||
|
||||
def report_progress(
|
||||
progress: float,
|
||||
phase: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
nonlocal last_reported_phase, last_reported_progress
|
||||
if progress_callback is None:
|
||||
return
|
||||
bounded_progress = min(1.0, max(0.0, progress))
|
||||
if (
|
||||
force
|
||||
or phase != last_reported_phase
|
||||
or bounded_progress - last_reported_progress >= 0.0025
|
||||
):
|
||||
last_reported_phase = phase
|
||||
last_reported_progress = max(
|
||||
last_reported_progress,
|
||||
bounded_progress,
|
||||
)
|
||||
progress_callback(last_reported_progress, phase)
|
||||
|
||||
report_progress(0.0, "initializing", force=True)
|
||||
t_eval = simulation_sample_times(config, sample_step)
|
||||
initial_state = self.consistent_initial_state_vector()
|
||||
report_progress(0.0, "integrating", force=True)
|
||||
duration = config.t_stop - config.t_start
|
||||
furthest_solver_time = config.t_start
|
||||
|
||||
def report_solver_time(time: float) -> None:
|
||||
nonlocal furthest_solver_time
|
||||
furthest_solver_time = max(furthest_solver_time, float(time))
|
||||
time_fraction = (
|
||||
(furthest_solver_time - config.t_start) / duration
|
||||
if duration > 0.0
|
||||
else 1.0
|
||||
)
|
||||
report_progress(time_fraction, "integrating")
|
||||
|
||||
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
|
||||
if cancel_check is None:
|
||||
report_solver_time(time)
|
||||
return self.rhs(time, state_vector)
|
||||
|
||||
solution = integrate_ode(
|
||||
rhs=monitored_rhs,
|
||||
initial_state=initial_state,
|
||||
config=config,
|
||||
t_eval=t_eval,
|
||||
cancel_check=cancel_check,
|
||||
accepted_step_callback=(
|
||||
report_solver_time if cancel_check is not None else None
|
||||
),
|
||||
)
|
||||
if isinstance(solution, ODESolution):
|
||||
run_status: SimulationRunStatus = solution.status
|
||||
integration_error = solution.error
|
||||
else:
|
||||
run_status = "completed" if bool(solution.success) else "failed"
|
||||
integration_error = None
|
||||
result_message = str(solution.message)
|
||||
postprocess_progress = (
|
||||
1.0
|
||||
if run_status == "completed"
|
||||
else max(0.0, last_reported_progress)
|
||||
)
|
||||
report_progress(postprocess_progress, "postprocessing", force=True)
|
||||
times = [float(value) for value in solution.t]
|
||||
series: dict[str, list[float]] = {"time": []}
|
||||
postprocessing_error: Exception | None = None
|
||||
for time_index in range(len(times)):
|
||||
if (
|
||||
run_status == "completed"
|
||||
and cancel_check is not None
|
||||
and cancel_check()
|
||||
):
|
||||
run_status = "cancelled"
|
||||
result_message = "Simulation was stopped while preparing partial results."
|
||||
break
|
||||
state = [
|
||||
float(solution.y[state_index][time_index])
|
||||
for state_index in range(len(solution.y))
|
||||
]
|
||||
try:
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state()
|
||||
self._append_current_state(series)
|
||||
series["time"].append(times[time_index])
|
||||
except Exception as exc:
|
||||
run_status = "failed"
|
||||
result_message = str(exc)
|
||||
postprocessing_error = exc
|
||||
break
|
||||
if len(series["time"]) < 2:
|
||||
if postprocessing_error is not None:
|
||||
raise postprocessing_error
|
||||
if integration_error is not None:
|
||||
raise integration_error
|
||||
|
||||
final = {
|
||||
key: values[-1]
|
||||
for key, values in series.items()
|
||||
if key != "time" and values
|
||||
}
|
||||
diagnostics = {
|
||||
"pressureFlow": {
|
||||
"solveCount": self.algebraic_solve_count,
|
||||
"maxScaledResidual": self.max_algebraic_residual,
|
||||
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
|
||||
"last": (
|
||||
self.pressure_flow_solver.last_diagnostics.as_dict()
|
||||
if self.pressure_flow_solver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stream": {
|
||||
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||
"last": (
|
||||
self.stream_resolver.last_diagnostics.as_dict()
|
||||
if self.stream_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stateCount": len(initial_state),
|
||||
"sampleCount": len(series["time"]),
|
||||
}
|
||||
variables = tuple(
|
||||
variable
|
||||
for variable in self.network.result_variable_metadata()
|
||||
if variable.key in series
|
||||
)
|
||||
report_progress(
|
||||
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||
"complete" if run_status == "completed" else run_status,
|
||||
force=True,
|
||||
)
|
||||
return GenericSimulationResult(
|
||||
success=run_status == "completed" and bool(solution.success),
|
||||
status=run_status,
|
||||
message=result_message,
|
||||
simulated_until=(
|
||||
float(series["time"][-1])
|
||||
if series["time"]
|
||||
else float(config.t_start)
|
||||
),
|
||||
requested_stop_time=float(config.t_stop),
|
||||
variables=variables,
|
||||
series=series,
|
||||
final=final,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
@@ -3,9 +3,12 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.simulation.core.base import Component, DynamicComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.equations import EquationDefinition
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.core.port_computation import (
|
||||
PortSupplyError, port_supply_issue, reference_supply_issues,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -91,11 +94,13 @@ class SimulationNetwork:
|
||||
) -> Connection:
|
||||
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
|
||||
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
|
||||
if endpoint_a == endpoint_b:
|
||||
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.")
|
||||
if endpoint_a.component == endpoint_b.component:
|
||||
raise ValueError(
|
||||
f"Cannot connect component {endpoint_a.component} to itself."
|
||||
)
|
||||
|
||||
first_port = self._port_for(endpoint_a)
|
||||
second_port = self._port_for(endpoint_b)
|
||||
first_port = self._active_port_for(endpoint_a)
|
||||
second_port = self._active_port_for(endpoint_b)
|
||||
first_definition = first_port.definition
|
||||
second_definition = second_port.definition
|
||||
if first_definition is None or second_definition is None:
|
||||
@@ -108,6 +113,10 @@ class SimulationNetwork:
|
||||
raise ValueError(
|
||||
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
|
||||
)
|
||||
supply_issue = port_supply_issue(first_definition, second_definition,
|
||||
str(endpoint_a), str(endpoint_b))
|
||||
if supply_issue:
|
||||
raise PortSupplyError(supply_issue)
|
||||
if first_definition.kind == "signal" and {
|
||||
first_definition.nominal_role,
|
||||
second_definition.nominal_role,
|
||||
@@ -131,6 +140,16 @@ class SimulationNetwork:
|
||||
+ ", ".join(occupied)
|
||||
+ ". Use a junction component for branching."
|
||||
)
|
||||
else:
|
||||
signal_input = (
|
||||
endpoint_a
|
||||
if first_definition.nominal_role == "input"
|
||||
else endpoint_b
|
||||
)
|
||||
if signal_input in occupied_endpoints:
|
||||
raise ValueError(
|
||||
f"Signal input {signal_input} already has a driver."
|
||||
)
|
||||
|
||||
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
|
||||
endpoint_a, endpoint_b = endpoint_b, endpoint_a
|
||||
@@ -149,6 +168,22 @@ class SimulationNetwork:
|
||||
self.connections.append(connection)
|
||||
return connection
|
||||
|
||||
def validate_port_supplies(self) -> None:
|
||||
"""Validate again at compile time, including manually assembled networks."""
|
||||
ports = {(c.name, p.name): p for c in self.components.values()
|
||||
for p in c.active_port_definitions}
|
||||
adjacency = {}
|
||||
for edge in self.connections:
|
||||
a, b = (endpoint.key for endpoint in edge.endpoints)
|
||||
issue = port_supply_issue(ports[a], ports[b], '.'.join(a), '.'.join(b))
|
||||
if issue:
|
||||
raise PortSupplyError(issue)
|
||||
if edge.kind == 'physical':
|
||||
adjacency[a], adjacency[b] = b, a
|
||||
issues = reference_supply_issues(ports, adjacency)
|
||||
if issues:
|
||||
raise PortSupplyError(issues[0])
|
||||
|
||||
def _port_for(self, endpoint: Endpoint) -> PortState:
|
||||
try:
|
||||
component = self.components[endpoint.component]
|
||||
@@ -156,68 +191,45 @@ class SimulationNetwork:
|
||||
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
|
||||
return component.get_port(endpoint.port)
|
||||
|
||||
def connection_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
"""Evaluate connector equations that have a direct scalar residual.
|
||||
def _active_port_for(self, endpoint: Endpoint) -> PortState:
|
||||
try:
|
||||
component = self.components[endpoint.component]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
|
||||
active_names = {
|
||||
definition.name for definition in component.active_port_definitions
|
||||
}
|
||||
if endpoint.port not in active_names:
|
||||
raise ValueError(
|
||||
f"Port {endpoint} is not active for the component's current parameters."
|
||||
)
|
||||
return component.get_port(endpoint.port)
|
||||
|
||||
Stream variables are resolved by the stream-mixing layer and therefore do
|
||||
not incorrectly appear here as an equality between outflow properties.
|
||||
"""
|
||||
|
||||
residuals: list[EquationResidual] = []
|
||||
def connection_equation_definitions(self) -> tuple[EquationDefinition, ...]:
|
||||
equations=[]
|
||||
for connection in self.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
|
||||
first_port = self._port_for(connection.endpoint_a)
|
||||
second_port = self._port_for(connection.endpoint_b)
|
||||
definition = first_port.definition
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"Connected port {connection.endpoint_a} has no interface definition."
|
||||
)
|
||||
|
||||
if connection.kind!='physical':continue
|
||||
definition=self._port_for(connection.endpoint_a).definition
|
||||
for variable in definition.variables:
|
||||
if variable.connection_rule == "equal":
|
||||
value = float(getattr(first_port, variable.name)) - float(
|
||||
getattr(second_port, variable.name)
|
||||
)
|
||||
elif variable.connection_rule == "sumToZero":
|
||||
value = float(getattr(first_port, variable.name)) + float(
|
||||
getattr(second_port, variable.name)
|
||||
)
|
||||
else:
|
||||
continue
|
||||
residuals.append(
|
||||
EquationResidual(
|
||||
id=f"{connection.id}:{variable.name}",
|
||||
owner="connection",
|
||||
owner_id=connection.id,
|
||||
relation=variable.connection_rule,
|
||||
variables=(
|
||||
f"{connection.endpoint_a}.{variable.name}",
|
||||
f"{connection.endpoint_b}.{variable.name}",
|
||||
),
|
||||
role=variable.role,
|
||||
value=value,
|
||||
)
|
||||
)
|
||||
return tuple(residuals)
|
||||
if variable.connection_rule not in ('equal','sumToZero'):continue
|
||||
equations.append(EquationDefinition(id=f'{connection.id}:{variable.name}',owner='connection',owner_id=connection.id,relation=variable.connection_rule,variables=(f'{connection.endpoint_a}.{variable.name}',f'{connection.endpoint_b}.{variable.name}'),role=variable.role))
|
||||
return tuple(equations)
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
def equation_definitions(self) -> tuple[EquationDefinition, ...]:
|
||||
"""Evaluate the complete algebraic pressure-flow equation subsystem."""
|
||||
|
||||
component_residuals = tuple(
|
||||
residual
|
||||
for component in self.components.values()
|
||||
for residual in component.pressure_flow_equation_residuals()
|
||||
for residual in component.equation_definitions()
|
||||
)
|
||||
return component_residuals + self.connection_equation_residuals()
|
||||
return component_residuals + self.connection_equation_definitions()
|
||||
|
||||
def pressure_flow_unknowns(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
f"{component.name}.{definition.name}.{variable.name}"
|
||||
for component in self.components.values()
|
||||
for definition in component.port_definitions
|
||||
for definition in component.active_port_definitions
|
||||
if definition.kind == "physical"
|
||||
for variable in definition.variables
|
||||
if variable.role in {"effort", "flow"}
|
||||
@@ -225,7 +237,7 @@ class SimulationNetwork:
|
||||
|
||||
def pressure_flow_structure_dict(self) -> dict[str, object]:
|
||||
unknowns = self.pressure_flow_unknowns()
|
||||
equations = self.pressure_flow_equation_residuals()
|
||||
equations = self.equation_definitions()
|
||||
return {
|
||||
"unknownCount": len(unknowns),
|
||||
"equationCount": len(equations),
|
||||
@@ -243,20 +255,7 @@ class SimulationNetwork:
|
||||
if isinstance(component, DynamicComponent)
|
||||
]
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
values: list[float] = []
|
||||
for component in self.dynamic_components():
|
||||
values.extend(component.get_state_vector())
|
||||
return values
|
||||
|
||||
def apply_state_vector(self, values: list[float]) -> None:
|
||||
cursor = 0
|
||||
for component in self.dynamic_components():
|
||||
next_cursor = cursor + component.state_size
|
||||
component.set_state_vector(values[cursor:next_cursor])
|
||||
cursor = next_cursor
|
||||
if cursor != len(values):
|
||||
raise ValueError("State vector length does not match dynamic components.")
|
||||
|
||||
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
||||
return tuple(
|
||||
@@ -292,7 +291,7 @@ class SimulationNetwork:
|
||||
"parameters": component.parameter_interface_dicts(),
|
||||
"ports": [
|
||||
definition.as_interface_dict()
|
||||
for definition in component.port_definitions
|
||||
for definition in component.active_port_definitions
|
||||
],
|
||||
"resultVariables": [
|
||||
variable.as_dict()
|
||||
@@ -308,7 +307,7 @@ class SimulationNetwork:
|
||||
"unconnectedPorts": [
|
||||
{"component": component.name, "port": definition.name}
|
||||
for component in self.components.values()
|
||||
for definition in component.port_definitions
|
||||
for definition in component.active_port_definitions
|
||||
if (component.name, definition.name) not in connected_endpoints
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Nonblocking startup diagnostics; editor availability is independent of GCC."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
from threading import Event, Lock, Thread
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
|
||||
from .native_codegen.self_test import CheckCancelled, CheckFailure, check_native_runtime
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
MAX_RETRIES = 5
|
||||
RETRY_DELAY_SECONDS = 0.5
|
||||
|
||||
|
||||
def simulation_warmup_enabled() -> bool:
|
||||
value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower()
|
||||
if value in {"", "1", "true", "yes", "on"}:
|
||||
return True
|
||||
if value in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ValueError("SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0.")
|
||||
|
||||
|
||||
class SimulationRuntimeCheck:
|
||||
"""One bounded background check per application lifespan, with readable stages."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = Lock()
|
||||
self._stop = Event()
|
||||
self._thread: Thread | None = None
|
||||
self._history: list[dict] = []
|
||||
self._started = perf_counter()
|
||||
self._report = {"checkId": uuid4().hex, "status": "pending", "stage": "queued",
|
||||
"message": "等待仿真环境启动自检", "durationMs": 0.0,
|
||||
"attempt": 0, "maxRetries": MAX_RETRIES, "maxAttempts": MAX_RETRIES + 1,
|
||||
"retryDelaySeconds": RETRY_DELAY_SECONDS, "startedAt": None, "finishedAt": None,
|
||||
"compiler": None, "compilerVersion": None, "sundialsRoot": None,
|
||||
"error": None, "failure": None}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
report = deepcopy(self._report)
|
||||
if report["status"] in {"running", "retrying"}:
|
||||
report["durationMs"] = (perf_counter() - self._started) * 1000
|
||||
elif report["status"] in {"completed", "failed", "disabled", "cancelled"}:
|
||||
# Preserve first-failure evidence without surfacing details mid-retry.
|
||||
report["attemptHistory"] = deepcopy(self._history)
|
||||
return report
|
||||
|
||||
def _update(self, **values):
|
||||
with self._lock:
|
||||
if values.get("status") in {"completed", "failed", "disabled", "cancelled"}:
|
||||
values["durationMs"] = (perf_counter() - self._started) * 1000
|
||||
values["finishedAt"] = datetime.now(timezone.utc).isoformat()
|
||||
self._report.update(values)
|
||||
|
||||
def start(self):
|
||||
with self._lock:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._started = perf_counter()
|
||||
self._report.update(status="running", message="正在进行仿真环境首次启动自检",
|
||||
startedAt=datetime.now(timezone.utc).isoformat())
|
||||
self._thread = Thread(target=self._run, name="simulation-runtime-check", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self):
|
||||
for attempt in range(1, MAX_RETRIES + 2):
|
||||
if self._stop.is_set():
|
||||
self._cancel()
|
||||
return
|
||||
attempt_started = perf_counter()
|
||||
self._update(status="running", attempt=attempt, stage="queued", error=None, failure=None,
|
||||
compiler=None, compilerVersion=None, sundialsRoot=None)
|
||||
|
||||
def progress(**values):
|
||||
if attempt > 1 and "message" in values:
|
||||
values["message"] = f"正在重试(第 {attempt - 1}/{MAX_RETRIES} 轮):{values['message']}"
|
||||
self._update(**values)
|
||||
|
||||
try:
|
||||
if not simulation_warmup_enabled():
|
||||
self._update(status="disabled", message="仿真环境启动自检已由服务器配置关闭")
|
||||
return
|
||||
check_native_runtime(progress, self._stop)
|
||||
if self._stop.is_set():
|
||||
raise CheckCancelled()
|
||||
except CheckCancelled:
|
||||
self._cancel()
|
||||
return
|
||||
except Exception as exc:
|
||||
failure = deepcopy(exc.details) if isinstance(exc, CheckFailure) else {
|
||||
"command": [], "cwd": None, "exitCode": None, "stdout": "", "stderr": "",
|
||||
"errorType": type(exc).__name__,
|
||||
}
|
||||
with self._lock:
|
||||
self._history.append({"attempt": attempt, "status": "failed", "stage": self._report["stage"],
|
||||
"durationMs": (perf_counter() - attempt_started) * 1000,
|
||||
"error": str(exc), "failure": failure})
|
||||
if self._stop.is_set():
|
||||
self._cancel()
|
||||
return
|
||||
if attempt <= MAX_RETRIES:
|
||||
previous = "首次检测" if attempt == 1 else f"第 {attempt - 1} 轮重试"
|
||||
message = (f"{previous}未通过,正在准备第 {attempt}/{MAX_RETRIES} 轮重试"
|
||||
f"(间隔 {RETRY_DELAY_SECONDS:g} 秒);编辑器可正常使用")
|
||||
self._update(status="retrying", stage="retry-wait", message=message)
|
||||
LOGGER.warning("%s", message)
|
||||
# Interruptible on both Windows and Linux; never sleep on the API thread.
|
||||
if self._stop.wait(RETRY_DELAY_SECONDS):
|
||||
self._cancel()
|
||||
return
|
||||
continue
|
||||
self._update(status="failed", error=str(exc), failure=failure,
|
||||
message=f"本次启动自检未通过:首次检测及 {MAX_RETRIES} 轮重试均失败;编辑器仍可使用")
|
||||
LOGGER.error("Simulation startup self-check exhausted retries at %s; attempt history: %s",
|
||||
self.snapshot()["stage"], self._history, exc_info=True)
|
||||
return
|
||||
else:
|
||||
with self._lock:
|
||||
self._history.append({"attempt": attempt, "status": "completed", "stage": "complete",
|
||||
"durationMs": (perf_counter() - attempt_started) * 1000,
|
||||
"error": None, "failure": None})
|
||||
recovery = f"(第 {attempt - 1} 轮重试成功)" if attempt > 1 else ""
|
||||
self._update(status="completed", stage="complete", error=None, failure=None,
|
||||
message=f"本次启动自检通过,仿真环境可用{recovery}")
|
||||
return
|
||||
|
||||
def _cancel(self):
|
||||
self._update(status="cancelled", message="后端正在关闭,启动自检及重试已取消")
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1)
|
||||
+468
-313
File diff suppressed because it is too large.
Load diff
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the pinned native dependency beside the Python virtual environment.
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
NATIVE_ENV="${SYSTEM_SIMULATION_NATIVE_ENV:-$REPO_ROOT/.venv/native}"
|
||||
VERSION=7.4.0
|
||||
ARCHIVE_SHA256=679ddacdd77610110e613164e8297d6d0cd35bae8e9c3afc8e8ff6f99a1c2a7b
|
||||
SOURCE_ROOT="$NATIVE_ENV/build/sundials-$VERSION"
|
||||
PREFIX="$NATIVE_ENV/sundials-$VERSION"
|
||||
for tool in curl cmake gcc tar sha256sum; do
|
||||
command -v "$tool" >/dev/null || { echo "Missing build tool: $tool" >&2; exit 1; }
|
||||
done
|
||||
mkdir -p "$SOURCE_ROOT"
|
||||
ARCHIVE="$SOURCE_ROOT/sundials-$VERSION.tar.gz"
|
||||
if [[ ! -f "$ARCHIVE" ]]; then
|
||||
curl --fail --location "https://github.com/LLNL/sundials/releases/download/v$VERSION/sundials-$VERSION.tar.gz" --output "$ARCHIVE"
|
||||
fi
|
||||
printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE" | sha256sum --check -
|
||||
if [[ ! -d "$SOURCE_ROOT/sundials-$VERSION" ]]; then
|
||||
tar -xzf "$ARCHIVE" -C "$SOURCE_ROOT"
|
||||
fi
|
||||
cmake -S "$SOURCE_ROOT/sundials-$VERSION" -B "$SOURCE_ROOT/build" \
|
||||
-DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=OFF \
|
||||
-DBUILD_ARKODE=OFF -DBUILD_CVODES=OFF -DBUILD_IDA=OFF \
|
||||
-DBUILD_IDAS=OFF -DBUILD_KINSOL=OFF \
|
||||
-DEXAMPLES_ENABLE_C=OFF -DEXAMPLES_INSTALL=OFF
|
||||
cmake --build "$SOURCE_ROOT/build" --parallel "${CMAKE_BUILD_PARALLEL_LEVEL:-4}"
|
||||
cmake --install "$SOURCE_ROOT/build"
|
||||
echo "Native dependency installed in $PREFIX"
|
||||
echo "The repository .venv Python discovers this default location automatically."
|
||||
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
title SystemSimulationApp Launcher
|
||||
|
||||
set "BACKEND_SCRIPT=%~dp0start-backend.bat"
|
||||
set "FRONTEND_SCRIPT=%~dp0start-reactflow.bat"
|
||||
|
||||
if not exist "%BACKEND_SCRIPT%" (
|
||||
echo [ERROR] start-backend.bat was not found:
|
||||
echo %BACKEND_SCRIPT%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%FRONTEND_SCRIPT%" (
|
||||
echo [ERROR] start-reactflow.bat was not found:
|
||||
echo %FRONTEND_SCRIPT%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting FastAPI and ReactFlow in separate windows...
|
||||
|
||||
set "LAUNCH_ERROR=0"
|
||||
ver >nul
|
||||
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%BACKEND_SCRIPT%"
|
||||
if errorlevel 1 set "LAUNCH_ERROR=1"
|
||||
ver >nul
|
||||
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%FRONTEND_SCRIPT%"
|
||||
if errorlevel 1 set "LAUNCH_ERROR=1"
|
||||
|
||||
if "%LAUNCH_ERROR%"=="1" (
|
||||
echo.
|
||||
echo [ERROR] One or more service windows could not be created.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
exit /b 0
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
|
||||
echo "[ERROR] start-all.sh requires Bash 4.3 or newer." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
BACKEND_SCRIPT="$SCRIPT_DIR/start-backend.sh"
|
||||
FRONTEND_SCRIPT="$SCRIPT_DIR/start-reactflow.sh"
|
||||
|
||||
if [[ ! -x "$BACKEND_SCRIPT" ]]; then
|
||||
echo "[ERROR] Backend start script is missing or not executable:" >&2
|
||||
echo " $BACKEND_SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "$FRONTEND_SCRIPT" ]]; then
|
||||
echo "[ERROR] Frontend start script is missing or not executable:" >&2
|
||||
echo " $FRONTEND_SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
|
||||
signal_process_group() {
|
||||
local signal="$1"
|
||||
local pid="$2"
|
||||
|
||||
[[ -n "$pid" ]] || return 0
|
||||
kill "-$signal" -- "-$pid" 2>/dev/null || kill "-$signal" "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
process_group_is_running() {
|
||||
local pid="$1"
|
||||
|
||||
[[ -n "$pid" ]] && kill -0 -- "-$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local backend_cleared=false
|
||||
local force_kill=false
|
||||
local frontend_cleared=false
|
||||
local launcher_pid=$$
|
||||
local timer_pid
|
||||
|
||||
trap '' INT TERM HUP
|
||||
trap - EXIT
|
||||
trap 'force_kill=true' ALRM
|
||||
|
||||
signal_process_group TERM "$BACKEND_PID"
|
||||
signal_process_group TERM "$FRONTEND_PID"
|
||||
|
||||
(
|
||||
sleep 5
|
||||
kill -ALRM "$launcher_pid" 2>/dev/null || true
|
||||
) &
|
||||
timer_pid=$!
|
||||
|
||||
if [[ -n "$BACKEND_PID" ]]; then
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$force_kill" == false && -n "$FRONTEND_PID" ]]; then
|
||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
while [[ "$force_kill" == false ]]; do
|
||||
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
|
||||
backend_cleared=true
|
||||
fi
|
||||
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
|
||||
frontend_cleared=true
|
||||
fi
|
||||
if [[ "$backend_cleared" == true && "$frontend_cleared" == true ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
kill -KILL "$timer_pid" 2>/dev/null || true
|
||||
wait "$timer_pid" 2>/dev/null || true
|
||||
|
||||
if [[ "$force_kill" == true ]]; then
|
||||
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
|
||||
backend_cleared=true
|
||||
fi
|
||||
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
|
||||
frontend_cleared=true
|
||||
fi
|
||||
if [[ "$backend_cleared" == false ]]; then
|
||||
signal_process_group KILL "$BACKEND_PID"
|
||||
fi
|
||||
if [[ "$frontend_cleared" == false ]]; then
|
||||
signal_process_group KILL "$FRONTEND_PID"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$BACKEND_PID" ]]; then
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$FRONTEND_PID" ]]; then
|
||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
trap - ALRM
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 129' HUP
|
||||
|
||||
echo "Starting FastAPI and ReactFlow..."
|
||||
echo "Press Ctrl+C to stop both services."
|
||||
echo
|
||||
|
||||
set -m
|
||||
"$BACKEND_SCRIPT" &
|
||||
BACKEND_PID=$!
|
||||
"$FRONTEND_SCRIPT" &
|
||||
FRONTEND_PID=$!
|
||||
set +m
|
||||
|
||||
wait -n
|
||||
EXIT_CODE=$?
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
@@ -0,0 +1,45 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
|
||||
title SystemSimulationApp FastAPI - 127.0.0.1:8000
|
||||
|
||||
set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe"
|
||||
if not defined SIMULATION_NUMERIC_ENGINE set "SIMULATION_NUMERIC_ENGINE=native"
|
||||
|
||||
if not exist "%PYTHON_EXE%" (
|
||||
echo [ERROR] Python virtual environment was not found:
|
||||
echo %PYTHON_EXE%
|
||||
echo.
|
||||
echo Create it and install the backend dependencies first:
|
||||
echo py -3 -m venv "%REPO_ROOT%\.venv-win"
|
||||
echo "%PYTHON_EXE%" -m pip install -r "%REPO_ROOT%\requirements.txt"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
pushd "%REPO_ROOT%" >nul
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Unable to enter the repository directory:
|
||||
echo %REPO_ROOT%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting FastAPI at http://127.0.0.1:8000
|
||||
echo Press Ctrl+C to stop the service.
|
||||
echo.
|
||||
|
||||
"%PYTHON_EXE%" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
if not "%EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo [ERROR] FastAPI exited with code %EXIT_CODE%.
|
||||
pause
|
||||
)
|
||||
|
||||
popd
|
||||
exit /b %EXIT_CODE%
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
|
||||
PYTHON_EXE="${SYSTEM_SIMULATION_PYTHON:-$REPO_ROOT/.venv/bin/python}"
|
||||
export SIMULATION_NUMERIC_ENGINE="${SIMULATION_NUMERIC_ENGINE:-native}"
|
||||
|
||||
if [[ "$PYTHON_EXE" != /* ]]; then
|
||||
PYTHON_EXE="$REPO_ROOT/$PYTHON_EXE"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PYTHON_EXE" ]]; then
|
||||
echo "[ERROR] Python virtual environment was not found:" >&2
|
||||
echo " $PYTHON_EXE" >&2
|
||||
echo >&2
|
||||
echo "Create it and install the backend dependencies first:" >&2
|
||||
echo " python3 -m venv \"$REPO_ROOT/.venv\"" >&2
|
||||
echo " \"$REPO_ROOT/.venv/bin/python\" -m pip install -r \"$REPO_ROOT/requirements.txt\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "Starting FastAPI at http://127.0.0.1:8000"
|
||||
echo "Press Ctrl+C to stop the service."
|
||||
echo
|
||||
|
||||
exec "$PYTHON_EXE" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
@@ -0,0 +1,77 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
|
||||
set "FRONTEND_DIR=%REPO_ROOT%\frontend"
|
||||
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
|
||||
|
||||
if not exist "%FRONTEND_DIR%\package.json" (
|
||||
echo [ERROR] Frontend package.json was not found:
|
||||
echo %FRONTEND_DIR%\package.json
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "NODE_DIR="
|
||||
for /d %%D in ("%REPO_ROOT%\.tools\node-*-win-x64") do (
|
||||
if not defined NODE_DIR if exist "%%~fD\node.exe" if exist "%%~fD\npm.cmd" (
|
||||
"%%~fD\node.exe" -e "v=process.versions.node.split('.');M=+v[0];m=+v[1];process.exit((M===20&&m>=19)||(M===22&&m>=12)||M>=23?0:1)" >nul 2>&1
|
||||
if not errorlevel 1 set "NODE_DIR=%%~fD"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined NODE_DIR (
|
||||
echo [ERROR] A compatible Node.js portable runtime was not found under:
|
||||
echo %REPO_ROOT%\.tools
|
||||
echo Vite requires Node.js 20.19+ or 22.12+.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "NPM_EXE=%NODE_DIR%\npm.cmd"
|
||||
if not exist "%NPM_EXE%" (
|
||||
echo [ERROR] npm.cmd was not found:
|
||||
echo %NPM_EXE%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%FRONTEND_DIR%\node_modules\.bin\vite.cmd" (
|
||||
echo [ERROR] Frontend dependencies are not installed.
|
||||
echo Run the following command first:
|
||||
echo cd /d "%FRONTEND_DIR%"
|
||||
echo call "%NPM_EXE%" ci
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
pushd "%FRONTEND_DIR%" >nul
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Unable to enter the frontend directory:
|
||||
echo %FRONTEND_DIR%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "PATH=%NODE_DIR%;%PATH%"
|
||||
|
||||
echo Starting ReactFlow at http://127.0.0.1:5173
|
||||
echo Press Ctrl+C to stop the service.
|
||||
echo.
|
||||
|
||||
call "%NPM_EXE%" run dev -- --strictPort
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
if not "%EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
|
||||
pause
|
||||
)
|
||||
|
||||
popd
|
||||
exit /b %EXIT_CODE%
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
|
||||
FRONTEND_DIR="$REPO_ROOT/frontend"
|
||||
|
||||
node_version_supported() {
|
||||
local version="$1"
|
||||
local major
|
||||
local minor
|
||||
|
||||
[[ "$version" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1
|
||||
major=$((10#${BASH_REMATCH[1]}))
|
||||
minor=$((10#${BASH_REMATCH[2]}))
|
||||
(( (major == 20 && minor >= 19) || (major == 22 && minor >= 12) || major >= 23 ))
|
||||
}
|
||||
|
||||
if [[ ! -f "$FRONTEND_DIR/package.json" ]]; then
|
||||
echo "[ERROR] Frontend package.json was not found:" >&2
|
||||
echo " $FRONTEND_DIR/package.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_EXE=""
|
||||
NPM_EXE=""
|
||||
for candidate in "$REPO_ROOT"/.tools/node-*-linux-x64/bin; do
|
||||
if [[ -x "$candidate/node" && -x "$candidate/npm" ]]; then
|
||||
CANDIDATE_VERSION="$("$candidate/node" --version 2>/dev/null || true)"
|
||||
if node_version_supported "$CANDIDATE_VERSION"; then
|
||||
NODE_EXE="$candidate/node"
|
||||
NPM_EXE="$candidate/npm"
|
||||
PATH="$candidate:${PATH:-}"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
export PATH
|
||||
|
||||
if [[ -z "$NODE_EXE" ]]; then
|
||||
NODE_EXE="$(command -v node || true)"
|
||||
NPM_EXE="$(command -v npm || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_EXE" || -z "$NPM_EXE" ]]; then
|
||||
echo "[ERROR] Node.js and npm were not found." >&2
|
||||
echo "Install Node.js 20.19+ or 22.12+ and make node/npm available on PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_VERSION="$("$NODE_EXE" --version 2>/dev/null || true)"
|
||||
if ! node_version_supported "$NODE_VERSION"; then
|
||||
echo "[ERROR] Unsupported Node.js version: $NODE_VERSION" >&2
|
||||
echo "Vite requires Node.js 20.19+ or 22.12+ (Node.js 21 is not supported)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "$FRONTEND_DIR/node_modules/.bin/vite" ]]; then
|
||||
echo "[ERROR] Frontend dependencies are not installed." >&2
|
||||
echo "Run the following command first:" >&2
|
||||
echo " cd \"$FRONTEND_DIR\" && \"$NPM_EXE\" ci" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
echo "Starting ReactFlow at http://127.0.0.1:5173"
|
||||
echo "Press Ctrl+C to stop the service."
|
||||
echo
|
||||
|
||||
exec "$NPM_EXE" run dev -- --strictPort
|
||||
@@ -0,0 +1,10 @@
|
||||
# Reference direct-dependency set for CPython 3.12.
|
||||
#
|
||||
# This intentionally pins only packages imported or invoked directly by the
|
||||
# application. In particular, it does not pin uvicorn[standard]'s optional,
|
||||
# platform-dependent transitive dependencies. Regenerate and validate these
|
||||
# pins in a clean CPython 3.12 environment when intentionally upgrading them.
|
||||
fastapi==0.141.1
|
||||
lxml==6.1.1
|
||||
pydantic==2.13.4
|
||||
uvicorn==0.52.3
|
||||
@@ -0,0 +1,29 @@
|
||||
# Fully resolved release lock for CPython 3.12 on manylinux_2_28-compatible
|
||||
# Linux x86_64. Resolved and wheel-verified on 2026-08-18.
|
||||
#
|
||||
# Install this file as a requirements file (with -r), not as a constraint
|
||||
# (with -c). Each package is pinned to the wheel selected for the reference
|
||||
# platform, and pip verifies that wheel against its SHA-256 digest.
|
||||
--only-binary=:all:
|
||||
--require-hashes
|
||||
|
||||
annotated-doc==0.0.5 --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101
|
||||
annotated-types==0.8.0 --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
||||
anyio==4.14.2 --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494
|
||||
click==8.4.2 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
||||
fastapi==0.141.1 --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3
|
||||
h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
httptools==0.8.0 --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2
|
||||
idna==3.18 --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2
|
||||
lxml==6.1.1 --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818
|
||||
pydantic==2.13.4 --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba
|
||||
pydantic-core==2.46.4 --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce
|
||||
python-dotenv==1.2.3 --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9
|
||||
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
|
||||
starlette==1.6.0 --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c
|
||||
typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
||||
typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
|
||||
uvicorn[standard]==0.52.3 --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c
|
||||
uvloop==0.22.1 --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4
|
||||
watchfiles==1.2.0 --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5
|
||||
websockets==17.0.1 --hash=sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a
|
||||
+26
-35
@@ -1,46 +1,37 @@
|
||||
# 开发文档索引
|
||||
# 文档目录说明
|
||||
|
||||
本目录是 SystemSimulationApp 协议和开发规范的统一入口。
|
||||
本目录是 SystemSimulationApp 更新日志、现行标准和其他技术报告的统一入口。
|
||||
|
||||
## 模型开发
|
||||
## 目录职责
|
||||
|
||||
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
|
||||
用于创建或修改模型,包括端口、参数、结果、方程、版本、测试和 AI 修改协议。
|
||||
2. [组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
|
||||
用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。
|
||||
3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json)
|
||||
`GET /api/components/catalog` 的机器可读结构。
|
||||
| 目录 | 职责 |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [`update-log/`](update-log/) | 按日期保存每日更新日志,记录当天已经完成的上传、修改及其影响。 |
|
||||
| [`standard/`](standard/) | 保存当前采用的标准、协议和开发规范。实现、评审和 AI 修改代码时,应优先以这里的文档为准。 |
|
||||
| [`other/`](other/) | 保存不属于现行标准的其他文档,例如性能仿真报告、优化报告、调研记录和技术总结。这些文档主要用于分析和参考,不默认作为强制规范。 |
|
||||
|
||||
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
|
||||
模型。不要从前端兜底数据反推后端物理契约。
|
||||
新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
|
||||
|
||||
## System XML
|
||||
## `update-log` 书写规范
|
||||
|
||||
- [System XML v2 协议](system-xml-v2.md)
|
||||
- [System XML v2 XSD](../schemas/system-simulation-v2.xsd)
|
||||
- [System XML v1 协议(旧版)](system-xml-v1.md)
|
||||
- [System XML v1 XSD(旧版)](../schemas/system-simulation-v1.xsd)
|
||||
以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。
|
||||
|
||||
新增模型时,模型类和组件库清单是后端事实来源;System XML 保存组件实例、参数和
|
||||
连接。XML 解析器不能自行创造模型端口或参数。
|
||||
1. 按日期填写日志。每个文件只记录一天的更新,文件名使用 `更新日志-YYYY-MM-DD.md`,标题使用 `# 更新日志 YYYY-MM-DD`。
|
||||
2. 同一天内有多次上传或更新时,按各项工作的实际完成时间分段记录。每段使用北京时间、24 小时制的 `## HH:mm` 标注时间,并按完成时间从早到晚排列。
|
||||
3. 不同时间完成的内容应分别记录,不要合并到同一时间段。一次上传或更新包含多项相关修改时,可以写在同一时间段内。
|
||||
4. 语言应简短且信息充分,让 AI 和人都能快速理解。优先说明完成了什么、结果是什么、影响哪些范围,删除重复描述和无关过程。
|
||||
5. 尽量使用通俗易懂的语言,减少难以理解的术语。必须使用专业术语时,应提供必要的简短说明。
|
||||
|
||||
## 当前代码入口
|
||||
推荐格式:
|
||||
|
||||
| 目的 | 文件 |
|
||||
| --- | --- |
|
||||
| 组件基类 | [`app/simulation/core/base.py`](../app/simulation/core/base.py) |
|
||||
| 端口契约 | [`app/simulation/core/ports.py`](../app/simulation/core/ports.py) |
|
||||
| 参数与结果元数据 | [`app/simulation/core/metadata.py`](../app/simulation/core/metadata.py) |
|
||||
| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) |
|
||||
| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) |
|
||||
| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) |
|
||||
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
|
||||
```markdown
|
||||
# 更新日志 YYYY-MM-DD
|
||||
|
||||
## AI 使用原则
|
||||
## HH:mm
|
||||
|
||||
- 先读规范和相邻模型,再改代码。
|
||||
- 只从 `library.py` 受控登记公开模型。
|
||||
- 不在前端复制后端端口、参数或默认值作为正式来源。
|
||||
- 不覆盖用户已有改动。
|
||||
- 不自行猜测缺失的物理方程。
|
||||
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
|
||||
- 完成的修改、结果及影响范围。
|
||||
|
||||
## HH:mm
|
||||
|
||||
- 完成的修改、结果及影响范围。
|
||||
```
|
||||
@@ -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 能映射到正确模型。
|
||||
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
|
||||
- 针对性测试、完整回归和必要的前端构建通过。
|
||||
- 文档记录了模型假设、适用范围和已知限制。
|
||||
Loaded 100 of 640 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user