Compare commits
6
Commits
main
...
743663e3a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743663e3a6 | ||
|
|
3b38f73fe0 | ||
|
|
48da6be21c | ||
|
|
ce353d2dd2 | ||
|
|
03b86f52ba | ||
|
|
ce62079335 |
No files matched your search
@@ -1,3 +1,12 @@
|
|||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.sh text eol=lf
|
*.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/data/test-mql-8.xml text eol=lf
|
||||||
|
tests/data/test-mql-8.json text eol=lf
|
||||||
|
tests/data/test_mql-full-branches-01-04.xml text eol=lf
|
||||||
|
tests/baselines/simulation/**/*.json text eol=lf
|
||||||
|
|
||||||
|
tests/baselines/simulation/**/sources/* text eol=lf
|
||||||
@@ -1,56 +1,31 @@
|
|||||||
name: Solver regression
|
name: Native backend regression
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- "app/simulation/**"
|
- "app/**"
|
||||||
|
- "native/**"
|
||||||
|
- "schemas/**"
|
||||||
- "tests/**"
|
- "tests/**"
|
||||||
- "requirements.txt"
|
- "requirements*.txt"
|
||||||
- "constraints/**"
|
- "constraints/**"
|
||||||
- ".python-version"
|
- ".python-version"
|
||||||
- "README.md"
|
- ".gitattributes"
|
||||||
- ".github/workflows/solver-regression.yml"
|
- ".github/workflows/solver-regression.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "app/simulation/**"
|
- "app/**"
|
||||||
|
- "native/**"
|
||||||
|
- "schemas/**"
|
||||||
- "tests/**"
|
- "tests/**"
|
||||||
- "requirements.txt"
|
- "requirements*.txt"
|
||||||
- "constraints/**"
|
- "constraints/**"
|
||||||
- ".python-version"
|
- ".python-version"
|
||||||
- "README.md"
|
- ".gitattributes"
|
||||||
- ".github/workflows/solver-regression.yml"
|
- ".github/workflows/solver-regression.yml"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "17 3 * * 1-6"
|
- cron: "17 3 * * *"
|
||||||
- cron: "17 3 * * 0"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
suite:
|
|
||||||
description: Regression tier
|
|
||||||
required: true
|
|
||||||
default: quick
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- quick
|
|
||||||
- historical
|
|
||||||
- main-long
|
|
||||||
case:
|
|
||||||
description: Longest main-model horizon (predecessors run first)
|
|
||||||
required: true
|
|
||||||
default: 0.2s
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- 0.2s
|
|
||||||
- 1s
|
|
||||||
- 5s
|
|
||||||
- 10s
|
|
||||||
lane:
|
|
||||||
description: Output sampling lane
|
|
||||||
required: true
|
|
||||||
default: production
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- solver-only
|
|
||||||
- production
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
|
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
|
||||||
@@ -60,121 +35,67 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quick:
|
contracts:
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' ||
|
|
||||||
github.event_name == 'pull_request' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 15
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: .python-version
|
python-version-file: .python-version
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: |
|
cache-dependency-path: constraints/python312-linux-x86_64.lock
|
||||||
requirements.txt
|
- name: Install backend runtime without numerical Python packages
|
||||||
constraints/python312-direct.txt
|
|
||||||
constraints/python312-linux-x86_64.lock
|
|
||||||
- name: Install hashed Linux release lock
|
|
||||||
run: |
|
run: |
|
||||||
python -m pip install \
|
python -m pip install -r constraints/python312-linux-x86_64.lock
|
||||||
--force-reinstall \
|
|
||||||
-r constraints/python312-linux-x86_64.lock
|
|
||||||
python -m pip check
|
python -m pip check
|
||||||
- name: Run solver foundation tests
|
- name: Validate portable metadata and IR contracts
|
||||||
env:
|
env:
|
||||||
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
|
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
|
||||||
run: |
|
run: |
|
||||||
python -W error::ResourceWarning -m unittest \
|
python -W error::ResourceWarning -m unittest \
|
||||||
tests.test_dependency_constraints \
|
tests.test_dependency_constraints \
|
||||||
tests.test_benchmark_regression \
|
tests.test_regression_fixture_line_endings \
|
||||||
tests.test_physical_state_v21 \
|
tests.test_component_catalog \
|
||||||
tests.test_test_mql_ame_contract \
|
tests.test_component_metadata \
|
||||||
tests.test_test_mql_8_regression \
|
tests.test_component_registry \
|
||||||
tests.test_mql_full_branches_regression \
|
tests.test_medium_reference_contract \
|
||||||
tests.test_pressure_flow_causal_execution \
|
tests.test_system_xml_v3 \
|
||||||
tests.test_stream_pressure_block_solver \
|
tests.test_system_numeric_ir_v2 \
|
||||||
tests.test_core_solver \
|
tests.test_native_only_backend
|
||||||
tests.test_supported_piston_tangent \
|
|
||||||
tests.test_three_piston_tangent \
|
|
||||||
tests.test_sparse_secant_jacobian \
|
|
||||||
tests.test_generic_jacobian_sparsity \
|
|
||||||
tests.test_generic_system_xml_simulation
|
|
||||||
|
|
||||||
historical-nightly:
|
native-windows:
|
||||||
if: >-
|
runs-on: windows-2022
|
||||||
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
|
timeout-minutes: 30
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
|
defaults:
|
||||||
runs-on: ubuntu-24.04
|
run:
|
||||||
timeout-minutes: 15
|
shell: pwsh
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: conda-incubator/setup-miniconda@v3
|
||||||
with:
|
with:
|
||||||
python-version-file: .python-version
|
python-version: "3.12"
|
||||||
cache: pip
|
activate-environment: simulation-native
|
||||||
cache-dependency-path: |
|
auto-activate-base: false
|
||||||
requirements.txt
|
- name: Install native compiler and SUNDIALS
|
||||||
constraints/python312-direct.txt
|
|
||||||
constraints/python312-linux-x86_64.lock
|
|
||||||
- name: Install hashed Linux release lock
|
|
||||||
run: |
|
run: |
|
||||||
python -m pip install \
|
conda install --yes -c conda-forge sundials=7.4.0 m2w64-gcc
|
||||||
--force-reinstall \
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
-r constraints/python312-linux-x86_64.lock
|
"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
|
python -m pip check
|
||||||
- name: Run 0.81 and 2.10 second historical regression
|
- name: Run catalog, numerical, API and schema regression
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
python -c "from app.simulation.native_codegen.build import toolchain; print(toolchain())"
|
||||||
python -m app.simulation.benchmark_regression \
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
|
python -W error::ResourceWarning -m unittest discover -s tests
|
||||||
--lane production \
|
- name: Complete the 10 second skill fixture in native RK45
|
||||||
--output artifacts/test-mql-full-branches.json
|
run: |
|
||||||
|
python -m app.simulation.native_codegen tests/data/native-skill-test.xml --output-dir test/ci-skill --method RK45 --max-step 0.001 --rtol 1e-7 --runs 1 --solve-only
|
||||||
- if: always()
|
- if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: historical-solver-regression
|
name: native-skill-regression
|
||||||
path: artifacts/*.json
|
path: test/ci-skill/summary.json
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
main-periodic:
|
|
||||||
if: >-
|
|
||||||
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 180
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version-file: .python-version
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: |
|
|
||||||
requirements.txt
|
|
||||||
constraints/python312-direct.txt
|
|
||||||
constraints/python312-linux-x86_64.lock
|
|
||||||
- name: Install hashed Linux release lock
|
|
||||||
run: |
|
|
||||||
python -m pip install \
|
|
||||||
--force-reinstall \
|
|
||||||
-r constraints/python312-linux-x86_64.lock
|
|
||||||
python -m pip check
|
|
||||||
- name: Run bounded progressive main-model regression
|
|
||||||
env:
|
|
||||||
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
|
|
||||||
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
|
|
||||||
run: |
|
|
||||||
mkdir -p artifacts
|
|
||||||
python -m app.simulation.benchmark_regression \
|
|
||||||
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
|
|
||||||
--lane "$REQUESTED_LANE" \
|
|
||||||
--case "$REQUESTED_CASE" \
|
|
||||||
--output artifacts/test-mql-8-progressive.json
|
|
||||||
- if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: main-model-progressive-regression
|
|
||||||
path: artifacts/*.json
|
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
@@ -15,6 +15,10 @@ htmlcov/
|
|||||||
|
|
||||||
# Local Linux toolchain (downloaded for the startup scripts)
|
# Local Linux toolchain (downloaded for the startup scripts)
|
||||||
.tools/node-*-linux-x64/
|
.tools/node-*-linux-x64/
|
||||||
|
.tools/node-*-win-x64/
|
||||||
|
|
||||||
|
# Local benchmark archives, generated executables and comparison outputs
|
||||||
|
/test/
|
||||||
|
|
||||||
app/data/
|
app/data/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
|||||||
|
|
||||||
## 开发环境准备
|
## 开发环境准备
|
||||||
|
|
||||||
后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
|
后端编排层统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
|
||||||
`3.12.3`。`requirements.txt` 保留支持范围,
|
`3.12.3`。`requirements.txt` 保留支持范围,
|
||||||
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
|
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
|
||||||
`constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64
|
`constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64
|
||||||
@@ -84,6 +84,10 @@ Linux:
|
|||||||
|
|
||||||
后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。
|
后端地址为 `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,已覆盖组件库当前注册的 27 类模型(22 类 Amesim、5 类实验组件)。具体公式范围与连接限制见[原生后端说明](native/README.md);不支持的自定义模型或未收敛的连接会明确报错,不自动切回 Python。旧 Python 数值后端已删除;Linux 暂支持编辑、校验与生成,原生构建打包尚待实现。旧固定拓扑示例接口返回 HTTP 410,请改用统一 XML 接口。
|
||||||
|
|
||||||
## 后端接口
|
## 后端接口
|
||||||
|
|
||||||
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
||||||
@@ -99,7 +103,7 @@ Linux:
|
|||||||
|
|
||||||
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
||||||
|
|
||||||
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
|
当前网络层按端口域处理气动压力/流量与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡。默认 C 后端在生成的 EXE 内完成连接闭合、RK45/CVODE BDF 积分及信号/限位事件。通用仿真仍有已声明的拓扑和物理公式范围,并不等价于完整 Amesim 或 Modelica.Fluid 实现。
|
||||||
|
|
||||||
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
|
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
|
||||||
|
|
||||||
@@ -112,3 +116,8 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `require
|
|||||||
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||||
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
|
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
|
||||||
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
|
- [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)。
|
||||||
+15
-209
@@ -23,8 +23,7 @@ from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
from app.simulation.performance import performance_span, profile_phase, profile_run
|
from app.simulation.performance import performance_span, profile_phase, profile_run
|
||||||
from app.simulation.property_cache import property_cache_run
|
from app.simulation.config import SolverActivityTracker
|
||||||
from app.simulation.solvers.solver import SolverActivityTracker
|
|
||||||
from app.system_xml import (
|
from app.system_xml import (
|
||||||
SystemXmlDocument,
|
SystemXmlDocument,
|
||||||
SystemXmlValidationReport,
|
SystemXmlValidationReport,
|
||||||
@@ -41,8 +40,15 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
|
async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.simulation.backends import numeric_engine_name
|
||||||
from app.simulation.warmup import warm_up_simulation_runtime
|
from app.simulation.warmup import warm_up_simulation_runtime
|
||||||
|
|
||||||
|
application.state.simulation_numeric_engine = numeric_engine_name()
|
||||||
|
logging.getLogger("uvicorn.error").info(
|
||||||
|
"Simulation numeric engine: %s", application.state.simulation_numeric_engine,
|
||||||
|
)
|
||||||
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict()
|
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -688,7 +694,6 @@ def run_system_xml_simulation(
|
|||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
activity_tracker: SolverActivityTracker | None = None,
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
with property_cache_run() as property_cache:
|
|
||||||
with profile_run() as trace:
|
with profile_run() as trace:
|
||||||
result = _run_system_xml_simulation_profiled(
|
result = _run_system_xml_simulation_profiled(
|
||||||
xml_bytes,
|
xml_bytes,
|
||||||
@@ -698,16 +703,6 @@ def run_system_xml_simulation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
performance = trace.snapshot()
|
performance = trace.snapshot()
|
||||||
if performance.get("mode") == "audit" and property_cache is not None:
|
|
||||||
cache_info = property_cache.info()
|
|
||||||
performance["propertyCache"] = {
|
|
||||||
"hits": cache_info.hits,
|
|
||||||
"misses": cache_info.misses,
|
|
||||||
"maxEntriesPerCache": cache_info.max_entries_per_cache,
|
|
||||||
"cacheCount": cache_info.cache_count,
|
|
||||||
"currentEntries": cache_info.current_entries,
|
|
||||||
"evictions": cache_info.evictions,
|
|
||||||
}
|
|
||||||
if performance.get("mode") != "off":
|
if performance.get("mode") != "off":
|
||||||
diagnostics = dict(result.get("diagnostics", {}))
|
diagnostics = dict(result.get("diagnostics", {}))
|
||||||
diagnostics["performance"] = performance
|
diagnostics["performance"] = performance
|
||||||
@@ -721,13 +716,8 @@ def _run_system_xml_simulation_profiled(
|
|||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
activity_tracker: SolverActivityTracker | None = None,
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
from app.simulation.backends import simulate_network
|
||||||
from app.simulation.solvers.solver import SolveIVPConfig
|
from app.simulation.results import SimulationPreparationError
|
||||||
from app.simulation.solvers.stream import StreamSolveError
|
|
||||||
from app.simulation.systems.generic import (
|
|
||||||
GenericFluidSystem,
|
|
||||||
SimulationPreparationError,
|
|
||||||
)
|
|
||||||
|
|
||||||
def emit(
|
def emit(
|
||||||
progress: int,
|
progress: int,
|
||||||
@@ -766,20 +756,9 @@ def _run_system_xml_simulation_profiled(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
system = GenericFluidSystem(network)
|
result = simulate_network(
|
||||||
result = system.simulate(
|
network,
|
||||||
SolveIVPConfig(
|
document.simulation,
|
||||||
t_start=document.simulation.t_start,
|
|
||||||
t_stop=document.simulation.t_stop,
|
|
||||||
method=document.simulation.method,
|
|
||||||
# The pressure-flow closure is solved to a scaled 1e-7
|
|
||||||
# residual. State-specific mechanical absolute tolerances now
|
|
||||||
# keep ideal-stop Jacobian perturbations stable, so the outer
|
|
||||||
# integrator can use its canonical 1e-6 relative accuracy.
|
|
||||||
rtol=1.0e-6,
|
|
||||||
max_step=document.simulation.max_step,
|
|
||||||
),
|
|
||||||
sample_step=document.simulation.sample_step,
|
|
||||||
progress_callback=report_system_progress,
|
progress_callback=report_system_progress,
|
||||||
cancel_check=cancel_check,
|
cancel_check=cancel_check,
|
||||||
activity_tracker=activity_tracker,
|
activity_tracker=activity_tracker,
|
||||||
@@ -799,41 +778,6 @@ def _run_system_xml_simulation_profiled(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except AlgebraicSolveError as exc:
|
|
||||||
algebraic_diagnostics = exc.diagnostics.as_dict()
|
|
||||||
algebraic_diagnostics["scopeKind"] = exc.scope_kind
|
|
||||||
algebraic_diagnostics["scopeComponents"] = list(exc.scope_components)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail={
|
|
||||||
"message": str(exc),
|
|
||||||
"issues": [
|
|
||||||
{
|
|
||||||
"severity": "error",
|
|
||||||
"layer": "simulation",
|
|
||||||
"code": "PRESSURE_FLOW_SOLVE_FAILED",
|
|
||||||
"message": str(exc),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"diagnostics": algebraic_diagnostics,
|
|
||||||
},
|
|
||||||
) from exc
|
|
||||||
except StreamSolveError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail={
|
|
||||||
"message": str(exc),
|
|
||||||
"issues": [
|
|
||||||
{
|
|
||||||
"severity": "error",
|
|
||||||
"layer": "simulation",
|
|
||||||
"code": "STREAM_SOLVE_FAILED",
|
|
||||||
"message": str(exc),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"diagnostics": exc.diagnostics.as_dict(),
|
|
||||||
},
|
|
||||||
) from exc
|
|
||||||
except (RuntimeError, ValueError) as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
@@ -1524,143 +1468,15 @@ def sanitize_project_id(project_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]:
|
def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]:
|
||||||
from app.simulation.examples.testmodel.run import (
|
raise HTTPException(status_code=410, detail="The legacy Python example runner has been removed. Export System XML and use /api/system-xml/simulate.")
|
||||||
TestModelExecutionConfig,
|
|
||||||
TestModelRunConfig,
|
|
||||||
TestModelSamplingConfig,
|
|
||||||
run_testmodel,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.testmodel.system import (
|
|
||||||
BranchConfig,
|
|
||||||
CylinderConfig,
|
|
||||||
OrificeConfig,
|
|
||||||
PipeConfig,
|
|
||||||
TankConfig,
|
|
||||||
TestModelConfig,
|
|
||||||
)
|
|
||||||
from app.simulation.solvers.solver import SolveIVPConfig
|
|
||||||
|
|
||||||
validate_reactflow_execution_contract(project)
|
|
||||||
|
|
||||||
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
|
|
||||||
for node in project.nodes:
|
|
||||||
nodes_by_type.setdefault(node.data.modelType, []).append(node)
|
|
||||||
|
|
||||||
cylinder = first_node(nodes_by_type, "cylinder")
|
|
||||||
tank = first_node(nodes_by_type, "tank")
|
|
||||||
orifices = nodes_by_type.get("orifice", [])
|
|
||||||
pipes = nodes_by_type.get("pipe", [])
|
|
||||||
|
|
||||||
model_config = TestModelConfig(
|
|
||||||
cylinder=CylinderConfig(
|
|
||||||
volume=parameter_float(cylinder, "volume", 0.01),
|
|
||||||
p0=parameter_float(cylinder, "p0", 35e6),
|
|
||||||
T0=parameter_float(cylinder, "T0", 300.0),
|
|
||||||
),
|
|
||||||
upper_branch=BranchConfig(
|
|
||||||
orifice=OrificeConfig(K=parameter_float(node_at(orifices, 0), "K", 1e-5)),
|
|
||||||
pipe=pipe_config_from_node(node_at(pipes, 0), PipeConfig),
|
|
||||||
),
|
|
||||||
lower_branch=BranchConfig(
|
|
||||||
orifice=OrificeConfig(K=parameter_float(node_at(orifices, 1), "K", 1e-5)),
|
|
||||||
pipe=pipe_config_from_node(node_at(pipes, 1), PipeConfig),
|
|
||||||
),
|
|
||||||
tank=TankConfig(
|
|
||||||
volume=parameter_float(tank, "volume", 0.1),
|
|
||||||
p0=parameter_float(tank, "p0", 1e5),
|
|
||||||
T0=parameter_float(tank, "T0", 300.0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
run_config = TestModelRunConfig(
|
|
||||||
model=model_config,
|
|
||||||
solver=SolveIVPConfig(
|
|
||||||
t_start=project.simulation.t_start,
|
|
||||||
t_stop=project.simulation.t_stop,
|
|
||||||
method=project.simulation.method,
|
|
||||||
max_step=project.simulation.max_step,
|
|
||||||
),
|
|
||||||
sampling=TestModelSamplingConfig(step=project.simulation.step),
|
|
||||||
execution=TestModelExecutionConfig(use_modelica_reference_if_available=False),
|
|
||||||
)
|
|
||||||
result = run_testmodel(run_config=run_config)
|
|
||||||
series_keys = ("time", "mytank.p", "mytank.T", "mycylinder.p", "mycylinder.T")
|
|
||||||
series = {
|
|
||||||
key: [float(value) for value in result.series[key]]
|
|
||||||
for key in series_keys
|
|
||||||
if key in result.series
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": bool(result.solution.success),
|
|
||||||
"message": str(result.solution.message),
|
|
||||||
"usedModelicaReference": result.used_modelica_reference,
|
|
||||||
"final": {
|
|
||||||
"time": series["time"][-1],
|
|
||||||
"tankPressure": series["mytank.p"][-1],
|
|
||||||
"tankTemperature": series["mytank.T"][-1],
|
|
||||||
"cylinderPressure": series["mycylinder.p"][-1],
|
|
||||||
"cylinderTemperature": series["mycylinder.T"][-1],
|
|
||||||
},
|
|
||||||
"series": series,
|
|
||||||
"artifacts": {
|
|
||||||
"primaryCsv": str(result.artifacts.primary_csv_path),
|
|
||||||
"temperatureCsv": str(result.artifacts.temperature_csv_path),
|
|
||||||
"temperatureSvg": str(result.artifacts.temperature_svg_path),
|
|
||||||
"runReport": str(result.artifacts.run_report_path),
|
|
||||||
},
|
|
||||||
"networkSummary": result.system.network.summary(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]:
|
def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]:
|
||||||
from app.simulation.examples.test_mql.run import run_test_mql
|
raise HTTPException(status_code=410, detail="The legacy Python example runner has been removed. Export System XML and use /api/system-xml/simulate.")
|
||||||
from app.simulation.examples.test_mql.system import TestMqlRunConfig
|
|
||||||
|
|
||||||
validate_reactflow_execution_contract(project)
|
|
||||||
|
|
||||||
run_config = TestMqlRunConfig(
|
|
||||||
t_start=project.simulation.t_start,
|
|
||||||
t_stop=project.simulation.t_stop,
|
|
||||||
sample_step=project.simulation.step,
|
|
||||||
)
|
|
||||||
result = run_test_mql(run_config=run_config)
|
|
||||||
series = {
|
|
||||||
key: [float(value) for value in values]
|
|
||||||
for key, values in result.result.series.items()
|
|
||||||
}
|
|
||||||
final = {key: values[-1] for key, values in series.items() if values}
|
|
||||||
snapshot = result.system.snapshot()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "test_mql fixed-topology simulation completed.",
|
|
||||||
"model": {
|
|
||||||
"name": snapshot.model_name,
|
|
||||||
"componentCount": snapshot.component_count,
|
|
||||||
"connectionCount": snapshot.connection_count,
|
|
||||||
"continuousStateCount": snapshot.continuous_state_count,
|
|
||||||
"discreteStateCount": snapshot.discrete_state_count,
|
|
||||||
},
|
|
||||||
"final": final,
|
|
||||||
"series": series,
|
|
||||||
"artifacts": {
|
|
||||||
"summary": str(result.summary_path),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def first_node(
|
|
||||||
nodes_by_type: dict[str, list[ReactFlowNodePayload]],
|
|
||||||
model_type: str,
|
|
||||||
) -> ReactFlowNodePayload | None:
|
|
||||||
nodes = nodes_by_type.get(model_type, [])
|
|
||||||
return nodes[0] if nodes else None
|
|
||||||
|
|
||||||
|
|
||||||
def node_at(
|
|
||||||
nodes: list[ReactFlowNodePayload],
|
|
||||||
index: int,
|
|
||||||
) -> ReactFlowNodePayload | None:
|
|
||||||
return nodes[index] if index < len(nodes) else None
|
|
||||||
|
|
||||||
|
|
||||||
def parameter_float(
|
def parameter_float(
|
||||||
@@ -1675,13 +1491,3 @@ def parameter_float(
|
|||||||
return float(value)
|
return float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
|
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
|
||||||
|
|
||||||
|
|
||||||
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
|
|
||||||
return pipe_config_type(
|
|
||||||
length=parameter_float(node, "length", 5.0),
|
|
||||||
diameter=parameter_float(node, "diameter", 0.02),
|
|
||||||
lambda_darcy=parameter_float(node, "lambda_darcy", 0.02),
|
|
||||||
p0=parameter_float(node, "p0", 1e5),
|
|
||||||
T0=parameter_float(node, "T0", 300.0),
|
|
||||||
)
|
|
||||||
+11
-401
@@ -1,407 +1,17 @@
|
|||||||
# 仿真后端
|
# 仿真后端
|
||||||
|
|
||||||
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
当前采用 Python 编排、C 数值执行。输入 XML 经校验后,Python 根据模型参数和连接生成系统专用 C;GCC 编译成 EXE,EXE 内执行初始化、物性、流量、机械、RK45/CVODE BDF、事件和采样。求解循环不调用 Python。
|
||||||
|
|
||||||
目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。
|
## 目录
|
||||||
|
|
||||||
当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。
|
- `components/`:模型参数、端口、显示和结果声明。
|
||||||
|
- `core/`、`registry.py`、`systems/network.py`:模型合同与网络结构校验。
|
||||||
|
- `native_codegen/`:C 生成、构建、进程运行和 CLI。
|
||||||
|
- `config.py`、`sampling.py`、`results.py`:公共配置、进度、采样网格校验与结果类型。
|
||||||
|
- `performance.py`、`warmup.py`:编排计时和启动工具链检查。
|
||||||
|
- `reporting/amesim_results.py`:外部 Amesim 结果读取。
|
||||||
|
- 仓库根目录 `native/`:C 组件公式和求解器。
|
||||||
|
|
||||||
## 当前目录
|
旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。
|
||||||
|
|
||||||
- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
|
运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。
|
||||||
- `solvers/`: ODE、压力流量代数方程和 stream 求解。
|
|
||||||
- `components/experimental/`: 用于验证元件开发规范的临时组件库。
|
|
||||||
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
|
|
||||||
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
|
|
||||||
- `components/experimental/junctions/`: 三通等连接节点。
|
|
||||||
- `components/amesim/`: AMESim 气动、信号和机械组件原语。
|
|
||||||
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
|
||||||
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和基线运行入口。
|
|
||||||
- `examples/test_mql/`: AMESim `test_mql` 的系统装配、校准原语、诊断和运行入口。
|
|
||||||
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
|
|
||||||
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
|
||||||
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
|
||||||
|
|
||||||
稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
|
|
||||||
`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。
|
|
||||||
需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。
|
|
||||||
|
|
||||||
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
|
|
||||||
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
|
|
||||||
|
|
||||||
临时组件库的声明入口是 `components/experimental/library.py`,AMESim 第一版
|
|
||||||
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
|
|
||||||
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
|
||||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
|
||||||
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
|
|
||||||
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
|
|
||||||
|
|
||||||
当前关键文件:
|
|
||||||
|
|
||||||
- `core/medium.py`: 气体介质协议 `GasMedium` 与通用理想气体实现 `IdealGasMedium`
|
|
||||||
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
|
|
||||||
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
|
|
||||||
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
|
|
||||||
- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点
|
|
||||||
- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具
|
|
||||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
|
||||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
|
||||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
|
||||||
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
|
||||||
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
|
||||||
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
|
||||||
- `examples/test_mql/system.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
|
|
||||||
- `examples/test_mql/closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
|
|
||||||
- `examples/test_mql/primitives/`: 固定算例专用的 Peng-Robinson 氦气、管路和机械校准原语
|
|
||||||
- `examples/test_mql/structural_network.py`: 固定算例专用的结构网络;不替代带端口契约校验的通用网络
|
|
||||||
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
|
||||||
- `reporting/amesim_results.py`: AMESim 结果读取入口
|
|
||||||
- `examples/test_mql/run_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
|
|
||||||
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
|
|
||||||
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
|
||||||
|
|
||||||
## 可选性能诊断
|
|
||||||
|
|
||||||
`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard`
|
|
||||||
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
|
|
||||||
明显更高。最终优化收益必须在 `off` 下复测。
|
|
||||||
|
|
||||||
Peng–Robinson 氦气的高开销物性默认使用一次仿真内独立的精确 LRU 缓存;不同
|
|
||||||
仿真任务不会共享条目,仿真结束后自动释放。可在启动进程前设置
|
|
||||||
`SIMULATIONAPP_PROPERTY_CACHE=off` 做数值和性能 A/B,正常运行保持默认 `on`。
|
|
||||||
缓存只复用完全相同的输入,不做四舍五入或容差匹配。
|
|
||||||
|
|
||||||
FastAPI worker 默认在 lifespan 启动阶段预热 SciPy 积分、非线性求解、稀疏
|
|
||||||
Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行业务模型,也不
|
|
||||||
写入文件;如需诊断冷启动,可设置 `SIMULATIONAPP_WARMUP=off`。每个 worker 都会
|
|
||||||
独立暖机一次。
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
|
|
||||||
--mode audit --warmups 1 --runs 3 `
|
|
||||||
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
|
|
||||||
--output app/data/performance-evaluations/helium-step.json
|
|
||||||
```
|
|
||||||
|
|
||||||
缓存关闭对照可在同一命令中增加 `--disable-property-cache`。缓存容量、命中、
|
|
||||||
未命中和驱逐数会在 audit 响应的
|
|
||||||
`diagnostics.performance.propertyCache` 中返回。
|
|
||||||
|
|
||||||
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
|
|
||||||
[`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。
|
|
||||||
|
|
||||||
## 当前阶段进度
|
|
||||||
|
|
||||||
这一阶段原先有 4 件重点工作,现在的状态如下:
|
|
||||||
|
|
||||||
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 v3 的拓扑驱动仿真 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/data/testmodel/testmodel_modelica_comparison_summary.txt)
|
|
||||||
|
|
||||||
当前四个主变量的最大误差为:
|
|
||||||
|
|
||||||
- `mytank.p`: `max_abs_error = 134.960858 Pa`, `max_rel_error = 0.006798%`
|
|
||||||
- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%`
|
|
||||||
- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%`
|
|
||||||
- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%`
|
|
||||||
|
|
||||||
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
|
|
||||||
|
|
||||||
## AMESim test_mql 当前进度
|
|
||||||
|
|
||||||
`test_mql` 从 `AmesimModels/test_mql.ame` 迁移,并与旧 `testmodel` 保持独立。
|
|
||||||
固定算例实现统一位于 `examples/test_mql/`,结果读取和比较能力位于
|
|
||||||
`reporting/`;公开拖拽组件由 `components/amesim/library.py` 单独登记。
|
|
||||||
|
|
||||||
当前已形成 112 个气动状态和 20 个机械状态的总闭包、AMESim 原生结果读取、
|
|
||||||
`Data_Path` 输出校验及短时域 comparison。这里不再复制易过期的数值进度;
|
|
||||||
最新对比结果、运行命令、限制和下一步校准路径以
|
|
||||||
[`AmesimModels/test_mql/README.md`](../../AmesimModels/test_mql/README.md)
|
|
||||||
为唯一说明。当前仍不能宣称 Python 时域仿真与 AMESim 全局一致。
|
|
||||||
|
|
||||||
## Testmodel 当前架构判断
|
|
||||||
|
|
||||||
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
|
|
||||||
|
|
||||||
- 组件级:已完成首版
|
|
||||||
- 系统闭合:已完成首版
|
|
||||||
- 积分入口:已完成首版
|
|
||||||
- 基线结果对齐:已具备初步能力
|
|
||||||
- 去近似:仍在进行中
|
|
||||||
|
|
||||||
所以当前最准确的说法不是“已完成移植”,而是:
|
|
||||||
|
|
||||||
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
|
|
||||||
|
|
||||||
## Testmodel 已知限制
|
|
||||||
|
|
||||||
当前最主要的限制可以直接理解成下面几条:
|
|
||||||
|
|
||||||
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
|
|
||||||
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
|
|
||||||
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
|
||||||
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。
|
|
||||||
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
|
|
||||||
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
|
|
||||||
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
|
|
||||||
- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。
|
|
||||||
|
|
||||||
所以,当前版本适合:
|
|
||||||
|
|
||||||
- 架构验证
|
|
||||||
- 组件接口验证
|
|
||||||
- 基线工况对比
|
|
||||||
- 结果导出与误差定位
|
|
||||||
|
|
||||||
但当前版本还不适合:
|
|
||||||
|
|
||||||
- 直接宣称与 OpenModelica 严格等价
|
|
||||||
- 作为最终工程结论的唯一依据
|
|
||||||
- 直接扩展到更复杂拓扑而不补通用连接器语义
|
|
||||||
|
|
||||||
## Testmodel 文件级现状
|
|
||||||
|
|
||||||
按代码现状逐项看:
|
|
||||||
|
|
||||||
- `core/base.py`: 正常
|
|
||||||
只提供最小抽象层,没有明显冗余。
|
|
||||||
- `core/ports.py`: 正常
|
|
||||||
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
|
|
||||||
- `core/state.py`: 正常
|
|
||||||
`VolumeState` 只负责 `[m, U]` 状态打包。
|
|
||||||
- `systems/network.py`: 正常
|
|
||||||
负责状态向量拼装和连接摘要,不参与物理求解。
|
|
||||||
- `solvers/solver.py`: 正常
|
|
||||||
已支持 SciPy、RK4 回退和零时长仿真。
|
|
||||||
- `components/experimental/**/*.py`: 正常
|
|
||||||
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
|
|
||||||
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
|
|
||||||
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
|
|
||||||
- `examples/testmodel/run.py`: 正常
|
|
||||||
已不是“最小打印脚本”,而是当前结果导出和对比入口。
|
|
||||||
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
|
||||||
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
|
||||||
|
|
||||||
## Testmodel 当前主技术债
|
|
||||||
|
|
||||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
|
||||||
|
|
||||||
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
|
|
||||||
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
|
||||||
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
|
|
||||||
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""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:
|
||||||
|
# Preserve the existing XML execution accuracy. The XML
|
||||||
|
# protocol's future tolerance fields are a separate compatibility change.
|
||||||
|
return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop,
|
||||||
|
method=simulation.method, rtol=1e-6, max_step=simulation.max_step)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_network(network, simulation, *, progress_callback=None,
|
||||||
|
cancel_check=None, activity_tracker=None, backend=None):
|
||||||
|
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,
|
||||||
|
cancel_check=cancel_check, activity_tracker=activity_tracker)
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import statistics
|
|
||||||
import sys
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from math import ceil
|
|
||||||
from pathlib import Path
|
|
||||||
from time import perf_counter_ns, process_time_ns
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _named_value(value: str, *, option: str) -> tuple[str, str]:
|
|
||||||
name, separator, target = value.partition("=")
|
|
||||||
if not separator or not name.strip() or not target.strip():
|
|
||||||
raise ValueError(
|
|
||||||
f"{option} must use NAME=VALUE syntax, received {value!r}."
|
|
||||||
)
|
|
||||||
return name.strip(), target.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _percentile(values: list[float], percentile: float) -> float:
|
|
||||||
ordered = sorted(values)
|
|
||||||
index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1))
|
|
||||||
return ordered[index]
|
|
||||||
|
|
||||||
|
|
||||||
def _duration_summary(values: list[float]) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"samplesMs": values,
|
|
||||||
"minimumMs": min(values),
|
|
||||||
"medianMs": statistics.median(values),
|
|
||||||
"p95Ms": _percentile(values, 0.95),
|
|
||||||
"maximumMs": max(values),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_factory_xml(specification: str) -> bytes:
|
|
||||||
module_name, separator, member_name = specification.partition(":")
|
|
||||||
if not separator or not module_name or not member_name:
|
|
||||||
raise ValueError(
|
|
||||||
"Factory specifications must use module.path:callable syntax."
|
|
||||||
)
|
|
||||||
factory = getattr(importlib.import_module(module_name), member_name)
|
|
||||||
value = factory()
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.encode("utf-8")
|
|
||||||
|
|
||||||
from app.main import build_reactflow_system_xml
|
|
||||||
|
|
||||||
return build_reactflow_system_xml(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_result_event(result: dict[str, object]) -> bytes:
|
|
||||||
"""Render the final NDJSON payload shape used by the streaming endpoint."""
|
|
||||||
|
|
||||||
status = str(result.get("status", "completed"))
|
|
||||||
event = {
|
|
||||||
"event": "result",
|
|
||||||
"progress": 100 if status == "completed" else 0,
|
|
||||||
"phase": status,
|
|
||||||
"message": "仿真完成" if status == "completed" else "仿真任务结束",
|
|
||||||
"simulatedTime": result.get("simulatedUntil"),
|
|
||||||
"totalTime": result.get("requestedStopTime"),
|
|
||||||
"result": result,
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _run_case(
|
|
||||||
name: str,
|
|
||||||
xml_bytes: bytes,
|
|
||||||
*,
|
|
||||||
warmups: int,
|
|
||||||
runs: int,
|
|
||||||
cancellable_path: bool,
|
|
||||||
allow_failures: bool,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
from app.main import run_system_xml_simulation
|
|
||||||
|
|
||||||
cancel_check = (lambda: False) if cancellable_path else None
|
|
||||||
for _ in range(warmups):
|
|
||||||
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
|
|
||||||
if not bool(result.get("success")) and not allow_failures:
|
|
||||||
raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}")
|
|
||||||
|
|
||||||
wall_samples_ms: list[float] = []
|
|
||||||
cpu_samples_ms: list[float] = []
|
|
||||||
serialization_samples_ms: list[float] = []
|
|
||||||
serialized_sizes: list[int] = []
|
|
||||||
profiles: list[dict[str, object]] = []
|
|
||||||
final_result: dict[str, object] | None = None
|
|
||||||
for _ in range(runs):
|
|
||||||
wall_start = perf_counter_ns()
|
|
||||||
cpu_start = process_time_ns()
|
|
||||||
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
|
|
||||||
cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0)
|
|
||||||
wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0)
|
|
||||||
if not bool(result.get("success")) and not allow_failures:
|
|
||||||
raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}")
|
|
||||||
diagnostics = result.get("diagnostics")
|
|
||||||
if isinstance(diagnostics, dict):
|
|
||||||
performance = diagnostics.get("performance")
|
|
||||||
if isinstance(performance, dict):
|
|
||||||
profiles.append(performance)
|
|
||||||
serialization_start = perf_counter_ns()
|
|
||||||
serialized_event = _serialize_result_event(result)
|
|
||||||
serialization_samples_ms.append(
|
|
||||||
(perf_counter_ns() - serialization_start) / 1_000_000.0
|
|
||||||
)
|
|
||||||
serialized_sizes.append(len(serialized_event))
|
|
||||||
final_result = result
|
|
||||||
|
|
||||||
assert final_result is not None
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"success": bool(final_result.get("success")),
|
|
||||||
"message": final_result.get("message"),
|
|
||||||
"inputBytes": len(xml_bytes),
|
|
||||||
"inputSha256": hashlib.sha256(xml_bytes).hexdigest(),
|
|
||||||
"status": final_result.get("status"),
|
|
||||||
"simulatedUntil": final_result.get("simulatedUntil"),
|
|
||||||
"requestedStopTime": final_result.get("requestedStopTime"),
|
|
||||||
"wall": _duration_summary(wall_samples_ms),
|
|
||||||
"cpu": _duration_summary(cpu_samples_ms),
|
|
||||||
"resultSerialization": _duration_summary(serialization_samples_ms),
|
|
||||||
"resultEventBytes": serialized_sizes,
|
|
||||||
"performanceRuns": profiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Benchmark the real System XML simulation path with optional profiling."
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mode",
|
|
||||||
choices=("off", "standard", "audit"),
|
|
||||||
default="audit",
|
|
||||||
help="Instrumentation depth selected before importing the simulation modules.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--warmups", type=int, default=1)
|
|
||||||
parser.add_argument("--runs", type=int, default=5)
|
|
||||||
parser.add_argument(
|
|
||||||
"--xml",
|
|
||||||
action="append",
|
|
||||||
default=[],
|
|
||||||
metavar="NAME=PATH",
|
|
||||||
help="Add an XML file benchmark case.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--factory",
|
|
||||||
action="append",
|
|
||||||
default=[],
|
|
||||||
metavar="NAME=MODULE:CALLABLE",
|
|
||||||
help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--direct-path",
|
|
||||||
action="store_true",
|
|
||||||
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--disable-property-cache",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable the run-local exact property cache for an A/B comparison.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--allow-failures",
|
|
||||||
action="store_true",
|
|
||||||
help="Record failed simulation runs instead of aborting the benchmark.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--output", type=Path)
|
|
||||||
arguments = parser.parse_args(argv)
|
|
||||||
if arguments.warmups < 0:
|
|
||||||
parser.error("--warmups must not be negative.")
|
|
||||||
if arguments.runs <= 0:
|
|
||||||
parser.error("--runs must be positive.")
|
|
||||||
if not arguments.xml and not arguments.factory:
|
|
||||||
parser.error("At least one --xml or --factory case is required.")
|
|
||||||
return arguments
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
arguments = _parse_arguments(argv)
|
|
||||||
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
|
|
||||||
os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = (
|
|
||||||
"off" if arguments.disable_property_cache else "on"
|
|
||||||
)
|
|
||||||
|
|
||||||
cases: list[tuple[str, bytes]] = []
|
|
||||||
for raw_case in arguments.xml:
|
|
||||||
name, raw_path = _named_value(raw_case, option="--xml")
|
|
||||||
cases.append((name, Path(raw_path).read_bytes()))
|
|
||||||
for raw_case in arguments.factory:
|
|
||||||
name, specification = _named_value(raw_case, option="--factory")
|
|
||||||
cases.append((name, _load_factory_xml(specification)))
|
|
||||||
|
|
||||||
report: dict[str, Any] = {
|
|
||||||
"generatedAt": datetime.now(UTC).isoformat(),
|
|
||||||
"profileMode": arguments.mode,
|
|
||||||
"cancellableSolverPath": not arguments.direct_path,
|
|
||||||
"propertyCacheEnabled": not arguments.disable_property_cache,
|
|
||||||
"allowFailures": bool(arguments.allow_failures),
|
|
||||||
"warmups": arguments.warmups,
|
|
||||||
"runs": arguments.runs,
|
|
||||||
"runtime": {
|
|
||||||
"python": sys.version,
|
|
||||||
"platform": platform.platform(),
|
|
||||||
"processor": platform.processor(),
|
|
||||||
},
|
|
||||||
"cases": [
|
|
||||||
_run_case(
|
|
||||||
name,
|
|
||||||
xml_bytes,
|
|
||||||
warmups=arguments.warmups,
|
|
||||||
runs=arguments.runs,
|
|
||||||
cancellable_path=not arguments.direct_path,
|
|
||||||
allow_failures=arguments.allow_failures,
|
|
||||||
)
|
|
||||||
for name, xml_bytes in cases
|
|
||||||
],
|
|
||||||
}
|
|
||||||
text = json.dumps(report, ensure_ascii=False, indent=2)
|
|
||||||
if arguments.output is not None:
|
|
||||||
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
arguments.output.write_text(text + "\n", encoding="utf-8")
|
|
||||||
print(f"Performance report written to {arguments.output.resolve()}")
|
|
||||||
else:
|
|
||||||
print(text)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,11 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnpl01(AlgebraicComponent):
|
class AmesimPnpl01(AlgebraicComponent):
|
||||||
"""AMESim PNPL01 zero pneumatic flow source.
|
"""AMESim PNPL01 zero pneumatic flow source.
|
||||||
|
|
||||||
@@ -16,53 +13,19 @@ class AmesimPnpl01(AlgebraicComponent):
|
|||||||
solver: it does not prescribe pressure, and only constrains its port mass
|
solver: it does not prescribe pressure, and only constrains its port mass
|
||||||
flow to zero.
|
flow to zero.
|
||||||
"""
|
"""
|
||||||
|
MODEL_TYPE = 'amesim_pnpl01'
|
||||||
MODEL_TYPE = "amesim_pnpl01"
|
MODEL_VERSION = '0.1.0'
|
||||||
MODEL_VERSION = "0.1.0"
|
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'),)
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
|
||||||
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
|
||||||
PARAMETERS = ()
|
PARAMETERS = ()
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
DISPLAY = ComponentDisplaySpec(
|
DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10)
|
||||||
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:
|
def __init__(self, name: str) -> None:
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values({})
|
self.set_parameter_values({})
|
||||||
self.port_1 = self.register_declared_port("port_1")
|
self.port_1 = self.register_declared_port('port_1')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPnpl01:
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> AmesimPnpl01:
|
|
||||||
return cls(name=name)
|
return cls(name=name)
|
||||||
|
EQUATIONS = ({'id': '__MODEL__:zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'},)
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
|
||||||
return (self.port_1.m_flow,)
|
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
||||||
return (
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:zero_mass_flow",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="constitutive",
|
|
||||||
variables=(f"{self.name}.port_1.m_flow",),
|
|
||||||
role="flow",
|
|
||||||
value=self.port_1.m_flow,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
|
||||||
if "port_1" in connected_h:
|
|
||||||
self.port_1.h_outflow = connected_h["port_1"]
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,31 +1,10 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition, PortState
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO = 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def _regularized_inverse_outflow(flow: float, transition_flow: float) -> float:
|
|
||||||
"""Return a C1 inverse that tends to zero as a negative flow vanishes."""
|
|
||||||
|
|
||||||
if flow >= 0.0:
|
|
||||||
return 0.0
|
|
||||||
transition_flow = max(float(transition_flow), 1.0e-12)
|
|
||||||
if -flow >= transition_flow:
|
|
||||||
return 1.0 / flow
|
|
||||||
return (
|
|
||||||
flow
|
|
||||||
* (2.0 * transition_flow * transition_flow - flow * flow)
|
|
||||||
/ transition_flow**4
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _AmesimPneumaticNode(AlgebraicComponent):
|
class _AmesimPneumaticNode(AlgebraicComponent):
|
||||||
"""Shared implementation for AMESim pneumatic junction submodels.
|
"""Shared implementation for AMESim pneumatic junction submodels.
|
||||||
@@ -35,9 +14,7 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
an outlet, its enthalpy is the residual that closes the junction energy
|
an outlet, its enthalpy is the residual that closes the junction energy
|
||||||
balance, matching the AMESim dh2 causality.
|
balance, matching the AMESim dh2 causality.
|
||||||
"""
|
"""
|
||||||
|
REFERENCE_PORT = 'port_2'
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
|
||||||
REFERENCE_PORT = "port_2"
|
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
@@ -46,194 +23,30 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
for definition in self.PORTS:
|
for definition in self.PORTS:
|
||||||
setattr(self, definition.name, self.register_declared_port(definition.name))
|
setattr(self, definition.name, self.register_declared_port(definition.name))
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
|
||||||
reference = self.get_port(self.REFERENCE_PORT)
|
|
||||||
return tuple(
|
|
||||||
self.get_port(definition.name).p - reference.p
|
|
||||||
for definition in self.PORTS
|
|
||||||
if definition.name != self.REFERENCE_PORT
|
|
||||||
) + (
|
|
||||||
sum(
|
|
||||||
self.get_port(definition.name).m_flow
|
|
||||||
for definition in self.PORTS
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
||||||
reference = self.get_port(self.REFERENCE_PORT)
|
|
||||||
residuals: list[EquationResidual] = []
|
|
||||||
for definition in self.PORTS:
|
|
||||||
if definition.name == self.REFERENCE_PORT:
|
|
||||||
continue
|
|
||||||
port = self.get_port(definition.name)
|
|
||||||
residuals.append(
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:{definition.name}_pressure_reference",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="equal",
|
|
||||||
variables=(
|
|
||||||
f"{self.name}.{definition.name}.p",
|
|
||||||
f"{self.name}.{self.REFERENCE_PORT}.p",
|
|
||||||
),
|
|
||||||
role="effort",
|
|
||||||
value=port.p - reference.p,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
residuals.append(
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:mass_flow_balance",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="sumToZero",
|
|
||||||
variables=tuple(
|
|
||||||
f"{self.name}.{definition.name}.m_flow"
|
|
||||||
for definition in self.PORTS
|
|
||||||
),
|
|
||||||
role="flow",
|
|
||||||
value=sum(self.get_port(definition.name).m_flow for definition in self.PORTS),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(residuals)
|
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
|
||||||
self.temperature_reference_h = connected_h.get(
|
|
||||||
self.REFERENCE_PORT,
|
|
||||||
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
|
|
||||||
)
|
|
||||||
incoming = [
|
|
||||||
(port.m_flow, connected_h[name])
|
|
||||||
for name, port in self.ports.items()
|
|
||||||
if port.m_flow > 1e-12
|
|
||||||
]
|
|
||||||
total_flow = sum(m_flow for m_flow, _ in incoming)
|
|
||||||
if total_flow > 1e-12:
|
|
||||||
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
|
|
||||||
else:
|
|
||||||
mixed_h = self.temperature_reference_h
|
|
||||||
|
|
||||||
reference_port = self.get_port(self.REFERENCE_PORT)
|
|
||||||
for name, port in self.ports.items():
|
|
||||||
port.h_outflow = (
|
|
||||||
mixed_h
|
|
||||||
if name == self.REFERENCE_PORT
|
|
||||||
else self.temperature_reference_h
|
|
||||||
)
|
|
||||||
|
|
||||||
if reference_port.m_flow < 0.0:
|
|
||||||
energy_without_reference = sum(
|
|
||||||
port.m_flow
|
|
||||||
* (
|
|
||||||
connected_h[name]
|
|
||||||
if port.m_flow > 1e-12
|
|
||||||
else self.temperature_reference_h
|
|
||||||
)
|
|
||||||
for name, port in self.ports.items()
|
|
||||||
if name != self.REFERENCE_PORT
|
|
||||||
)
|
|
||||||
non_reference_flow_scale = sum(
|
|
||||||
abs(port.m_flow)
|
|
||||||
for name, port in self.ports.items()
|
|
||||||
if name != self.REFERENCE_PORT
|
|
||||||
)
|
|
||||||
transition_flow = (
|
|
||||||
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO
|
|
||||||
* non_reference_flow_scale
|
|
||||||
)
|
|
||||||
# Port 2 carries AMESim's residual-energy causality. Exact
|
|
||||||
# division is singular when its outflow reverses through zero, so
|
|
||||||
# use a C1 band that matches the exact balance at its boundary and
|
|
||||||
# tends to the mixed enthalpy at zero flow.
|
|
||||||
inverse_flow = _regularized_inverse_outflow(
|
|
||||||
reference_port.m_flow,
|
|
||||||
transition_flow,
|
|
||||||
)
|
|
||||||
energy_residual_at_mixed_h = (
|
|
||||||
energy_without_reference
|
|
||||||
+ reference_port.m_flow * mixed_h
|
|
||||||
)
|
|
||||||
reference_port.h_outflow = (
|
|
||||||
mixed_h - energy_residual_at_mixed_h * inverse_flow
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPn3Node2(_AmesimPneumaticNode):
|
class AmesimPn3Node2(_AmesimPneumaticNode):
|
||||||
"""AMESim PN3NODE2 pneumatic three-port junction."""
|
"""AMESim PN3NODE2 pneumatic three-port junction."""
|
||||||
|
MODEL_TYPE = 'amesim_pn3node2'
|
||||||
MODEL_TYPE = "amesim_pn3node2"
|
MODEL_VERSION = '0.3.0'
|
||||||
MODEL_VERSION = "0.3.0"
|
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'))
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
|
||||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
|
||||||
("mass_flow_balance",)
|
|
||||||
)
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
|
||||||
)
|
|
||||||
PARAMETERS = ()
|
PARAMETERS = ()
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
DISPLAY = ComponentDisplaySpec(
|
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)
|
||||||
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
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPn3Node2:
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> AmesimPn3Node2:
|
|
||||||
return cls(name=name)
|
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):
|
class AmesimP4Node2(_AmesimPneumaticNode):
|
||||||
"""AMESim P4NODE2 pneumatic four-port junction."""
|
"""AMESim P4NODE2 pneumatic four-port junction."""
|
||||||
|
MODEL_TYPE = 'amesim_p4node2'
|
||||||
MODEL_TYPE = "amesim_p4node2"
|
MODEL_VERSION = '0.3.0'
|
||||||
MODEL_VERSION = "0.3.0"
|
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional'))
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
|
||||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
|
||||||
("mass_flow_balance",)
|
|
||||||
)
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
|
||||||
)
|
|
||||||
PARAMETERS = ()
|
PARAMETERS = ()
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
DISPLAY = ComponentDisplaySpec(
|
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)
|
||||||
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
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimP4Node2:
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> AmesimP4Node2:
|
|
||||||
return cls(name=name)
|
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'})
|
||||||
@@ -1,36 +1,15 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from collections.abc import Mapping
|
||||||
from collections.abc import Mapping, Sequence
|
from math import pi
|
||||||
from dataclasses import dataclass
|
from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index
|
||||||
from math import isfinite, pi
|
|
||||||
|
|
||||||
from app.simulation.components.amesim.gases import (
|
|
||||||
AMESIM_GAS_INDEX_PARAMETER,
|
|
||||||
normalize_amesim_gas_index,
|
|
||||||
)
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
|
||||||
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
|
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
|
||||||
from app.simulation.core.medium import GasMedium
|
from app.simulation.core.medium import GasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
|
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Pnrp17Linearization:
|
|
||||||
volume: float
|
|
||||||
volume_flow: float
|
|
||||||
pressure_force: float
|
|
||||||
volume_tangent: tuple[float, ...]
|
|
||||||
volume_flow_tangent: tuple[float, ...]
|
|
||||||
pressure_force_tangent: tuple[float, ...]
|
|
||||||
valid: bool = True
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnrp17(AlgebraicComponent):
|
class AmesimPnrp17(AlgebraicComponent):
|
||||||
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
|
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
|
||||||
|
|
||||||
@@ -38,268 +17,32 @@ class AmesimPnrp17(AlgebraicComponent):
|
|||||||
cylinder-side motion. The pneumatic port contributes its swept volume and
|
cylinder-side motion. The pneumatic port contributes its swept volume and
|
||||||
volume rate to the connected variable-volume chamber.
|
volume rate to the connected variable-volume chamber.
|
||||||
"""
|
"""
|
||||||
|
MODEL_TYPE = 'amesim_pnrp17'
|
||||||
|
MODEL_VERSION = '0.1.0'
|
||||||
|
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5'))
|
||||||
|
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('dp', 0.2, label='活塞直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='活塞外径;与活塞杆直径共同确定有效受压面积。'), ParameterDefinition('dr', 0.001, label='活塞杆直径', quantity='length', unit='m', minimum=0.0, description='穿过气室一侧的活塞杆直径,必须不大于活塞直径。'), ParameterDefinition('x0', 0.0, label='初始腔长', quantity='length', unit='m', description='机械端位移均为零时的气动腔长度。'))
|
||||||
|
RESULT_VARIABLES = (ResultVariableDefinition('volume', '扫掠容积', 'volume', 'm3', 'derived', 10), ResultVariableDefinition('volume_flow', '扫掠容积变化率', 'volume_flow', 'm3/s', 'derived', 20), ResultVariableDefinition('length', '气动腔长度', 'length', 'm', 'derived', 30), ResultVariableDefinition('pressure_force', '气压力', 'force', 'N', 'derived', 40))
|
||||||
|
DISPLAY = ComponentDisplaySpec(label='PNRP17 气动活塞', library_id='amesim', category_id='mechanical', symbol='amesim_pnrp17', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_3', 'left', order=20), PortDisplaySpec('port_2', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40), PortDisplaySpec('port_5', 'right', order=50)), order=60)
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnrp17"
|
def __init__(self, name: str, medium: GasMedium, *, gi: float=0.0, dp: float=0.2, dr: float=0.001, x0: float=0.0) -> None:
|
||||||
MODEL_VERSION = "0.1.0"
|
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.mechanical_translational("port_2"),
|
|
||||||
PortDefinition.mechanical_translational("port_3"),
|
|
||||||
PortDefinition.mechanical_translational("port_4"),
|
|
||||||
PortDefinition.mechanical_translational("port_5"),
|
|
||||||
)
|
|
||||||
PARAMETERS = (
|
|
||||||
AMESIM_GAS_INDEX_PARAMETER,
|
|
||||||
ParameterDefinition(
|
|
||||||
"dp",
|
|
||||||
0.2,
|
|
||||||
label="活塞直径",
|
|
||||||
quantity="length",
|
|
||||||
unit="m",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
description="活塞外径;与活塞杆直径共同确定有效受压面积。",
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"dr",
|
|
||||||
0.001,
|
|
||||||
label="活塞杆直径",
|
|
||||||
quantity="length",
|
|
||||||
unit="m",
|
|
||||||
minimum=0.0,
|
|
||||||
description="穿过气室一侧的活塞杆直径,必须不大于活塞直径。",
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"x0",
|
|
||||||
0.0,
|
|
||||||
label="初始腔长",
|
|
||||||
quantity="length",
|
|
||||||
unit="m",
|
|
||||||
description="机械端位移均为零时的气动腔长度。",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
RESULT_VARIABLES = (
|
|
||||||
ResultVariableDefinition("volume", "扫掠容积", "volume", "m3", "derived", 10),
|
|
||||||
ResultVariableDefinition(
|
|
||||||
"volume_flow",
|
|
||||||
"扫掠容积变化率",
|
|
||||||
"volume_flow",
|
|
||||||
"m3/s",
|
|
||||||
"derived",
|
|
||||||
20,
|
|
||||||
),
|
|
||||||
ResultVariableDefinition("length", "气动腔长度", "length", "m", "derived", 30),
|
|
||||||
ResultVariableDefinition(
|
|
||||||
"pressure_force",
|
|
||||||
"气压力",
|
|
||||||
"force",
|
|
||||||
"N",
|
|
||||||
"derived",
|
|
||||||
40,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
DISPLAY = ComponentDisplaySpec(
|
|
||||||
label="PNRP17 气动活塞",
|
|
||||||
library_id="amesim",
|
|
||||||
category_id="mechanical",
|
|
||||||
symbol="amesim_pnrp17",
|
|
||||||
ports=(
|
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
|
||||||
PortDisplaySpec("port_3", "left", order=20),
|
|
||||||
PortDisplaySpec("port_2", "left", order=30),
|
|
||||||
PortDisplaySpec("port_4", "right", order=40),
|
|
||||||
PortDisplaySpec("port_5", "right", order=50),
|
|
||||||
),
|
|
||||||
order=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
medium: GasMedium,
|
|
||||||
*,
|
|
||||||
gi: float = 0.0,
|
|
||||||
dp: float = 0.2,
|
|
||||||
dr: float = 0.001,
|
|
||||||
x0: float = 0.0,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values({"gi": gi, "dp": dp, "dr": dr, "x0": x0})
|
self.set_parameter_values({'gi': gi, 'dp': dp, 'dr': dr, 'x0': x0})
|
||||||
self.medium = medium
|
self.medium = medium
|
||||||
self.gi = normalize_amesim_gas_index(gi)
|
self.gi = normalize_amesim_gas_index(gi)
|
||||||
self.dp = float(dp)
|
self.dp = float(dp)
|
||||||
self.dr = float(dr)
|
self.dr = float(dr)
|
||||||
self.x0 = float(x0)
|
self.x0 = float(x0)
|
||||||
if self.dr > self.dp:
|
if self.dr > self.dp:
|
||||||
raise ValueError("PNRP17 rod diameter dr must not exceed piston diameter dp.")
|
raise ValueError('PNRP17 rod diameter dr must not exceed piston diameter dp.')
|
||||||
for definition in self.PORTS:
|
for definition in self.PORTS:
|
||||||
port = self.register_declared_port(definition.name)
|
port = self.register_declared_port(definition.name)
|
||||||
setattr(self, definition.name, port)
|
setattr(self, definition.name, port)
|
||||||
self.port_1.h_outflow = medium.specific_enthalpy(medium.T_ref)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnrp17':
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: GasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> "AmesimPnrp17":
|
|
||||||
return cls(name=name, medium=medium, **dict(parameters))
|
return cls(name=name, medium=medium, **dict(parameters))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_area(self) -> float:
|
def effective_area(self) -> float:
|
||||||
return pi * (self.dp * self.dp - self.dr * self.dr) / 4.0
|
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'})
|
||||||
@property
|
|
||||||
def chamber_length(self) -> float:
|
|
||||||
return self.x0 + self.port_5.x - self.port_4.x
|
|
||||||
|
|
||||||
@property
|
|
||||||
def chamber_volume(self) -> float:
|
|
||||||
return self.effective_area * self.chamber_length
|
|
||||||
|
|
||||||
@property
|
|
||||||
def chamber_volume_flow(self) -> float:
|
|
||||||
return self.effective_area * (self.port_5.v - self.port_4.v)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pressure_force(self) -> float:
|
|
||||||
return (self.port_1.p - AMESIM_REFERENCE_PRESSURE_PA) * self.effective_area
|
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
|
||||||
values = [self.port_1.m_flow]
|
|
||||||
effort_pairs = (("port_2", "port_5"), ("port_3", "port_4"))
|
|
||||||
for first_name, second_name in effort_pairs:
|
|
||||||
first = self.get_port(first_name)
|
|
||||||
second = self.get_port(second_name)
|
|
||||||
values.extend((first.x - second.x, first.v - second.v))
|
|
||||||
force = self.pressure_force
|
|
||||||
values.extend(
|
|
||||||
(
|
|
||||||
self.port_2.f + self.port_5.f + force,
|
|
||||||
self.port_3.f + self.port_4.f - force,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(values)
|
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
||||||
effort_pairs = (("port_2", "port_5"), ("port_3", "port_4"))
|
|
||||||
residuals: list[EquationResidual] = [
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:pneumatic_zero_mass_flow",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="constitutive",
|
|
||||||
variables=(f"{self.name}.port_1.m_flow",),
|
|
||||||
role="flow",
|
|
||||||
value=self.port_1.m_flow,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for first_name, second_name in effort_pairs:
|
|
||||||
first = self.get_port(first_name)
|
|
||||||
second = self.get_port(second_name)
|
|
||||||
for variable in ("x", "v"):
|
|
||||||
residuals.append(
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:{first_name}_{second_name}_{variable}_equal",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="equal",
|
|
||||||
variables=(
|
|
||||||
f"{self.name}.{first_name}.{variable}",
|
|
||||||
f"{self.name}.{second_name}.{variable}",
|
|
||||||
),
|
|
||||||
role="effort",
|
|
||||||
value=getattr(first, variable) - getattr(second, variable),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
force = self.pressure_force
|
|
||||||
residuals.extend(
|
|
||||||
(
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:piston_side_force_balance",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="constitutive",
|
|
||||||
variables=(f"{self.name}.port_2.f", f"{self.name}.port_5.f", f"{self.name}.port_1.p"),
|
|
||||||
role="flow",
|
|
||||||
value=self.port_2.f + self.port_5.f + force,
|
|
||||||
),
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:cylinder_side_force_balance",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="constitutive",
|
|
||||||
variables=(f"{self.name}.port_3.f", f"{self.name}.port_4.f", f"{self.name}.port_1.p"),
|
|
||||||
role="flow",
|
|
||||||
value=self.port_3.f + self.port_4.f - force,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(residuals)
|
|
||||||
|
|
||||||
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
|
|
||||||
return {"port_1": (self.chamber_volume, self.chamber_volume_flow)}
|
|
||||||
|
|
||||||
def linearize_geometry_and_force(
|
|
||||||
self,
|
|
||||||
port_4_x_tangent: Sequence[float],
|
|
||||||
port_5_x_tangent: Sequence[float],
|
|
||||||
port_4_v_tangent: Sequence[float],
|
|
||||||
port_5_v_tangent: Sequence[float],
|
|
||||||
port_1_pressure_tangent: Sequence[float],
|
|
||||||
) -> Pnrp17Linearization:
|
|
||||||
"""Return exact piston geometry and pressure-force tangents."""
|
|
||||||
|
|
||||||
vectors = tuple(
|
|
||||||
tuple(float(value) for value in values)
|
|
||||||
for values in (
|
|
||||||
port_4_x_tangent,
|
|
||||||
port_5_x_tangent,
|
|
||||||
port_4_v_tangent,
|
|
||||||
port_5_v_tangent,
|
|
||||||
port_1_pressure_tangent,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
widths = {len(values) for values in vectors}
|
|
||||||
if len(widths) != 1:
|
|
||||||
raise ValueError("PNRP17 tangent vectors must have equal lengths.")
|
|
||||||
valid = all(isfinite(value) for values in vectors for value in values)
|
|
||||||
area = self.effective_area
|
|
||||||
volume_tangent = tuple(
|
|
||||||
area * (right - left)
|
|
||||||
for left, right in zip(vectors[0], vectors[1], strict=True)
|
|
||||||
)
|
|
||||||
volume_flow_tangent = tuple(
|
|
||||||
area * (right - left)
|
|
||||||
for left, right in zip(vectors[2], vectors[3], strict=True)
|
|
||||||
)
|
|
||||||
pressure_force_tangent = tuple(
|
|
||||||
area * value for value in vectors[4]
|
|
||||||
)
|
|
||||||
return Pnrp17Linearization(
|
|
||||||
volume=self.chamber_volume,
|
|
||||||
volume_flow=self.chamber_volume_flow,
|
|
||||||
pressure_force=self.pressure_force,
|
|
||||||
volume_tangent=volume_tangent,
|
|
||||||
volume_flow_tangent=volume_flow_tangent,
|
|
||||||
pressure_force_tangent=pressure_force_tangent,
|
|
||||||
valid=valid,
|
|
||||||
reason=None if valid else "non_finite_tangent_input",
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
|
||||||
self.port_1.h_outflow = connected_h.get(
|
|
||||||
"port_1",
|
|
||||||
self.medium.specific_enthalpy(self.medium.T_ref),
|
|
||||||
)
|
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
|
||||||
return {
|
|
||||||
"volume": self.chamber_volume,
|
|
||||||
"volume_flow": self.chamber_volume_flow,
|
|
||||||
"length": self.chamber_length,
|
|
||||||
"pressure_force": self.pressure_force,
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,488 +1,42 @@
|
|||||||
|
"""Gas identities and constants passed to the native compiler."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from collections.abc import Callable
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import exp, isfinite, log
|
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
from app.simulation.core.medium import GasMedium, IdealGasMedium
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
|
||||||
from app.simulation.core.medium import (
|
|
||||||
GasMedium,
|
|
||||||
IdealGasMedium,
|
|
||||||
ThermodynamicProperties,
|
|
||||||
ThermodynamicPropertiesLinearization,
|
|
||||||
ThermodynamicPropertyTangents,
|
|
||||||
)
|
|
||||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
|
||||||
from app.simulation.performance import profile_property, record_property_iterations
|
|
||||||
from app.simulation.property_cache import cache_property_calculation
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AmesimIdealAirMedium(IdealGasMedium):
|
class AmesimIdealAirMedium(IdealGasMedium):
|
||||||
"""AMESim air properties evaluated with the ideal-gas method.
|
SUBSTANCE_ID: ClassVar[str] = 'air'
|
||||||
|
PROPERTY_METHOD_ID: ClassVar[str] = 'ideal_gas'
|
||||||
Substance identity and property method are part of the concrete Python
|
name: str = 'AMESimAirIdealGas'
|
||||||
type. A future air correlation or helium Peng-Robinson implementation can
|
|
||||||
therefore coexist as a sibling type without turning ``gi`` into a fluid
|
|
||||||
enumeration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SUBSTANCE_ID: ClassVar[str] = "air"
|
|
||||||
PROPERTY_METHOD_ID: ClassVar[str] = "ideal_gas"
|
|
||||||
|
|
||||||
name: str = "AMESimAirIdealGas"
|
|
||||||
R_gas: float = 287.0
|
R_gas: float = 287.0
|
||||||
cp_ref: float = 1005.0
|
cp_ref: float = 1005.0
|
||||||
T_ref: float = 300.0
|
T_ref: float = 300.0
|
||||||
cp_slope: float = 0.0
|
cp_slope: float = 0.0
|
||||||
viscosity_ref: float = 1.82e-5
|
viscosity_ref: float = 1.82e-05
|
||||||
viscosity_T_ref: float = 293.15
|
viscosity_T_ref: float = 293.15
|
||||||
sutherland_constant: float = 110.4
|
sutherland_constant: float = 110.4
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||||
"""AMESim helium with a Peng-Robinson mechanical equation of state.
|
SUBSTANCE_ID: ClassVar[str] = 'helium'
|
||||||
|
PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson'
|
||||||
The pressure-density-temperature relation is evaluated by the shared
|
|
||||||
``HELIUM_PR`` fluid. The caloric reference follows the constant NASA
|
|
||||||
polynomial from Simcenter Amesim 2404 ``helium_cp_h_s.data``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SUBSTANCE_ID: ClassVar[str] = "helium"
|
|
||||||
PROPERTY_METHOD_ID: ClassVar[str] = "peng_robinson"
|
|
||||||
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
|
|
||||||
nasa_cp_over_R: ClassVar[float] = 2.5
|
nasa_cp_over_R: ClassVar[float] = 2.5
|
||||||
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
|
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
|
||||||
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (
|
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (0.7501594, 35.76324, -2212.129, 0.9212635)
|
||||||
0.7501594,
|
name: str = 'AMESimHeliumPengRobinson'
|
||||||
35.76324,
|
R_gas: float = 8.31446261815324 / 0.004002602
|
||||||
-2212.129,
|
cp_ref: float = 2.5 * (8.31446261815324 / 0.004002602)
|
||||||
0.9212635,
|
|
||||||
)
|
|
||||||
|
|
||||||
name: str = "AMESimHeliumPengRobinson"
|
|
||||||
R_gas: float = HELIUM_PR.specific_gas_constant
|
|
||||||
cp_ref: float = nasa_cp_over_R * HELIUM_PR.specific_gas_constant
|
|
||||||
T_ref: float = 293.15
|
T_ref: float = 293.15
|
||||||
cp_slope: float = 0.0
|
cp_slope: float = 0.0
|
||||||
viscosity_ref: float = 1.96e-5
|
viscosity_ref: float = 1.96e-05
|
||||||
viscosity_T_ref: float = 293.15
|
viscosity_T_ref: float = 293.15
|
||||||
sutherland_constant: float = 79.4
|
sutherland_constant: float = 79.4
|
||||||
|
|
||||||
@property
|
|
||||||
def cv(self) -> float:
|
|
||||||
return (self.nasa_cp_over_R - 1.0) * self.R_gas
|
|
||||||
|
|
||||||
def cv_at_temperature(self, T: float) -> float:
|
|
||||||
del T
|
|
||||||
return self.cv
|
|
||||||
|
|
||||||
def diagnostic_dynamic_viscosity(self, T: float) -> float:
|
|
||||||
"""Return the AMESim NASA-table viscosity used by pipe diagnostics.
|
|
||||||
|
|
||||||
pn2pipefr reports Reynolds number with sagum viscosity. Keep this
|
|
||||||
separate from dynamic_viscosity so matching that diagnostic cannot
|
|
||||||
alter the already-validated pipe flow or friction dynamics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if T <= 0.0:
|
|
||||||
raise ValueError("Temperature must be positive.")
|
|
||||||
a, b, c, d = self.nasa_viscosity_coefficients
|
|
||||||
return 1.0e-7 * exp(a * log(T) + b / T + c / (T * T) + d)
|
|
||||||
|
|
||||||
@profile_property("density")
|
|
||||||
@cache_property_calculation("density")
|
|
||||||
def density(self, p: float, T: float) -> float:
|
|
||||||
return self.fluid.density(p, T)
|
|
||||||
|
|
||||||
def _real_heat_capacities(
|
|
||||||
self,
|
|
||||||
p: float,
|
|
||||||
T: float,
|
|
||||||
) -> tuple[float, float, float, float, float]:
|
|
||||||
density = self.density(p, T)
|
|
||||||
pressure_density_derivative = (
|
|
||||||
self.fluid.pressure_density_derivative_at_temperature(
|
|
||||||
T,
|
|
||||||
density,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pressure_temperature_derivative = (
|
|
||||||
self.fluid.pressure_temperature_derivative_at_density(
|
|
||||||
T,
|
|
||||||
density,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cv = (
|
|
||||||
self.cv_at_temperature(T)
|
|
||||||
+ self.fluid.residual_isochoric_heat_capacity_at_density(T, density)
|
|
||||||
)
|
|
||||||
cp = (
|
|
||||||
cv
|
|
||||||
+ T
|
|
||||||
* pressure_temperature_derivative
|
|
||||||
* pressure_temperature_derivative
|
|
||||||
/ (density * density * pressure_density_derivative)
|
|
||||||
)
|
|
||||||
if cp <= 0.0 or cv <= 0.0:
|
|
||||||
raise ValueError("Real-gas heat capacities must be positive.")
|
|
||||||
return (
|
|
||||||
cp,
|
|
||||||
cv,
|
|
||||||
density,
|
|
||||||
pressure_density_derivative,
|
|
||||||
pressure_temperature_derivative,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _local_isentropic_density_pressure_factor(
|
|
||||||
self,
|
|
||||||
p: float,
|
|
||||||
T: float,
|
|
||||||
) -> tuple[float, float]:
|
|
||||||
cp, cv, density, pressure_density_derivative, pressure_temperature_derivative = (
|
|
||||||
self._real_heat_capacities(p, T)
|
|
||||||
)
|
|
||||||
heat_capacity_ratio = cp / cv
|
|
||||||
factor = p / (
|
|
||||||
density * pressure_density_derivative * heat_capacity_ratio
|
|
||||||
)
|
|
||||||
exponent = (
|
|
||||||
p
|
|
||||||
* (heat_capacity_ratio - 1.0)
|
|
||||||
/ (
|
|
||||||
heat_capacity_ratio
|
|
||||||
* T
|
|
||||||
* pressure_temperature_derivative
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return factor, exponent
|
|
||||||
|
|
||||||
@profile_property("isentropic_density_pressure_factor")
|
|
||||||
@cache_property_calculation("isentropic_density_pressure_factor")
|
|
||||||
def isentropic_density_pressure_factor(
|
|
||||||
self,
|
|
||||||
p: float,
|
|
||||||
T: float,
|
|
||||||
downstream_pressure: float | None = None,
|
|
||||||
) -> float:
|
|
||||||
upstream_factor, isentropic_temperature_exponent = (
|
|
||||||
self._local_isentropic_density_pressure_factor(p, T)
|
|
||||||
)
|
|
||||||
if downstream_pressure is None or downstream_pressure >= p:
|
|
||||||
return upstream_factor
|
|
||||||
|
|
||||||
pressure_ratio = max(downstream_pressure / p, 1.0e-12)
|
|
||||||
isentropic_temperature = max(
|
|
||||||
T * pressure_ratio**isentropic_temperature_exponent,
|
|
||||||
2.2,
|
|
||||||
)
|
|
||||||
downstream_factor, _unused_exponent = (
|
|
||||||
self._local_isentropic_density_pressure_factor(
|
|
||||||
max(downstream_pressure, 1.0),
|
|
||||||
isentropic_temperature,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# AMESim 2404 saggs_ evaluates the local factor at the upstream
|
|
||||||
# state and at an approximate isentropic downstream state.
|
|
||||||
return 0.5 * (upstream_factor + downstream_factor)
|
|
||||||
|
|
||||||
def pressure(self, m: float, T: float, V: float) -> float:
|
|
||||||
if V <= 0.0:
|
|
||||||
raise ValueError("Volume must stay positive.")
|
|
||||||
return self.fluid.pressure_from_density(T, m / V)
|
|
||||||
|
|
||||||
@profile_property("specific_internal_energy")
|
|
||||||
def specific_internal_energy(self, T: float) -> float:
|
|
||||||
return self.R_gas * (
|
|
||||||
(self.nasa_cp_over_R - 1.0) * T
|
|
||||||
+ self.nasa_enthalpy_constant_K
|
|
||||||
)
|
|
||||||
|
|
||||||
@profile_property("specific_internal_energy_at_pressure")
|
|
||||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
|
||||||
density = self.density(p, T)
|
|
||||||
return (
|
|
||||||
self.specific_internal_energy(T)
|
|
||||||
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
|
|
||||||
)
|
|
||||||
|
|
||||||
@profile_property("specific_enthalpy")
|
|
||||||
def specific_enthalpy(self, T: float) -> float:
|
|
||||||
return self.R_gas * (
|
|
||||||
self.nasa_cp_over_R * T
|
|
||||||
+ self.nasa_enthalpy_constant_K
|
|
||||||
)
|
|
||||||
|
|
||||||
@profile_property("specific_enthalpy_at_pressure")
|
|
||||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
|
||||||
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
|
|
||||||
|
|
||||||
def temperature_from_internal_energy(self, u: float) -> float:
|
|
||||||
return (
|
|
||||||
u / self.R_gas - self.nasa_enthalpy_constant_K
|
|
||||||
) / (self.nasa_cp_over_R - 1.0)
|
|
||||||
|
|
||||||
def temperature_from_enthalpy(self, h: float) -> float:
|
|
||||||
return (
|
|
||||||
h / self.R_gas - self.nasa_enthalpy_constant_K
|
|
||||||
) / self.nasa_cp_over_R
|
|
||||||
|
|
||||||
@profile_property("temperature_from_pressure_enthalpy")
|
|
||||||
@cache_property_calculation("temperature_from_pressure_enthalpy")
|
|
||||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
|
||||||
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
|
||||||
for _iteration in range(16):
|
|
||||||
residual_enthalpy = self.fluid.residual_specific_enthalpy(p, temperature)
|
|
||||||
next_temperature = max(
|
|
||||||
self.temperature_from_enthalpy(h - residual_enthalpy),
|
|
||||||
2.2,
|
|
||||||
)
|
|
||||||
if abs(next_temperature - temperature) <= 1.0e-10 * max(
|
|
||||||
temperature,
|
|
||||||
1.0,
|
|
||||||
):
|
|
||||||
record_property_iterations(
|
|
||||||
"temperature_from_pressure_enthalpy",
|
|
||||||
_iteration + 1,
|
|
||||||
True,
|
|
||||||
)
|
|
||||||
return next_temperature
|
|
||||||
temperature = next_temperature
|
|
||||||
record_property_iterations(
|
|
||||||
"temperature_from_pressure_enthalpy",
|
|
||||||
16,
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
return temperature
|
|
||||||
|
|
||||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
|
||||||
if m <= 0.0:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Mass must stay positive when recovering temperature."
|
|
||||||
)
|
|
||||||
return self.temperature_from_internal_energy(U / m)
|
|
||||||
|
|
||||||
@profile_property("properties_from_mU")
|
|
||||||
@cache_property_calculation("properties_from_mU")
|
|
||||||
def properties_from_mU(
|
|
||||||
self,
|
|
||||||
m: float,
|
|
||||||
U: float,
|
|
||||||
V: float,
|
|
||||||
) -> ThermodynamicProperties:
|
|
||||||
"""Recover a real-gas state, reusing exact repeated evaluations.
|
|
||||||
|
|
||||||
Implicit integration asks several component interfaces for the same
|
|
||||||
``(m, U, V)`` state while closing one RHS evaluation and while building
|
|
||||||
finite-difference Jacobians. The calculation is pure and its result is
|
|
||||||
immutable, so an exact-key bounded cache avoids repeating the
|
|
||||||
Peng-Robinson temperature iteration without changing model semantics.
|
|
||||||
"""
|
|
||||||
if m <= 0.0:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Mass must stay positive when recovering temperature."
|
|
||||||
)
|
|
||||||
if V <= 0.0:
|
|
||||||
raise ValueError("Volume must stay positive.")
|
|
||||||
density = m / V
|
|
||||||
target_internal_energy = U / m
|
|
||||||
temperature = max(
|
|
||||||
self.temperature_from_internal_energy(target_internal_energy),
|
|
||||||
2.2,
|
|
||||||
)
|
|
||||||
converged = False
|
|
||||||
for _iteration in range(16):
|
|
||||||
residual_internal_energy = (
|
|
||||||
self.fluid.residual_specific_internal_energy_at_density(
|
|
||||||
temperature,
|
|
||||||
density,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
next_temperature = max(
|
|
||||||
self.temperature_from_internal_energy(
|
|
||||||
target_internal_energy - residual_internal_energy
|
|
||||||
),
|
|
||||||
2.2,
|
|
||||||
)
|
|
||||||
if abs(next_temperature - temperature) <= 1.0e-10 * max(
|
|
||||||
temperature,
|
|
||||||
1.0,
|
|
||||||
):
|
|
||||||
temperature = next_temperature
|
|
||||||
converged = True
|
|
||||||
break
|
|
||||||
temperature = next_temperature
|
|
||||||
record_property_iterations(
|
|
||||||
"properties_from_mU",
|
|
||||||
_iteration + 1,
|
|
||||||
converged,
|
|
||||||
)
|
|
||||||
pressure = self.fluid.pressure_from_density(temperature, density)
|
|
||||||
return ThermodynamicProperties(
|
|
||||||
p=pressure,
|
|
||||||
T=temperature,
|
|
||||||
rho=density,
|
|
||||||
u=target_internal_energy,
|
|
||||||
h=self.specific_enthalpy_at_pressure(
|
|
||||||
pressure,
|
|
||||||
temperature,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def linearize_properties_from_mU(
|
|
||||||
self,
|
|
||||||
m: float,
|
|
||||||
U: float,
|
|
||||||
V: float,
|
|
||||||
dm: Sequence[float],
|
|
||||||
dU: Sequence[float],
|
|
||||||
dV: Sequence[float],
|
|
||||||
*,
|
|
||||||
properties: ThermodynamicProperties | None = None,
|
|
||||||
) -> ThermodynamicPropertiesLinearization:
|
|
||||||
"""Implicitly differentiate the Peng-Robinson m/U/V recovery."""
|
|
||||||
|
|
||||||
dm_values = tuple(float(value) for value in dm)
|
|
||||||
dU_values = tuple(float(value) for value in dU)
|
|
||||||
dV_values = tuple(float(value) for value in dV)
|
|
||||||
if not (len(dm_values) == len(dU_values) == len(dV_values)):
|
|
||||||
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
|
|
||||||
props = properties or self.properties_from_mU(m, U, V)
|
|
||||||
width = len(dm_values)
|
|
||||||
|
|
||||||
def invalid(reason: str) -> ThermodynamicPropertiesLinearization:
|
|
||||||
return ThermodynamicPropertiesLinearization(
|
|
||||||
properties=props,
|
|
||||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
|
||||||
valid=False,
|
|
||||||
reason=reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_density = m / V
|
|
||||||
expected_internal_energy = U / m
|
|
||||||
if (
|
|
||||||
abs(props.rho - expected_density)
|
|
||||||
> 1.0e-12 * max(abs(expected_density), 1.0)
|
|
||||||
or abs(props.u - expected_internal_energy)
|
|
||||||
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
|
|
||||||
):
|
|
||||||
return invalid("properties_primal_mismatch")
|
|
||||||
if not all(
|
|
||||||
isfinite(value)
|
|
||||||
for values in (dm_values, dU_values, dV_values)
|
|
||||||
for value in values
|
|
||||||
):
|
|
||||||
return invalid("non_finite_tangent_input")
|
|
||||||
if props.T <= 2.2 * (1.0 + 1.0e-10):
|
|
||||||
return invalid("temperature_floor_boundary")
|
|
||||||
|
|
||||||
pressure_temperature_derivative = (
|
|
||||||
self.fluid.pressure_temperature_derivative_at_density(
|
|
||||||
props.T,
|
|
||||||
props.rho,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pressure_density_derivative = (
|
|
||||||
self.fluid.pressure_density_derivative_at_temperature(
|
|
||||||
props.T,
|
|
||||||
props.rho,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cv = (
|
|
||||||
self.cv_at_temperature(props.T)
|
|
||||||
+ self.fluid.residual_isochoric_heat_capacity_at_density(
|
|
||||||
props.T,
|
|
||||||
props.rho,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recovered_internal_energy = (
|
|
||||||
self.specific_internal_energy(props.T)
|
|
||||||
+ self.fluid.residual_specific_internal_energy_at_density(
|
|
||||||
props.T,
|
|
||||||
props.rho,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recovery_scale = max(
|
|
||||||
abs(props.u),
|
|
||||||
abs(cv * props.T) if isfinite(cv) else 0.0,
|
|
||||||
1.0,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not all(
|
|
||||||
isfinite(value)
|
|
||||||
for value in (
|
|
||||||
pressure_temperature_derivative,
|
|
||||||
pressure_density_derivative,
|
|
||||||
cv,
|
|
||||||
recovered_internal_energy,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
or cv <= 0.0
|
|
||||||
):
|
|
||||||
return invalid("invalid_peng_robinson_derivative")
|
|
||||||
if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale:
|
|
||||||
return invalid("properties_recovery_not_converged")
|
|
||||||
|
|
||||||
internal_energy_density_derivative = (
|
|
||||||
props.p - props.T * pressure_temperature_derivative
|
|
||||||
) / (props.rho * props.rho)
|
|
||||||
drho: list[float] = []
|
|
||||||
du: list[float] = []
|
|
||||||
dT: list[float] = []
|
|
||||||
dp: list[float] = []
|
|
||||||
dh: list[float] = []
|
|
||||||
for mass_tangent, energy_tangent, volume_tangent in zip(
|
|
||||||
dm_values,
|
|
||||||
dU_values,
|
|
||||||
dV_values,
|
|
||||||
strict=True,
|
|
||||||
):
|
|
||||||
density_tangent = (
|
|
||||||
mass_tangent / V - m * volume_tangent / (V * V)
|
|
||||||
)
|
|
||||||
internal_energy_tangent = (
|
|
||||||
energy_tangent / m - U * mass_tangent / (m * m)
|
|
||||||
)
|
|
||||||
temperature_tangent = (
|
|
||||||
internal_energy_tangent
|
|
||||||
- internal_energy_density_derivative * density_tangent
|
|
||||||
) / cv
|
|
||||||
pressure_tangent = (
|
|
||||||
pressure_temperature_derivative * temperature_tangent
|
|
||||||
+ pressure_density_derivative * density_tangent
|
|
||||||
)
|
|
||||||
enthalpy_tangent = (
|
|
||||||
internal_energy_tangent
|
|
||||||
+ pressure_tangent / props.rho
|
|
||||||
- props.p * density_tangent / (props.rho * props.rho)
|
|
||||||
)
|
|
||||||
drho.append(density_tangent)
|
|
||||||
du.append(internal_energy_tangent)
|
|
||||||
dT.append(temperature_tangent)
|
|
||||||
dp.append(pressure_tangent)
|
|
||||||
dh.append(enthalpy_tangent)
|
|
||||||
|
|
||||||
tangent_values = (*drho, *du, *dT, *dp, *dh)
|
|
||||||
if not all(isfinite(value) for value in tangent_values):
|
|
||||||
return invalid("non_finite_property_tangent")
|
|
||||||
return ThermodynamicPropertiesLinearization(
|
|
||||||
properties=props,
|
|
||||||
tangents=ThermodynamicPropertyTangents(
|
|
||||||
p=tuple(dp),
|
|
||||||
T=tuple(dT),
|
|
||||||
rho=tuple(drho),
|
|
||||||
u=tuple(du),
|
|
||||||
h=tuple(dh),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AmesimGasPropertyModelSpec:
|
class AmesimGasPropertyModelSpec:
|
||||||
"""A selectable calculation method for one AMESim gas substance."""
|
"""A selectable calculation method for one AMESim gas substance."""
|
||||||
|
|
||||||
value: int
|
value: int
|
||||||
label: str
|
label: str
|
||||||
method_id: str
|
method_id: str
|
||||||
@@ -491,27 +45,7 @@ class AmesimGasPropertyModelSpec:
|
|||||||
|
|
||||||
def build_medium(self) -> GasMedium:
|
def build_medium(self) -> GasMedium:
|
||||||
return self.factory()
|
return self.factory()
|
||||||
|
|
||||||
|
|
||||||
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL = 0
|
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL = 0
|
||||||
AMESIM_AIR_PROPERTY_MODELS = (
|
AMESIM_AIR_PROPERTY_MODELS = (AmesimGasPropertyModelSpec(value=AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL, label='理想气体', method_id=AmesimIdealAirMedium.PROPERTY_METHOD_ID, factory=AmesimIdealAirMedium, eos_type=1),)
|
||||||
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_PENG_ROBINSON_PROPERTY_MODEL = 0
|
||||||
AMESIM_HELIUM_PROPERTY_MODELS = (
|
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),)
|
||||||
AmesimGasPropertyModelSpec(
|
|
||||||
value=AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
|
|
||||||
label="Peng–Robinson",
|
|
||||||
method_id=AmesimHeliumPengRobinsonMedium.PROPERTY_METHOD_ID,
|
|
||||||
factory=AmesimHeliumPengRobinsonMedium,
|
|
||||||
eos_type=6,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,355 +1,79 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from math import floor
|
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import (
|
from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec
|
||||||
ComponentDisplaySpec,
|
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
|
||||||
ParameterGroupDisplaySpec,
|
|
||||||
PortDisplaySpec,
|
|
||||||
)
|
|
||||||
from app.simulation.core.metadata import (
|
|
||||||
ParameterCondition,
|
|
||||||
ParameterDefinition,
|
|
||||||
ParameterOption,
|
|
||||||
ResultVariableDefinition,
|
|
||||||
)
|
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]:
|
def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]:
|
||||||
visible_when = (
|
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))
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
_UD00_STAGE_PARAMETERS = tuple((parameter for stage_index in range(1, 9) for parameter in _ud00_stage_parameters(stage_index)))
|
||||||
|
|
||||||
class AmesimStep0(AlgebraicComponent):
|
class AmesimStep0(AlgebraicComponent):
|
||||||
"""AMESim STEP0 scalar step signal source."""
|
"""AMESim STEP0 scalar step signal source."""
|
||||||
|
MODEL_TYPE = 'amesim_step0'
|
||||||
|
MODEL_VERSION = '0.1.0'
|
||||||
|
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
|
||||||
|
PARAMETERS = (ParameterDefinition('initial', 0.0, label='初始值', quantity='dimensionless', unit=''), ParameterDefinition('final', 1.0, label='阶跃后值', quantity='dimensionless', unit=''), ParameterDefinition('time', 0.0, label='阶跃时间', quantity='time', unit='s'))
|
||||||
|
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
|
||||||
|
DISPLAY = ComponentDisplaySpec(label='STEP0 阶跃信号', library_id='amesim', category_id='signals', symbol='amesim_step0', ports=(PortDisplaySpec('out', 'right', order=10),), order=10)
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_step0"
|
def __init__(self, name: str, medium: IdealGasMedium, *, initial: float=0.0, final: float=1.0, time: float=0.0) -> None:
|
||||||
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)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values({"initial": initial, "final": final, "time": time})
|
self.set_parameter_values({'initial': initial, 'final': final, 'time': time})
|
||||||
self.initial = float(initial)
|
self.initial = float(initial)
|
||||||
self.final = float(final)
|
self.final = float(final)
|
||||||
self.time = float(time)
|
self.time = float(time)
|
||||||
self.out = self.register_declared_port("out")
|
self.out = self.register_declared_port('out')
|
||||||
self.out.signal = self.output_at(0.0)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimStep0':
|
||||||
cls,
|
return cls(name=name, medium=medium, initial=parameters['initial'], final=parameters['final'], time=parameters['time'])
|
||||||
*,
|
EQUATIONS = ()
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> "AmesimStep0":
|
|
||||||
return cls(
|
|
||||||
name=name,
|
|
||||||
medium=medium,
|
|
||||||
initial=parameters["initial"],
|
|
||||||
final=parameters["final"],
|
|
||||||
time=parameters["time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def output_at(self, time: float) -> float:
|
|
||||||
return self.final if time >= self.time else self.initial
|
|
||||||
|
|
||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
|
||||||
return {"out": self.output_at(time)}
|
|
||||||
|
|
||||||
def signal_event_times(
|
|
||||||
self,
|
|
||||||
start_time: float,
|
|
||||||
stop_time: float,
|
|
||||||
) -> tuple[float, ...]:
|
|
||||||
"""Expose the exact STEP0 switch time as an integration split point."""
|
|
||||||
|
|
||||||
return (self.time,) if start_time < self.time < stop_time else ()
|
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
|
||||||
return {"y": self.out.signal}
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimUd00(AlgebraicComponent):
|
class AmesimUd00(AlgebraicComponent):
|
||||||
"""AMESim UD00 piecewise-linear scalar signal source."""
|
"""AMESim UD00 piecewise-linear scalar signal source."""
|
||||||
|
MODEL_TYPE = 'amesim_ud00'
|
||||||
|
MODEL_VERSION = '0.2.0'
|
||||||
|
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
|
||||||
|
PARAMETERS = (ParameterDefinition('tstart', 0.0, label='启动时间', quantity='time', unit='s', description='分段信号开始输出第一段之前的等待时间。'), *_UD00_STAGE_PARAMETERS, ParameterDefinition('nstages', 1.0, label='段数', quantity='dimensionless', unit='', minimum=1.0, maximum=8.0, editor='choice', options=tuple((ParameterOption(float(stage_count), str(stage_count)) for stage_count in range(1, 9))), description='参与输出计算的有效线性分段数量。'), ParameterDefinition('iscyclic', 0.0, label='循环', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, editor='choice', options=(ParameterOption(0.0, '否'), ParameterOption(1.0, '是')), description='当前公共协议编码:0 表示单次输出,1 表示循环输出。'))
|
||||||
|
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
|
||||||
|
DISPLAY = ComponentDisplaySpec(label='UD00 分段线性信号', library_id='amesim', category_id='signals', symbol='amesim_ud00', ports=(PortDisplaySpec('out', 'right', order=10),), order=20, parameter_groups=(ParameterGroupDisplaySpec(id='stages', label='分段参数', parameters=tuple((parameter.name for parameter in _UD00_STAGE_PARAMETERS)), order=10),))
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_ud00"
|
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:
|
||||||
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)
|
super().__init__(name=name)
|
||||||
if len(starts) != 8 or len(ends) != 8 or len(durations) != 8:
|
if len(starts) != 8 or len(ends) != 8 or len(durations) != 8:
|
||||||
raise ValueError("UD00 requires exactly eight start, end, and duration values.")
|
raise ValueError('UD00 requires exactly eight start, end, and duration values.')
|
||||||
if nstages < 1 or nstages > 8:
|
if nstages < 1 or nstages > 8:
|
||||||
raise ValueError("UD00 nstages must be between 1 and 8.")
|
raise ValueError('UD00 nstages must be between 1 and 8.')
|
||||||
self.tstart = float(tstart)
|
self.tstart = float(tstart)
|
||||||
self.starts = tuple(float(value) for value in starts)
|
self.starts = tuple((float(value) for value in starts))
|
||||||
self.ends = tuple(float(value) for value in ends)
|
self.ends = tuple((float(value) for value in ends))
|
||||||
self.durations = tuple(float(value) for value in durations)
|
self.durations = tuple((float(value) for value in durations))
|
||||||
self.nstages = int(nstages)
|
self.nstages = int(nstages)
|
||||||
self.iscyclic = bool(iscyclic)
|
self.iscyclic = bool(iscyclic)
|
||||||
values: dict[str, float] = {"tstart": self.tstart, "nstages": float(self.nstages), "iscyclic": float(int(self.iscyclic))}
|
values: dict[str, float] = {'tstart': self.tstart, 'nstages': float(self.nstages), 'iscyclic': float(int(self.iscyclic))}
|
||||||
for index in range(1, 9):
|
for index in range(1, 9):
|
||||||
values[f"start{index}"] = self.starts[index - 1]
|
values[f'start{index}'] = self.starts[index - 1]
|
||||||
values[f"end{index}"] = self.ends[index - 1]
|
values[f'end{index}'] = self.ends[index - 1]
|
||||||
values[f"t{index}"] = self.durations[index - 1]
|
values[f't{index}'] = self.durations[index - 1]
|
||||||
self.set_parameter_values(values)
|
self.set_parameter_values(values)
|
||||||
self.out = self.register_declared_port("out")
|
self.out = self.register_declared_port('out')
|
||||||
self.out.signal = self.output_at(0.0)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimUd00':
|
||||||
cls,
|
nstages = parameters['nstages']
|
||||||
*,
|
iscyclic = parameters['iscyclic']
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> "AmesimUd00":
|
|
||||||
nstages = parameters["nstages"]
|
|
||||||
iscyclic = parameters["iscyclic"]
|
|
||||||
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
definitions = {definition.name: definition for definition in cls.PARAMETERS}
|
||||||
for parameter_name, value in (
|
for parameter_name, value in (('nstages', nstages), ('iscyclic', iscyclic)):
|
||||||
("nstages", nstages),
|
|
||||||
("iscyclic", iscyclic),
|
|
||||||
):
|
|
||||||
numeric_value = float(value)
|
numeric_value = float(value)
|
||||||
if not numeric_value.is_integer():
|
if not numeric_value.is_integer():
|
||||||
raise ValueError(f"UD00 {parameter_name} must be an integer.")
|
raise ValueError(f'UD00 {parameter_name} must be an integer.')
|
||||||
message = definitions[parameter_name].validation_message(numeric_value)
|
message = definitions[parameter_name].validation_message(numeric_value)
|
||||||
if message is not None:
|
if message is not None:
|
||||||
raise ValueError(f"UD00 {parameter_name} {message}.")
|
raise ValueError(f'UD00 {parameter_name} {message}.')
|
||||||
return cls(
|
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)))
|
||||||
name=name,
|
EQUATIONS = ()
|
||||||
medium=medium,
|
|
||||||
tstart=parameters["tstart"],
|
|
||||||
starts=tuple(parameters[f"start{index}"] for index in range(1, 9)),
|
|
||||||
ends=tuple(parameters[f"end{index}"] for index in range(1, 9)),
|
|
||||||
durations=tuple(parameters[f"t{index}"] for index in range(1, 9)),
|
|
||||||
nstages=int(nstages),
|
|
||||||
iscyclic=bool(int(iscyclic)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def output_at(self, time: float) -> float:
|
|
||||||
elapsed = max(float(time) - self.tstart, 0.0)
|
|
||||||
active_durations = self.durations[: self.nstages]
|
|
||||||
total_duration = sum(active_durations)
|
|
||||||
if self.iscyclic and total_duration > 0.0:
|
|
||||||
elapsed = elapsed % total_duration
|
|
||||||
|
|
||||||
stage_start_time = 0.0
|
|
||||||
for index, duration in enumerate(active_durations):
|
|
||||||
stage_end_time = stage_start_time + duration
|
|
||||||
if elapsed < stage_end_time or index == self.nstages - 1:
|
|
||||||
if duration <= 0.0:
|
|
||||||
return self.ends[index]
|
|
||||||
fraction = (elapsed - stage_start_time) / duration
|
|
||||||
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
|
|
||||||
stage_start_time = stage_end_time
|
|
||||||
return self.ends[self.nstages - 1]
|
|
||||||
|
|
||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
|
||||||
return {"out": self.output_at(time)}
|
|
||||||
|
|
||||||
def signal_event_times(
|
|
||||||
self,
|
|
||||||
start_time: float,
|
|
||||||
stop_time: float,
|
|
||||||
) -> tuple[float, ...]:
|
|
||||||
"""Return UD00 start, stage, and repeated cycle boundaries.
|
|
||||||
|
|
||||||
The final non-cyclic stage is intentionally not given an end event:
|
|
||||||
``output_at`` continues that stage's slope after its configured duration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if stop_time <= start_time:
|
|
||||||
return ()
|
|
||||||
|
|
||||||
active_durations = self.durations[: self.nstages]
|
|
||||||
stage_offsets = [0.0]
|
|
||||||
elapsed = 0.0
|
|
||||||
for duration in active_durations[:-1]:
|
|
||||||
elapsed += duration
|
|
||||||
stage_offsets.append(elapsed)
|
|
||||||
|
|
||||||
if not self.iscyclic:
|
|
||||||
return tuple(
|
|
||||||
sorted(
|
|
||||||
{
|
|
||||||
event_time
|
|
||||||
for offset in stage_offsets
|
|
||||||
if start_time
|
|
||||||
< (event_time := self.tstart + offset)
|
|
||||||
< stop_time
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
cycle_duration = sum(active_durations)
|
|
||||||
if cycle_duration <= 0.0:
|
|
||||||
return ()
|
|
||||||
|
|
||||||
events: set[float] = set()
|
|
||||||
for offset in stage_offsets:
|
|
||||||
first_boundary = self.tstart + offset
|
|
||||||
cycle_index = max(
|
|
||||||
0,
|
|
||||||
floor((start_time - first_boundary) / cycle_duration) + 1,
|
|
||||||
)
|
|
||||||
event_time = first_boundary + cycle_index * cycle_duration
|
|
||||||
while event_time < stop_time:
|
|
||||||
if event_time > start_time:
|
|
||||||
events.add(event_time)
|
|
||||||
cycle_index += 1
|
|
||||||
event_time = first_boundary + cycle_index * cycle_duration
|
|
||||||
return tuple(sorted(events))
|
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
|
||||||
return {"y": self.out.signal}
|
|
||||||
@@ -1,38 +1,12 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from collections.abc import Mapping
|
||||||
from collections.abc import Mapping, Sequence
|
from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import isfinite
|
|
||||||
|
|
||||||
from app.simulation.components.amesim.gases import (
|
|
||||||
AMESIM_GAS_INDEX_PARAMETER,
|
|
||||||
normalize_amesim_gas_index,
|
|
||||||
)
|
|
||||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.medium import GasMedium
|
||||||
ParameterDefinition,
|
|
||||||
ResultVariableDefinition,
|
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
|
||||||
)
|
|
||||||
from app.simulation.core.medium import (
|
|
||||||
GasMedium,
|
|
||||||
ThermodynamicProperties,
|
|
||||||
ThermodynamicPropertiesLinearization,
|
|
||||||
)
|
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
from app.simulation.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Pnch012DerivativeLinearization:
|
|
||||||
derivative: tuple[float, float]
|
|
||||||
tangents: tuple[tuple[float, ...], tuple[float, ...]]
|
|
||||||
properties: ThermodynamicPropertiesLinearization
|
|
||||||
valid: bool = True
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnch023(ThermodynamicVolumeComponent):
|
class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||||
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
||||||
@@ -42,112 +16,16 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
framework's mass/internal-energy volume state and keeps the AMESim
|
framework's mass/internal-energy volume state and keeps the AMESim
|
||||||
heat-transfer contract `kth * sth * (extemp - T)`.
|
heat-transfer contract `kth * sth * (extemp - T)`.
|
||||||
"""
|
"""
|
||||||
|
MODEL_TYPE = 'amesim_pnch023'
|
||||||
MODEL_TYPE = "amesim_pnch023"
|
MODEL_VERSION = '0.1.0'
|
||||||
MODEL_VERSION = "0.1.0"
|
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
|
||||||
PORTS = (
|
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='仿真开始时气室内气体的绝对温度。'))
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
|
||||||
)
|
|
||||||
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
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
DISPLAY = ComponentDisplaySpec(
|
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)
|
||||||
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__(
|
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:
|
||||||
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)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values(
|
self.set_parameter_values({'cvol': cvol, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0})
|
||||||
{
|
|
||||||
"cvol": cvol,
|
|
||||||
"kth": kth,
|
|
||||||
"sth": sth,
|
|
||||||
"extemp": extemp,
|
|
||||||
"gi": gi,
|
|
||||||
"p0": p0,
|
|
||||||
"T0": T0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.medium = medium
|
self.medium = medium
|
||||||
self.cvol = float(cvol)
|
self.cvol = float(cvol)
|
||||||
self.kth = float(kth)
|
self.kth = float(kth)
|
||||||
@@ -156,120 +34,13 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
self.gi = normalize_amesim_gas_index(gi)
|
self.gi = normalize_amesim_gas_index(gi)
|
||||||
self.p0 = float(p0)
|
self.p0 = float(p0)
|
||||||
self.T0 = float(T0)
|
self.T0 = float(T0)
|
||||||
m0 = medium.density(self.p0, self.T0) * self.cvol
|
self.port_1 = self.register_declared_port('port_1')
|
||||||
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
|
self.port_2 = self.register_declared_port('port_2')
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0)
|
|
||||||
self.port_1 = self.register_declared_port("port_1")
|
|
||||||
self.port_1.p = self.p0
|
|
||||||
self.port_1.h_outflow = initial_h
|
|
||||||
self.port_2 = self.register_declared_port("port_2")
|
|
||||||
self.port_2.p = self.p0
|
|
||||||
self.port_2.h_outflow = initial_h
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnch023:
|
||||||
cls,
|
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'})
|
||||||
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"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
return self.state.as_vector()
|
|
||||||
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
self.state = VolumeState.from_vector(values)
|
|
||||||
|
|
||||||
def properties(self) -> ThermodynamicProperties:
|
|
||||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol)
|
|
||||||
self.port_1.p = props.p
|
|
||||||
self.port_1.h_outflow = props.h
|
|
||||||
self.port_2.p = props.p
|
|
||||||
self.port_2.h_outflow = props.h
|
|
||||||
return props
|
|
||||||
|
|
||||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
|
||||||
return self.properties()
|
|
||||||
|
|
||||||
def thermal_energy_flow_w(self, temperature: float) -> float:
|
|
||||||
return self.kth * self.sth * (self.extemp - temperature)
|
|
||||||
|
|
||||||
def state_derivative_from_ports(
|
|
||||||
self,
|
|
||||||
connected_h: Mapping[str, float],
|
|
||||||
) -> list[float]:
|
|
||||||
props = self.properties()
|
|
||||||
inlet_h_1 = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=self.port_1.m_flow,
|
|
||||||
connected_h=connected_h["port_1"],
|
|
||||||
internal_h=props.h,
|
|
||||||
)
|
|
||||||
inlet_h_2 = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=self.port_2.m_flow,
|
|
||||||
connected_h=connected_h["port_2"],
|
|
||||||
internal_h=props.h,
|
|
||||||
)
|
|
||||||
derivative = VolumeState(
|
|
||||||
m=self.port_1.m_flow + self.port_2.m_flow,
|
|
||||||
U=(
|
|
||||||
self.port_1.m_flow * inlet_h_1
|
|
||||||
+ self.port_2.m_flow * inlet_h_2
|
|
||||||
+ self.thermal_energy_flow_w(props.T)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return derivative.as_vector()
|
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
|
||||||
pressure = self.medium.properties_from_mU(
|
|
||||||
self.state.m,
|
|
||||||
self.state.U,
|
|
||||||
self.cvol,
|
|
||||||
).p
|
|
||||||
return (
|
|
||||||
self.port_1.p - pressure,
|
|
||||||
self.port_2.p - pressure,
|
|
||||||
)
|
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
||||||
pressure = self.medium.properties_from_mU(
|
|
||||||
self.state.m,
|
|
||||||
self.state.U,
|
|
||||||
self.cvol,
|
|
||||||
).p
|
|
||||||
return (
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:port_1_pressure_state",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="state",
|
|
||||||
variables=(f"{self.name}.port_1.p", f"{self.name}.state"),
|
|
||||||
role="effort",
|
|
||||||
value=self.port_1.p - pressure,
|
|
||||||
),
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:port_2_pressure_state",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="state",
|
|
||||||
variables=(f"{self.name}.port_2.p", f"{self.name}.state"),
|
|
||||||
role="effort",
|
|
||||||
value=self.port_2.p - pressure,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnch012(ThermodynamicVolumeComponent):
|
class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||||
"""AMESim PNCH012 variable-volume pneumatic chamber.
|
"""AMESim PNCH012 variable-volume pneumatic chamber.
|
||||||
@@ -279,143 +50,16 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
parameters, while connected moving-boundary components can now add live
|
parameters, while connected moving-boundary components can now add live
|
||||||
volume and volume-rate values through the pneumatic connector contract.
|
volume and volume-rate values through the pneumatic connector contract.
|
||||||
"""
|
"""
|
||||||
|
MODEL_TYPE = 'amesim_pnch012'
|
||||||
|
MODEL_VERSION = '0.1.0'
|
||||||
|
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional'))
|
||||||
|
PARAMETERS = (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'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s'))
|
||||||
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110))
|
||||||
|
DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnch012"
|
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:
|
||||||
MODEL_VERSION = "0.1.0"
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
|
|
||||||
)
|
|
||||||
PARAMETERS = (
|
|
||||||
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"),
|
|
||||||
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
|
|
||||||
ParameterDefinition("vol3", 0.0, label="端口 3 外部容积", quantity="volume", unit="m3"),
|
|
||||||
ParameterDefinition("vol4", 0.0, label="端口 4 外部容积", quantity="volume", unit="m3"),
|
|
||||||
ParameterDefinition("dvol1", 0.0, label="端口 1 容积变化率", quantity="volume_flow", unit="m3/s"),
|
|
||||||
ParameterDefinition("dvol2", 0.0, label="端口 2 容积变化率", quantity="volume_flow", unit="m3/s"),
|
|
||||||
ParameterDefinition("dvol3", 0.0, label="端口 3 容积变化率", quantity="volume_flow", unit="m3/s"),
|
|
||||||
ParameterDefinition("dvol4", 0.0, label="端口 4 容积变化率", quantity="volume_flow", unit="m3/s"),
|
|
||||||
)
|
|
||||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (
|
|
||||||
ResultVariableDefinition("vol", "气室总容积", "volume", "m3", "derived", 100),
|
|
||||||
ResultVariableDefinition("dvol", "总容积变化率", "volume_flow", "m3/s", "derived", 110),
|
|
||||||
)
|
|
||||||
DISPLAY = ComponentDisplaySpec(
|
|
||||||
label="PNCH012 变容气室",
|
|
||||||
library_id="amesim",
|
|
||||||
category_id="storage",
|
|
||||||
symbol="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)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values(
|
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})
|
||||||
{
|
|
||||||
"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.medium = medium
|
||||||
self.cvol0 = float(cvol0)
|
self.cvol0 = float(cvol0)
|
||||||
self.kth = float(kth)
|
self.kth = float(kth)
|
||||||
@@ -424,267 +68,13 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
self.gi = normalize_amesim_gas_index(gi)
|
self.gi = normalize_amesim_gas_index(gi)
|
||||||
self.p0 = float(p0)
|
self.p0 = float(p0)
|
||||||
self.T0 = float(T0)
|
self.T0 = float(T0)
|
||||||
self.external_volumes = {
|
self.external_volumes = {'port_1': float(vol1), 'port_2': float(vol2), 'port_3': float(vol3), 'port_4': float(vol4)}
|
||||||
"port_1": float(vol1),
|
self.external_volume_rates = {'port_1': float(dvol1), 'port_2': float(dvol2), 'port_3': float(dvol3), 'port_4': float(dvol4)}
|
||||||
"port_2": float(vol2),
|
for port_name in ('port_1', 'port_2', 'port_3', 'port_4'):
|
||||||
"port_3": float(vol3),
|
|
||||||
"port_4": float(vol4),
|
|
||||||
}
|
|
||||||
self.external_volume_rates = {
|
|
||||||
"port_1": float(dvol1),
|
|
||||||
"port_2": float(dvol2),
|
|
||||||
"port_3": float(dvol3),
|
|
||||||
"port_4": float(dvol4),
|
|
||||||
}
|
|
||||||
if self.total_volume() <= 0.0:
|
|
||||||
raise ValueError("PNCH012 total volume must be positive.")
|
|
||||||
m0 = medium.density(self.p0, self.T0) * self.total_volume()
|
|
||||||
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
|
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0)
|
|
||||||
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
|
||||||
port = self.register_declared_port(port_name)
|
port = self.register_declared_port(port_name)
|
||||||
port.p = self.p0
|
|
||||||
port.h_outflow = initial_h
|
|
||||||
setattr(self, port_name, port)
|
setattr(self, port_name, port)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnch012':
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: GasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> "AmesimPnch012":
|
|
||||||
return cls(name=name, medium=medium, **dict(parameters))
|
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'})
|
||||||
def connected_external_volume(self) -> float:
|
|
||||||
return sum(
|
|
||||||
getattr(getattr(self, port_name, None), "volume", 0.0)
|
|
||||||
for port_name in self.external_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
def connected_external_volume_rate(self) -> float:
|
|
||||||
return sum(
|
|
||||||
getattr(getattr(self, port_name, None), "volume_flow", 0.0)
|
|
||||||
for port_name in self.external_volume_rates
|
|
||||||
)
|
|
||||||
|
|
||||||
def total_volume(self) -> float:
|
|
||||||
minimum_volume = self.cvol0 / 100.0
|
|
||||||
return max(
|
|
||||||
self.cvol0 + sum(self.external_volumes.values()) + self.connected_external_volume(),
|
|
||||||
minimum_volume,
|
|
||||||
)
|
|
||||||
|
|
||||||
def total_volume_rate(self) -> float:
|
|
||||||
if self.total_volume() <= self.cvol0 / 100.0:
|
|
||||||
return 0.0
|
|
||||||
return sum(self.external_volume_rates.values()) + self.connected_external_volume_rate()
|
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
return self.state.as_vector()
|
|
||||||
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
self.state = VolumeState.from_vector(values)
|
|
||||||
|
|
||||||
def properties(self) -> ThermodynamicProperties:
|
|
||||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.total_volume())
|
|
||||||
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
|
||||||
port = self.get_port(port_name)
|
|
||||||
port.p = props.p
|
|
||||||
port.h_outflow = props.h
|
|
||||||
return props
|
|
||||||
|
|
||||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
|
||||||
return self.properties()
|
|
||||||
|
|
||||||
def thermal_energy_flow_w(self, temperature: float) -> float:
|
|
||||||
return self.kth * self.sth * (self.extemp - temperature)
|
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
|
||||||
props = self.properties()
|
|
||||||
return {
|
|
||||||
"m": self.state.m,
|
|
||||||
"U": self.state.U,
|
|
||||||
"p": props.p,
|
|
||||||
"T": props.T,
|
|
||||||
"rho": props.rho,
|
|
||||||
"u": props.u,
|
|
||||||
"h": props.h,
|
|
||||||
"vol": self.total_volume(),
|
|
||||||
"dvol": self.total_volume_rate(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
|
|
||||||
props = self.properties()
|
|
||||||
mass_derivative = 0.0
|
|
||||||
energy_derivative = 0.0
|
|
||||||
for port_name in ("port_1", "port_2", "port_3", "port_4"):
|
|
||||||
port = self.get_port(port_name)
|
|
||||||
inlet_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port.m_flow,
|
|
||||||
connected_h=connected_h[port_name],
|
|
||||||
internal_h=props.h,
|
|
||||||
)
|
|
||||||
mass_derivative += port.m_flow
|
|
||||||
energy_derivative += port.m_flow * inlet_h
|
|
||||||
energy_derivative += self.thermal_energy_flow_w(props.T)
|
|
||||||
energy_derivative -= props.p * self.total_volume_rate()
|
|
||||||
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
|
|
||||||
|
|
||||||
def linearize_state_derivative(
|
|
||||||
self,
|
|
||||||
connected_h: Mapping[str, float],
|
|
||||||
*,
|
|
||||||
state_mass_tangent: Sequence[float],
|
|
||||||
state_energy_tangent: Sequence[float],
|
|
||||||
external_volume_tangent: Sequence[float],
|
|
||||||
external_volume_rate_tangent: Sequence[float],
|
|
||||||
port_mass_flow_tangents: Mapping[str, Sequence[float]],
|
|
||||||
connected_h_tangents: Mapping[str, Sequence[float]],
|
|
||||||
property_linearization: ThermodynamicPropertiesLinearization | None = None,
|
|
||||||
flow_boundary_tolerance: float = 1.0e-12,
|
|
||||||
) -> Pnch012DerivativeLinearization:
|
|
||||||
"""Linearize the chamber balance while keeping stream modes fixed."""
|
|
||||||
|
|
||||||
port_names = ("port_1", "port_2", "port_3", "port_4")
|
|
||||||
vectors = {
|
|
||||||
"state_mass": tuple(float(value) for value in state_mass_tangent),
|
|
||||||
"state_energy": tuple(float(value) for value in state_energy_tangent),
|
|
||||||
"volume": tuple(float(value) for value in external_volume_tangent),
|
|
||||||
"volume_rate": tuple(
|
|
||||||
float(value) for value in external_volume_rate_tangent
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for port_name in port_names:
|
|
||||||
vectors[f"flow:{port_name}"] = tuple(
|
|
||||||
float(value) for value in port_mass_flow_tangents[port_name]
|
|
||||||
)
|
|
||||||
vectors[f"enthalpy:{port_name}"] = tuple(
|
|
||||||
float(value) for value in connected_h_tangents[port_name]
|
|
||||||
)
|
|
||||||
widths = {len(values) for values in vectors.values()}
|
|
||||||
if len(widths) != 1:
|
|
||||||
raise ValueError("PNCH012 tangent vectors must have equal lengths.")
|
|
||||||
width = len(vectors["state_mass"])
|
|
||||||
invalid_reason: str | None = None
|
|
||||||
if not all(isfinite(value) for values in vectors.values() for value in values):
|
|
||||||
invalid_reason = "non_finite_tangent_input"
|
|
||||||
|
|
||||||
raw_volume = (
|
|
||||||
self.cvol0
|
|
||||||
+ sum(self.external_volumes.values())
|
|
||||||
+ self.connected_external_volume()
|
|
||||||
)
|
|
||||||
minimum_volume = self.cvol0 / 100.0
|
|
||||||
volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18)
|
|
||||||
on_volume_boundary = (
|
|
||||||
abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale
|
|
||||||
)
|
|
||||||
supplied_volume_tangent = vectors["volume"]
|
|
||||||
if raw_volume < minimum_volume or on_volume_boundary:
|
|
||||||
used_volume_tangent = (0.0,) * width
|
|
||||||
used_volume_rate_tangent = (0.0,) * width
|
|
||||||
if on_volume_boundary and any(
|
|
||||||
value != 0.0
|
|
||||||
for value in (
|
|
||||||
*supplied_volume_tangent,
|
|
||||||
*vectors["volume_rate"],
|
|
||||||
)
|
|
||||||
):
|
|
||||||
invalid_reason = invalid_reason or "volume_floor_boundary"
|
|
||||||
else:
|
|
||||||
used_volume_tangent = supplied_volume_tangent
|
|
||||||
used_volume_rate_tangent = vectors["volume_rate"]
|
|
||||||
|
|
||||||
properties = property_linearization or self.medium.linearize_properties_from_mU(
|
|
||||||
self.state.m,
|
|
||||||
self.state.U,
|
|
||||||
self.total_volume(),
|
|
||||||
vectors["state_mass"],
|
|
||||||
vectors["state_energy"],
|
|
||||||
used_volume_tangent,
|
|
||||||
)
|
|
||||||
if properties.tangents.width != width:
|
|
||||||
raise ValueError(
|
|
||||||
"PNCH012 property tangent width must match balance tangents."
|
|
||||||
)
|
|
||||||
props = properties.properties
|
|
||||||
if not properties.valid:
|
|
||||||
invalid_reason = invalid_reason or properties.reason
|
|
||||||
|
|
||||||
mass_derivative = sum(
|
|
||||||
self.get_port(port_name).m_flow for port_name in port_names
|
|
||||||
)
|
|
||||||
volume_rate = self.total_volume_rate()
|
|
||||||
energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate
|
|
||||||
mass_tangent = [0.0] * width
|
|
||||||
energy_tangent = [
|
|
||||||
-self.kth * self.sth * properties.tangents.T[index]
|
|
||||||
- volume_rate * properties.tangents.p[index]
|
|
||||||
- props.p * used_volume_rate_tangent[index]
|
|
||||||
for index in range(width)
|
|
||||||
]
|
|
||||||
|
|
||||||
for port_name in port_names:
|
|
||||||
port = self.get_port(port_name)
|
|
||||||
flow_tangent = vectors[f"flow:{port_name}"]
|
|
||||||
if (
|
|
||||||
abs(port.m_flow) <= flow_boundary_tolerance
|
|
||||||
and any(value != 0.0 for value in flow_tangent)
|
|
||||||
):
|
|
||||||
invalid_reason = invalid_reason or (
|
|
||||||
f"flow_direction_boundary:{port_name}"
|
|
||||||
)
|
|
||||||
if port.m_flow > 0.0:
|
|
||||||
inlet_h = connected_h[port_name]
|
|
||||||
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
|
|
||||||
else:
|
|
||||||
inlet_h = props.h
|
|
||||||
inlet_h_tangent = properties.tangents.h
|
|
||||||
energy_derivative += port.m_flow * inlet_h
|
|
||||||
for index in range(width):
|
|
||||||
mass_tangent[index] += flow_tangent[index]
|
|
||||||
energy_tangent[index] += (
|
|
||||||
inlet_h * flow_tangent[index]
|
|
||||||
+ port.m_flow * inlet_h_tangent[index]
|
|
||||||
)
|
|
||||||
|
|
||||||
return Pnch012DerivativeLinearization(
|
|
||||||
derivative=(mass_derivative, energy_derivative),
|
|
||||||
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
|
|
||||||
properties=properties,
|
|
||||||
valid=invalid_reason is None,
|
|
||||||
reason=invalid_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
|
||||||
pressure = self.medium.properties_from_mU(
|
|
||||||
self.state.m,
|
|
||||||
self.state.U,
|
|
||||||
self.total_volume(),
|
|
||||||
).p
|
|
||||||
return tuple(
|
|
||||||
self.get_port(port_name).p - pressure
|
|
||||||
for port_name in ("port_1", "port_2", "port_3", "port_4")
|
|
||||||
)
|
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
|
||||||
pressure = self.medium.properties_from_mU(
|
|
||||||
self.state.m,
|
|
||||||
self.state.U,
|
|
||||||
self.total_volume(),
|
|
||||||
).p
|
|
||||||
return tuple(
|
|
||||||
EquationResidual(
|
|
||||||
id=f"{self.name}:{port_name}_pressure_state",
|
|
||||||
owner="component",
|
|
||||||
owner_id=self.name,
|
|
||||||
relation="state",
|
|
||||||
variables=(f"{self.name}.{port_name}.p", f"{self.name}.state"),
|
|
||||||
role="effort",
|
|
||||||
value=self.get_port(port_name).p - pressure,
|
|
||||||
)
|
|
||||||
for port_name in ("port_1", "port_2", "port_3", "port_4")
|
|
||||||
)
|
|
||||||
@@ -1,282 +1,14 @@
|
|||||||
# 元件建模规范与示例
|
# 元件开发示例
|
||||||
|
|
||||||
规范的权威版本位于
|
权威规则见 [组件模型建模规范](../../../docs/standard/component-model-authoring-spec-v1.md)。当前模型采用 Python 声明、C 数值实现。
|
||||||
[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
|
|
||||||
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
|
|
||||||
修改中同步,不能让示例形成另一套规则。
|
|
||||||
|
|
||||||
本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前
|
以气瓶为例:
|
||||||
`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库,
|
|
||||||
不要继续堆放在 `experimental` 中。
|
|
||||||
|
|
||||||
目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML
|
1. 在 [cylinder.py](experimental/storage/cylinder.py) 声明 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、`DISPLAY` 和 `create()`。
|
||||||
校验、求解器和前端分别维护同一份含义。
|
2. 构造函数调用 `set_parameter_values()`、`register_declared_port()`,保存介质选择和容积。不要在 Python 中计算密度、内能或状态导数。
|
||||||
|
3. 通过 `EQUATIONS` 声明端口压力与气瓶状态之间的约束;只保存变量名和关系。
|
||||||
|
4. 在 [extended.py](../native_codegen/extended.py) 分配状态及输出位置,生成 `native_medium_init()` 初始化调用和气瓶质量/能量导数计算。
|
||||||
|
5. 公共物性和数值公式由 [kernels.c](../../../native/components/kernels.c) 实现,积分和事件由 `native/runtime/` 处理。
|
||||||
|
6. 加入组件库 `library.py` 及 C 版本白名单,验证目录/XML 合同、边界输入、逆流、守恒、RK45/BDF 和输出键。
|
||||||
|
|
||||||
## 一、元件类必须声明的内容
|
新增模型的参考值应来自独立解析结果、外部可信结果或已有冻结基准;不恢复第二套 Python 数值实现。
|
||||||
|
|
||||||
每个对外注册的元件类至少需要声明以下六个类属性:
|
|
||||||
|
|
||||||
```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/standard/component-library-spec-v1.md)。
|
|
||||||
@@ -1,121 +1,34 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from math import sqrt
|
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
|
||||||
from app.simulation.core.metadata import ParameterDefinition
|
from app.simulation.core.metadata import ParameterDefinition
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
class Orifice(AlgebraicComponent):
|
class Orifice(AlgebraicComponent):
|
||||||
"""Python port of ModelicaModels.Myorifice."""
|
"""Python port of ModelicaModels.Myorifice."""
|
||||||
|
MODEL_TYPE = 'orifice'
|
||||||
MODEL_TYPE = "orifice"
|
MODEL_VERSION = '1.0.0'
|
||||||
MODEL_VERSION = "1.0.0"
|
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'), PortDefinition.pneumatic('port_b', nominal_role='outlet'))
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
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))
|
||||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
|
||||||
("mass_flow_balance",)
|
|
||||||
)
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
|
||||||
)
|
|
||||||
PARAMETERS = (
|
|
||||||
ParameterDefinition(
|
|
||||||
"K",
|
|
||||||
1e-5,
|
|
||||||
label="流量系数",
|
|
||||||
quantity="flow_coefficient",
|
|
||||||
unit="kg/(s*Pa^0.5)",
|
|
||||||
minimum=0.0,
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"opening",
|
|
||||||
1.0,
|
|
||||||
label="开度",
|
|
||||||
minimum=0.0,
|
|
||||||
maximum=1.0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
DISPLAY = ComponentDisplaySpec(
|
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)
|
||||||
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)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values({"K": K, "opening": opening})
|
self.set_parameter_values({'K': K, 'opening': opening})
|
||||||
self.opening = opening
|
self.opening = opening
|
||||||
self.K = K
|
self.K = K
|
||||||
self.port_a = self.register_declared_port("port_a")
|
self.port_a = self.register_declared_port('port_a')
|
||||||
self.port_b = self.register_declared_port("port_b")
|
self.port_b = self.register_declared_port('port_b')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Orifice:
|
||||||
cls,
|
return cls(name=name, opening=parameters['opening'], K=parameters['K'])
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> Orifice:
|
|
||||||
return cls(
|
|
||||||
name=name,
|
|
||||||
opening=parameters["opening"],
|
|
||||||
K=parameters["K"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def K_eff(self) -> float:
|
def K_eff(self) -> float:
|
||||||
return self.K * max(self.opening, 0.001)
|
return self.K * max(self.opening, 0.001)
|
||||||
|
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'})
|
||||||
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"]
|
|
||||||
@@ -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,106 +1,25 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from math import pi
|
from math import pi
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
|
||||||
from app.simulation.core.metadata import ParameterDefinition
|
from app.simulation.core.metadata import ParameterDefinition
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
class ResistivePipe(AlgebraicComponent):
|
class ResistivePipe(AlgebraicComponent):
|
||||||
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
|
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
|
||||||
|
MODEL_TYPE = 'pipe'
|
||||||
MODEL_TYPE = "pipe"
|
MODEL_VERSION = '1.0.0'
|
||||||
MODEL_VERSION = "1.0.0"
|
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'), PortDefinition.pneumatic('port_b', nominal_role='outlet'))
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
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))
|
||||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
|
||||||
("mass_flow_balance",)
|
|
||||||
)
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
|
||||||
)
|
|
||||||
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 = ()
|
RESULT_VARIABLES = ()
|
||||||
DISPLAY = ComponentDisplaySpec(
|
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)
|
||||||
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__(
|
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:
|
||||||
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)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values(
|
self.set_parameter_values({'length': L, 'diameter': D, 'lambda_darcy': lambda_darcy, 'p0': p0, 'T0': T0})
|
||||||
{
|
|
||||||
"length": L,
|
|
||||||
"diameter": D,
|
|
||||||
"lambda_darcy": lambda_darcy,
|
|
||||||
"p0": p0,
|
|
||||||
"T0": T0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.medium = medium
|
self.medium = medium
|
||||||
self.L = L
|
self.L = L
|
||||||
self.D = D
|
self.D = D
|
||||||
@@ -108,82 +27,10 @@ class ResistivePipe(AlgebraicComponent):
|
|||||||
self.p0 = p0
|
self.p0 = p0
|
||||||
self.T0 = T0
|
self.T0 = T0
|
||||||
self.area = pi * D * D / 4.0
|
self.area = pi * D * D / 4.0
|
||||||
initial_h = medium.specific_enthalpy(T0)
|
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_a.p = p0
|
|
||||||
self.port_a.h_outflow = initial_h
|
|
||||||
|
|
||||||
self.port_b = self.register_declared_port("port_b")
|
|
||||||
self.port_b.p = p0
|
|
||||||
self.port_b.h_outflow = initial_h
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> ResistivePipe:
|
||||||
cls,
|
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'})
|
||||||
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"]
|
|
||||||
@@ -1,270 +1,28 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
class Tee(AlgebraicComponent):
|
class Tee(AlgebraicComponent):
|
||||||
"""Python port of ModelicaModels.Mytee."""
|
"""Python port of ModelicaModels.Mytee."""
|
||||||
|
MODEL_TYPE = 'tee'
|
||||||
MODEL_TYPE = "tee"
|
MODEL_VERSION = '1.0.0'
|
||||||
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'))
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
|
||||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
|
||||||
("mass_flow_balance",)
|
|
||||||
)
|
|
||||||
PORTS = (
|
|
||||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
|
||||||
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
|
|
||||||
)
|
|
||||||
PARAMETERS = ()
|
PARAMETERS = ()
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
DISPLAY = ComponentDisplaySpec(
|
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)
|
||||||
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:
|
def __init__(self, name: str) -> None:
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.set_parameter_values({})
|
self.set_parameter_values({})
|
||||||
self.port_in = self.register_declared_port("port_in")
|
self.port_in = self.register_declared_port('port_in')
|
||||||
self.port_out1 = self.register_declared_port("port_out1")
|
self.port_out1 = self.register_declared_port('port_out1')
|
||||||
self.port_out2 = self.register_declared_port("port_out2")
|
self.port_out2 = self.register_declared_port('port_out2')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tee:
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> Tee:
|
|
||||||
return cls(name=name)
|
return cls(name=name)
|
||||||
|
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'})
|
||||||
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)
|
|
||||||
@@ -1,155 +1,29 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
ParameterDefinition,
|
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
|
||||||
)
|
|
||||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
from app.simulation.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
class Cylinder(ThermodynamicVolumeComponent):
|
class Cylinder(ThermodynamicVolumeComponent):
|
||||||
"""Python port of ModelicaModels.Mycylinder."""
|
"""Python port of ModelicaModels.Mycylinder."""
|
||||||
|
MODEL_TYPE = 'cylinder'
|
||||||
MODEL_TYPE = "cylinder"
|
MODEL_VERSION = '1.0.0'
|
||||||
MODEL_VERSION = "1.0.0"
|
PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet'),)
|
||||||
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', 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))
|
||||||
PARAMETERS = (
|
|
||||||
ParameterDefinition(
|
|
||||||
"volume",
|
|
||||||
0.01,
|
|
||||||
label="容积",
|
|
||||||
quantity="volume",
|
|
||||||
unit="m3",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"p0",
|
|
||||||
35e6,
|
|
||||||
label="初始压力",
|
|
||||||
quantity="pressure",
|
|
||||||
unit="Pa",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"T0",
|
|
||||||
300.0,
|
|
||||||
label="初始温度",
|
|
||||||
quantity="temperature",
|
|
||||||
unit="K",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
DISPLAY = ComponentDisplaySpec(
|
DISPLAY = ComponentDisplaySpec(label='气瓶', library_id='experimental', category_id='storage', symbol='cylinder', ports=(PortDisplaySpec('port_b', 'right'),), order=10)
|
||||||
label="气瓶",
|
|
||||||
library_id="experimental",
|
|
||||||
category_id="storage",
|
|
||||||
symbol="cylinder",
|
|
||||||
ports=(PortDisplaySpec("port_b", "right"),),
|
|
||||||
order=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, name: str, medium: IdealGasMedium, V: float=0.01, p0: float=35000000.0, T0: float=300.0) -> None:
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
V: float = 0.01,
|
|
||||||
p0: float = 35e6,
|
|
||||||
T0: float = 300.0,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(name=name)
|
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.medium = medium
|
||||||
self.V = V
|
self.V = V
|
||||||
m0 = p0 * V / (medium.R_gas * T0)
|
self.port_b = self.register_declared_port('port_b')
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
self.port_b = self.register_declared_port("port_b")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Cylinder:
|
||||||
cls,
|
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'},)
|
||||||
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)
|
|
||||||
@@ -1,155 +1,29 @@
|
|||||||
|
"""Component parameters, ports and output definitions; numerical equations execute in C."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
ParameterDefinition,
|
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
|
||||||
)
|
|
||||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
from app.simulation.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
class Tank(ThermodynamicVolumeComponent):
|
class Tank(ThermodynamicVolumeComponent):
|
||||||
"""Python port of ModelicaModels.Mytank."""
|
"""Python port of ModelicaModels.Mytank."""
|
||||||
|
MODEL_TYPE = 'tank'
|
||||||
MODEL_TYPE = "tank"
|
MODEL_VERSION = '1.0.0'
|
||||||
MODEL_VERSION = "1.0.0"
|
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'),)
|
||||||
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', 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))
|
||||||
PARAMETERS = (
|
|
||||||
ParameterDefinition(
|
|
||||||
"volume",
|
|
||||||
0.1,
|
|
||||||
label="容积",
|
|
||||||
quantity="volume",
|
|
||||||
unit="m3",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"p0",
|
|
||||||
1e5,
|
|
||||||
label="初始压力",
|
|
||||||
quantity="pressure",
|
|
||||||
unit="Pa",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
),
|
|
||||||
ParameterDefinition(
|
|
||||||
"T0",
|
|
||||||
300.0,
|
|
||||||
label="初始温度",
|
|
||||||
quantity="temperature",
|
|
||||||
unit="K",
|
|
||||||
minimum=0.0,
|
|
||||||
minimum_exclusive=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
DISPLAY = ComponentDisplaySpec(
|
DISPLAY = ComponentDisplaySpec(label='贮箱', library_id='experimental', category_id='storage', symbol='tank', ports=(PortDisplaySpec('port_a', 'left'),), order=20)
|
||||||
label="贮箱",
|
|
||||||
library_id="experimental",
|
|
||||||
category_id="storage",
|
|
||||||
symbol="tank",
|
|
||||||
ports=(PortDisplaySpec("port_a", "left"),),
|
|
||||||
order=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, name: str, medium: IdealGasMedium, V: float=0.1, p0: float=100000.0, T0: float=300.0) -> None:
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
V: float = 0.1,
|
|
||||||
p0: float = 1e5,
|
|
||||||
T0: float = 300.0,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(name=name)
|
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.medium = medium
|
||||||
self.V = V
|
self.V = V
|
||||||
m0 = p0 * V / (medium.R_gas * T0)
|
self.port_a = self.register_declared_port('port_a')
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
self.port_a = self.register_declared_port("port_a")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tank:
|
||||||
cls,
|
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'},)
|
||||||
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)
|
|
||||||
@@ -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
|
||||||
+28
-267
@@ -1,40 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from abc import ABC
|
||||||
from abc import ABC, abstractmethod
|
from collections.abc import Mapping
|
||||||
from collections.abc import Callable, Mapping
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
|
||||||
|
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||||
from app.simulation.core.equations import EquationResidual
|
from app.simulation.core.equations import EquationDefinition
|
||||||
from app.simulation.core.metadata import (
|
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, ResultVariableMetadata, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
ParameterDefinition,
|
|
||||||
ResultVariableDefinition,
|
|
||||||
ResultVariableMetadata,
|
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
|
||||||
)
|
|
||||||
from app.simulation.core.ports import PortDefinition, PortState
|
from app.simulation.core.ports import PortDefinition, PortState
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.simulation.core.medium import GasMedium
|
from app.simulation.core.medium import GasMedium
|
||||||
|
|
||||||
|
|
||||||
class Component(ABC):
|
class Component(ABC):
|
||||||
MODEL_TYPE: ClassVar[str | None] = None
|
MODEL_TYPE: ClassVar[str | None] = None
|
||||||
MODEL_VERSION: ClassVar[str | None] = None
|
MODEL_VERSION: ClassVar[str | None] = None
|
||||||
# ``True`` means that pressure/flow residuals read values written by
|
|
||||||
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
|
|
||||||
# ``False`` is an explicit promise that those residuals are independent of
|
|
||||||
# stream propagation. ``None`` keeps custom components conservative: when
|
|
||||||
# they override either stream hook, the closure planner retains the legacy
|
|
||||||
# full-network thermofluid fixed point.
|
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
|
|
||||||
# Exact residual suffixes whose declared variables are summed, in order,
|
|
||||||
# to form a ``sumToZero`` flow equation. The causal solver deliberately
|
|
||||||
# reads this capability from the concrete class ``__dict__``: subclasses
|
|
||||||
# must repeat the promise after changing any equation semantics.
|
|
||||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
|
|
||||||
frozenset[str]
|
|
||||||
] = frozenset()
|
|
||||||
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||||
@@ -52,80 +29,53 @@ class Component(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def port_definitions(self) -> tuple[PortDefinition, ...]:
|
def port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||||
return tuple(
|
return tuple((port.definition for port in self._ports.values() if port.definition is not None))
|
||||||
port.definition
|
|
||||||
for port in self._ports.values()
|
|
||||||
if port.definition is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def active_port_definitions_for_parameters(
|
def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]:
|
||||||
cls,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> tuple[PortDefinition, ...]:
|
|
||||||
"""Declared ports enabled by one normalized parameter set."""
|
"""Declared ports enabled by one normalized parameter set."""
|
||||||
|
|
||||||
return cls.PORTS
|
return cls.PORTS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
|
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||||
"""Instance ports that participate in execution and result reporting."""
|
"""Instance ports that participate in execution and result reporting."""
|
||||||
|
|
||||||
return self.port_definitions
|
return self.port_definitions
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_connection_ports(self) -> tuple[str, ...]:
|
def required_connection_ports(self) -> tuple[str, ...]:
|
||||||
"""Physical ports that must have an external connection before simulation."""
|
"""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'))
|
||||||
return tuple(
|
|
||||||
definition.name
|
|
||||||
for definition in self.active_port_definitions
|
|
||||||
if definition.kind == "physical"
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_port(self, port: PortState) -> PortState:
|
def register_port(self, port: PortState) -> PortState:
|
||||||
definition = port.definition
|
definition = port.definition
|
||||||
if definition is None:
|
if definition is None:
|
||||||
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:
|
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
|
self._ports[definition.name] = port
|
||||||
return port
|
return port
|
||||||
|
|
||||||
def register_declared_port(self, name: str) -> PortState:
|
def register_declared_port(self, name: str) -> PortState:
|
||||||
try:
|
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:
|
except StopIteration as exc:
|
||||||
raise ValueError(
|
raise ValueError(f'Component model {self.model_type} does not declare port {name}.') from exc
|
||||||
f"Component model {self.model_type} does not declare port {name}."
|
|
||||||
) from exc
|
|
||||||
return self.register_port(PortState(definition=definition))
|
return self.register_port(PortState(definition=definition))
|
||||||
|
|
||||||
def set_parameter_values(self, values: Mapping[str, float]) -> None:
|
def set_parameter_values(self, values: Mapping[str, float]) -> None:
|
||||||
definitions = {definition.name: definition for definition in self.PARAMETERS}
|
definitions = {definition.name: definition for definition in self.PARAMETERS}
|
||||||
unknown = sorted(set(values) - set(definitions))
|
unknown = sorted(set(values) - set(definitions))
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(
|
raise ValueError(f'Component {self.name} contains unsupported parameters: ' + ', '.join(unknown) + '.')
|
||||||
f"Component {self.name} contains unsupported parameters: "
|
|
||||||
+ ", ".join(unknown)
|
|
||||||
+ "."
|
|
||||||
)
|
|
||||||
missing = sorted(set(definitions) - set(values))
|
missing = sorted(set(definitions) - set(values))
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(
|
raise ValueError(f'Component {self.name} is missing parameters: ' + ', '.join(missing) + '.')
|
||||||
f"Component {self.name} is missing parameters: "
|
|
||||||
+ ", ".join(missing)
|
|
||||||
+ "."
|
|
||||||
)
|
|
||||||
|
|
||||||
resolved: dict[str, float] = {}
|
resolved: dict[str, float] = {}
|
||||||
for name, definition in definitions.items():
|
for name, definition in definitions.items():
|
||||||
value = float(values[name])
|
value = float(values[name])
|
||||||
message = definition.validation_message(value)
|
message = definition.validation_message(value)
|
||||||
if message is not None:
|
if message is not None:
|
||||||
raise ValueError(
|
raise ValueError(f"Parameter '{name}' on component '{self.name}' {message}.")
|
||||||
f"Parameter '{name}' on component '{self.name}' {message}."
|
|
||||||
)
|
|
||||||
resolved[name] = value
|
resolved[name] = value
|
||||||
self._parameter_values = resolved
|
self._parameter_values = resolved
|
||||||
|
|
||||||
@@ -137,229 +87,40 @@ class Component(ABC):
|
|||||||
try:
|
try:
|
||||||
return self._ports[name]
|
return self._ports[name]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise ValueError(f"Component {self.name} has no port named {name}.") from 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.active_port_definitions:
|
|
||||||
port = self.get_port(port_definition.name)
|
|
||||||
for variable in port_definition.variables:
|
|
||||||
if not variable.result_visible:
|
|
||||||
continue
|
|
||||||
values[f"{port_definition.name}.{variable.name}"] = float(
|
|
||||||
getattr(port, variable.name)
|
|
||||||
)
|
|
||||||
return values
|
|
||||||
|
|
||||||
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
||||||
metadata = [
|
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]
|
||||||
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 port_definition in self.active_port_definitions:
|
||||||
for variable in port_definition.variables:
|
for variable in port_definition.variables:
|
||||||
if not variable.result_visible:
|
if not variable.result_visible:
|
||||||
continue
|
continue
|
||||||
metadata.append(
|
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))
|
||||||
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)
|
return tuple(metadata)
|
||||||
|
|
||||||
def parameter_interface_dicts(self) -> list[dict[str, object]]:
|
def parameter_interface_dicts(self) -> list[dict[str, object]]:
|
||||||
return [
|
return [definition.as_interface_dict(value=self._parameter_values.get(definition.name)) for definition in self.PARAMETERS]
|
||||||
definition.as_interface_dict(
|
|
||||||
value=self._parameter_values.get(definition.name)
|
|
||||||
)
|
|
||||||
for definition in self.PARAMETERS
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> Component:
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
medium: GasMedium,
|
|
||||||
parameters: Mapping[str, float],
|
|
||||||
) -> Component:
|
|
||||||
"""Create a catalog model from normalized SI parameters."""
|
"""Create a catalog model from normalized SI parameters."""
|
||||||
|
raise NotImplementedError(f'Component model {cls.__name__} must implement create().')
|
||||||
|
EQUATIONS = ()
|
||||||
|
|
||||||
raise NotImplementedError(
|
def equation_definitions(self):
|
||||||
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 pressure_flow_equation_values(self) -> tuple[float, ...]:
|
|
||||||
"""Return live residual values in the declared equation order.
|
|
||||||
|
|
||||||
Components with frequently evaluated equations can override this
|
|
||||||
method to avoid rebuilding immutable equation metadata during closure.
|
|
||||||
The default keeps third-party components compatible with the public
|
|
||||||
residual API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return tuple(
|
|
||||||
float(equation.value)
|
|
||||||
for equation in self.pressure_flow_equation_residuals()
|
|
||||||
)
|
|
||||||
|
|
||||||
def pressure_flow_equation_value_readers(
|
|
||||||
self,
|
|
||||||
) -> Mapping[str, Callable[[], float]]:
|
|
||||||
"""Return explicitly separable scalar residual readers.
|
|
||||||
|
|
||||||
The solver consumes this optional capability only when the concrete
|
|
||||||
component class declares the method itself. Subclasses therefore
|
|
||||||
cannot accidentally inherit an equation-purity promise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
|
||||||
"""Update connector outflow properties from current flow directions."""
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update_flow_temperature_references(
|
|
||||||
self,
|
|
||||||
connected_h: Mapping[str, float],
|
|
||||||
) -> None:
|
|
||||||
"""Update enthalpy references used only by pressure-flow laws.
|
|
||||||
|
|
||||||
Most components use the normal stream enthalpy for both energy
|
|
||||||
transport and upstream-property evaluation. AMESim node submodels can
|
|
||||||
expose a distinct temperature reference, so the default is a no-op.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
|
|
||||||
"""Return directed ``volume``/``volume_flow`` values by pneumatic port.
|
|
||||||
|
|
||||||
Most pneumatic components contribute no external chamber volume. Moving
|
|
||||||
boundaries such as PNRP17 override this hook; the network resolver then
|
|
||||||
propagates the pair to the component connected at the same physical port.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
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):
|
class DynamicComponent(Component):
|
||||||
state_size = 2
|
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):
|
class ThermodynamicVolumeComponent(DynamicComponent):
|
||||||
"""Two-state gas volume exposing the shared thermodynamic result contract."""
|
"""Two-state gas volume exposing the shared thermodynamic result contract."""
|
||||||
|
|
||||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
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):
|
class AlgebraicComponent(Component):
|
||||||
"""Stateless element described by algebraic constraints only."""
|
"""Stateless element described by algebraic constraints only."""
|
||||||
@@ -11,15 +11,14 @@ EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class EquationResidual:
|
class EquationDefinition:
|
||||||
"""One executable scalar equation in the pressure-flow subsystem."""
|
"""One declarative equation in the compiled model interface."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
owner: EquationOwner
|
owner: EquationOwner
|
||||||
owner_id: str
|
owner_id: str
|
||||||
relation: EquationRelation
|
relation: EquationRelation
|
||||||
variables: tuple[str, ...]
|
variables: tuple[str, ...]
|
||||||
value: float
|
|
||||||
role: VariableRole | None = None
|
role: VariableRole | None = None
|
||||||
|
|
||||||
def as_definition_dict(self) -> dict[str, object]:
|
def as_definition_dict(self) -> dict[str, object]:
|
||||||
@@ -33,4 +32,4 @@ class EquationResidual:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def as_interface_dict(self) -> dict[str, object]:
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
return {**self.as_definition_dict(), "residual": self.value}
|
return self.as_definition_dict()
|
||||||
@@ -1,378 +1,16 @@
|
|||||||
|
"""Compile-time gas property constants. No Python property evaluator."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import isfinite
|
|
||||||
from typing import Protocol, Sequence
|
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
|
||||||
from app.simulation.performance import profile_property
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ThermodynamicProperties:
|
|
||||||
p: float
|
|
||||||
T: float
|
|
||||||
rho: float
|
|
||||||
u: float
|
|
||||||
h: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ThermodynamicPropertyTangents:
|
|
||||||
"""Directional derivatives of a recovered thermodynamic state."""
|
|
||||||
|
|
||||||
p: tuple[float, ...]
|
|
||||||
T: tuple[float, ...]
|
|
||||||
rho: tuple[float, ...]
|
|
||||||
u: tuple[float, ...]
|
|
||||||
h: tuple[float, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def width(self) -> int:
|
|
||||||
return len(self.p)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
|
|
||||||
values = (0.0,) * width
|
|
||||||
return cls(p=values, T=values, rho=values, u=values, h=values)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ThermodynamicPropertiesLinearization:
|
|
||||||
"""Primal properties and a validity-checked directional linearization."""
|
|
||||||
|
|
||||||
properties: ThermodynamicProperties
|
|
||||||
tangents: ThermodynamicPropertyTangents
|
|
||||||
valid: bool = True
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class GasMedium(Protocol):
|
|
||||||
"""Thermodynamic contract required by pneumatic components.
|
|
||||||
|
|
||||||
``IdealGasMedium`` is the default implementation. Keeping the component
|
|
||||||
boundary structural allows a later helium/Peng-Robinson implementation to
|
|
||||||
be registered without changing every AMESim component constructor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
R_gas: float
|
|
||||||
cp_ref: float
|
|
||||||
T_ref: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cv(self) -> float: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gamma(self) -> float: ...
|
|
||||||
|
|
||||||
def cp_at_temperature(self, T: float) -> float: ...
|
|
||||||
|
|
||||||
def cv_at_temperature(self, T: float) -> float: ...
|
|
||||||
|
|
||||||
def density(self, p: float, T: float) -> float: ...
|
|
||||||
|
|
||||||
def isentropic_density_pressure_factor(
|
|
||||||
self,
|
|
||||||
p: float,
|
|
||||||
T: float,
|
|
||||||
downstream_pressure: float | None = None,
|
|
||||||
) -> float: ...
|
|
||||||
|
|
||||||
def dynamic_viscosity(self, T: float) -> float: ...
|
|
||||||
|
|
||||||
def diagnostic_dynamic_viscosity(self, T: float) -> float: ...
|
|
||||||
|
|
||||||
def specific_internal_energy(self, T: float) -> float: ...
|
|
||||||
|
|
||||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ...
|
|
||||||
|
|
||||||
def specific_enthalpy(self, T: float) -> float: ...
|
|
||||||
|
|
||||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: ...
|
|
||||||
|
|
||||||
def temperature_from_internal_energy(self, u: float) -> float: ...
|
|
||||||
|
|
||||||
def temperature_from_enthalpy(self, h: float) -> float: ...
|
|
||||||
|
|
||||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: ...
|
|
||||||
|
|
||||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: ...
|
|
||||||
|
|
||||||
def pressure(self, m: float, T: float, V: float) -> float: ...
|
|
||||||
|
|
||||||
def properties_from_mU(
|
|
||||||
self,
|
|
||||||
m: float,
|
|
||||||
U: float,
|
|
||||||
V: float,
|
|
||||||
) -> ThermodynamicProperties: ...
|
|
||||||
|
|
||||||
def linearize_properties_from_mU(
|
|
||||||
self,
|
|
||||||
m: float,
|
|
||||||
U: float,
|
|
||||||
V: float,
|
|
||||||
dm: Sequence[float],
|
|
||||||
dU: Sequence[float],
|
|
||||||
dV: Sequence[float],
|
|
||||||
*,
|
|
||||||
properties: ThermodynamicProperties | None = None,
|
|
||||||
) -> ThermodynamicPropertiesLinearization: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class IdealGasMedium:
|
class IdealGasMedium:
|
||||||
"""Temperature-dependent ideal-gas air approximation.
|
name: str = 'SimpleAirApprox'
|
||||||
|
|
||||||
This is still not a strict clone of `Modelica.Media.Air.SimpleAir`.
|
|
||||||
The small linear `cp(T)` term is kept configurable for calibration, but the
|
|
||||||
current default is calibrated against the committed Testmodel baseline and
|
|
||||||
therefore falls back to the constant-heat-capacity limit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = "SimpleAirApprox"
|
|
||||||
R_gas: float = 287.0
|
R_gas: float = 287.0
|
||||||
cp_ref: float = 1005.0
|
cp_ref: float = 1005.0
|
||||||
T_ref: float = 300.0
|
T_ref: float = 300.0
|
||||||
cp_slope: float = 0.0
|
cp_slope: float = 0.0
|
||||||
viscosity_ref: float = 1.82e-5
|
viscosity_ref: float = 1.82e-05
|
||||||
viscosity_T_ref: float = 293.15
|
viscosity_T_ref: float = 293.15
|
||||||
sutherland_constant: float = 110.4
|
sutherland_constant: float = 110.4
|
||||||
|
|
||||||
@property
|
GasMedium = IdealGasMedium
|
||||||
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
|
|
||||||
|
|
||||||
@profile_property("density")
|
|
||||||
def density(self, p: float, T: float) -> float:
|
|
||||||
return p / (self.R_gas * T)
|
|
||||||
|
|
||||||
@profile_property("isentropic_density_pressure_factor")
|
|
||||||
def isentropic_density_pressure_factor(
|
|
||||||
self,
|
|
||||||
p: float,
|
|
||||||
T: float,
|
|
||||||
downstream_pressure: float | None = None,
|
|
||||||
) -> float:
|
|
||||||
del p
|
|
||||||
del downstream_pressure
|
|
||||||
cp = self.cp_at_temperature(T)
|
|
||||||
cv = self.cv_at_temperature(T)
|
|
||||||
return cv / cp
|
|
||||||
|
|
||||||
@profile_property("dynamic_viscosity")
|
|
||||||
def dynamic_viscosity(self, T: float) -> float:
|
|
||||||
"""Return dynamic viscosity using the default air Sutherland law."""
|
|
||||||
|
|
||||||
if T <= 0.0:
|
|
||||||
raise ValueError("Temperature must be positive.")
|
|
||||||
return (
|
|
||||||
self.viscosity_ref
|
|
||||||
* (T / self.viscosity_T_ref) ** 1.5
|
|
||||||
* (self.viscosity_T_ref + self.sutherland_constant)
|
|
||||||
/ (T + self.sutherland_constant)
|
|
||||||
)
|
|
||||||
|
|
||||||
def diagnostic_dynamic_viscosity(self, T: float) -> float:
|
|
||||||
"""Return the viscosity convention used by derived diagnostics.
|
|
||||||
|
|
||||||
Most media use the same transport property for dynamics and reported
|
|
||||||
diagnostics. Reference-library media may override this without
|
|
||||||
changing a calibrated constitutive flow relation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.dynamic_viscosity(T)
|
|
||||||
|
|
||||||
@profile_property("specific_internal_energy")
|
|
||||||
def specific_internal_energy(self, T: float) -> float:
|
|
||||||
delta_T = T - self.T_ref
|
|
||||||
return (
|
|
||||||
self.cv * self.T_ref
|
|
||||||
+ self.cv * delta_T
|
|
||||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
|
||||||
)
|
|
||||||
|
|
||||||
@profile_property("specific_internal_energy_at_pressure")
|
|
||||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
|
||||||
del p
|
|
||||||
return self.specific_internal_energy(T)
|
|
||||||
|
|
||||||
@profile_property("specific_enthalpy")
|
|
||||||
def specific_enthalpy(self, T: float) -> float:
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
@profile_property("specific_enthalpy_at_pressure")
|
|
||||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
|
||||||
del p
|
|
||||||
return self.specific_enthalpy(T)
|
|
||||||
|
|
||||||
def temperature_from_internal_energy(self, u: float) -> float:
|
|
||||||
reference_internal_energy = self.cv * self.T_ref
|
|
||||||
delta_u = u - reference_internal_energy
|
|
||||||
|
|
||||||
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_enthalpy(self, h: float) -> float:
|
|
||||||
reference_enthalpy = self.cp_ref * self.T_ref
|
|
||||||
delta_h = h - reference_enthalpy
|
|
||||||
|
|
||||||
if abs(self.cp_slope) <= 1e-15:
|
|
||||||
return self.T_ref + delta_h / self.cp_ref
|
|
||||||
|
|
||||||
a = 0.5 * self.cp_slope
|
|
||||||
b = self.cp_ref
|
|
||||||
c = -delta_h
|
|
||||||
discriminant = max(b * b - 4.0 * a * c, 0.0)
|
|
||||||
positive_root = (-b + discriminant**0.5) / (2.0 * a)
|
|
||||||
negative_root = (-b - discriminant**0.5) / (2.0 * a)
|
|
||||||
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
|
||||||
return self.T_ref + delta_T
|
|
||||||
|
|
||||||
@profile_property("temperature_from_pressure_enthalpy")
|
|
||||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
|
||||||
del p
|
|
||||||
return self.temperature_from_enthalpy(h)
|
|
||||||
|
|
||||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
|
||||||
if m <= 0.0:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Mass must stay positive when recovering temperature."
|
|
||||||
)
|
|
||||||
return self.temperature_from_internal_energy(U / m)
|
|
||||||
|
|
||||||
def pressure(self, m: float, T: float, V: float) -> float:
|
|
||||||
if V <= 0.0:
|
|
||||||
raise ValueError("Volume must stay positive.")
|
|
||||||
return m * self.R_gas * T / V
|
|
||||||
|
|
||||||
@profile_property("properties_from_mU")
|
|
||||||
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
|
||||||
T = self.temperature_from_mass_internal_energy(m, U)
|
|
||||||
p = self.pressure(m, T, V)
|
|
||||||
rho = m / V
|
|
||||||
u = U / m
|
|
||||||
h = self.specific_enthalpy(T)
|
|
||||||
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
|
||||||
|
|
||||||
def linearize_properties_from_mU(
|
|
||||||
self,
|
|
||||||
m: float,
|
|
||||||
U: float,
|
|
||||||
V: float,
|
|
||||||
dm: Sequence[float],
|
|
||||||
dU: Sequence[float],
|
|
||||||
dV: Sequence[float],
|
|
||||||
*,
|
|
||||||
properties: ThermodynamicProperties | None = None,
|
|
||||||
) -> ThermodynamicPropertiesLinearization:
|
|
||||||
"""Linearize properties_from_mU for several seed directions."""
|
|
||||||
|
|
||||||
dm_values = tuple(float(value) for value in dm)
|
|
||||||
dU_values = tuple(float(value) for value in dU)
|
|
||||||
dV_values = tuple(float(value) for value in dV)
|
|
||||||
if not (len(dm_values) == len(dU_values) == len(dV_values)):
|
|
||||||
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
|
|
||||||
props = properties or self.properties_from_mU(m, U, V)
|
|
||||||
width = len(dm_values)
|
|
||||||
expected_density = m / V
|
|
||||||
expected_internal_energy = U / m
|
|
||||||
if (
|
|
||||||
abs(props.rho - expected_density)
|
|
||||||
> 1.0e-12 * max(abs(expected_density), 1.0)
|
|
||||||
or abs(props.u - expected_internal_energy)
|
|
||||||
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
|
|
||||||
):
|
|
||||||
return ThermodynamicPropertiesLinearization(
|
|
||||||
properties=props,
|
|
||||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
|
||||||
valid=False,
|
|
||||||
reason="properties_primal_mismatch",
|
|
||||||
)
|
|
||||||
if not all(
|
|
||||||
isfinite(value)
|
|
||||||
for values in (dm_values, dU_values, dV_values)
|
|
||||||
for value in values
|
|
||||||
):
|
|
||||||
return ThermodynamicPropertiesLinearization(
|
|
||||||
properties=props,
|
|
||||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
|
||||||
valid=False,
|
|
||||||
reason="non_finite_tangent_input",
|
|
||||||
)
|
|
||||||
|
|
||||||
cv = self.cv_at_temperature(props.T)
|
|
||||||
cp = self.cp_at_temperature(props.T)
|
|
||||||
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
|
|
||||||
return ThermodynamicPropertiesLinearization(
|
|
||||||
properties=props,
|
|
||||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
|
||||||
valid=False,
|
|
||||||
reason="non_positive_heat_capacity",
|
|
||||||
)
|
|
||||||
|
|
||||||
drho: list[float] = []
|
|
||||||
du: list[float] = []
|
|
||||||
dT: list[float] = []
|
|
||||||
dp: list[float] = []
|
|
||||||
dh: list[float] = []
|
|
||||||
for mass_tangent, energy_tangent, volume_tangent in zip(
|
|
||||||
dm_values,
|
|
||||||
dU_values,
|
|
||||||
dV_values,
|
|
||||||
strict=True,
|
|
||||||
):
|
|
||||||
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
|
|
||||||
internal_energy_tangent = (
|
|
||||||
energy_tangent / m - U * mass_tangent / (m * m)
|
|
||||||
)
|
|
||||||
temperature_tangent = internal_energy_tangent / cv
|
|
||||||
pressure_tangent = self.R_gas * (
|
|
||||||
props.T * density_tangent + props.rho * temperature_tangent
|
|
||||||
)
|
|
||||||
enthalpy_tangent = cp * temperature_tangent
|
|
||||||
drho.append(density_tangent)
|
|
||||||
du.append(internal_energy_tangent)
|
|
||||||
dT.append(temperature_tangent)
|
|
||||||
dp.append(pressure_tangent)
|
|
||||||
dh.append(enthalpy_tangent)
|
|
||||||
|
|
||||||
tangent_values = (*drho, *du, *dT, *dp, *dh)
|
|
||||||
valid = all(isfinite(value) for value in tangent_values)
|
|
||||||
return ThermodynamicPropertiesLinearization(
|
|
||||||
properties=props,
|
|
||||||
tangents=ThermodynamicPropertyTangents(
|
|
||||||
p=tuple(dp),
|
|
||||||
T=tuple(dT),
|
|
||||||
rho=tuple(drho),
|
|
||||||
u=tuple(du),
|
|
||||||
h=tuple(dh),
|
|
||||||
),
|
|
||||||
valid=valid,
|
|
||||||
reason=None if valid else "non_finite_property_tangent",
|
|
||||||
)
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import acos, cos, isfinite, log, pi, sqrt
|
|
||||||
|
|
||||||
from app.simulation.performance import profile_property
|
|
||||||
|
|
||||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
|
||||||
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
|
|
||||||
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
|
|
||||||
PENG_ROBINSON_A_COEFFICIENT = 0.457235583
|
|
||||||
PENG_ROBINSON_B_COEFFICIENT = 0.07779607
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PengRobinsonFluid:
|
|
||||||
"""Pure-fluid Peng-Robinson equation-of-state helper.
|
|
||||||
|
|
||||||
The class covers the equation-of-state layer plus the enthalpy departure
|
|
||||||
needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
molar_mass: float
|
|
||||||
critical_temperature: float
|
|
||||||
critical_pressure: float
|
|
||||||
acentric_factor: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def specific_gas_constant(self) -> float:
|
|
||||||
return UNIVERSAL_GAS_CONSTANT / self.molar_mass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a_parameter(self) -> float:
|
|
||||||
return (
|
|
||||||
PENG_ROBINSON_A_COEFFICIENT
|
|
||||||
* UNIVERSAL_GAS_CONSTANT
|
|
||||||
* UNIVERSAL_GAS_CONSTANT
|
|
||||||
* self.critical_temperature
|
|
||||||
* self.critical_temperature
|
|
||||||
/ self.critical_pressure
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def b_parameter(self) -> float:
|
|
||||||
return (
|
|
||||||
PENG_ROBINSON_B_COEFFICIENT
|
|
||||||
* UNIVERSAL_GAS_CONSTANT
|
|
||||||
* self.critical_temperature
|
|
||||||
/ self.critical_pressure
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kappa(self) -> float:
|
|
||||||
omega = self.acentric_factor
|
|
||||||
return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega
|
|
||||||
|
|
||||||
def alpha(self, temperature: float) -> float:
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
reduced_temperature = temperature / self.critical_temperature
|
|
||||||
return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0
|
|
||||||
|
|
||||||
def alpha_temperature_derivative(self, temperature: float) -> float:
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
reduced_temperature = temperature / self.critical_temperature
|
|
||||||
sqrt_reduced_temperature = sqrt(reduced_temperature)
|
|
||||||
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
|
|
||||||
return -(
|
|
||||||
alpha_base
|
|
||||||
* self.kappa
|
|
||||||
/ (self.critical_temperature * sqrt_reduced_temperature)
|
|
||||||
)
|
|
||||||
|
|
||||||
def alpha_temperature_second_derivative(self, temperature: float) -> float:
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
reduced_temperature = temperature / self.critical_temperature
|
|
||||||
sqrt_reduced_temperature = sqrt(reduced_temperature)
|
|
||||||
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
|
|
||||||
return (
|
|
||||||
self.kappa
|
|
||||||
/ (2.0 * self.critical_temperature * self.critical_temperature)
|
|
||||||
* (
|
|
||||||
self.kappa / reduced_temperature
|
|
||||||
+ alpha_base / (reduced_temperature * sqrt_reduced_temperature)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def attractive_parameter(self, temperature: float) -> float:
|
|
||||||
return self.a_parameter * self.alpha(temperature)
|
|
||||||
|
|
||||||
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
|
|
||||||
return self.a_parameter * self.alpha_temperature_derivative(temperature)
|
|
||||||
|
|
||||||
def attractive_parameter_temperature_second_derivative(
|
|
||||||
self,
|
|
||||||
temperature: float,
|
|
||||||
) -> float:
|
|
||||||
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"pressure_from_molar_volume",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
if molar_volume <= self.b_parameter:
|
|
||||||
raise RecoverableTrialStateError("Molar volume must be larger than Peng-Robinson b parameter.")
|
|
||||||
a_alpha = self.attractive_parameter(temperature)
|
|
||||||
b = self.b_parameter
|
|
||||||
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
|
|
||||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
|
||||||
return repulsive - attractive
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"pressure_from_density",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
|
||||||
if density <= 0.0:
|
|
||||||
raise ValueError("Density must be positive.")
|
|
||||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"pressure_temperature_derivative_at_density",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def pressure_temperature_derivative_at_density(
|
|
||||||
self,
|
|
||||||
temperature: float,
|
|
||||||
density: float,
|
|
||||||
) -> float:
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
if density <= 0.0:
|
|
||||||
raise ValueError("Density must be positive.")
|
|
||||||
molar_volume = self.molar_mass / density
|
|
||||||
if molar_volume <= self.b_parameter:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Molar volume must be larger than Peng-Robinson b parameter."
|
|
||||||
)
|
|
||||||
b = self.b_parameter
|
|
||||||
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
|
|
||||||
return (
|
|
||||||
UNIVERSAL_GAS_CONSTANT / (molar_volume - b)
|
|
||||||
- self.attractive_parameter_temperature_derivative(temperature) / denominator
|
|
||||||
)
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"pressure_density_derivative_at_temperature",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def pressure_density_derivative_at_temperature(
|
|
||||||
self,
|
|
||||||
temperature: float,
|
|
||||||
density: float,
|
|
||||||
) -> float:
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
if density <= 0.0:
|
|
||||||
raise ValueError("Density must be positive.")
|
|
||||||
molar_volume = self.molar_mass / density
|
|
||||||
if molar_volume <= self.b_parameter:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Molar volume must be larger than Peng-Robinson b parameter."
|
|
||||||
)
|
|
||||||
b = self.b_parameter
|
|
||||||
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
|
|
||||||
pressure_molar_volume_derivative = (
|
|
||||||
-UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) ** 2
|
|
||||||
+ self.attractive_parameter(temperature)
|
|
||||||
* 2.0
|
|
||||||
* (molar_volume + b)
|
|
||||||
/ denominator**2
|
|
||||||
)
|
|
||||||
molar_volume_density_derivative = -self.molar_mass / (density * density)
|
|
||||||
return pressure_molar_volume_derivative * molar_volume_density_derivative
|
|
||||||
|
|
||||||
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
|
|
||||||
self._validate_pressure_temperature(pressure, temperature)
|
|
||||||
a_alpha = self.attractive_parameter(temperature)
|
|
||||||
b = self.b_parameter
|
|
||||||
A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature)
|
|
||||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
|
||||||
return A, B
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"compressibility_roots",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
|
||||||
A, B = self.reduced_parameters(pressure, temperature)
|
|
||||||
coefficients = (
|
|
||||||
-(1.0 - B),
|
|
||||||
A - 3.0 * B * B - 2.0 * B,
|
|
||||||
-(A * B - B * B - B * B * B),
|
|
||||||
)
|
|
||||||
roots = _real_cubic_roots(*coefficients)
|
|
||||||
physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root)))
|
|
||||||
if not physical_roots:
|
|
||||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
|
||||||
return physical_roots
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"compressibility_factor",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def compressibility_factor(
|
|
||||||
self,
|
|
||||||
pressure: float,
|
|
||||||
temperature: float,
|
|
||||||
phase: str = "vapor",
|
|
||||||
) -> float:
|
|
||||||
roots = self.compressibility_roots(pressure, temperature)
|
|
||||||
if phase == "vapor":
|
|
||||||
return roots[-1]
|
|
||||||
if phase == "liquid":
|
|
||||||
return roots[0]
|
|
||||||
if phase == "stable-single-root":
|
|
||||||
return roots[-1]
|
|
||||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"molar_volume",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def molar_volume(
|
|
||||||
self,
|
|
||||||
pressure: float,
|
|
||||||
temperature: float,
|
|
||||||
phase: str = "vapor",
|
|
||||||
) -> float:
|
|
||||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
|
||||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
|
||||||
|
|
||||||
@profile_property("density", layer="kernel", minimum_mode="audit")
|
|
||||||
def density(
|
|
||||||
self,
|
|
||||||
pressure: float,
|
|
||||||
temperature: float,
|
|
||||||
phase: str = "vapor",
|
|
||||||
) -> float:
|
|
||||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"residual_specific_enthalpy",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def residual_specific_enthalpy(
|
|
||||||
self,
|
|
||||||
pressure: float,
|
|
||||||
temperature: float,
|
|
||||||
phase: str = "vapor",
|
|
||||||
) -> float:
|
|
||||||
"""Return Peng-Robinson enthalpy departure from ideal gas, J/kg."""
|
|
||||||
self._validate_pressure_temperature(pressure, temperature)
|
|
||||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
|
||||||
_, B = self.reduced_parameters(pressure, temperature)
|
|
||||||
b = self.b_parameter
|
|
||||||
attractive = self.attractive_parameter(temperature)
|
|
||||||
d_attractive_d_temperature = (
|
|
||||||
self.attractive_parameter_temperature_derivative(temperature)
|
|
||||||
)
|
|
||||||
log_argument = (z + (1.0 + sqrt(2.0)) * B) / (
|
|
||||||
z + (1.0 - sqrt(2.0)) * B
|
|
||||||
)
|
|
||||||
residual_molar_enthalpy = (
|
|
||||||
UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0)
|
|
||||||
+ (
|
|
||||||
temperature * d_attractive_d_temperature
|
|
||||||
- attractive
|
|
||||||
)
|
|
||||||
* log(log_argument)
|
|
||||||
/ (2.0 * sqrt(2.0) * b)
|
|
||||||
)
|
|
||||||
return residual_molar_enthalpy / self.molar_mass
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"residual_specific_internal_energy_at_density",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def residual_specific_internal_energy_at_density(
|
|
||||||
self,
|
|
||||||
temperature: float,
|
|
||||||
density: float,
|
|
||||||
) -> float:
|
|
||||||
"""Return Peng-Robinson internal-energy departure, J/kg."""
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
if density <= 0.0:
|
|
||||||
raise ValueError("Density must be positive.")
|
|
||||||
molar_volume = self.molar_mass / density
|
|
||||||
b = self.b_parameter
|
|
||||||
if molar_volume <= b:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Molar volume must be larger than Peng-Robinson b parameter."
|
|
||||||
)
|
|
||||||
attractive = self.attractive_parameter(temperature)
|
|
||||||
d_attractive_d_temperature = (
|
|
||||||
self.attractive_parameter_temperature_derivative(temperature)
|
|
||||||
)
|
|
||||||
log_argument = (
|
|
||||||
molar_volume + (1.0 + sqrt(2.0)) * b
|
|
||||||
) / (
|
|
||||||
molar_volume + (1.0 - sqrt(2.0)) * b
|
|
||||||
)
|
|
||||||
residual_molar_internal_energy = (
|
|
||||||
temperature * d_attractive_d_temperature - attractive
|
|
||||||
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
|
|
||||||
return residual_molar_internal_energy / self.molar_mass
|
|
||||||
|
|
||||||
@profile_property(
|
|
||||||
"residual_isochoric_heat_capacity_at_density",
|
|
||||||
layer="kernel",
|
|
||||||
minimum_mode="audit",
|
|
||||||
)
|
|
||||||
def residual_isochoric_heat_capacity_at_density(
|
|
||||||
self,
|
|
||||||
temperature: float,
|
|
||||||
density: float,
|
|
||||||
) -> float:
|
|
||||||
"""Return the constant-volume heat-capacity departure, J/kg/K."""
|
|
||||||
self._validate_temperature(temperature)
|
|
||||||
if density <= 0.0:
|
|
||||||
raise ValueError("Density must be positive.")
|
|
||||||
molar_volume = self.molar_mass / density
|
|
||||||
b = self.b_parameter
|
|
||||||
if molar_volume <= b:
|
|
||||||
raise RecoverableTrialStateError(
|
|
||||||
"Molar volume must be larger than Peng-Robinson b parameter."
|
|
||||||
)
|
|
||||||
log_argument = (
|
|
||||||
molar_volume + (1.0 + sqrt(2.0)) * b
|
|
||||||
) / (
|
|
||||||
molar_volume + (1.0 - sqrt(2.0)) * b
|
|
||||||
)
|
|
||||||
residual_molar_cv = (
|
|
||||||
temperature
|
|
||||||
* self.attractive_parameter_temperature_second_derivative(temperature)
|
|
||||||
* log(log_argument)
|
|
||||||
/ (2.0 * sqrt(2.0) * b)
|
|
||||||
)
|
|
||||||
return residual_molar_cv / self.molar_mass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_temperature(temperature: float) -> None:
|
|
||||||
if temperature <= 0.0:
|
|
||||||
raise RecoverableTrialStateError("Temperature must be positive.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
|
|
||||||
if pressure <= 0.0:
|
|
||||||
raise RecoverableTrialStateError("Pressure must be positive.")
|
|
||||||
cls._validate_temperature(temperature)
|
|
||||||
|
|
||||||
HELIUM_PR = PengRobinsonFluid(
|
|
||||||
name="helium",
|
|
||||||
molar_mass=0.004002602,
|
|
||||||
critical_temperature=5.1953,
|
|
||||||
critical_pressure=227_460.0,
|
|
||||||
# Simcenter Amesim 2404 helium_eos.data.
|
|
||||||
acentric_factor=-0.382,
|
|
||||||
)
|
|
||||||
|
|
||||||
NITROGEN_PR = PengRobinsonFluid(
|
|
||||||
name="nitrogen",
|
|
||||||
molar_mass=0.0280134,
|
|
||||||
critical_temperature=126.192,
|
|
||||||
critical_pressure=3.3958e6,
|
|
||||||
acentric_factor=0.0372,
|
|
||||||
)
|
|
||||||
|
|
||||||
AIR_PR = PengRobinsonFluid(
|
|
||||||
name="air",
|
|
||||||
molar_mass=0.02896513,
|
|
||||||
critical_temperature=132.5306,
|
|
||||||
critical_pressure=3.786e6,
|
|
||||||
acentric_factor=0.0335,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]:
|
|
||||||
"""Return real roots for x**3 + a*x**2 + b*x + c = 0."""
|
|
||||||
|
|
||||||
depressed_p = b - a * a / 3.0
|
|
||||||
depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c
|
|
||||||
discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0
|
|
||||||
offset = -a / 3.0
|
|
||||||
tolerance = 1e-14
|
|
||||||
|
|
||||||
if discriminant > tolerance:
|
|
||||||
sqrt_discriminant = sqrt(discriminant)
|
|
||||||
u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant)
|
|
||||||
v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant)
|
|
||||||
return (u + v + offset,)
|
|
||||||
|
|
||||||
if abs(discriminant) <= tolerance:
|
|
||||||
u = _real_cube_root(-depressed_q / 2.0)
|
|
||||||
return tuple(sorted({2.0 * u + offset, -u + offset}))
|
|
||||||
|
|
||||||
if depressed_p >= 0.0:
|
|
||||||
raise ValueError("Unexpected cubic state with three real roots and non-negative p.")
|
|
||||||
radius = 2.0 * sqrt(-depressed_p / 3.0)
|
|
||||||
argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p)
|
|
||||||
argument = max(-1.0, min(1.0, argument))
|
|
||||||
theta = acos(argument) / 3.0
|
|
||||||
roots = [
|
|
||||||
radius * cos(theta - 2.0 * pi * index / 3.0) + offset
|
|
||||||
for index in range(3)
|
|
||||||
]
|
|
||||||
return tuple(sorted(roots))
|
|
||||||
|
|
||||||
|
|
||||||
def _real_cube_root(value: float) -> float:
|
|
||||||
if value == 0.0:
|
|
||||||
return 0.0
|
|
||||||
return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0)
|
|
||||||
@@ -1,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,25 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.system import (
|
|
||||||
TestMqlRunConfig,
|
|
||||||
TestMqlSimulationResult,
|
|
||||||
TestMqlSystem,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.run import (
|
|
||||||
PreparedTestMqlRun,
|
|
||||||
TestMqlRunResult,
|
|
||||||
prepare_test_mql_run,
|
|
||||||
run_prepared_test_mql,
|
|
||||||
run_test_mql,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"PreparedTestMqlRun",
|
|
||||||
"TestMqlRunConfig",
|
|
||||||
"TestMqlRunResult",
|
|
||||||
"TestMqlSimulationResult",
|
|
||||||
"TestMqlSystem",
|
|
||||||
"prepare_test_mql_run",
|
|
||||||
"run_prepared_test_mql",
|
|
||||||
"run_test_mql",
|
|
||||||
]
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
|
||||||
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
|
|
||||||
from app.simulation.reporting.test_mql_observations import (
|
|
||||||
TestMqlObservationCatalog,
|
|
||||||
build_test_mql_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_output_schema import (
|
|
||||||
TestMqlOutputSchema,
|
|
||||||
build_test_mql_output_schema,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_output_validation import (
|
|
||||||
TestMqlValidatedOutput,
|
|
||||||
compare_validated_test_mql_output,
|
|
||||||
validate_test_mql_output,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlBaselineRun:
|
|
||||||
amesim_results: AmesimResults
|
|
||||||
observation_catalog: TestMqlObservationCatalog
|
|
||||||
output_schema: TestMqlOutputSchema
|
|
||||||
output: TestMqlValidatedOutput
|
|
||||||
comparison: TestMqlComparisonResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_count(self) -> int:
|
|
||||||
return len(self.output.times)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.output.data_paths)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_baseline_passthrough(
|
|
||||||
archive_path: Path,
|
|
||||||
*,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
|
||||||
) -> TestMqlBaselineRun:
|
|
||||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
|
||||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
|
||||||
output_schema = build_test_mql_output_schema(
|
|
||||||
amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
)
|
|
||||||
selected_paths = tuple(data_paths) if data_paths is not None else output_schema.data_paths()
|
|
||||||
baseline_series = observation_catalog.baseline_series_by_data_path(
|
|
||||||
amesim_results,
|
|
||||||
selected_paths,
|
|
||||||
)
|
|
||||||
output = validate_test_mql_output(
|
|
||||||
times=amesim_results.times,
|
|
||||||
series_by_data_path=baseline_series,
|
|
||||||
schema=output_schema,
|
|
||||||
data_paths=selected_paths,
|
|
||||||
require_all_schema_paths=data_paths is None,
|
|
||||||
)
|
|
||||||
comparison = compare_validated_test_mql_output(
|
|
||||||
times=output.times,
|
|
||||||
series_by_data_path=output.series_by_data_path,
|
|
||||||
schema=output_schema,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=output.data_paths,
|
|
||||||
require_all_schema_paths=data_paths is None,
|
|
||||||
)
|
|
||||||
return TestMqlBaselineRun(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
output_schema=output_schema,
|
|
||||||
output=output,
|
|
||||||
comparison=comparison,
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,468 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.primitives.pneumatic import m3_to_cm3
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
|
|
||||||
from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult
|
|
||||||
from app.simulation.reporting.test_mql_observations import (
|
|
||||||
TestMqlObservationCatalog,
|
|
||||||
build_test_mql_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_output_schema import (
|
|
||||||
TestMqlOutputSchema,
|
|
||||||
build_test_mql_output_schema,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_output_validation import (
|
|
||||||
TestMqlValidatedOutput,
|
|
||||||
compare_validated_test_mql_output,
|
|
||||||
validate_test_mql_output,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog
|
|
||||||
from app.simulation.examples.test_mql.mechanical import (
|
|
||||||
TestMqlMechanicalAssembly,
|
|
||||||
build_test_mql_mechanical_assembly,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.pneumatic import (
|
|
||||||
TestMqlPneumaticAssembly,
|
|
||||||
build_test_mql_pneumatic_assembly,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComputedPistonGeometryRun:
|
|
||||||
amesim_results: AmesimResults
|
|
||||||
observation_catalog: TestMqlObservationCatalog
|
|
||||||
output_schema: TestMqlOutputSchema
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly
|
|
||||||
output: TestMqlValidatedOutput
|
|
||||||
comparison: TestMqlComparisonResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_count(self) -> int:
|
|
||||||
return len(self.output.times)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.output.data_paths)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComputedGeometryRun:
|
|
||||||
amesim_results: AmesimResults
|
|
||||||
observation_catalog: TestMqlObservationCatalog
|
|
||||||
output_schema: TestMqlOutputSchema
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly
|
|
||||||
pneumatic_assembly: TestMqlPneumaticAssembly
|
|
||||||
output: TestMqlValidatedOutput
|
|
||||||
comparison: TestMqlComparisonResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_count(self) -> int:
|
|
||||||
return len(self.output.times)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.output.data_paths)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComputedLineRelationsRun:
|
|
||||||
amesim_results: AmesimResults
|
|
||||||
observation_catalog: TestMqlObservationCatalog
|
|
||||||
output_schema: TestMqlOutputSchema
|
|
||||||
output: TestMqlValidatedOutput
|
|
||||||
comparison: TestMqlComparisonResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_count(self) -> int:
|
|
||||||
return len(self.output.times)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.output.data_paths)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComputedPneumaticRelationsRun:
|
|
||||||
amesim_results: AmesimResults
|
|
||||||
observation_catalog: TestMqlObservationCatalog
|
|
||||||
output_schema: TestMqlOutputSchema
|
|
||||||
output: TestMqlValidatedOutput
|
|
||||||
comparison: TestMqlComparisonResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_count(self) -> int:
|
|
||||||
return len(self.output.times)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.output.data_paths)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComputedMechanicalRelationsRun:
|
|
||||||
amesim_results: AmesimResults
|
|
||||||
observation_catalog: TestMqlObservationCatalog
|
|
||||||
output_schema: TestMqlOutputSchema
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly
|
|
||||||
output: TestMqlValidatedOutput
|
|
||||||
comparison: TestMqlComparisonResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sample_count(self) -> int:
|
|
||||||
return len(self.output.times)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.output.data_paths)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_computed_piston_geometry(
|
|
||||||
archive_path: Path,
|
|
||||||
) -> TestMqlComputedPistonGeometryRun:
|
|
||||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
|
||||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
|
||||||
output_schema = build_test_mql_output_schema(
|
|
||||||
amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
)
|
|
||||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
|
||||||
mechanical_assembly = build_test_mql_mechanical_assembly(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
)
|
|
||||||
output_series = _compute_piston_geometry_series(amesim_results, mechanical_assembly)
|
|
||||||
output_data_paths = tuple(output_series)
|
|
||||||
output = validate_test_mql_output(
|
|
||||||
times=amesim_results.times,
|
|
||||||
series_by_data_path=output_series,
|
|
||||||
schema=output_schema,
|
|
||||||
data_paths=output_data_paths,
|
|
||||||
)
|
|
||||||
comparison = compare_validated_test_mql_output(
|
|
||||||
times=output.times,
|
|
||||||
series_by_data_path=output.series_by_data_path,
|
|
||||||
schema=output_schema,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=output.data_paths,
|
|
||||||
)
|
|
||||||
return TestMqlComputedPistonGeometryRun(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
output_schema=output_schema,
|
|
||||||
mechanical_assembly=mechanical_assembly,
|
|
||||||
output=output,
|
|
||||||
comparison=comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_computed_geometry(
|
|
||||||
archive_path: Path,
|
|
||||||
) -> TestMqlComputedGeometryRun:
|
|
||||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
|
||||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
|
||||||
output_schema = build_test_mql_output_schema(
|
|
||||||
amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
)
|
|
||||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
|
||||||
mechanical_assembly = build_test_mql_mechanical_assembly(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
)
|
|
||||||
pneumatic_assembly = build_test_mql_pneumatic_assembly()
|
|
||||||
output_series = {
|
|
||||||
**_compute_piston_geometry_series(amesim_results, mechanical_assembly),
|
|
||||||
**_compute_variable_chamber_volume_series(
|
|
||||||
amesim_results,
|
|
||||||
mechanical_assembly,
|
|
||||||
pneumatic_assembly,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
output_data_paths = tuple(output_series)
|
|
||||||
output = validate_test_mql_output(
|
|
||||||
times=amesim_results.times,
|
|
||||||
series_by_data_path=output_series,
|
|
||||||
schema=output_schema,
|
|
||||||
data_paths=output_data_paths,
|
|
||||||
)
|
|
||||||
comparison = compare_validated_test_mql_output(
|
|
||||||
times=output.times,
|
|
||||||
series_by_data_path=output.series_by_data_path,
|
|
||||||
schema=output_schema,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=output.data_paths,
|
|
||||||
)
|
|
||||||
return TestMqlComputedGeometryRun(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
output_schema=output_schema,
|
|
||||||
mechanical_assembly=mechanical_assembly,
|
|
||||||
pneumatic_assembly=pneumatic_assembly,
|
|
||||||
output=output,
|
|
||||||
comparison=comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_computed_line_relations(
|
|
||||||
archive_path: Path,
|
|
||||||
) -> TestMqlComputedLineRelationsRun:
|
|
||||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
|
||||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
|
||||||
output_schema = build_test_mql_output_schema(
|
|
||||||
amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
)
|
|
||||||
output_series = _compute_line_reversed_series(amesim_results, observation_catalog)
|
|
||||||
output_data_paths = tuple(output_series)
|
|
||||||
output = validate_test_mql_output(
|
|
||||||
times=amesim_results.times,
|
|
||||||
series_by_data_path=output_series,
|
|
||||||
schema=output_schema,
|
|
||||||
data_paths=output_data_paths,
|
|
||||||
)
|
|
||||||
comparison = compare_validated_test_mql_output(
|
|
||||||
times=output.times,
|
|
||||||
series_by_data_path=output.series_by_data_path,
|
|
||||||
schema=output_schema,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=output.data_paths,
|
|
||||||
)
|
|
||||||
return TestMqlComputedLineRelationsRun(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
output_schema=output_schema,
|
|
||||||
output=output,
|
|
||||||
comparison=comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_computed_pneumatic_relations(
|
|
||||||
archive_path: Path,
|
|
||||||
) -> TestMqlComputedPneumaticRelationsRun:
|
|
||||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
|
||||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
|
||||||
output_schema = build_test_mql_output_schema(
|
|
||||||
amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
)
|
|
||||||
output_series = {
|
|
||||||
**_compute_chamber_duplicate_series(amesim_results, observation_catalog),
|
|
||||||
**_compute_orifice_reversed_series(amesim_results, observation_catalog),
|
|
||||||
}
|
|
||||||
output_data_paths = tuple(output_series)
|
|
||||||
output = validate_test_mql_output(
|
|
||||||
times=amesim_results.times,
|
|
||||||
series_by_data_path=output_series,
|
|
||||||
schema=output_schema,
|
|
||||||
data_paths=output_data_paths,
|
|
||||||
)
|
|
||||||
comparison = compare_validated_test_mql_output(
|
|
||||||
times=output.times,
|
|
||||||
series_by_data_path=output.series_by_data_path,
|
|
||||||
schema=output_schema,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=output.data_paths,
|
|
||||||
)
|
|
||||||
return TestMqlComputedPneumaticRelationsRun(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
output_schema=output_schema,
|
|
||||||
output=output,
|
|
||||||
comparison=comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_computed_mechanical_relations(
|
|
||||||
archive_path: Path,
|
|
||||||
) -> TestMqlComputedMechanicalRelationsRun:
|
|
||||||
amesim_results = load_test_mql_amesim_results(archive_path)
|
|
||||||
observation_catalog = build_test_mql_observation_catalog(amesim_results)
|
|
||||||
output_schema = build_test_mql_output_schema(
|
|
||||||
amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
)
|
|
||||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
|
||||||
mechanical_assembly = build_test_mql_mechanical_assembly(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
)
|
|
||||||
output_series = {
|
|
||||||
**_compute_mass_duplicate_series(amesim_results, mechanical_assembly),
|
|
||||||
**_compute_inactive_mass_force_series(amesim_results, mechanical_assembly),
|
|
||||||
**_compute_zero_force_source_series(amesim_results, mechanical_assembly),
|
|
||||||
}
|
|
||||||
output_data_paths = tuple(output_series)
|
|
||||||
output = validate_test_mql_output(
|
|
||||||
times=amesim_results.times,
|
|
||||||
series_by_data_path=output_series,
|
|
||||||
schema=output_schema,
|
|
||||||
data_paths=output_data_paths,
|
|
||||||
)
|
|
||||||
comparison = compare_validated_test_mql_output(
|
|
||||||
times=output.times,
|
|
||||||
series_by_data_path=output.series_by_data_path,
|
|
||||||
schema=output_schema,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=output.data_paths,
|
|
||||||
)
|
|
||||||
return TestMqlComputedMechanicalRelationsRun(
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
observation_catalog=observation_catalog,
|
|
||||||
output_schema=output_schema,
|
|
||||||
mechanical_assembly=mechanical_assembly,
|
|
||||||
output=output,
|
|
||||||
comparison=comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_piston_geometry_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for alias in sorted(mechanical_assembly.pistons):
|
|
||||||
piston = mechanical_assembly.pistons[alias]
|
|
||||||
geometry = piston.geometry()
|
|
||||||
x4 = amesim_results.series(f"x4@{alias}")
|
|
||||||
x5 = amesim_results.series(f"x5@{alias}")
|
|
||||||
v4 = amesim_results.series(f"v4@{alias}")
|
|
||||||
v5 = amesim_results.series(f"v5@{alias}")
|
|
||||||
series_by_data_path[f"length@{alias}"] = tuple(
|
|
||||||
geometry.chamber_length_mm(port4, port5)
|
|
||||||
for port4, port5 in zip(x4, x5)
|
|
||||||
)
|
|
||||||
series_by_data_path[f"vol1@{alias}"] = tuple(
|
|
||||||
geometry.chamber_volume_cm3(port4, port5)
|
|
||||||
for port4, port5 in zip(x4, x5)
|
|
||||||
)
|
|
||||||
series_by_data_path[f"vvol1@{alias}"] = tuple(
|
|
||||||
geometry.chamber_volume_rate_l_min(port4, port5)
|
|
||||||
for port4, port5 in zip(v4, v5)
|
|
||||||
)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_variable_chamber_volume_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
|
||||||
pneumatic_assembly: TestMqlPneumaticAssembly,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for chamber_alias in sorted(pneumatic_assembly.variable_chambers):
|
|
||||||
chamber = pneumatic_assembly.variable_chambers[chamber_alias]
|
|
||||||
piston_alias = _piston_alias_for_variable_chamber(chamber_alias)
|
|
||||||
piston = mechanical_assembly.pistons[piston_alias]
|
|
||||||
geometry = piston.geometry()
|
|
||||||
x4 = amesim_results.series(f"x4@{piston_alias}")
|
|
||||||
x5 = amesim_results.series(f"x5@{piston_alias}")
|
|
||||||
dead_volume_cm3 = m3_to_cm3(chamber.dead_volume)
|
|
||||||
series_by_data_path[f"vol@{chamber_alias}"] = tuple(
|
|
||||||
dead_volume_cm3 + geometry.chamber_volume_cm3(port4, port5)
|
|
||||||
for port4, port5 in zip(x4, x5)
|
|
||||||
)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _piston_alias_for_variable_chamber(chamber_alias: str) -> str:
|
|
||||||
if not chamber_alias.startswith("pn_c1"):
|
|
||||||
raise ValueError(f"Unexpected PNCH012 alias: {chamber_alias}")
|
|
||||||
return chamber_alias.replace("pn_c1", "pn_brp2", 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_mass_duplicate_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for alias in sorted(mechanical_assembly.masses):
|
|
||||||
for signal_name in ("x1", "v1", "acc1"):
|
|
||||||
source_path = f"{signal_name}@{alias}"
|
|
||||||
duplicate_path = f"{signal_name}dup@{alias}"
|
|
||||||
series_by_data_path[duplicate_path] = tuple(
|
|
||||||
-value for value in amesim_results.series(source_path)
|
|
||||||
)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_inactive_mass_force_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for alias in sorted(mechanical_assembly.masses):
|
|
||||||
mass = mechanical_assembly.masses[alias].endstop()
|
|
||||||
x1 = amesim_results.series(f"x1@{alias}")
|
|
||||||
v1 = amesim_results.series(f"v1@{alias}")
|
|
||||||
series_by_data_path[f"Fmin@{alias}"] = tuple(
|
|
||||||
mass.lower_static_force_magnitude(displacement)
|
|
||||||
for displacement in x1
|
|
||||||
)
|
|
||||||
series_by_data_path[f"Fvisc@{alias}"] = tuple(
|
|
||||||
mass.viscous_friction_force(velocity)
|
|
||||||
for velocity in v1
|
|
||||||
)
|
|
||||||
series_by_data_path[f"Ffric@{alias}"] = tuple(0.0 for _ in x1)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_zero_force_source_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
return {
|
|
||||||
f"fzero@{alias}": tuple(0.0 for _ in amesim_results.times)
|
|
||||||
for alias in sorted(mechanical_assembly.zero_force_sources)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_chamber_duplicate_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
observation_catalog: TestMqlObservationCatalog,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for binding in observation_catalog.chambers.bindings:
|
|
||||||
pressure_series = amesim_results.series(binding.pressure_path)
|
|
||||||
temperature_series = amesim_results.series(binding.temperature_path)
|
|
||||||
for duplicate_path in binding.pressure_duplicate_paths:
|
|
||||||
series_by_data_path[duplicate_path] = tuple(pressure_series)
|
|
||||||
for duplicate_path in binding.temperature_duplicate_paths:
|
|
||||||
series_by_data_path[duplicate_path] = tuple(temperature_series)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_orifice_reversed_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
observation_catalog: TestMqlObservationCatalog,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for binding in observation_catalog.orifices.bindings:
|
|
||||||
series_by_data_path[binding.reversed_mass_flow_path] = tuple(
|
|
||||||
-value for value in amesim_results.series(binding.primary_mass_flow_path)
|
|
||||||
)
|
|
||||||
series_by_data_path[binding.reversed_enthalpy_flow_path] = tuple(
|
|
||||||
-value for value in amesim_results.series(binding.primary_enthalpy_flow_path)
|
|
||||||
)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_line_reversed_series(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
observation_catalog: TestMqlObservationCatalog,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
|
||||||
for binding in observation_catalog.lines.by_submodel("PNL00R"):
|
|
||||||
if len(binding.mass_flow_paths) != 2 or len(binding.enthalpy_flow_paths) != 2:
|
|
||||||
raise ValueError(f"Expected two PNL00R flow paths for {binding.alias}.")
|
|
||||||
primary_mass_path, reversed_mass_path = binding.mass_flow_paths
|
|
||||||
primary_enthalpy_path, reversed_enthalpy_path = binding.enthalpy_flow_paths
|
|
||||||
series_by_data_path[reversed_mass_path] = tuple(
|
|
||||||
-value for value in amesim_results.series(primary_mass_path)
|
|
||||||
)
|
|
||||||
series_by_data_path[reversed_enthalpy_path] = tuple(
|
|
||||||
-value for value in amesim_results.series(primary_enthalpy_path)
|
|
||||||
)
|
|
||||||
return series_by_data_path
|
|
||||||
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import operator
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import isfinite
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
|
||||||
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, GLOBAL_PARAMETERS
|
|
||||||
|
|
||||||
|
|
||||||
_BINARY_OPERATORS = {
|
|
||||||
ast.Add: operator.add,
|
|
||||||
ast.Sub: operator.sub,
|
|
||||||
ast.Mult: operator.mul,
|
|
||||||
ast.Div: operator.truediv,
|
|
||||||
ast.Pow: operator.pow,
|
|
||||||
}
|
|
||||||
_UNARY_OPERATORS = {
|
|
||||||
ast.UAdd: operator.pos,
|
|
||||||
ast.USub: operator.neg,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestMqlExpressionError(ValueError):
|
|
||||||
"""Raised when an AMESim parameter expression cannot be resolved safely."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlResolvedParameter:
|
|
||||||
name: str
|
|
||||||
title: str
|
|
||||||
raw_value: str
|
|
||||||
units: str
|
|
||||||
value: float | None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_numeric(self) -> bool:
|
|
||||||
return self.value is not None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlResolvedComponent:
|
|
||||||
alias: str
|
|
||||||
component_name: str
|
|
||||||
submodel: str
|
|
||||||
label: str
|
|
||||||
parameters: dict[str, TestMqlResolvedParameter]
|
|
||||||
|
|
||||||
def parameter_value(self, name: str) -> float:
|
|
||||||
parameter = self.parameters[name]
|
|
||||||
if parameter.value is None:
|
|
||||||
raise KeyError(f"Parameter {name!r} on {self.alias!r} is not numeric")
|
|
||||||
return parameter.value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlConfig:
|
|
||||||
raw_global_parameters: dict[str, str]
|
|
||||||
global_parameters: dict[str, float]
|
|
||||||
fluid: PengRobinsonFluid
|
|
||||||
components: tuple[TestMqlResolvedComponent, ...]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_amesim_specs(cls) -> "TestMqlConfig":
|
|
||||||
raw_globals = dict(GLOBAL_PARAMETERS)
|
|
||||||
numeric_globals = {
|
|
||||||
name: value
|
|
||||||
for name, raw in raw_globals.items()
|
|
||||||
if (value := resolve_numeric_expression(raw, {})) is not None
|
|
||||||
}
|
|
||||||
components = tuple(
|
|
||||||
_resolve_component(spec, numeric_globals)
|
|
||||||
for spec in COMPONENT_SPECS
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
raw_global_parameters=raw_globals,
|
|
||||||
global_parameters=numeric_globals,
|
|
||||||
fluid=HELIUM_PR,
|
|
||||||
components=components,
|
|
||||||
)
|
|
||||||
|
|
||||||
def component(self, alias: str) -> TestMqlResolvedComponent:
|
|
||||||
for component in self.components:
|
|
||||||
if component.alias == alias:
|
|
||||||
return component
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
def components_by_submodel(self, submodel: str) -> tuple[TestMqlResolvedComponent, ...]:
|
|
||||||
return tuple(component for component in self.components if component.submodel == submodel)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_component(
|
|
||||||
spec: dict[str, Any],
|
|
||||||
variables: dict[str, float],
|
|
||||||
) -> TestMqlResolvedComponent:
|
|
||||||
parameters = {}
|
|
||||||
for parameter in spec.get("parameters", []):
|
|
||||||
name = str(parameter["name"])
|
|
||||||
raw_value = str(parameter["value"])
|
|
||||||
parameters[name] = TestMqlResolvedParameter(
|
|
||||||
name=name,
|
|
||||||
title=str(parameter["title"]),
|
|
||||||
raw_value=raw_value,
|
|
||||||
units=str(parameter["units"]),
|
|
||||||
value=resolve_numeric_expression(raw_value, variables),
|
|
||||||
)
|
|
||||||
return TestMqlResolvedComponent(
|
|
||||||
alias=str(spec["alias"]),
|
|
||||||
component_name=str(spec["component_name"]),
|
|
||||||
submodel=str(spec["submodel"]),
|
|
||||||
label=str(spec["label"]),
|
|
||||||
parameters=parameters,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_numeric_expression(
|
|
||||||
expression: str,
|
|
||||||
variables: dict[str, float],
|
|
||||||
) -> float | None:
|
|
||||||
expression = expression.strip()
|
|
||||||
if not expression:
|
|
||||||
return None
|
|
||||||
normalized = expression.replace("^", "**")
|
|
||||||
try:
|
|
||||||
parsed = ast.parse(normalized, mode="eval")
|
|
||||||
value = float(_eval_node(parsed.body, variables))
|
|
||||||
except (SyntaxError, TestMqlExpressionError, ValueError, TypeError, ZeroDivisionError):
|
|
||||||
return None
|
|
||||||
return value if isfinite(value) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _eval_node(node: ast.AST, variables: dict[str, float]) -> float:
|
|
||||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
||||||
return float(node.value)
|
|
||||||
if isinstance(node, ast.Name):
|
|
||||||
if node.id not in variables:
|
|
||||||
raise TestMqlExpressionError(f"Unknown variable: {node.id}")
|
|
||||||
return float(variables[node.id])
|
|
||||||
if isinstance(node, ast.BinOp):
|
|
||||||
operator_type = type(node.op)
|
|
||||||
if operator_type not in _BINARY_OPERATORS:
|
|
||||||
raise TestMqlExpressionError(f"Unsupported binary operator: {operator_type}")
|
|
||||||
return float(_BINARY_OPERATORS[operator_type](_eval_node(node.left, variables), _eval_node(node.right, variables)))
|
|
||||||
if isinstance(node, ast.UnaryOp):
|
|
||||||
operator_type = type(node.op)
|
|
||||||
if operator_type not in _UNARY_OPERATORS:
|
|
||||||
raise TestMqlExpressionError(f"Unsupported unary operator: {operator_type}")
|
|
||||||
return float(_UNARY_OPERATORS[operator_type](_eval_node(node.operand, variables)))
|
|
||||||
raise TestMqlExpressionError(f"Unsupported expression node: {type(node)}")
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import tarfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.system import CONNECTION_SPECS, GLOBAL_PARAMETERS
|
|
||||||
from app.simulation.examples.test_mql.config import resolve_numeric_expression
|
|
||||||
|
|
||||||
|
|
||||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl0001Spec:
|
|
||||||
alias: str
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
diameter_mm: float
|
|
||||||
length_m: float
|
|
||||||
relative_roughness: float
|
|
||||||
polytropic_constant: float
|
|
||||||
heat_transfer_coefficient: float
|
|
||||||
external_temperature_k: float
|
|
||||||
gas_type_index: int
|
|
||||||
mode: int
|
|
||||||
initial_temperature_k: float
|
|
||||||
initial_gauge_pressure_pa: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def initial_absolute_pressure_pa(self) -> float:
|
|
||||||
return self.initial_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl0002Spec:
|
|
||||||
alias: str
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
diameter_mm: float
|
|
||||||
length_m: float
|
|
||||||
relative_roughness: float
|
|
||||||
polytropic_constant: float
|
|
||||||
heat_transfer_coefficient: float
|
|
||||||
external_temperature_k: float
|
|
||||||
gas_type_index: int
|
|
||||||
mode: int
|
|
||||||
initial_center_temperature_k: float
|
|
||||||
initial_center_gauge_pressure_pa: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def initial_center_absolute_pressure_pa(self) -> float:
|
|
||||||
return self.initial_center_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl0003Spec:
|
|
||||||
alias: str
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
diameter_mm: float
|
|
||||||
length_m: float
|
|
||||||
relative_roughness: float
|
|
||||||
polytropic_constant: float
|
|
||||||
heat_transfer_coefficient: float
|
|
||||||
external_temperature_k: float
|
|
||||||
gas_type_index: int
|
|
||||||
mode: int
|
|
||||||
initial_temperature_1_k: float
|
|
||||||
initial_gauge_pressure_1_pa: float
|
|
||||||
initial_temperature_2_k: float
|
|
||||||
initial_gauge_pressure_2_pa: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def initial_absolute_pressure_1_pa(self) -> float:
|
|
||||||
return self.initial_gauge_pressure_1_pa + AMESIM_REFERENCE_PRESSURE_PA
|
|
||||||
|
|
||||||
@property
|
|
||||||
def initial_absolute_pressure_2_pa(self) -> float:
|
|
||||||
return self.initial_gauge_pressure_2_pa + AMESIM_REFERENCE_PRESSURE_PA
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl00rSpec:
|
|
||||||
alias: str
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
diameter_mm: float
|
|
||||||
length_m: float
|
|
||||||
relative_roughness: float
|
|
||||||
gas_type_index: int
|
|
||||||
|
|
||||||
|
|
||||||
def load_test_mql_pnl0001_specs(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
cir_member: str = "test_mql_.cir",
|
|
||||||
) -> tuple[TestMqlPnl0001Spec, ...]:
|
|
||||||
"""Load resolved PNL0001 geometry and initial states from the AMESim source."""
|
|
||||||
with tarfile.open(archive_path) as archive:
|
|
||||||
cir_file = archive.extractfile(cir_member)
|
|
||||||
if cir_file is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
|
||||||
cir_text = cir_file.read().decode("latin1")
|
|
||||||
|
|
||||||
numeric_globals = {
|
|
||||||
name: value
|
|
||||||
for name, expression in GLOBAL_PARAMETERS.items()
|
|
||||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
|
||||||
}
|
|
||||||
connections = {
|
|
||||||
str(connection["alias"]): connection
|
|
||||||
for connection in CONNECTION_SPECS
|
|
||||||
if connection["submodel"] == "PNL0001"
|
|
||||||
}
|
|
||||||
specs = []
|
|
||||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
|
||||||
if _optional_text(block, "SUB_NAME") != "PNL0001":
|
|
||||||
continue
|
|
||||||
alias = _required_text(block, "ALIAS")
|
|
||||||
connection = connections.get(alias)
|
|
||||||
if connection is None:
|
|
||||||
raise ValueError(f"PNL0001 line {alias!r} is absent from CONNECTION_SPECS")
|
|
||||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
|
||||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
|
||||||
state_values = _evar_values(block)
|
|
||||||
specs.append(
|
|
||||||
TestMqlPnl0001Spec(
|
|
||||||
alias=alias,
|
|
||||||
source_component=str(connection["source_component"]),
|
|
||||||
source_port=str(connection["source_port"]),
|
|
||||||
target_component=str(connection["target_component"]),
|
|
||||||
target_port=str(connection["target_port"]),
|
|
||||||
diameter_mm=_required_numeric(
|
|
||||||
alias, "diam", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
|
||||||
relative_roughness=_required_numeric(
|
|
||||||
alias, "rr", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
polytropic_constant=_required_numeric(
|
|
||||||
alias, "k", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
heat_transfer_coefficient=_required_numeric(
|
|
||||||
alias, "kth", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
external_temperature_k=_required_numeric(
|
|
||||||
alias, "extemp", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
gas_type_index=int(
|
|
||||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
mode=int(
|
|
||||||
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
initial_temperature_k=_required_numeric(
|
|
||||||
alias, "t2", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
initial_gauge_pressure_pa=_required_numeric(
|
|
||||||
alias, "p2", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if set(connections) != {spec.alias for spec in specs}:
|
|
||||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
|
||||||
raise ValueError(f"Missing PNL0001 parameter blocks: {missing}")
|
|
||||||
return tuple(specs)
|
|
||||||
|
|
||||||
|
|
||||||
def load_test_mql_pnl0002_specs(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
cir_member: str = "test_mql_.cir",
|
|
||||||
) -> tuple[TestMqlPnl0002Spec, ...]:
|
|
||||||
"""Load resolved PNL0002 geometry and center compliance initial state."""
|
|
||||||
with tarfile.open(archive_path) as archive:
|
|
||||||
cir_file = archive.extractfile(cir_member)
|
|
||||||
if cir_file is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
|
||||||
cir_text = cir_file.read().decode("latin1")
|
|
||||||
|
|
||||||
numeric_globals = {
|
|
||||||
name: value
|
|
||||||
for name, expression in GLOBAL_PARAMETERS.items()
|
|
||||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
|
||||||
}
|
|
||||||
connections = {
|
|
||||||
str(connection["alias"]): connection
|
|
||||||
for connection in CONNECTION_SPECS
|
|
||||||
if connection["submodel"] == "PNL0002"
|
|
||||||
}
|
|
||||||
specs = []
|
|
||||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
|
||||||
if _optional_text(block, "SUB_NAME") != "PNL0002":
|
|
||||||
continue
|
|
||||||
alias = _required_text(block, "ALIAS")
|
|
||||||
connection = connections.get(alias)
|
|
||||||
if connection is None:
|
|
||||||
raise ValueError(f"PNL0002 line {alias!r} is absent from CONNECTION_SPECS")
|
|
||||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
|
||||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
|
||||||
state_values = _ivar_values(block)
|
|
||||||
specs.append(
|
|
||||||
TestMqlPnl0002Spec(
|
|
||||||
alias=alias,
|
|
||||||
source_component=str(connection["source_component"]),
|
|
||||||
source_port=str(connection["source_port"]),
|
|
||||||
target_component=str(connection["target_component"]),
|
|
||||||
target_port=str(connection["target_port"]),
|
|
||||||
diameter_mm=_required_numeric(
|
|
||||||
alias, "diam", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
|
||||||
relative_roughness=_required_numeric(
|
|
||||||
alias, "rr", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
polytropic_constant=_required_numeric(
|
|
||||||
alias, "k", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
heat_transfer_coefficient=_required_numeric(
|
|
||||||
alias, "kth", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
external_temperature_k=_required_numeric(
|
|
||||||
alias, "extemp", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
gas_type_index=int(
|
|
||||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
mode=int(
|
|
||||||
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
initial_center_temperature_k=_required_numeric(
|
|
||||||
alias, "tctr", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
initial_center_gauge_pressure_pa=_required_numeric(
|
|
||||||
alias, "pctr", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if set(connections) != {spec.alias for spec in specs}:
|
|
||||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
|
||||||
raise ValueError(f"Missing PNL0002 parameter blocks: {missing}")
|
|
||||||
return tuple(specs)
|
|
||||||
|
|
||||||
|
|
||||||
def load_test_mql_pnl0003_specs(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
cir_member: str = "test_mql_.cir",
|
|
||||||
) -> tuple[TestMqlPnl0003Spec, ...]:
|
|
||||||
"""Load resolved PNL0003 geometry and both compliance initial states."""
|
|
||||||
with tarfile.open(archive_path) as archive:
|
|
||||||
cir_file = archive.extractfile(cir_member)
|
|
||||||
if cir_file is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
|
||||||
cir_text = cir_file.read().decode("latin1")
|
|
||||||
|
|
||||||
numeric_globals = {
|
|
||||||
name: value
|
|
||||||
for name, expression in GLOBAL_PARAMETERS.items()
|
|
||||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
|
||||||
}
|
|
||||||
connections = {
|
|
||||||
str(connection["alias"]): connection
|
|
||||||
for connection in CONNECTION_SPECS
|
|
||||||
if connection["submodel"] == "PNL0003"
|
|
||||||
}
|
|
||||||
specs = []
|
|
||||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
|
||||||
if _optional_text(block, "SUB_NAME") != "PNL0003":
|
|
||||||
continue
|
|
||||||
alias = _required_text(block, "ALIAS")
|
|
||||||
connection = connections.get(alias)
|
|
||||||
if connection is None:
|
|
||||||
raise ValueError(f"PNL0003 line {alias!r} is absent from CONNECTION_SPECS")
|
|
||||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
|
||||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
|
||||||
state_values = _evar_values(block)
|
|
||||||
specs.append(
|
|
||||||
TestMqlPnl0003Spec(
|
|
||||||
alias=alias,
|
|
||||||
source_component=str(connection["source_component"]),
|
|
||||||
source_port=str(connection["source_port"]),
|
|
||||||
target_component=str(connection["target_component"]),
|
|
||||||
target_port=str(connection["target_port"]),
|
|
||||||
diameter_mm=_required_numeric(
|
|
||||||
alias, "diam", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
|
||||||
relative_roughness=_required_numeric(
|
|
||||||
alias, "rr", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
polytropic_constant=_required_numeric(
|
|
||||||
alias, "k", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
heat_transfer_coefficient=_required_numeric(
|
|
||||||
alias, "kth", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
external_temperature_k=_required_numeric(
|
|
||||||
alias, "extemp", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
gas_type_index=int(
|
|
||||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
mode=int(
|
|
||||||
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
initial_temperature_1_k=_required_numeric(
|
|
||||||
alias, "t1", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
initial_gauge_pressure_1_pa=_required_numeric(
|
|
||||||
alias, "p1", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
initial_temperature_2_k=_required_numeric(
|
|
||||||
alias, "t2", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
initial_gauge_pressure_2_pa=_required_numeric(
|
|
||||||
alias, "p2", state_values, numeric_globals
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if set(connections) != {spec.alias for spec in specs}:
|
|
||||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
|
||||||
raise ValueError(f"Missing PNL0003 parameter blocks: {missing}")
|
|
||||||
return tuple(specs)
|
|
||||||
|
|
||||||
|
|
||||||
def load_test_mql_pnl00r_specs(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
cir_member: str = "test_mql_.cir",
|
|
||||||
) -> tuple[TestMqlPnl00rSpec, ...]:
|
|
||||||
"""Load resolved PNL00R geometry from the AMESim source."""
|
|
||||||
with tarfile.open(archive_path) as archive:
|
|
||||||
cir_file = archive.extractfile(cir_member)
|
|
||||||
if cir_file is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
|
||||||
cir_text = cir_file.read().decode("latin1")
|
|
||||||
|
|
||||||
numeric_globals = {
|
|
||||||
name: value
|
|
||||||
for name, expression in GLOBAL_PARAMETERS.items()
|
|
||||||
if (value := resolve_numeric_expression(expression, {})) is not None
|
|
||||||
}
|
|
||||||
connections = {
|
|
||||||
str(connection["alias"]): connection
|
|
||||||
for connection in CONNECTION_SPECS
|
|
||||||
if connection["submodel"] == "PNL00R"
|
|
||||||
}
|
|
||||||
specs = []
|
|
||||||
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
|
|
||||||
if _optional_text(block, "SUB_NAME") != "PNL00R":
|
|
||||||
continue
|
|
||||||
alias = _required_text(block, "ALIAS")
|
|
||||||
connection = connections.get(alias)
|
|
||||||
if connection is None:
|
|
||||||
raise ValueError(f"PNL00R line {alias!r} is absent from CONNECTION_SPECS")
|
|
||||||
real_parameters = _parameter_expressions(block, "RPARAM")
|
|
||||||
integer_parameters = _parameter_expressions(block, "IPARAM")
|
|
||||||
specs.append(
|
|
||||||
TestMqlPnl00rSpec(
|
|
||||||
alias=alias,
|
|
||||||
source_component=str(connection["source_component"]),
|
|
||||||
source_port=str(connection["source_port"]),
|
|
||||||
target_component=str(connection["target_component"]),
|
|
||||||
target_port=str(connection["target_port"]),
|
|
||||||
diameter_mm=_required_numeric(
|
|
||||||
alias, "diam", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
|
|
||||||
relative_roughness=_required_numeric(
|
|
||||||
alias, "rr", real_parameters, numeric_globals
|
|
||||||
),
|
|
||||||
gas_type_index=int(
|
|
||||||
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if set(connections) != {spec.alias for spec in specs}:
|
|
||||||
missing = sorted(set(connections) - {spec.alias for spec in specs})
|
|
||||||
raise ValueError(f"Missing PNL00R parameter blocks: {missing}")
|
|
||||||
return tuple(specs)
|
|
||||||
|
|
||||||
|
|
||||||
def _parameter_expressions(block: str, tag_name: str) -> dict[str, str]:
|
|
||||||
parameters = {}
|
|
||||||
for parameter_block in re.findall(
|
|
||||||
rf"<{tag_name}>.*?</{tag_name}>",
|
|
||||||
block,
|
|
||||||
flags=re.DOTALL,
|
|
||||||
):
|
|
||||||
parameters[_required_text(parameter_block, "VARNAME")] = _required_text(
|
|
||||||
parameter_block,
|
|
||||||
"VALUE",
|
|
||||||
)
|
|
||||||
return parameters
|
|
||||||
|
|
||||||
|
|
||||||
def _ivar_values(block: str) -> dict[str, str]:
|
|
||||||
values = {}
|
|
||||||
for variable_block in re.findall(r"<IVAR>.*?</IVAR>", block, flags=re.DOTALL):
|
|
||||||
value = _optional_text(variable_block, "VALUE")
|
|
||||||
if value:
|
|
||||||
values[_required_text(variable_block, "VARNAME")] = value
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _evar_values(block: str) -> dict[str, str]:
|
|
||||||
values = {}
|
|
||||||
for variable_block in re.findall(r"<EVAR>.*?</EVAR>", block, flags=re.DOTALL):
|
|
||||||
value = _optional_text(variable_block, "VALUE")
|
|
||||||
if value:
|
|
||||||
values[_required_text(variable_block, "VARNAME")] = value
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _required_numeric(
|
|
||||||
alias: str,
|
|
||||||
name: str,
|
|
||||||
expressions: dict[str, str],
|
|
||||||
variables: dict[str, float],
|
|
||||||
) -> float:
|
|
||||||
if name not in expressions:
|
|
||||||
raise ValueError(f"Missing {name!r} on line {alias!r}")
|
|
||||||
value = resolve_numeric_expression(expressions[name], variables)
|
|
||||||
if value is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"Cannot resolve {name!r}={expressions[name]!r} on line {alias!r}"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _required_text(block: str, tag_name: str) -> str:
|
|
||||||
value = _optional_text(block, tag_name)
|
|
||||||
if value is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit element: {tag_name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_text(block: str, tag_name: str) -> str | None:
|
|
||||||
match = re.search(rf"<{tag_name}>(.*?)</{tag_name}>", block, flags=re.DOTALL)
|
|
||||||
return match.group(1).strip() if match is not None else None
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
|
|
||||||
|
|
||||||
|
|
||||||
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
|
|
||||||
_LINE_PATTERN_RE = re.compile(r"\(([^()]+)\)\s*$")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlLineConnection:
|
|
||||||
index: int
|
|
||||||
alias: str
|
|
||||||
submodel: str
|
|
||||||
pattern: str
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
label: str
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
signal_names: tuple[str, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_compliance(self) -> bool:
|
|
||||||
return "C" in self.pattern
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_resistance(self) -> bool:
|
|
||||||
return "R" in self.pattern
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlLineAssembly:
|
|
||||||
lines: tuple[TestMqlLineConnection, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def line_count(self) -> int:
|
|
||||||
return len(self.lines)
|
|
||||||
|
|
||||||
def by_alias(self, alias: str) -> TestMqlLineConnection:
|
|
||||||
for line in self.lines:
|
|
||||||
if line.alias == alias:
|
|
||||||
return line
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
def by_submodel(self, submodel: str) -> tuple[TestMqlLineConnection, ...]:
|
|
||||||
return tuple(line for line in self.lines if line.submodel == submodel)
|
|
||||||
|
|
||||||
def counts_by_submodel(self) -> dict[str, int]:
|
|
||||||
return dict(Counter(line.submodel for line in self.lines))
|
|
||||||
|
|
||||||
def aliases(self) -> tuple[str, ...]:
|
|
||||||
return tuple(line.alias for line in self.lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_line_assembly(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
|
||||||
) -> TestMqlLineAssembly:
|
|
||||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(amesim_results)
|
|
||||||
lines = []
|
|
||||||
for spec in CONNECTION_SPECS:
|
|
||||||
submodel = str(spec["submodel"])
|
|
||||||
if submodel not in TEST_MQL_PNEUMATIC_LINE_SUBMODELS:
|
|
||||||
continue
|
|
||||||
data_paths = variable_catalog.data_paths_for_owner(str(spec["alias"]))
|
|
||||||
signal_names = tuple(path.rsplit("@", 1)[0] for path in data_paths)
|
|
||||||
lines.append(
|
|
||||||
TestMqlLineConnection(
|
|
||||||
index=int(spec["index"]),
|
|
||||||
alias=str(spec["alias"]),
|
|
||||||
submodel=submodel,
|
|
||||||
pattern=_line_pattern(str(spec["label"]), submodel),
|
|
||||||
source_component=str(spec["source_component"]),
|
|
||||||
source_port=str(spec["source_port"]),
|
|
||||||
target_component=str(spec["target_component"]),
|
|
||||||
target_port=str(spec["target_port"]),
|
|
||||||
label=str(spec["label"]),
|
|
||||||
data_paths=data_paths,
|
|
||||||
signal_names=signal_names,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TestMqlLineAssembly(lines=tuple(lines))
|
|
||||||
|
|
||||||
|
|
||||||
def _line_pattern(label: str, submodel: str) -> str:
|
|
||||||
match = _LINE_PATTERN_RE.search(label)
|
|
||||||
if match is not None:
|
|
||||||
return match.group(1)
|
|
||||||
if submodel == "PNL00R":
|
|
||||||
return "R"
|
|
||||||
return submodel
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.primitives.mechanical import (
|
|
||||||
AmesimElasticEndstop,
|
|
||||||
AmesimMassFrictionEndstops,
|
|
||||||
AmesimPistonGeometry,
|
|
||||||
circular_area,
|
|
||||||
mm_to_m,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
|
|
||||||
|
|
||||||
|
|
||||||
MM_TO_M = 1.0e-3
|
|
||||||
N_PER_MM_TO_N_PER_M = 1.0e3
|
|
||||||
N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPistonSpec:
|
|
||||||
alias: str
|
|
||||||
piston_diameter_m: float
|
|
||||||
rod_diameter_m: float
|
|
||||||
zero_displacement_m: float
|
|
||||||
piston_area_m2: float
|
|
||||||
rod_area_m2: float
|
|
||||||
annulus_area_m2: float
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
def geometry(self) -> AmesimPistonGeometry:
|
|
||||||
return AmesimPistonGeometry(
|
|
||||||
piston_diameter_m=self.piston_diameter_m,
|
|
||||||
rod_diameter_m=self.rod_diameter_m,
|
|
||||||
zero_length_m=self.zero_displacement_m,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMassEndstopSpec:
|
|
||||||
alias: str
|
|
||||||
mass_kg: float
|
|
||||||
xmin_m: float
|
|
||||||
xmax_m: float
|
|
||||||
min_stiffness_n_per_m: float
|
|
||||||
max_stiffness_n_per_m: float
|
|
||||||
min_damping_n_per_m_per_s: float
|
|
||||||
max_damping_n_per_m_per_s: float
|
|
||||||
min_penetration_m: float
|
|
||||||
max_penetration_m: float
|
|
||||||
stiction_force_n: float
|
|
||||||
coulomb_friction_n: float
|
|
||||||
viscous_friction_n_per_m_per_s: float
|
|
||||||
windage_n_per_m2_per_s2: float
|
|
||||||
stick_velocity_threshold_m_s: float
|
|
||||||
reset_velocity_threshold_m_s: float
|
|
||||||
rest_coeff: float
|
|
||||||
stribeck_constant_m_s: float
|
|
||||||
use_friction: bool
|
|
||||||
stop_type: int
|
|
||||||
initial_velocity_m_s: float
|
|
||||||
initial_displacement_m: float
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
def endstop(self) -> AmesimMassFrictionEndstops:
|
|
||||||
return AmesimMassFrictionEndstops(
|
|
||||||
mass_kg=self.mass_kg,
|
|
||||||
lower_limit_m=self.xmin_m,
|
|
||||||
upper_limit_m=self.xmax_m,
|
|
||||||
lower_stiffness_n_per_m=self.min_stiffness_n_per_m,
|
|
||||||
upper_stiffness_n_per_m=self.max_stiffness_n_per_m,
|
|
||||||
lower_damping_n_per_m_per_s=self.min_damping_n_per_m_per_s,
|
|
||||||
upper_damping_n_per_m_per_s=self.max_damping_n_per_m_per_s,
|
|
||||||
viscous_friction_n_per_m_per_s=self.viscous_friction_n_per_m_per_s,
|
|
||||||
coulomb_friction_n=self.coulomb_friction_n,
|
|
||||||
stiction_force_n=self.stiction_force_n,
|
|
||||||
windage_n_per_m2_per_s2=self.windage_n_per_m2_per_s2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlElasticEndstopSpec:
|
|
||||||
alias: str
|
|
||||||
gap_m: float
|
|
||||||
contact_stiffness_n_per_m: float
|
|
||||||
contact_damping_n_per_m_per_s: float
|
|
||||||
spring_diameter_m: float
|
|
||||||
wire_diameter_m: float
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
def endstop(self) -> AmesimElasticEndstop:
|
|
||||||
return AmesimElasticEndstop(
|
|
||||||
contact_stiffness_n_per_m=self.contact_stiffness_n_per_m,
|
|
||||||
contact_damping_n_per_m_per_s=self.contact_damping_n_per_m_per_s,
|
|
||||||
gap0_m=self.gap_m,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalNodeSpec:
|
|
||||||
alias: str
|
|
||||||
port_count: int
|
|
||||||
sum_mode: int
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPiecewiseLinearSignalSpec:
|
|
||||||
alias: str
|
|
||||||
t_start_s: float
|
|
||||||
starts: tuple[float, ...]
|
|
||||||
ends: tuple[float, ...]
|
|
||||||
durations_s: tuple[float, ...]
|
|
||||||
stage_count: int
|
|
||||||
is_cyclic: bool
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
def output_at(self, time_s: float) -> float:
|
|
||||||
if self.stage_count <= 0:
|
|
||||||
return 0.0
|
|
||||||
elapsed = max(time_s - self.t_start_s, 0.0)
|
|
||||||
active_durations = self.durations_s[: self.stage_count]
|
|
||||||
total_duration = sum(active_durations)
|
|
||||||
if self.is_cyclic and total_duration > 0.0:
|
|
||||||
elapsed = elapsed % total_duration
|
|
||||||
|
|
||||||
stage_start_time = 0.0
|
|
||||||
for index, duration in enumerate(active_durations):
|
|
||||||
stage_end_time = stage_start_time + duration
|
|
||||||
if elapsed < stage_end_time or index == self.stage_count - 1:
|
|
||||||
if duration <= 0.0:
|
|
||||||
return self.ends[index]
|
|
||||||
fraction = (elapsed - stage_start_time) / duration
|
|
||||||
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
|
|
||||||
stage_start_time = stage_end_time
|
|
||||||
return self.ends[self.stage_count - 1]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlForceConnectorSpec:
|
|
||||||
alias: str
|
|
||||||
signal_alias: str
|
|
||||||
target_mass_alias: str
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
def force_at(
|
|
||||||
self,
|
|
||||||
time_s: float,
|
|
||||||
signals: dict[str, TestMqlPiecewiseLinearSignalSpec],
|
|
||||||
) -> float:
|
|
||||||
return signals[self.signal_alias].output_at(time_s)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalAssembly:
|
|
||||||
pistons: dict[str, TestMqlPistonSpec]
|
|
||||||
masses: dict[str, TestMqlMassEndstopSpec]
|
|
||||||
elastic_endstops: dict[str, TestMqlElasticEndstopSpec]
|
|
||||||
mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec]
|
|
||||||
piecewise_signals: dict[str, TestMqlPiecewiseLinearSignalSpec]
|
|
||||||
force_connectors: dict[str, TestMqlForceConnectorSpec]
|
|
||||||
zero_force_sources: tuple[str, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def component_count(self) -> int:
|
|
||||||
return (
|
|
||||||
len(self.pistons)
|
|
||||||
+ len(self.masses)
|
|
||||||
+ len(self.elastic_endstops)
|
|
||||||
+ len(self.mechanical_nodes)
|
|
||||||
+ len(self.piecewise_signals)
|
|
||||||
+ len(self.force_connectors)
|
|
||||||
+ len(self.zero_force_sources)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def aliases(self) -> tuple[str, ...]:
|
|
||||||
return tuple(
|
|
||||||
[
|
|
||||||
*self.pistons,
|
|
||||||
*self.masses,
|
|
||||||
*self.elastic_endstops,
|
|
||||||
*self.mechanical_nodes,
|
|
||||||
*self.piecewise_signals,
|
|
||||||
*self.force_connectors,
|
|
||||||
*self.zero_force_sources,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalMassState:
|
|
||||||
alias: str
|
|
||||||
velocity_m_s: float
|
|
||||||
displacement_m: float
|
|
||||||
|
|
||||||
def as_vector(self) -> list[float]:
|
|
||||||
return [self.velocity_m_s, self.displacement_m]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalNodeKinematics:
|
|
||||||
alias: str
|
|
||||||
velocities_m_s: dict[int, float]
|
|
||||||
displacements_m: dict[int, float]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPistonKinematics:
|
|
||||||
alias: str
|
|
||||||
port_2_velocity_m_s: float
|
|
||||||
port_2_displacement_m: float
|
|
||||||
port_3_velocity_m_s: float
|
|
||||||
port_3_displacement_m: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalMassSnapshot:
|
|
||||||
states: tuple[TestMqlMechanicalMassState, ...]
|
|
||||||
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics]
|
|
||||||
piston_kinematics_by_alias: dict[str, TestMqlPistonKinematics]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state_count(self) -> int:
|
|
||||||
return 2 * len(self.states)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMqlMechanicalMassClosure:
|
|
||||||
def __init__(self, assembly: TestMqlMechanicalAssembly) -> None:
|
|
||||||
self.assembly = assembly
|
|
||||||
self.mass_aliases = tuple(assembly.masses)
|
|
||||||
|
|
||||||
def initial_state_vector(self) -> list[float]:
|
|
||||||
state: list[float] = []
|
|
||||||
for alias in self.mass_aliases:
|
|
||||||
spec = self.assembly.masses[alias]
|
|
||||||
state.extend([spec.initial_velocity_m_s, spec.initial_displacement_m])
|
|
||||||
return state
|
|
||||||
|
|
||||||
def snapshot(self, state_vector: list[float] | None = None) -> TestMqlMechanicalMassSnapshot:
|
|
||||||
values = self.initial_state_vector() if state_vector is None else list(state_vector)
|
|
||||||
if len(values) != 2 * len(self.mass_aliases):
|
|
||||||
raise ValueError("mechanical mass state vector requires two values per mass")
|
|
||||||
states = tuple(
|
|
||||||
TestMqlMechanicalMassState(
|
|
||||||
alias=alias,
|
|
||||||
velocity_m_s=values[2 * index],
|
|
||||||
displacement_m=values[2 * index + 1],
|
|
||||||
)
|
|
||||||
for index, alias in enumerate(self.mass_aliases)
|
|
||||||
)
|
|
||||||
node_kinematics = self._node_kinematics_by_alias(states)
|
|
||||||
return TestMqlMechanicalMassSnapshot(
|
|
||||||
states=states,
|
|
||||||
node_kinematics_by_alias=node_kinematics,
|
|
||||||
piston_kinematics_by_alias=self._piston_kinematics_by_alias(
|
|
||||||
states,
|
|
||||||
node_kinematics,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _node_kinematics_by_alias(
|
|
||||||
self,
|
|
||||||
states: tuple[TestMqlMechanicalMassState, ...],
|
|
||||||
) -> dict[str, TestMqlMechanicalNodeKinematics]:
|
|
||||||
state_by_alias = {state.alias: state for state in states}
|
|
||||||
front = state_by_alias["mass_friction_endstops_18"]
|
|
||||||
rear = state_by_alias["mass_friction_endstops_19"]
|
|
||||||
return {
|
|
||||||
"dynamic_mechanical_node_alternative_2": TestMqlMechanicalNodeKinematics(
|
|
||||||
alias="dynamic_mechanical_node_alternative_2",
|
|
||||||
velocities_m_s={port: -front.velocity_m_s for port in range(1, 9)},
|
|
||||||
displacements_m={port: -front.displacement_m for port in range(1, 9)},
|
|
||||||
),
|
|
||||||
"dynamic_mechanical_node_alternative_3": TestMqlMechanicalNodeKinematics(
|
|
||||||
alias="dynamic_mechanical_node_alternative_3",
|
|
||||||
velocities_m_s={port: rear.velocity_m_s for port in range(1, 9)},
|
|
||||||
displacements_m={port: rear.displacement_m for port in range(1, 9)},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _piston_kinematics_by_alias(
|
|
||||||
self,
|
|
||||||
states: tuple[TestMqlMechanicalMassState, ...],
|
|
||||||
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics],
|
|
||||||
) -> dict[str, TestMqlPistonKinematics]:
|
|
||||||
state_by_alias = {state.alias: state for state in states}
|
|
||||||
rear_node = node_kinematics_by_alias["dynamic_mechanical_node_alternative_3"]
|
|
||||||
piston_bindings = (
|
|
||||||
("pn_brp2_8", "mass_friction_endstops_10", 8),
|
|
||||||
("pn_brp2_9", "mass_friction_endstops_11", 7),
|
|
||||||
("pn_brp2_10", "mass_friction_endstops_12", 6),
|
|
||||||
("pn_brp2_11", "mass_friction_endstops_13", 5),
|
|
||||||
("pn_brp2_12", "mass_friction_endstops_14", 4),
|
|
||||||
("pn_brp2_13", "mass_friction_endstops_15", 3),
|
|
||||||
("pn_brp2_14", "mass_friction_endstops_16", 2),
|
|
||||||
("pn_brp2_15", "mass_friction_endstops_17", 1),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
piston_alias: TestMqlPistonKinematics(
|
|
||||||
alias=piston_alias,
|
|
||||||
port_2_velocity_m_s=state_by_alias[mass_alias].velocity_m_s,
|
|
||||||
port_2_displacement_m=state_by_alias[mass_alias].displacement_m,
|
|
||||||
port_3_velocity_m_s=rear_node.velocities_m_s[rear_node_port],
|
|
||||||
port_3_displacement_m=rear_node.displacements_m[rear_node_port],
|
|
||||||
)
|
|
||||||
for piston_alias, mass_alias, rear_node_port in piston_bindings
|
|
||||||
}
|
|
||||||
|
|
||||||
def rhs(
|
|
||||||
self,
|
|
||||||
state_vector: list[float],
|
|
||||||
*,
|
|
||||||
force_by_mass_alias: dict[str, float] | None = None,
|
|
||||||
constrained_mass_aliases: set[str] | None = None,
|
|
||||||
) -> list[float]:
|
|
||||||
snapshot = self.snapshot(state_vector)
|
|
||||||
force_by_mass_alias = force_by_mass_alias or {}
|
|
||||||
constrained_mass_aliases = constrained_mass_aliases or set()
|
|
||||||
derivatives: list[float] = []
|
|
||||||
for state in snapshot.states:
|
|
||||||
spec = self.assembly.masses[state.alias]
|
|
||||||
mass = spec.endstop()
|
|
||||||
applied_force = force_by_mass_alias.get(state.alias, 0.0)
|
|
||||||
acceleration, velocity = mass.derivatives(
|
|
||||||
velocity_m_s=state.velocity_m_s,
|
|
||||||
displacement_m=state.displacement_m,
|
|
||||||
port_1_force_n=applied_force,
|
|
||||||
)
|
|
||||||
if state.alias in constrained_mass_aliases and _limit_constraint_holds(
|
|
||||||
spec,
|
|
||||||
state,
|
|
||||||
applied_force,
|
|
||||||
):
|
|
||||||
acceleration = 0.0
|
|
||||||
velocity = 0.0
|
|
||||||
derivatives.extend([acceleration, velocity])
|
|
||||||
return derivatives
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_mechanical_assembly(
|
|
||||||
config: TestMqlConfig | None = None,
|
|
||||||
amesim_results: AmesimResults | None = None,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
|
||||||
) -> TestMqlMechanicalAssembly:
|
|
||||||
config = config or TestMqlConfig.from_amesim_specs()
|
|
||||||
if variable_catalog is None and amesim_results is not None:
|
|
||||||
variable_catalog = build_test_mql_variable_catalog(amesim_results)
|
|
||||||
|
|
||||||
pistons = {
|
|
||||||
component.alias: _build_piston(component, variable_catalog)
|
|
||||||
for component in config.components_by_submodel("PNRP17")
|
|
||||||
}
|
|
||||||
masses = {
|
|
||||||
component.alias: _build_mass(component, variable_catalog, amesim_results)
|
|
||||||
for component in config.components_by_submodel("MECMAS21")
|
|
||||||
}
|
|
||||||
elastic_endstops = {
|
|
||||||
component.alias: _build_elastic_endstop(component, variable_catalog)
|
|
||||||
for component in config.components_by_submodel("LSTP00A")
|
|
||||||
}
|
|
||||||
mechanical_nodes = {
|
|
||||||
component.alias: _build_mechanical_node(component, variable_catalog)
|
|
||||||
for component in config.components_by_submodel("LMECHN1")
|
|
||||||
}
|
|
||||||
piecewise_signals = {
|
|
||||||
component.alias: _build_piecewise_signal(component, variable_catalog)
|
|
||||||
for component in config.components_by_submodel("UD00")
|
|
||||||
}
|
|
||||||
force_connectors = {
|
|
||||||
component.alias: _build_force_connector(component, variable_catalog)
|
|
||||||
for component in config.components_by_submodel("FORC")
|
|
||||||
}
|
|
||||||
zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000"))
|
|
||||||
return TestMqlMechanicalAssembly(
|
|
||||||
pistons=pistons,
|
|
||||||
masses=masses,
|
|
||||||
elastic_endstops=elastic_endstops,
|
|
||||||
mechanical_nodes=mechanical_nodes,
|
|
||||||
piecewise_signals=piecewise_signals,
|
|
||||||
force_connectors=force_connectors,
|
|
||||||
zero_force_sources=zero_force_sources,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_piston(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
) -> TestMqlPistonSpec:
|
|
||||||
geometry = AmesimPistonGeometry(
|
|
||||||
piston_diameter_m=mm_to_m(component.parameter_value("dp")),
|
|
||||||
rod_diameter_m=mm_to_m(component.parameter_value("dr")),
|
|
||||||
zero_length_m=mm_to_m(component.parameter_value("x0")),
|
|
||||||
)
|
|
||||||
return TestMqlPistonSpec(
|
|
||||||
alias=component.alias,
|
|
||||||
piston_diameter_m=geometry.piston_diameter_m,
|
|
||||||
rod_diameter_m=geometry.rod_diameter_m,
|
|
||||||
zero_displacement_m=geometry.zero_length_m,
|
|
||||||
piston_area_m2=geometry.piston_area_m2,
|
|
||||||
rod_area_m2=geometry.rod_area_m2,
|
|
||||||
annulus_area_m2=geometry.annulus_area_m2,
|
|
||||||
data_paths=_data_paths(variable_catalog, component.alias),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_mass(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
amesim_results: AmesimResults | None,
|
|
||||||
) -> TestMqlMassEndstopSpec:
|
|
||||||
return TestMqlMassEndstopSpec(
|
|
||||||
alias=component.alias,
|
|
||||||
mass_kg=component.parameter_value("mass"),
|
|
||||||
xmin_m=component.parameter_value("xmin"),
|
|
||||||
xmax_m=component.parameter_value("xmax"),
|
|
||||||
min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")),
|
|
||||||
max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")),
|
|
||||||
min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")),
|
|
||||||
max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")),
|
|
||||||
min_penetration_m=mm_to_m(component.parameter_value("Pdmin")),
|
|
||||||
max_penetration_m=mm_to_m(component.parameter_value("Pdmax")),
|
|
||||||
stiction_force_n=component.parameter_value("fstick"),
|
|
||||||
coulomb_friction_n=component.parameter_value("fcoul"),
|
|
||||||
viscous_friction_n_per_m_per_s=component.parameter_value("rvisc"),
|
|
||||||
windage_n_per_m2_per_s2=component.parameter_value("wind"),
|
|
||||||
stick_velocity_threshold_m_s=component.parameter_value("dvel"),
|
|
||||||
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
|
|
||||||
rest_coeff=component.parameter_value("restcoeff"),
|
|
||||||
stribeck_constant_m_s=component.parameter_value("astrib"),
|
|
||||||
use_friction=int(component.parameter_value("useFriction")) == 2,
|
|
||||||
stop_type=int(component.parameter_value("stoptype")),
|
|
||||||
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
|
|
||||||
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
|
|
||||||
data_paths=_data_paths(variable_catalog, component.alias),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_elastic_endstop(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
) -> TestMqlElasticEndstopSpec:
|
|
||||||
return TestMqlElasticEndstopSpec(
|
|
||||||
alias=component.alias,
|
|
||||||
gap_m=mm_to_m(component.parameter_value("gap0")),
|
|
||||||
contact_stiffness_n_per_m=component.parameter_value("kcont"),
|
|
||||||
contact_damping_n_per_m_per_s=component.parameter_value("rcont"),
|
|
||||||
spring_diameter_m=mm_to_m(component.parameter_value("sdiam")),
|
|
||||||
wire_diameter_m=mm_to_m(component.parameter_value("wdiam")),
|
|
||||||
data_paths=_data_paths(variable_catalog, component.alias),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_mechanical_node(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
) -> TestMqlMechanicalNodeSpec:
|
|
||||||
return TestMqlMechanicalNodeSpec(
|
|
||||||
alias=component.alias,
|
|
||||||
port_count=int(component.parameter_value("v1")),
|
|
||||||
sum_mode=int(component.parameter_value("sum")),
|
|
||||||
data_paths=_data_paths(variable_catalog, component.alias),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _limit_constraint_holds(
|
|
||||||
spec: TestMqlMassEndstopSpec,
|
|
||||||
state: TestMqlMechanicalMassState,
|
|
||||||
applied_force_n: float,
|
|
||||||
) -> bool:
|
|
||||||
if abs(state.velocity_m_s) > spec.stick_velocity_threshold_m_s:
|
|
||||||
return False
|
|
||||||
at_lower_limit = state.displacement_m <= spec.xmin_m + spec.min_penetration_m
|
|
||||||
at_upper_limit = state.displacement_m >= spec.xmax_m - spec.max_penetration_m
|
|
||||||
return (at_lower_limit and applied_force_n <= 0.0) or (
|
|
||||||
at_upper_limit and applied_force_n >= 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_piecewise_signal(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
) -> TestMqlPiecewiseLinearSignalSpec:
|
|
||||||
starts = tuple(component.parameter_value(f"start{index}") for index in range(1, 9))
|
|
||||||
ends = tuple(component.parameter_value(f"end{index}") for index in range(1, 9))
|
|
||||||
durations = tuple(component.parameter_value(f"t{index}") for index in range(1, 9))
|
|
||||||
return TestMqlPiecewiseLinearSignalSpec(
|
|
||||||
alias=component.alias,
|
|
||||||
t_start_s=component.parameter_value("tstart"),
|
|
||||||
starts=starts,
|
|
||||||
ends=ends,
|
|
||||||
durations_s=durations,
|
|
||||||
stage_count=int(component.parameter_value("nstages")),
|
|
||||||
is_cyclic=bool(int(component.parameter_value("iscyclic"))),
|
|
||||||
data_paths=_data_paths(variable_catalog, component.alias),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_force_connector(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
) -> TestMqlForceConnectorSpec:
|
|
||||||
signal_alias_by_force_connector = {
|
|
||||||
"forcecon_1": "piecewiselinear",
|
|
||||||
"forcecon_2": "piecewiselinear_1",
|
|
||||||
}
|
|
||||||
target_mass_by_force_connector = {
|
|
||||||
"forcecon_1": "mass_friction_endstops_19",
|
|
||||||
"forcecon_2": "mass_friction_endstops_18",
|
|
||||||
}
|
|
||||||
return TestMqlForceConnectorSpec(
|
|
||||||
alias=component.alias,
|
|
||||||
signal_alias=signal_alias_by_force_connector[component.alias],
|
|
||||||
target_mass_alias=target_mass_by_force_connector[component.alias],
|
|
||||||
data_paths=_data_paths(variable_catalog, component.alias),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def n_per_mm_to_n_per_m(value: float) -> float:
|
|
||||||
return value * N_PER_MM_TO_N_PER_M
|
|
||||||
|
|
||||||
|
|
||||||
def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float:
|
|
||||||
return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S
|
|
||||||
|
|
||||||
|
|
||||||
def _initial_value(amesim_results: AmesimResults | None, data_path: str) -> float:
|
|
||||||
if amesim_results is None:
|
|
||||||
return 0.0
|
|
||||||
return float(amesim_results.series(data_path)[0])
|
|
||||||
|
|
||||||
|
|
||||||
def _data_paths(
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None,
|
|
||||||
alias: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
if variable_catalog is None:
|
|
||||||
return ()
|
|
||||||
return variable_catalog.data_paths_for_owner(alias)
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.system import COMPONENT_SPECS
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPneumaticNode3Balance:
|
|
||||||
temperature_k: float
|
|
||||||
pressure_pa: float
|
|
||||||
port_1_enthalpy_flow_w: float
|
|
||||||
port_1_mass_flow_g_s: float
|
|
||||||
port_1_volume_derivative_l_min: float
|
|
||||||
port_1_volume_cm3: float
|
|
||||||
port_2_enthalpy_flow_w: float
|
|
||||||
port_2_mass_flow_g_s: float
|
|
||||||
port_2_volume_derivative_l_min: float
|
|
||||||
port_2_volume_cm3: float
|
|
||||||
port_3_enthalpy_flow_w: float
|
|
||||||
port_3_mass_flow_g_s: float
|
|
||||||
port_3_volume_derivative_l_min: float
|
|
||||||
port_3_volume_cm3: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPneumaticNode3:
|
|
||||||
"""Exact algebraic contract of AMESim ``PN3NODE2``.
|
|
||||||
|
|
||||||
Pressure and temperature are fixed by port 2 and duplicated to ports 1 and
|
|
||||||
3. Flow and volume signals at port 2 are the sums of ports 1 and 3, matching
|
|
||||||
the ``EXPRESS2`` equations stored in ``test_mql_.cir``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias: str
|
|
||||||
|
|
||||||
def balance(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_2_temperature_k: float,
|
|
||||||
port_2_pressure_pa: float,
|
|
||||||
port_1_enthalpy_flow_w: float,
|
|
||||||
port_1_mass_flow_g_s: float,
|
|
||||||
port_3_enthalpy_flow_w: float,
|
|
||||||
port_3_mass_flow_g_s: float,
|
|
||||||
port_1_volume_derivative_l_min: float = 0.0,
|
|
||||||
port_1_volume_cm3: float = 0.0,
|
|
||||||
port_3_volume_derivative_l_min: float = 0.0,
|
|
||||||
port_3_volume_cm3: float = 0.0,
|
|
||||||
) -> TestMqlPneumaticNode3Balance:
|
|
||||||
if port_2_temperature_k <= 0.0:
|
|
||||||
raise ValueError("port_2_temperature_k must be positive")
|
|
||||||
if port_2_pressure_pa <= 0.0:
|
|
||||||
raise ValueError("port_2_pressure_pa must be positive")
|
|
||||||
return TestMqlPneumaticNode3Balance(
|
|
||||||
temperature_k=port_2_temperature_k,
|
|
||||||
pressure_pa=port_2_pressure_pa,
|
|
||||||
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
|
|
||||||
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
|
|
||||||
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
|
|
||||||
port_1_volume_cm3=port_1_volume_cm3,
|
|
||||||
port_2_enthalpy_flow_w=(
|
|
||||||
port_1_enthalpy_flow_w + port_3_enthalpy_flow_w
|
|
||||||
),
|
|
||||||
port_2_mass_flow_g_s=port_1_mass_flow_g_s + port_3_mass_flow_g_s,
|
|
||||||
port_2_volume_derivative_l_min=(
|
|
||||||
port_1_volume_derivative_l_min + port_3_volume_derivative_l_min
|
|
||||||
),
|
|
||||||
port_2_volume_cm3=port_1_volume_cm3 + port_3_volume_cm3,
|
|
||||||
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
|
|
||||||
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
|
|
||||||
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
|
|
||||||
port_3_volume_cm3=port_3_volume_cm3,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPneumaticNode4Balance:
|
|
||||||
temperature_k: float
|
|
||||||
pressure_pa: float
|
|
||||||
port_1_enthalpy_flow_w: float
|
|
||||||
port_1_mass_flow_g_s: float
|
|
||||||
port_1_volume_derivative_l_min: float
|
|
||||||
port_1_volume_cm3: float
|
|
||||||
port_2_enthalpy_flow_w: float
|
|
||||||
port_2_mass_flow_g_s: float
|
|
||||||
port_2_volume_derivative_l_min: float
|
|
||||||
port_2_volume_cm3: float
|
|
||||||
port_3_enthalpy_flow_w: float
|
|
||||||
port_3_mass_flow_g_s: float
|
|
||||||
port_3_volume_derivative_l_min: float
|
|
||||||
port_3_volume_cm3: float
|
|
||||||
port_4_enthalpy_flow_w: float
|
|
||||||
port_4_mass_flow_g_s: float
|
|
||||||
port_4_volume_derivative_l_min: float
|
|
||||||
port_4_volume_cm3: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPneumaticNode4:
|
|
||||||
"""Exact algebraic contract of AMESim ``P4NODE2``.
|
|
||||||
|
|
||||||
Pressure and temperature are fixed by port 2 and duplicated to ports 1, 3,
|
|
||||||
and 4. Flow and volume signals at port 2 are the sums of ports 1, 3, and
|
|
||||||
4, matching the saved AMESim variables for ``pnnode4_*`` instances.
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias: str
|
|
||||||
|
|
||||||
def balance(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_2_temperature_k: float,
|
|
||||||
port_2_pressure_pa: float,
|
|
||||||
port_1_enthalpy_flow_w: float,
|
|
||||||
port_1_mass_flow_g_s: float,
|
|
||||||
port_3_enthalpy_flow_w: float,
|
|
||||||
port_3_mass_flow_g_s: float,
|
|
||||||
port_4_enthalpy_flow_w: float,
|
|
||||||
port_4_mass_flow_g_s: float,
|
|
||||||
port_1_volume_derivative_l_min: float = 0.0,
|
|
||||||
port_1_volume_cm3: float = 0.0,
|
|
||||||
port_3_volume_derivative_l_min: float = 0.0,
|
|
||||||
port_3_volume_cm3: float = 0.0,
|
|
||||||
port_4_volume_derivative_l_min: float = 0.0,
|
|
||||||
port_4_volume_cm3: float = 0.0,
|
|
||||||
) -> TestMqlPneumaticNode4Balance:
|
|
||||||
if port_2_temperature_k <= 0.0:
|
|
||||||
raise ValueError("port_2_temperature_k must be positive")
|
|
||||||
if port_2_pressure_pa <= 0.0:
|
|
||||||
raise ValueError("port_2_pressure_pa must be positive")
|
|
||||||
return TestMqlPneumaticNode4Balance(
|
|
||||||
temperature_k=port_2_temperature_k,
|
|
||||||
pressure_pa=port_2_pressure_pa,
|
|
||||||
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
|
|
||||||
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
|
|
||||||
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
|
|
||||||
port_1_volume_cm3=port_1_volume_cm3,
|
|
||||||
port_2_enthalpy_flow_w=(
|
|
||||||
port_1_enthalpy_flow_w
|
|
||||||
+ port_3_enthalpy_flow_w
|
|
||||||
+ port_4_enthalpy_flow_w
|
|
||||||
),
|
|
||||||
port_2_mass_flow_g_s=(
|
|
||||||
port_1_mass_flow_g_s
|
|
||||||
+ port_3_mass_flow_g_s
|
|
||||||
+ port_4_mass_flow_g_s
|
|
||||||
),
|
|
||||||
port_2_volume_derivative_l_min=(
|
|
||||||
port_1_volume_derivative_l_min
|
|
||||||
+ port_3_volume_derivative_l_min
|
|
||||||
+ port_4_volume_derivative_l_min
|
|
||||||
),
|
|
||||||
port_2_volume_cm3=(
|
|
||||||
port_1_volume_cm3 + port_3_volume_cm3 + port_4_volume_cm3
|
|
||||||
),
|
|
||||||
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
|
|
||||||
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
|
|
||||||
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
|
|
||||||
port_3_volume_cm3=port_3_volume_cm3,
|
|
||||||
port_4_enthalpy_flow_w=port_4_enthalpy_flow_w,
|
|
||||||
port_4_mass_flow_g_s=port_4_mass_flow_g_s,
|
|
||||||
port_4_volume_derivative_l_min=port_4_volume_derivative_l_min,
|
|
||||||
port_4_volume_cm3=port_4_volume_cm3,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlP4NodePortConnection:
|
|
||||||
line_alias: str
|
|
||||||
local_node_alias: str
|
|
||||||
local_port: str
|
|
||||||
remote_node_alias: str
|
|
||||||
remote_port: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlP4NodePrimaryConnection:
|
|
||||||
line_alias: str
|
|
||||||
node_alias: str
|
|
||||||
node_port: str
|
|
||||||
chamber_alias: str
|
|
||||||
chamber_port: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlP4NodeOrificeConnection:
|
|
||||||
orifice_alias: str
|
|
||||||
node_alias: str
|
|
||||||
node_port: str
|
|
||||||
direct_line_alias: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlP4NodeNeighborhood:
|
|
||||||
node_alias: str
|
|
||||||
primary: TestMqlP4NodePrimaryConnection
|
|
||||||
port_1: TestMqlP4NodePortConnection
|
|
||||||
port_3: TestMqlP4NodePortConnection
|
|
||||||
port_4: TestMqlP4NodeOrificeConnection
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_node3_assembly() -> dict[str, TestMqlPneumaticNode3]:
|
|
||||||
return {
|
|
||||||
str(spec["alias"]): TestMqlPneumaticNode3(alias=str(spec["alias"]))
|
|
||||||
for spec in COMPONENT_SPECS
|
|
||||||
if spec["submodel"] == "PN3NODE2"
|
|
||||||
}
|
|
||||||
|
|
||||||
def build_test_mql_node4_assembly() -> dict[str, TestMqlPneumaticNode4]:
|
|
||||||
return {
|
|
||||||
str(spec["alias"]): TestMqlPneumaticNode4(alias=str(spec["alias"]))
|
|
||||||
for spec in COMPONENT_SPECS
|
|
||||||
if spec["submodel"] == "P4NODE2"
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
|
||||||
HELIUM_PNEUMATIC_GAS,
|
|
||||||
AmesimPneumaticGas,
|
|
||||||
AmesimPneumaticOrifice,
|
|
||||||
AmesimPneumaticVolume,
|
|
||||||
AmesimVariablePneumaticVolume,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
|
|
||||||
|
|
||||||
|
|
||||||
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
|
||||||
BAR_TO_PA = 1.0e5
|
|
||||||
DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15
|
|
||||||
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0
|
|
||||||
# Matched to PNVO001 event-window mass flow near the 0.04 s opening event.
|
|
||||||
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER = 0.99805
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlStepSignalSpec:
|
|
||||||
alias: str
|
|
||||||
initial_output: float
|
|
||||||
final_output: float
|
|
||||||
step_time_s: float
|
|
||||||
transition_duration_s: float
|
|
||||||
transition_type: int
|
|
||||||
|
|
||||||
def output_at(self, time_s: float) -> float:
|
|
||||||
if self.transition_type != 1:
|
|
||||||
raise ValueError(
|
|
||||||
f"unsupported STEP0 transition type {self.transition_type} on {self.alias}"
|
|
||||||
)
|
|
||||||
return self.initial_output if time_s < self.step_time_s else self.final_output
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlVariableOrificeControl:
|
|
||||||
orifice_alias: str
|
|
||||||
step: TestMqlStepSignalSpec
|
|
||||||
|
|
||||||
def opening_at(self, time_s: float) -> float:
|
|
||||||
return self.step.output_at(time_s)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPneumaticAssembly:
|
|
||||||
fixed_chambers: dict[str, AmesimPneumaticVolume]
|
|
||||||
variable_chambers: dict[str, AmesimVariablePneumaticVolume]
|
|
||||||
fixed_orifices: dict[str, AmesimPneumaticOrifice]
|
|
||||||
variable_orifices: dict[str, AmesimPneumaticOrifice]
|
|
||||||
variable_orifice_controls: dict[str, TestMqlVariableOrificeControl]
|
|
||||||
fixed_initial_absolute_pressure_pa: float
|
|
||||||
variable_initial_absolute_pressure_pa: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def initial_pressure_pa(self) -> float:
|
|
||||||
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fixed_initial_gauge_pressure_pa(self) -> float:
|
|
||||||
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def variable_initial_gauge_pressure_pa(self) -> float:
|
|
||||||
return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def chamber_count(self) -> int:
|
|
||||||
return len(self.fixed_chambers) + len(self.variable_chambers)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def orifice_count(self) -> int:
|
|
||||||
return len(self.fixed_orifices) + len(self.variable_orifices)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def component_count(self) -> int:
|
|
||||||
return self.chamber_count + self.orifice_count
|
|
||||||
|
|
||||||
@property
|
|
||||||
def variable_orifice_control_count(self) -> int:
|
|
||||||
return len(self.variable_orifice_controls)
|
|
||||||
|
|
||||||
def set_variable_orifice_openings(self, time_s: float) -> None:
|
|
||||||
for alias, control in self.variable_orifice_controls.items():
|
|
||||||
self.variable_orifices[alias].opening = control.opening_at(time_s)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def aliases(self) -> tuple[str, ...]:
|
|
||||||
return tuple(
|
|
||||||
[
|
|
||||||
*self.fixed_chambers,
|
|
||||||
*self.variable_chambers,
|
|
||||||
*self.fixed_orifices,
|
|
||||||
*self.variable_orifices,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_pneumatic_assembly(
|
|
||||||
config: TestMqlConfig | None = None,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> TestMqlPneumaticAssembly:
|
|
||||||
config = config or TestMqlConfig.from_amesim_specs()
|
|
||||||
fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
|
|
||||||
config.global_parameters["P0"]
|
|
||||||
)
|
|
||||||
variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
|
|
||||||
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR
|
|
||||||
)
|
|
||||||
fixed_chambers = {
|
|
||||||
component.alias: _build_chamber(
|
|
||||||
component,
|
|
||||||
volume_parameter="cvol",
|
|
||||||
gas=gas,
|
|
||||||
initial_pressure_pa=fixed_initial_absolute_pressure_pa,
|
|
||||||
)
|
|
||||||
for component in config.components_by_submodel("PNCH023")
|
|
||||||
}
|
|
||||||
variable_chambers = {
|
|
||||||
component.alias: _build_chamber(
|
|
||||||
component,
|
|
||||||
volume_parameter="cvol0",
|
|
||||||
gas=gas,
|
|
||||||
initial_pressure_pa=variable_initial_absolute_pressure_pa,
|
|
||||||
)
|
|
||||||
for component in config.components_by_submodel("PNCH012")
|
|
||||||
}
|
|
||||||
fixed_orifices = {
|
|
||||||
component.alias: _build_orifice(
|
|
||||||
component,
|
|
||||||
area_parameter="area",
|
|
||||||
gas=gas,
|
|
||||||
opening=1.0,
|
|
||||||
)
|
|
||||||
for component in config.components_by_submodel("PNOR001")
|
|
||||||
}
|
|
||||||
variable_orifice_controls = _build_variable_orifice_controls(config)
|
|
||||||
variable_orifices = {
|
|
||||||
component.alias: _build_orifice(
|
|
||||||
component,
|
|
||||||
area_parameter="area0",
|
|
||||||
gas=gas,
|
|
||||||
opening=variable_orifice_controls[component.alias].opening_at(0.0),
|
|
||||||
)
|
|
||||||
for component in config.components_by_submodel("PNVO001")
|
|
||||||
}
|
|
||||||
return TestMqlPneumaticAssembly(
|
|
||||||
fixed_chambers=fixed_chambers,
|
|
||||||
variable_chambers=variable_chambers,
|
|
||||||
fixed_orifices=fixed_orifices,
|
|
||||||
variable_orifices=variable_orifices,
|
|
||||||
variable_orifice_controls=variable_orifice_controls,
|
|
||||||
fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa,
|
|
||||||
variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_variable_orifice_controls(
|
|
||||||
config: TestMqlConfig,
|
|
||||||
) -> dict[str, TestMqlVariableOrificeControl]:
|
|
||||||
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
|
|
||||||
|
|
||||||
components_by_alias = {component.alias: component for component in config.components}
|
|
||||||
variable_orifice_aliases = {
|
|
||||||
component.alias for component in config.components_by_submodel("PNVO001")
|
|
||||||
}
|
|
||||||
controls: dict[str, TestMqlVariableOrificeControl] = {}
|
|
||||||
for connection in CONNECTION_SPECS:
|
|
||||||
if connection["submodel"] != "DIRECT":
|
|
||||||
continue
|
|
||||||
source_alias = str(connection["source_component"])
|
|
||||||
target_alias = str(connection["target_component"])
|
|
||||||
if target_alias in variable_orifice_aliases:
|
|
||||||
orifice_alias = target_alias
|
|
||||||
step_alias = source_alias
|
|
||||||
elif source_alias in variable_orifice_aliases:
|
|
||||||
orifice_alias = source_alias
|
|
||||||
step_alias = target_alias
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
step_component = components_by_alias.get(step_alias)
|
|
||||||
if step_component is None or step_component.submodel != "STEP0":
|
|
||||||
continue
|
|
||||||
controls[orifice_alias] = TestMqlVariableOrificeControl(
|
|
||||||
orifice_alias=orifice_alias,
|
|
||||||
step=TestMqlStepSignalSpec(
|
|
||||||
alias=step_alias,
|
|
||||||
initial_output=step_component.parameter_value("out0"),
|
|
||||||
final_output=step_component.parameter_value("out1"),
|
|
||||||
step_time_s=step_component.parameter_value("t0"),
|
|
||||||
transition_duration_s=step_component.parameter_value("td"),
|
|
||||||
transition_type=int(step_component.parameter_value("transitionType")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
missing = variable_orifice_aliases - controls.keys()
|
|
||||||
if missing:
|
|
||||||
raise ValueError(
|
|
||||||
"missing STEP0 controls for PNVO001 components: "
|
|
||||||
+ ", ".join(sorted(missing))
|
|
||||||
)
|
|
||||||
return controls
|
|
||||||
|
|
||||||
|
|
||||||
def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
|
|
||||||
return pressure_bar * BAR_TO_PA
|
|
||||||
|
|
||||||
|
|
||||||
def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float:
|
|
||||||
return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA
|
|
||||||
|
|
||||||
|
|
||||||
def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
|
|
||||||
return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar))
|
|
||||||
|
|
||||||
|
|
||||||
def _build_chamber(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
*,
|
|
||||||
volume_parameter: str,
|
|
||||||
gas: AmesimPneumaticGas,
|
|
||||||
initial_pressure_pa: float,
|
|
||||||
) -> AmesimPneumaticVolume:
|
|
||||||
if volume_parameter == "cvol0":
|
|
||||||
return AmesimVariablePneumaticVolume.from_liters(
|
|
||||||
name=component.alias,
|
|
||||||
dead_volume_liters=component.parameter_value(volume_parameter),
|
|
||||||
gas=gas,
|
|
||||||
p0=initial_pressure_pa,
|
|
||||||
T0=_component_temperature(component),
|
|
||||||
heat_transfer_coefficient=component.parameter_value("kth"),
|
|
||||||
heat_transfer_area=component.parameter_value("sth"),
|
|
||||||
external_temperature_k=_component_temperature(component),
|
|
||||||
)
|
|
||||||
return AmesimPneumaticVolume.from_liters(
|
|
||||||
name=component.alias,
|
|
||||||
volume_liters=component.parameter_value(volume_parameter),
|
|
||||||
gas=gas,
|
|
||||||
p0=initial_pressure_pa,
|
|
||||||
T0=_component_temperature(component),
|
|
||||||
heat_transfer_coefficient=component.parameter_value("kth"),
|
|
||||||
heat_transfer_area=component.parameter_value("sth"),
|
|
||||||
external_temperature_k=_component_temperature(component),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_orifice(
|
|
||||||
component: TestMqlResolvedComponent,
|
|
||||||
*,
|
|
||||||
area_parameter: str,
|
|
||||||
gas: AmesimPneumaticGas,
|
|
||||||
opening: float,
|
|
||||||
) -> AmesimPneumaticOrifice:
|
|
||||||
flow_coefficient = component.parameter_value("cq")
|
|
||||||
if component.submodel == "PNVO001":
|
|
||||||
flow_coefficient *= TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER
|
|
||||||
return AmesimPneumaticOrifice.from_mm2(
|
|
||||||
name=component.alias,
|
|
||||||
area_mm2=component.parameter_value(area_parameter),
|
|
||||||
flow_coefficient=flow_coefficient,
|
|
||||||
gas=gas,
|
|
||||||
opening=opening,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _component_temperature(component: TestMqlResolvedComponent) -> float:
|
|
||||||
parameter = component.parameters.get("extemp")
|
|
||||||
if parameter is None or parameter.value is None:
|
|
||||||
return DEFAULT_TEST_MQL_TEMPERATURE_K
|
|
||||||
return parameter.value
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
|
||||||
HELIUM_PNEUMATIC_GAS,
|
|
||||||
AmesimPneumaticGas,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
|
|
||||||
AmesimPnl0001Pipe,
|
|
||||||
AmesimPnl0002Pipe,
|
|
||||||
AmesimPnl0003Pipe,
|
|
||||||
AmesimPnl00rPipe,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.line_parameters import (
|
|
||||||
TestMqlPnl0001Spec,
|
|
||||||
TestMqlPnl0002Spec,
|
|
||||||
TestMqlPnl0003Spec,
|
|
||||||
TestMqlPnl00rSpec,
|
|
||||||
load_test_mql_pnl0001_specs,
|
|
||||||
load_test_mql_pnl0002_specs,
|
|
||||||
load_test_mql_pnl0003_specs,
|
|
||||||
load_test_mql_pnl00r_specs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE = 5.5636e-6
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl0001Assembly:
|
|
||||||
specs: tuple[TestMqlPnl0001Spec, ...]
|
|
||||||
lines: dict[str, AmesimPnl0001Pipe]
|
|
||||||
|
|
||||||
def spec(self, alias: str) -> TestMqlPnl0001Spec:
|
|
||||||
for spec in self.specs:
|
|
||||||
if spec.alias == alias:
|
|
||||||
return spec
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl0002Assembly:
|
|
||||||
specs: tuple[TestMqlPnl0002Spec, ...]
|
|
||||||
lines: dict[str, AmesimPnl0002Pipe]
|
|
||||||
|
|
||||||
def spec(self, alias: str) -> TestMqlPnl0002Spec:
|
|
||||||
for spec in self.specs:
|
|
||||||
if spec.alias == alias:
|
|
||||||
return spec
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl0003Assembly:
|
|
||||||
specs: tuple[TestMqlPnl0003Spec, ...]
|
|
||||||
lines: dict[str, AmesimPnl0003Pipe]
|
|
||||||
|
|
||||||
def spec(self, alias: str) -> TestMqlPnl0003Spec:
|
|
||||||
for spec in self.specs:
|
|
||||||
if spec.alias == alias:
|
|
||||||
return spec
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPnl00rAssembly:
|
|
||||||
specs: tuple[TestMqlPnl00rSpec, ...]
|
|
||||||
lines: dict[str, AmesimPnl00rPipe]
|
|
||||||
|
|
||||||
def spec(self, alias: str) -> TestMqlPnl00rSpec:
|
|
||||||
for spec in self.specs:
|
|
||||||
if spec.alias == alias:
|
|
||||||
return spec
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_pnl0001_assembly(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> TestMqlPnl0001Assembly:
|
|
||||||
specs = load_test_mql_pnl0001_specs(archive_path)
|
|
||||||
lines = {
|
|
||||||
spec.alias: AmesimPnl0001Pipe(
|
|
||||||
name=spec.alias,
|
|
||||||
diameter_mm=spec.diameter_mm,
|
|
||||||
length_m=spec.length_m,
|
|
||||||
relative_roughness=spec.relative_roughness,
|
|
||||||
polytropic_constant=spec.polytropic_constant,
|
|
||||||
heat_transfer_coefficient=spec.heat_transfer_coefficient,
|
|
||||||
external_temperature_k=spec.external_temperature_k,
|
|
||||||
calibrated_linear_conductance=(
|
|
||||||
_test_mql_pnl0001_calibrated_linear_conductance(spec)
|
|
||||||
),
|
|
||||||
gas=gas,
|
|
||||||
p0=spec.initial_absolute_pressure_pa,
|
|
||||||
T0=spec.initial_temperature_k,
|
|
||||||
)
|
|
||||||
for spec in specs
|
|
||||||
}
|
|
||||||
return TestMqlPnl0001Assembly(specs=specs, lines=lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _test_mql_pnl0001_calibrated_linear_conductance(
|
|
||||||
spec: TestMqlPnl0001Spec,
|
|
||||||
) -> float | None:
|
|
||||||
if spec.target_component.startswith("pn_c1_") and _matches_geometry(
|
|
||||||
spec, diameter_mm=20.0, length_m=1.0
|
|
||||||
):
|
|
||||||
return TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _matches_geometry(
|
|
||||||
spec: TestMqlPnl0001Spec,
|
|
||||||
*,
|
|
||||||
diameter_mm: float,
|
|
||||||
length_m: float,
|
|
||||||
) -> bool:
|
|
||||||
return (
|
|
||||||
abs(spec.diameter_mm - diameter_mm) < 1.0e-12
|
|
||||||
and abs(spec.length_m - length_m) < 1.0e-12
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_pnl0002_assembly(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> TestMqlPnl0002Assembly:
|
|
||||||
specs = load_test_mql_pnl0002_specs(archive_path)
|
|
||||||
lines = {
|
|
||||||
spec.alias: AmesimPnl0002Pipe(
|
|
||||||
name=spec.alias,
|
|
||||||
diameter_mm=spec.diameter_mm,
|
|
||||||
length_m=spec.length_m,
|
|
||||||
relative_roughness=spec.relative_roughness,
|
|
||||||
polytropic_constant=spec.polytropic_constant,
|
|
||||||
heat_transfer_coefficient=spec.heat_transfer_coefficient,
|
|
||||||
external_temperature_k=spec.external_temperature_k,
|
|
||||||
gas=gas,
|
|
||||||
pctr_0=spec.initial_center_absolute_pressure_pa,
|
|
||||||
Tctr_0=spec.initial_center_temperature_k,
|
|
||||||
)
|
|
||||||
for spec in specs
|
|
||||||
}
|
|
||||||
return TestMqlPnl0002Assembly(specs=specs, lines=lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_pnl0003_assembly(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> TestMqlPnl0003Assembly:
|
|
||||||
specs = load_test_mql_pnl0003_specs(archive_path)
|
|
||||||
lines = {
|
|
||||||
spec.alias: AmesimPnl0003Pipe(
|
|
||||||
name=spec.alias,
|
|
||||||
diameter_mm=spec.diameter_mm,
|
|
||||||
length_m=spec.length_m,
|
|
||||||
relative_roughness=spec.relative_roughness,
|
|
||||||
polytropic_constant=spec.polytropic_constant,
|
|
||||||
heat_transfer_coefficient=spec.heat_transfer_coefficient,
|
|
||||||
external_temperature_k=spec.external_temperature_k,
|
|
||||||
gas=gas,
|
|
||||||
p1_0=spec.initial_absolute_pressure_1_pa,
|
|
||||||
T1_0=spec.initial_temperature_1_k,
|
|
||||||
p2_0=spec.initial_absolute_pressure_2_pa,
|
|
||||||
T2_0=spec.initial_temperature_2_k,
|
|
||||||
)
|
|
||||||
for spec in specs
|
|
||||||
}
|
|
||||||
return TestMqlPnl0003Assembly(specs=specs, lines=lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_pnl00r_assembly(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> TestMqlPnl00rAssembly:
|
|
||||||
specs = load_test_mql_pnl00r_specs(archive_path)
|
|
||||||
lines = {
|
|
||||||
spec.alias: AmesimPnl00rPipe(
|
|
||||||
name=spec.alias,
|
|
||||||
diameter_mm=spec.diameter_mm,
|
|
||||||
length_m=spec.length_m,
|
|
||||||
relative_roughness=spec.relative_roughness,
|
|
||||||
gas=gas,
|
|
||||||
)
|
|
||||||
for spec in specs
|
|
||||||
}
|
|
||||||
return TestMqlPnl00rAssembly(specs=specs, lines=lines)
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.closure import TestMqlPneumaticChamberSegmentSpec
|
|
||||||
from app.simulation.examples.test_mql.topology import TestMqlCirTopology
|
|
||||||
|
|
||||||
|
|
||||||
def discover_fixed_chamber_segments(
|
|
||||||
topology: TestMqlCirTopology,
|
|
||||||
component_specs: list[dict[str, object]],
|
|
||||||
connection_specs: list[dict[str, object]],
|
|
||||||
) -> tuple[TestMqlPneumaticChamberSegmentSpec, ...]:
|
|
||||||
submodel_by_alias = {
|
|
||||||
str(component["alias"]): str(component["submodel"])
|
|
||||||
for component in component_specs
|
|
||||||
}
|
|
||||||
segments = []
|
|
||||||
for component in component_specs:
|
|
||||||
volume_alias = str(component["alias"])
|
|
||||||
if component["submodel"] != "PNCH023":
|
|
||||||
continue
|
|
||||||
|
|
||||||
orifice_contacts = []
|
|
||||||
for contact in topology.contacts_for(volume_alias):
|
|
||||||
other_alias, other_port = contact.other_endpoint(volume_alias)
|
|
||||||
if submodel_by_alias.get(other_alias) == "PNOR001":
|
|
||||||
orifice_contacts.append(
|
|
||||||
(
|
|
||||||
other_alias,
|
|
||||||
other_port,
|
|
||||||
contact.port_for(volume_alias),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if len(orifice_contacts) != 2:
|
|
||||||
raise ValueError(
|
|
||||||
f"{volume_alias} must contact exactly two PNOR001 orifices; "
|
|
||||||
f"found {len(orifice_contacts)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
sides = [
|
|
||||||
_resolve_orifice_boundary(
|
|
||||||
orifice_alias=orifice_alias,
|
|
||||||
orifice_volume_port=orifice_volume_port,
|
|
||||||
volume_port=volume_port,
|
|
||||||
connection_specs=connection_specs,
|
|
||||||
submodel_by_alias=submodel_by_alias,
|
|
||||||
)
|
|
||||||
for orifice_alias, orifice_volume_port, volume_port in orifice_contacts
|
|
||||||
]
|
|
||||||
inlet_sides = [side for side in sides if side["role"] == "inlet"]
|
|
||||||
outlet_sides = [side for side in sides if side["role"] == "outlet"]
|
|
||||||
if len(inlet_sides) != 1 or len(outlet_sides) != 1:
|
|
||||||
raise ValueError(
|
|
||||||
f"{volume_alias} requires one inlet and one outlet topology side"
|
|
||||||
)
|
|
||||||
inlet = inlet_sides[0]
|
|
||||||
outlet = outlet_sides[0]
|
|
||||||
segments.append(
|
|
||||||
TestMqlPneumaticChamberSegmentSpec(
|
|
||||||
name=f"{volume_alias}_segment",
|
|
||||||
inlet_node_alias=inlet["node_alias"],
|
|
||||||
inlet_line_alias=inlet["line_alias"],
|
|
||||||
inlet_orifice_alias=inlet["orifice_alias"],
|
|
||||||
inlet_orifice_boundary_port=inlet["orifice_boundary_port"],
|
|
||||||
inlet_orifice_volume_port=inlet["orifice_volume_port"],
|
|
||||||
volume_alias=volume_alias,
|
|
||||||
volume_inlet_port=inlet["volume_port"],
|
|
||||||
volume_outlet_port=outlet["volume_port"],
|
|
||||||
outlet_orifice_alias=outlet["orifice_alias"],
|
|
||||||
outlet_orifice_volume_port=outlet["orifice_volume_port"],
|
|
||||||
outlet_orifice_boundary_port=outlet["orifice_boundary_port"],
|
|
||||||
outlet_line_alias=outlet["line_alias"],
|
|
||||||
outlet_node_alias=outlet["node_alias"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(segments)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_orifice_boundary(
|
|
||||||
*,
|
|
||||||
orifice_alias: str,
|
|
||||||
orifice_volume_port: str,
|
|
||||||
volume_port: str,
|
|
||||||
connection_specs: list[dict[str, object]],
|
|
||||||
submodel_by_alias: dict[str, str],
|
|
||||||
) -> dict[str, str]:
|
|
||||||
boundary_connections = []
|
|
||||||
for connection in connection_specs:
|
|
||||||
if (
|
|
||||||
connection["source_component"] == orifice_alias
|
|
||||||
and connection["source_port"] != orifice_volume_port
|
|
||||||
) or (
|
|
||||||
connection["target_component"] == orifice_alias
|
|
||||||
and connection["target_port"] != orifice_volume_port
|
|
||||||
):
|
|
||||||
boundary_connections.append(connection)
|
|
||||||
if len(boundary_connections) != 1:
|
|
||||||
raise ValueError(
|
|
||||||
f"{orifice_alias} must have exactly one non-volume boundary connection; "
|
|
||||||
f"found {len(boundary_connections)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
connection = boundary_connections[0]
|
|
||||||
if connection["submodel"] != "PNL0001":
|
|
||||||
raise ValueError(
|
|
||||||
f"{orifice_alias} boundary must use PNL0001, got {connection['submodel']}"
|
|
||||||
)
|
|
||||||
if connection["target_component"] == orifice_alias:
|
|
||||||
role = "inlet"
|
|
||||||
node_alias = str(connection["source_component"])
|
|
||||||
orifice_boundary_port = str(connection["target_port"])
|
|
||||||
else:
|
|
||||||
role = "outlet"
|
|
||||||
node_alias = str(connection["target_component"])
|
|
||||||
orifice_boundary_port = str(connection["source_port"])
|
|
||||||
if submodel_by_alias.get(node_alias) != "PN3NODE2":
|
|
||||||
raise ValueError(
|
|
||||||
f"{orifice_alias} PNL0001 boundary must terminate at PN3NODE2, "
|
|
||||||
f"got {node_alias}"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"role": role,
|
|
||||||
"node_alias": node_alias,
|
|
||||||
"line_alias": str(connection["alias"]),
|
|
||||||
"orifice_alias": orifice_alias,
|
|
||||||
"orifice_boundary_port": orifice_boundary_port,
|
|
||||||
"orifice_volume_port": orifice_volume_port,
|
|
||||||
"volume_port": volume_port,
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Calibrated component primitives used only by the ``test_mql`` example."""
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import pi
|
|
||||||
|
|
||||||
|
|
||||||
MM_TO_M = 1.0e-3
|
|
||||||
M_TO_MM = 1.0e3
|
|
||||||
M3_TO_CM3 = 1.0e6
|
|
||||||
M3_PER_S_TO_L_PER_MIN = 60_000.0
|
|
||||||
|
|
||||||
|
|
||||||
def circular_area(diameter_m: float) -> float:
|
|
||||||
if diameter_m < 0.0:
|
|
||||||
raise ValueError("diameter_m must be non-negative.")
|
|
||||||
return pi * diameter_m * diameter_m / 4.0
|
|
||||||
|
|
||||||
|
|
||||||
def mm_to_m(value: float) -> float:
|
|
||||||
return value * MM_TO_M
|
|
||||||
|
|
||||||
|
|
||||||
def m_to_mm(value: float) -> float:
|
|
||||||
return value * M_TO_MM
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AmesimPistonGeometry:
|
|
||||||
"""Geometry relations used by AMESim PNRP17 pneumatic piston variables."""
|
|
||||||
|
|
||||||
piston_diameter_m: float
|
|
||||||
rod_diameter_m: float = 0.0
|
|
||||||
zero_length_m: float = 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def piston_area_m2(self) -> float:
|
|
||||||
return circular_area(self.piston_diameter_m)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rod_area_m2(self) -> float:
|
|
||||||
return circular_area(self.rod_diameter_m)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def annulus_area_m2(self) -> float:
|
|
||||||
return self.piston_area_m2 - self.rod_area_m2
|
|
||||||
|
|
||||||
def chamber_length_m(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
|
||||||
return self.zero_length_m + port5_displacement_m - port4_displacement_m
|
|
||||||
|
|
||||||
def chamber_length_mm(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
|
||||||
return m_to_mm(self.chamber_length_m(port4_displacement_m, port5_displacement_m))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def chamber_area_m2(self) -> float:
|
|
||||||
return self.annulus_area_m2
|
|
||||||
|
|
||||||
def chamber_volume_m3(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
|
||||||
return self.chamber_area_m2 * self.chamber_length_m(
|
|
||||||
port4_displacement_m,
|
|
||||||
port5_displacement_m,
|
|
||||||
)
|
|
||||||
|
|
||||||
def chamber_volume_cm3(self, port4_displacement_m: float, port5_displacement_m: float) -> float:
|
|
||||||
return self.chamber_volume_m3(port4_displacement_m, port5_displacement_m) * M3_TO_CM3
|
|
||||||
|
|
||||||
def chamber_volume_rate_m3_s(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float:
|
|
||||||
return self.chamber_area_m2 * (port5_velocity_m_s - port4_velocity_m_s)
|
|
||||||
|
|
||||||
def chamber_volume_rate_l_min(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float:
|
|
||||||
return self.chamber_volume_rate_m3_s(
|
|
||||||
port4_velocity_m_s,
|
|
||||||
port5_velocity_m_s,
|
|
||||||
) * M3_PER_S_TO_L_PER_MIN
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AmesimElasticEndstop:
|
|
||||||
"""Contact force part of AMESim LSTP00A elastic endstop."""
|
|
||||||
|
|
||||||
contact_stiffness_n_per_m: float
|
|
||||||
contact_damping_n_per_m_per_s: float = 0.0
|
|
||||||
gap0_m: float = 0.0
|
|
||||||
|
|
||||||
def penetration_m_from_gap_mm(self, gap_mm: float) -> float:
|
|
||||||
return max(-(mm_to_m(gap_mm) - self.gap0_m), 0.0)
|
|
||||||
|
|
||||||
def static_contact_force(self, gap_mm: float) -> float:
|
|
||||||
return self.contact_stiffness_n_per_m * self.penetration_m_from_gap_mm(gap_mm)
|
|
||||||
|
|
||||||
def contact_force(self, gap_mm: float, penetration_velocity_m_s: float = 0.0) -> float:
|
|
||||||
if self.penetration_m_from_gap_mm(gap_mm) <= 0.0:
|
|
||||||
return 0.0
|
|
||||||
damping_force = self.contact_damping_n_per_m_per_s * penetration_velocity_m_s
|
|
||||||
return max(self.static_contact_force(gap_mm) + damping_force, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AmesimMassFrictionEndstops:
|
|
||||||
"""Parameter and observable helpers for AMESim MECMAS21 translation masses."""
|
|
||||||
|
|
||||||
mass_kg: float
|
|
||||||
lower_limit_m: float
|
|
||||||
upper_limit_m: float
|
|
||||||
lower_stiffness_n_per_m: float
|
|
||||||
upper_stiffness_n_per_m: float
|
|
||||||
lower_damping_n_per_m_per_s: float = 0.0
|
|
||||||
upper_damping_n_per_m_per_s: float = 0.0
|
|
||||||
viscous_friction_n_per_m_per_s: float = 0.0
|
|
||||||
coulomb_friction_n: float = 0.0
|
|
||||||
stiction_force_n: float = 0.0
|
|
||||||
windage_n_per_m2_per_s2: float = 0.0
|
|
||||||
|
|
||||||
def lower_penetration_m(self, displacement_m: float) -> float:
|
|
||||||
return max(self.lower_limit_m - displacement_m, 0.0)
|
|
||||||
|
|
||||||
def upper_penetration_m(self, displacement_m: float) -> float:
|
|
||||||
return max(displacement_m - self.upper_limit_m, 0.0)
|
|
||||||
|
|
||||||
def lower_static_force_magnitude(self, displacement_m: float) -> float:
|
|
||||||
return self.lower_stiffness_n_per_m * self.lower_penetration_m(displacement_m)
|
|
||||||
|
|
||||||
def upper_static_force_magnitude(self, displacement_m: float) -> float:
|
|
||||||
return self.upper_stiffness_n_per_m * self.upper_penetration_m(displacement_m)
|
|
||||||
|
|
||||||
def viscous_friction_force(self, velocity_m_s: float) -> float:
|
|
||||||
return -self.viscous_friction_n_per_m_per_s * velocity_m_s
|
|
||||||
|
|
||||||
def windage_force(self, velocity_m_s: float) -> float:
|
|
||||||
return -self.windage_n_per_m2_per_s2 * velocity_m_s * abs(velocity_m_s)
|
|
||||||
|
|
||||||
def dry_friction_force(self, velocity_m_s: float) -> float:
|
|
||||||
if velocity_m_s > 0.0:
|
|
||||||
return -self.coulomb_friction_n
|
|
||||||
if velocity_m_s < 0.0:
|
|
||||||
return self.coulomb_friction_n
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def limit_contact_force(self, displacement_m: float, velocity_m_s: float) -> float:
|
|
||||||
lower_force = self.lower_static_force_magnitude(displacement_m)
|
|
||||||
if lower_force > 0.0:
|
|
||||||
lower_force += max(-self.lower_damping_n_per_m_per_s * velocity_m_s, 0.0)
|
|
||||||
|
|
||||||
upper_force = self.upper_static_force_magnitude(displacement_m)
|
|
||||||
if upper_force > 0.0:
|
|
||||||
upper_force += max(self.upper_damping_n_per_m_per_s * velocity_m_s, 0.0)
|
|
||||||
|
|
||||||
return lower_force - upper_force
|
|
||||||
|
|
||||||
def derivatives(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
velocity_m_s: float,
|
|
||||||
displacement_m: float,
|
|
||||||
port_1_force_n: float = 0.0,
|
|
||||||
port_2_force_n: float = 0.0,
|
|
||||||
external_force_n: float = 0.0,
|
|
||||||
) -> tuple[float, float]:
|
|
||||||
total_force = (
|
|
||||||
port_1_force_n
|
|
||||||
+ port_2_force_n
|
|
||||||
+ external_force_n
|
|
||||||
+ self.viscous_friction_force(velocity_m_s)
|
|
||||||
+ self.windage_force(velocity_m_s)
|
|
||||||
+ self.dry_friction_force(velocity_m_s)
|
|
||||||
+ self.limit_contact_force(displacement_m, velocity_m_s)
|
|
||||||
)
|
|
||||||
return total_force / self.mass_kg, velocity_m_s
|
|
||||||
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import pi, sqrt
|
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
|
||||||
from app.simulation.core.medium import ThermodynamicProperties
|
|
||||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
|
||||||
from app.simulation.core.ports import PortState
|
|
||||||
from app.simulation.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AmesimPneumaticGas:
|
|
||||||
"""Caloric constants plus Peng-Robinson EOS for AMESim pneumatic components."""
|
|
||||||
|
|
||||||
fluid: PengRobinsonFluid = HELIUM_PR
|
|
||||||
cp: float = 5193.0
|
|
||||||
cv: float = 3116.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gamma(self) -> float:
|
|
||||||
return self.cp / self.cv
|
|
||||||
|
|
||||||
@property
|
|
||||||
def R_gas(self) -> float:
|
|
||||||
return self.fluid.specific_gas_constant
|
|
||||||
|
|
||||||
def density(self, pressure: float, temperature: float) -> float:
|
|
||||||
return self.fluid.density(pressure, temperature)
|
|
||||||
|
|
||||||
def pressure(self, density: float, temperature: float) -> float:
|
|
||||||
return self.fluid.pressure_from_density(temperature, density)
|
|
||||||
|
|
||||||
def specific_internal_energy(self, temperature: float) -> float:
|
|
||||||
return self.cv * temperature
|
|
||||||
|
|
||||||
def specific_enthalpy(self, temperature: float) -> float:
|
|
||||||
return self.cp * temperature
|
|
||||||
|
|
||||||
def specific_reference_enthalpy(
|
|
||||||
self,
|
|
||||||
temperature: float,
|
|
||||||
reference_temperature: float = 298.15,
|
|
||||||
) -> float:
|
|
||||||
return self.cp * (temperature - reference_temperature)
|
|
||||||
|
|
||||||
def reference_temperature_from_specific_enthalpy(
|
|
||||||
self,
|
|
||||||
specific_enthalpy: float,
|
|
||||||
reference_temperature: float = 298.15,
|
|
||||||
) -> float:
|
|
||||||
if self.cp <= 0.0:
|
|
||||||
raise ValueError("cp must be positive.")
|
|
||||||
return reference_temperature + specific_enthalpy / self.cp
|
|
||||||
|
|
||||||
def pressure_reference_enthalpy(
|
|
||||||
self,
|
|
||||||
pressure: float,
|
|
||||||
temperature: float,
|
|
||||||
reference_pressure: float = 101_300.0,
|
|
||||||
reference_temperature: float = 298.15,
|
|
||||||
) -> float:
|
|
||||||
return (
|
|
||||||
self.specific_reference_enthalpy(temperature, reference_temperature)
|
|
||||||
+ self.fluid.residual_specific_enthalpy(pressure, temperature)
|
|
||||||
- self.fluid.residual_specific_enthalpy(
|
|
||||||
reference_pressure,
|
|
||||||
reference_temperature,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def pressure_transport_enthalpy(
|
|
||||||
self,
|
|
||||||
pressure: float,
|
|
||||||
temperature: float,
|
|
||||||
reference_pressure: float = 101_300.0,
|
|
||||||
reference_temperature: float = 298.15,
|
|
||||||
) -> float:
|
|
||||||
"""Convert AMESim reference enthalpy to the absolute-energy state basis."""
|
|
||||||
return (
|
|
||||||
self.pressure_reference_enthalpy(
|
|
||||||
pressure,
|
|
||||||
temperature,
|
|
||||||
reference_pressure,
|
|
||||||
reference_temperature,
|
|
||||||
)
|
|
||||||
+ self.cp * reference_temperature
|
|
||||||
)
|
|
||||||
|
|
||||||
def temperature_from_internal_energy(self, specific_internal_energy: float) -> float:
|
|
||||||
if self.cv <= 0.0:
|
|
||||||
raise ValueError("cv must be positive.")
|
|
||||||
return specific_internal_energy / self.cv
|
|
||||||
|
|
||||||
|
|
||||||
HELIUM_PNEUMATIC_GAS = AmesimPneumaticGas()
|
|
||||||
|
|
||||||
|
|
||||||
def liters_to_m3(value: float) -> float:
|
|
||||||
return value * 1.0e-3
|
|
||||||
|
|
||||||
|
|
||||||
def m3_to_cm3(value: float) -> float:
|
|
||||||
return value * 1.0e6
|
|
||||||
|
|
||||||
|
|
||||||
def cm3_to_m3(value: float) -> float:
|
|
||||||
return value * 1.0e-6
|
|
||||||
|
|
||||||
|
|
||||||
def kg_to_g(value: float) -> float:
|
|
||||||
return value * 1.0e3
|
|
||||||
|
|
||||||
|
|
||||||
def mm2_to_m2(value: float) -> float:
|
|
||||||
return value * 1.0e-6
|
|
||||||
|
|
||||||
|
|
||||||
def diameter_mm_to_area_m2(diameter_mm: float) -> float:
|
|
||||||
diameter_m = diameter_mm * 1.0e-3
|
|
||||||
return pi * diameter_m * diameter_m / 4.0
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPneumaticVolume(DynamicComponent):
|
|
||||||
"""First-pass AMESim pneumatic control volume using helium PR pressure closure."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
volume: float,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
p0: float = 101_325.0,
|
|
||||||
T0: float = 293.15,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
heat_transfer_area: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
) -> None:
|
|
||||||
if volume <= 0.0:
|
|
||||||
raise ValueError("volume must be positive.")
|
|
||||||
if heat_transfer_coefficient < 0.0:
|
|
||||||
raise ValueError("heat_transfer_coefficient must be non-negative.")
|
|
||||||
if heat_transfer_area < 0.0:
|
|
||||||
raise ValueError("heat_transfer_area must be non-negative.")
|
|
||||||
if external_temperature_k <= 0.0:
|
|
||||||
raise ValueError("external_temperature_k must be positive.")
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.volume = volume
|
|
||||||
self.gas = gas
|
|
||||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
|
||||||
self.heat_transfer_area = heat_transfer_area
|
|
||||||
self.external_temperature = external_temperature_k
|
|
||||||
rho0 = gas.density(p0, T0)
|
|
||||||
m0 = rho0 * volume
|
|
||||||
U0 = m0 * gas.specific_internal_energy(T0)
|
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
self.port_a = PortState()
|
|
||||||
self.port_b = PortState()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_liters(
|
|
||||||
cls,
|
|
||||||
name: str,
|
|
||||||
volume_liters: float,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
p0: float = 101_325.0,
|
|
||||||
T0: float = 293.15,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
heat_transfer_area: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
) -> "AmesimPneumaticVolume":
|
|
||||||
return cls(
|
|
||||||
name=name,
|
|
||||||
volume=liters_to_m3(volume_liters),
|
|
||||||
gas=gas,
|
|
||||||
p0=p0,
|
|
||||||
T0=T0,
|
|
||||||
heat_transfer_coefficient=heat_transfer_coefficient,
|
|
||||||
heat_transfer_area=heat_transfer_area,
|
|
||||||
external_temperature_k=external_temperature_k,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
return self.state.as_vector()
|
|
||||||
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
self.state = VolumeState.from_vector(values)
|
|
||||||
|
|
||||||
def volume_cm3(self) -> float:
|
|
||||||
return m3_to_cm3(self.volume)
|
|
||||||
|
|
||||||
def volume_rate_m3_s(self) -> float:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def thermal_energy_flow_w(self, temperature_k: float | None = None) -> float:
|
|
||||||
temperature = self.properties().T if temperature_k is None else temperature_k
|
|
||||||
return (
|
|
||||||
self.heat_transfer_coefficient
|
|
||||||
* self.heat_transfer_area
|
|
||||||
* (self.external_temperature - temperature)
|
|
||||||
)
|
|
||||||
|
|
||||||
def gas_mass_g(self) -> float:
|
|
||||||
return kg_to_g(self.state.m)
|
|
||||||
|
|
||||||
def pressure_gauge_pa(self, reference_pressure_pa: float = 101_300.0) -> float:
|
|
||||||
return self.properties().p - reference_pressure_pa
|
|
||||||
|
|
||||||
def properties(self) -> ThermodynamicProperties:
|
|
||||||
if self.state.m <= 0.0:
|
|
||||||
raise ValueError("volume mass must stay positive.")
|
|
||||||
T = self.gas.temperature_from_internal_energy(self.state.U / self.state.m)
|
|
||||||
rho = self.state.m / self.volume
|
|
||||||
p = self.gas.pressure(rho, T)
|
|
||||||
u = self.state.U / self.state.m
|
|
||||||
h = self.gas.specific_enthalpy(T)
|
|
||||||
self.port_a.p = p
|
|
||||||
self.port_a.h_outflow = h
|
|
||||||
self.port_b.p = p
|
|
||||||
self.port_b.h_outflow = h
|
|
||||||
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
|
||||||
|
|
||||||
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
|
||||||
return VolumeState(
|
|
||||||
m=m_flow,
|
|
||||||
U=m_flow * inlet_h + self.thermal_energy_flow_w(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def derivatives_from_two_connections(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_a_m_flow: float,
|
|
||||||
connected_h_a: float,
|
|
||||||
port_b_m_flow: float,
|
|
||||||
connected_h_b: float,
|
|
||||||
internal_h: float,
|
|
||||||
volume_rate_m3_s: float | None = None,
|
|
||||||
) -> VolumeState:
|
|
||||||
properties = self.properties()
|
|
||||||
inlet_h_a = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_a_m_flow,
|
|
||||||
connected_h=connected_h_a,
|
|
||||||
internal_h=internal_h,
|
|
||||||
)
|
|
||||||
inlet_h_b = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_b_m_flow,
|
|
||||||
connected_h=connected_h_b,
|
|
||||||
internal_h=internal_h,
|
|
||||||
)
|
|
||||||
return VolumeState(
|
|
||||||
m=port_a_m_flow + port_b_m_flow,
|
|
||||||
U=(
|
|
||||||
port_a_m_flow * inlet_h_a
|
|
||||||
+ port_b_m_flow * inlet_h_b
|
|
||||||
+ self.thermal_energy_flow_w(properties.T)
|
|
||||||
- properties.p * (
|
|
||||||
self.volume_rate_m3_s()
|
|
||||||
if volume_rate_m3_s is None
|
|
||||||
else volume_rate_m3_s
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimVariablePneumaticVolume(AmesimPneumaticVolume):
|
|
||||||
"""PNCH012-style volume with a dead volume plus an external moving volume."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
dead_volume: float,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
p0: float = 101_325.0,
|
|
||||||
T0: float = 293.15,
|
|
||||||
external_volume: float = 0.0,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
heat_transfer_area: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
) -> None:
|
|
||||||
if dead_volume <= 0.0:
|
|
||||||
raise ValueError("dead_volume must be positive.")
|
|
||||||
if dead_volume + external_volume <= 0.0:
|
|
||||||
raise ValueError("total volume must be positive.")
|
|
||||||
self.dead_volume = dead_volume
|
|
||||||
self.external_volume = external_volume
|
|
||||||
self.external_volume_rate = 0.0
|
|
||||||
super().__init__(
|
|
||||||
name=name,
|
|
||||||
volume=dead_volume + external_volume,
|
|
||||||
gas=gas,
|
|
||||||
p0=p0,
|
|
||||||
T0=T0,
|
|
||||||
heat_transfer_coefficient=heat_transfer_coefficient,
|
|
||||||
heat_transfer_area=heat_transfer_area,
|
|
||||||
external_temperature_k=external_temperature_k,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_liters(
|
|
||||||
cls,
|
|
||||||
name: str,
|
|
||||||
dead_volume_liters: float,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
p0: float = 101_325.0,
|
|
||||||
T0: float = 293.15,
|
|
||||||
external_volume_liters: float = 0.0,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
heat_transfer_area: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
) -> "AmesimVariablePneumaticVolume":
|
|
||||||
return cls(
|
|
||||||
name=name,
|
|
||||||
dead_volume=liters_to_m3(dead_volume_liters),
|
|
||||||
gas=gas,
|
|
||||||
p0=p0,
|
|
||||||
T0=T0,
|
|
||||||
external_volume=liters_to_m3(external_volume_liters),
|
|
||||||
heat_transfer_coefficient=heat_transfer_coefficient,
|
|
||||||
heat_transfer_area=heat_transfer_area,
|
|
||||||
external_temperature_k=external_temperature_k,
|
|
||||||
)
|
|
||||||
|
|
||||||
def volume_rate_m3_s(self) -> float:
|
|
||||||
return self.external_volume_rate
|
|
||||||
|
|
||||||
def set_external_volume_m3(
|
|
||||||
self,
|
|
||||||
external_volume: float,
|
|
||||||
external_volume_rate_m3_s: float = 0.0,
|
|
||||||
) -> None:
|
|
||||||
if self.dead_volume + external_volume <= 0.0:
|
|
||||||
raise ValueError("total volume must be positive.")
|
|
||||||
self.external_volume = external_volume
|
|
||||||
self.external_volume_rate = external_volume_rate_m3_s
|
|
||||||
self.volume = self.dead_volume + self.external_volume
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPneumaticOrifice(AlgebraicComponent):
|
|
||||||
"""First-pass PNOR001/PNVO001-style compressible helium orifice.
|
|
||||||
|
|
||||||
This is a calibrated placeholder boundary for the Python port. It preserves
|
|
||||||
AMESim-style area and coefficient inputs, but final parity must be checked
|
|
||||||
against AMESim CSV results before treating it as numerically equivalent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
area: float,
|
|
||||||
flow_coefficient: float = 1.0,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
opening: float = 1.0,
|
|
||||||
) -> None:
|
|
||||||
if area < 0.0:
|
|
||||||
raise ValueError("area must be non-negative.")
|
|
||||||
if flow_coefficient < 0.0:
|
|
||||||
raise ValueError("flow_coefficient must be non-negative.")
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.area = area
|
|
||||||
self.flow_coefficient = flow_coefficient
|
|
||||||
self.gas = gas
|
|
||||||
self.opening = opening
|
|
||||||
self.port_a = PortState()
|
|
||||||
self.port_b = PortState()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mm2(
|
|
||||||
cls,
|
|
||||||
name: str,
|
|
||||||
area_mm2: float,
|
|
||||||
flow_coefficient: float = 1.0,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
opening: float = 1.0,
|
|
||||||
) -> "AmesimPneumaticOrifice":
|
|
||||||
return cls(
|
|
||||||
name=name,
|
|
||||||
area=mm2_to_m2(area_mm2),
|
|
||||||
flow_coefficient=flow_coefficient,
|
|
||||||
gas=gas,
|
|
||||||
opening=opening,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def effective_area(self) -> float:
|
|
||||||
opening = min(max(self.opening, 0.0), 1.0)
|
|
||||||
return self.area * opening
|
|
||||||
|
|
||||||
def mass_flow(self, p_a: float, p_b: float, upstream_temperature: float) -> float:
|
|
||||||
if p_a == p_b or self.effective_area == 0.0 or self.flow_coefficient == 0.0:
|
|
||||||
return 0.0
|
|
||||||
if p_a > p_b:
|
|
||||||
return compressible_orifice_mass_flow(
|
|
||||||
upstream_pressure=p_a,
|
|
||||||
downstream_pressure=p_b,
|
|
||||||
upstream_temperature=upstream_temperature,
|
|
||||||
area=self.effective_area,
|
|
||||||
flow_coefficient=self.flow_coefficient,
|
|
||||||
gas=self.gas,
|
|
||||||
)
|
|
||||||
return -compressible_orifice_mass_flow(
|
|
||||||
upstream_pressure=p_b,
|
|
||||||
downstream_pressure=p_a,
|
|
||||||
upstream_temperature=upstream_temperature,
|
|
||||||
area=self.effective_area,
|
|
||||||
flow_coefficient=self.flow_coefficient,
|
|
||||||
gas=self.gas,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compressible_orifice_mass_flow(
|
|
||||||
*,
|
|
||||||
upstream_pressure: float,
|
|
||||||
downstream_pressure: float,
|
|
||||||
upstream_temperature: float,
|
|
||||||
area: float,
|
|
||||||
flow_coefficient: float,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> float:
|
|
||||||
if upstream_pressure <= 0.0 or downstream_pressure < 0.0:
|
|
||||||
raise ValueError("pressures must be non-negative and upstream pressure must be positive.")
|
|
||||||
if upstream_temperature <= 0.0:
|
|
||||||
raise ValueError("upstream_temperature must be positive.")
|
|
||||||
if area < 0.0 or flow_coefficient < 0.0:
|
|
||||||
raise ValueError("area and flow_coefficient must be non-negative.")
|
|
||||||
if downstream_pressure >= upstream_pressure or area == 0.0 or flow_coefficient == 0.0:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
gamma = gas.gamma
|
|
||||||
pressure_ratio = max(downstream_pressure / upstream_pressure, 0.0)
|
|
||||||
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
|
|
||||||
coefficient = flow_coefficient * area * upstream_pressure / sqrt(gas.R_gas * upstream_temperature)
|
|
||||||
if pressure_ratio <= critical_ratio:
|
|
||||||
flow_function = sqrt(gamma) * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
|
|
||||||
else:
|
|
||||||
term = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ((gamma + 1.0) / gamma)
|
|
||||||
flow_function = sqrt((2.0 * gamma / (gamma - 1.0)) * max(term, 0.0))
|
|
||||||
return coefficient * flow_function
|
|
||||||
@@ -1,881 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import log10, pi, sqrt
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.primitives.pneumatic import (
|
|
||||||
HELIUM_PNEUMATIC_GAS,
|
|
||||||
AmesimPneumaticGas,
|
|
||||||
compressible_orifice_mass_flow,
|
|
||||||
diameter_mm_to_area_m2,
|
|
||||||
)
|
|
||||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
|
||||||
from app.simulation.core.medium import ThermodynamicProperties
|
|
||||||
from app.simulation.core.ports import PortState
|
|
||||||
from app.simulation.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AmesimPnl0001Diagnostics:
|
|
||||||
mass_flow_kg_s: float
|
|
||||||
reynolds_number: float
|
|
||||||
gas_velocity_m_s: float
|
|
||||||
friction_factor: float
|
|
||||||
pressure_drop_pa: float
|
|
||||||
|
|
||||||
|
|
||||||
class _DarcyPipeResistanceMixin:
|
|
||||||
diameter: float
|
|
||||||
length: float
|
|
||||||
relative_roughness: float
|
|
||||||
area: float
|
|
||||||
|
|
||||||
def _mass_flow_for_pressure_drop(
|
|
||||||
self,
|
|
||||||
pressure_drop_pa: float,
|
|
||||||
*,
|
|
||||||
density: float,
|
|
||||||
temperature: float,
|
|
||||||
) -> float:
|
|
||||||
if pressure_drop_pa <= 0.0:
|
|
||||||
return 0.0
|
|
||||||
upper = 1.0e-9
|
|
||||||
while self._darcy_pressure_drop(
|
|
||||||
upper,
|
|
||||||
density=density,
|
|
||||||
temperature=temperature,
|
|
||||||
) < pressure_drop_pa:
|
|
||||||
upper *= 10.0
|
|
||||||
if upper > 1.0e3:
|
|
||||||
raise ValueError("unable to bracket pneumatic pipe resistance flow")
|
|
||||||
lower = 0.0
|
|
||||||
for _ in range(48):
|
|
||||||
middle = 0.5 * (lower + upper)
|
|
||||||
if self._darcy_pressure_drop(
|
|
||||||
middle,
|
|
||||||
density=density,
|
|
||||||
temperature=temperature,
|
|
||||||
) < pressure_drop_pa:
|
|
||||||
lower = middle
|
|
||||||
else:
|
|
||||||
upper = middle
|
|
||||||
return 0.5 * (lower + upper)
|
|
||||||
|
|
||||||
def pn2pipefr_mass_flow(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_pressure_pa: float,
|
|
||||||
port_1_temperature_k: float,
|
|
||||||
port_2_pressure_pa: float,
|
|
||||||
port_2_temperature_k: float,
|
|
||||||
length: float | None = None,
|
|
||||||
) -> float:
|
|
||||||
pressure_difference = port_1_pressure_pa - port_2_pressure_pa
|
|
||||||
if pressure_difference == 0.0:
|
|
||||||
return 0.0
|
|
||||||
upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa)
|
|
||||||
downstream_pressure = min(port_1_pressure_pa, port_2_pressure_pa)
|
|
||||||
upstream_temperature = (
|
|
||||||
port_1_temperature_k
|
|
||||||
if pressure_difference > 0.0
|
|
||||||
else port_2_temperature_k
|
|
||||||
)
|
|
||||||
resistance_length = self.length if length is None else length
|
|
||||||
if resistance_length <= 0.0:
|
|
||||||
raise ValueError("length must be positive")
|
|
||||||
|
|
||||||
def target_flow(mass_flow_kg_s: float) -> float:
|
|
||||||
reynolds = self._reynolds_number(mass_flow_kg_s, upstream_temperature)
|
|
||||||
friction_factor = self._friction_factor(reynolds)
|
|
||||||
flow_coefficient = sqrt(
|
|
||||||
self.diameter / (resistance_length * friction_factor)
|
|
||||||
)
|
|
||||||
return compressible_orifice_mass_flow(
|
|
||||||
upstream_pressure=upstream_pressure,
|
|
||||||
downstream_pressure=downstream_pressure,
|
|
||||||
upstream_temperature=upstream_temperature,
|
|
||||||
area=self.area,
|
|
||||||
flow_coefficient=flow_coefficient,
|
|
||||||
gas=self.gas,
|
|
||||||
)
|
|
||||||
|
|
||||||
flow_coefficient = sqrt(self.diameter / (resistance_length * 0.02))
|
|
||||||
magnitude = compressible_orifice_mass_flow(
|
|
||||||
upstream_pressure=upstream_pressure,
|
|
||||||
downstream_pressure=downstream_pressure,
|
|
||||||
upstream_temperature=upstream_temperature,
|
|
||||||
area=self.area,
|
|
||||||
flow_coefficient=flow_coefficient,
|
|
||||||
gas=self.gas,
|
|
||||||
)
|
|
||||||
for _ in range(12):
|
|
||||||
next_magnitude = target_flow(magnitude)
|
|
||||||
if abs(next_magnitude - magnitude) <= max(1.0e-12, abs(magnitude) * 1.0e-9):
|
|
||||||
magnitude = next_magnitude
|
|
||||||
break
|
|
||||||
magnitude = 0.5 * (magnitude + next_magnitude)
|
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
|
||||||
|
|
||||||
def _darcy_pressure_drop(
|
|
||||||
self,
|
|
||||||
mass_flow_kg_s: float,
|
|
||||||
*,
|
|
||||||
density: float,
|
|
||||||
temperature: float,
|
|
||||||
) -> float:
|
|
||||||
if mass_flow_kg_s == 0.0:
|
|
||||||
return 0.0
|
|
||||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
|
||||||
friction_factor = self._friction_factor(reynolds)
|
|
||||||
velocity = mass_flow_kg_s / (density * self.area)
|
|
||||||
magnitude = (
|
|
||||||
friction_factor
|
|
||||||
* (self.length / self.diameter)
|
|
||||||
* density
|
|
||||||
* velocity
|
|
||||||
* velocity
|
|
||||||
/ 2.0
|
|
||||||
)
|
|
||||||
return magnitude if mass_flow_kg_s > 0.0 else -magnitude
|
|
||||||
|
|
||||||
def _reynolds_number(self, mass_flow_kg_s: float, temperature: float) -> float:
|
|
||||||
viscosity = helium_dynamic_viscosity(temperature)
|
|
||||||
return 4.0 * abs(mass_flow_kg_s) / (pi * self.diameter * viscosity)
|
|
||||||
|
|
||||||
def _friction_factor(self, reynolds_number: float) -> float:
|
|
||||||
if reynolds_number <= 0.0:
|
|
||||||
return 64_000_000.0
|
|
||||||
laminar = 64.0 / reynolds_number
|
|
||||||
if reynolds_number <= 2_300.0:
|
|
||||||
return laminar
|
|
||||||
turbulent = 1.0 / (
|
|
||||||
-1.8
|
|
||||||
* log10(
|
|
||||||
(self.relative_roughness / 3.7) ** 1.11
|
|
||||||
+ 6.9 / reynolds_number
|
|
||||||
)
|
|
||||||
) ** 2
|
|
||||||
if reynolds_number >= 4_000.0:
|
|
||||||
return turbulent
|
|
||||||
fraction = (reynolds_number - 2_300.0) / 1_700.0
|
|
||||||
return laminar + fraction * (turbulent - laminar)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnl0001Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
|
||||||
"""Physical first-pass implementation of AMESim ``PNL0001`` (C-R).
|
|
||||||
|
|
||||||
Port 2 owns the lumped gas storage. Port 1 is connected through a Darcy
|
|
||||||
resistance. Both connection mass flows use the simulation convention:
|
|
||||||
positive values enter the pipe storage.
|
|
||||||
|
|
||||||
AMESim's proprietary ``pn2pipefr`` utility is represented by an
|
|
||||||
optional calibrated linear conductance when a model-specific baseline
|
|
||||||
supports it; otherwise the component falls back to an auditable
|
|
||||||
Darcy-Weisbach law. Both paths preserve the real geometry, state count,
|
|
||||||
mass/energy balance, heat-transfer parameter, and observable diagnostics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
diameter_mm: float,
|
|
||||||
length_m: float,
|
|
||||||
relative_roughness: float,
|
|
||||||
polytropic_constant: float = 1.35,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
calibrated_linear_conductance: float | None = None,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
p0: float = 101_325.0,
|
|
||||||
T0: float = 293.15,
|
|
||||||
) -> None:
|
|
||||||
if diameter_mm <= 0.0:
|
|
||||||
raise ValueError("diameter_mm must be positive")
|
|
||||||
if length_m <= 0.0:
|
|
||||||
raise ValueError("length_m must be positive")
|
|
||||||
if relative_roughness < 0.0:
|
|
||||||
raise ValueError("relative_roughness must be non-negative")
|
|
||||||
if polytropic_constant <= 0.0:
|
|
||||||
raise ValueError("polytropic_constant must be positive")
|
|
||||||
if heat_transfer_coefficient < 0.0:
|
|
||||||
raise ValueError("heat_transfer_coefficient must be non-negative")
|
|
||||||
if external_temperature_k <= 0.0:
|
|
||||||
raise ValueError("external_temperature_k must be positive")
|
|
||||||
if (
|
|
||||||
calibrated_linear_conductance is not None
|
|
||||||
and calibrated_linear_conductance <= 0.0
|
|
||||||
):
|
|
||||||
raise ValueError("calibrated_linear_conductance must be positive")
|
|
||||||
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.diameter = diameter_mm * 1.0e-3
|
|
||||||
self.length = length_m
|
|
||||||
self.relative_roughness = relative_roughness
|
|
||||||
self.polytropic_constant = polytropic_constant
|
|
||||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
|
||||||
self.external_temperature = external_temperature_k
|
|
||||||
self.calibrated_linear_conductance = calibrated_linear_conductance
|
|
||||||
self.gas = gas
|
|
||||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
|
||||||
self.volume = self.area * self.length
|
|
||||||
self.heat_transfer_area = pi * self.diameter * self.length
|
|
||||||
|
|
||||||
rho0 = gas.density(p0, T0)
|
|
||||||
mass0 = rho0 * self.volume
|
|
||||||
self.state = VolumeState(
|
|
||||||
m=mass0,
|
|
||||||
U=mass0 * gas.specific_internal_energy(T0),
|
|
||||||
)
|
|
||||||
self.port_1 = PortState()
|
|
||||||
self.port_2 = PortState()
|
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
return self.state.as_vector()
|
|
||||||
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
self.state = VolumeState.from_vector(values)
|
|
||||||
|
|
||||||
def properties(self) -> ThermodynamicProperties:
|
|
||||||
if self.state.m <= 0.0:
|
|
||||||
raise ValueError("pipe mass must stay positive")
|
|
||||||
temperature = self.gas.temperature_from_internal_energy(
|
|
||||||
self.state.U / self.state.m
|
|
||||||
)
|
|
||||||
density = self.state.m / self.volume
|
|
||||||
pressure = self.gas.pressure(density, temperature)
|
|
||||||
properties = ThermodynamicProperties(
|
|
||||||
p=pressure,
|
|
||||||
T=temperature,
|
|
||||||
rho=density,
|
|
||||||
u=self.state.U / self.state.m,
|
|
||||||
h=self.gas.specific_enthalpy(temperature),
|
|
||||||
)
|
|
||||||
self.port_2.p = pressure
|
|
||||||
self.port_2.h_outflow = properties.h
|
|
||||||
return properties
|
|
||||||
|
|
||||||
def gas_mass_g(self) -> float:
|
|
||||||
return self.state.m * 1.0e3
|
|
||||||
|
|
||||||
def resistance_mass_flow(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_pressure_pa: float,
|
|
||||||
port_1_temperature_k: float,
|
|
||||||
) -> float:
|
|
||||||
"""Return mass flow from port 1 into the port-2 storage in kg/s."""
|
|
||||||
if port_1_pressure_pa <= 0.0:
|
|
||||||
raise ValueError("port_1_pressure_pa must be positive")
|
|
||||||
if port_1_temperature_k <= 0.0:
|
|
||||||
raise ValueError("port_1_temperature_k must be positive")
|
|
||||||
|
|
||||||
internal = self.properties()
|
|
||||||
pressure_difference = port_1_pressure_pa - internal.p
|
|
||||||
if pressure_difference == 0.0:
|
|
||||||
return 0.0
|
|
||||||
if self.calibrated_linear_conductance is not None:
|
|
||||||
return (
|
|
||||||
self.calibrated_linear_conductance
|
|
||||||
* pressure_difference
|
|
||||||
/ sqrt(internal.T)
|
|
||||||
)
|
|
||||||
upstream_pressure = max(port_1_pressure_pa, internal.p)
|
|
||||||
upstream_temperature = (
|
|
||||||
port_1_temperature_k if pressure_difference > 0.0 else internal.T
|
|
||||||
)
|
|
||||||
density = self.gas.density(upstream_pressure, upstream_temperature)
|
|
||||||
magnitude = self._mass_flow_for_pressure_drop(
|
|
||||||
abs(pressure_difference),
|
|
||||||
density=density,
|
|
||||||
temperature=upstream_temperature,
|
|
||||||
)
|
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
|
||||||
|
|
||||||
def diagnostics(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mass_flow_kg_s: float,
|
|
||||||
temperature_k: float | None = None,
|
|
||||||
) -> AmesimPnl0001Diagnostics:
|
|
||||||
properties = self.properties()
|
|
||||||
temperature = temperature_k or properties.T
|
|
||||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
|
||||||
friction_factor = self._friction_factor(reynolds)
|
|
||||||
velocity = mass_flow_kg_s / (properties.rho * self.area)
|
|
||||||
pressure_drop = self._darcy_pressure_drop(
|
|
||||||
mass_flow_kg_s,
|
|
||||||
density=properties.rho,
|
|
||||||
temperature=temperature,
|
|
||||||
)
|
|
||||||
return AmesimPnl0001Diagnostics(
|
|
||||||
mass_flow_kg_s=mass_flow_kg_s,
|
|
||||||
reynolds_number=reynolds,
|
|
||||||
gas_velocity_m_s=velocity,
|
|
||||||
friction_factor=friction_factor,
|
|
||||||
pressure_drop_pa=pressure_drop,
|
|
||||||
)
|
|
||||||
|
|
||||||
def darcy_pressure_drop_for_state(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mass_flow_kg_s: float,
|
|
||||||
pressure_pa: float,
|
|
||||||
temperature_k: float,
|
|
||||||
) -> float:
|
|
||||||
if pressure_pa <= 0.0:
|
|
||||||
raise ValueError("pressure_pa must be positive")
|
|
||||||
if temperature_k <= 0.0:
|
|
||||||
raise ValueError("temperature_k must be positive")
|
|
||||||
density = self.gas.density(pressure_pa, temperature_k)
|
|
||||||
return self._darcy_pressure_drop(
|
|
||||||
mass_flow_kg_s,
|
|
||||||
density=density,
|
|
||||||
temperature=temperature_k,
|
|
||||||
)
|
|
||||||
|
|
||||||
def derivatives_from_connections(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_m_flow: float,
|
|
||||||
connected_h_1: float,
|
|
||||||
port_2_m_flow: float,
|
|
||||||
connected_h_2: float,
|
|
||||||
) -> VolumeState:
|
|
||||||
internal = self.properties()
|
|
||||||
# Default first-pass PNL0001 behavior uses the historical internal-energy
|
|
||||||
# approximation. AMESim-specific transport-enthalpy corrections are kept
|
|
||||||
# behind derivatives_from_transport_enthalpy_connections so they can be
|
|
||||||
# applied only where validated against baseline data.
|
|
||||||
inlet_u_1 = (
|
|
||||||
connected_h_1 / self.gas.gamma
|
|
||||||
if port_1_m_flow > 0.0
|
|
||||||
else internal.u
|
|
||||||
)
|
|
||||||
inlet_u_2 = (
|
|
||||||
connected_h_2 / self.gas.gamma
|
|
||||||
if port_2_m_flow > 0.0
|
|
||||||
else internal.u
|
|
||||||
)
|
|
||||||
heat_flow = (
|
|
||||||
self.heat_transfer_coefficient
|
|
||||||
* self.heat_transfer_area
|
|
||||||
* (self.external_temperature - internal.T)
|
|
||||||
)
|
|
||||||
return VolumeState(
|
|
||||||
m=port_1_m_flow + port_2_m_flow,
|
|
||||||
U=port_1_m_flow * inlet_u_1 + port_2_m_flow * inlet_u_2 + heat_flow,
|
|
||||||
)
|
|
||||||
|
|
||||||
def derivatives_from_transport_enthalpy_connections(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_m_flow: float,
|
|
||||||
connected_h_1: float,
|
|
||||||
port_2_m_flow: float,
|
|
||||||
connected_h_2: float,
|
|
||||||
) -> VolumeState:
|
|
||||||
internal = self.properties()
|
|
||||||
inlet_h_1 = connected_h_1 if port_1_m_flow > 0.0 else internal.h
|
|
||||||
inlet_h_2 = connected_h_2 if port_2_m_flow > 0.0 else internal.h
|
|
||||||
heat_flow = (
|
|
||||||
self.heat_transfer_coefficient
|
|
||||||
* self.heat_transfer_area
|
|
||||||
* (self.external_temperature - internal.T)
|
|
||||||
)
|
|
||||||
return VolumeState(
|
|
||||||
m=port_1_m_flow + port_2_m_flow,
|
|
||||||
U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnl0003Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
|
||||||
"""First-pass AMESim ``PNL0003`` (C-R-C) pipe.
|
|
||||||
|
|
||||||
The two pipe-end compliances are represented as equal half-volume gas
|
|
||||||
stores connected by the same auditable Darcy resistance used for PNL0001.
|
|
||||||
Center flow is positive from port 1 storage to port 2 storage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
state_size = 4
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
diameter_mm: float,
|
|
||||||
length_m: float,
|
|
||||||
relative_roughness: float,
|
|
||||||
polytropic_constant: float = 1.35,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
p1_0: float = 101_325.0,
|
|
||||||
T1_0: float = 293.15,
|
|
||||||
p2_0: float = 101_325.0,
|
|
||||||
T2_0: float = 293.15,
|
|
||||||
) -> None:
|
|
||||||
if diameter_mm <= 0.0:
|
|
||||||
raise ValueError("diameter_mm must be positive")
|
|
||||||
if length_m <= 0.0:
|
|
||||||
raise ValueError("length_m must be positive")
|
|
||||||
if relative_roughness < 0.0:
|
|
||||||
raise ValueError("relative_roughness must be non-negative")
|
|
||||||
if polytropic_constant <= 0.0:
|
|
||||||
raise ValueError("polytropic_constant must be positive")
|
|
||||||
if heat_transfer_coefficient < 0.0:
|
|
||||||
raise ValueError("heat_transfer_coefficient must be non-negative")
|
|
||||||
if external_temperature_k <= 0.0:
|
|
||||||
raise ValueError("external_temperature_k must be positive")
|
|
||||||
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.diameter = diameter_mm * 1.0e-3
|
|
||||||
self.length = length_m
|
|
||||||
self.relative_roughness = relative_roughness
|
|
||||||
self.polytropic_constant = polytropic_constant
|
|
||||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
|
||||||
self.external_temperature = external_temperature_k
|
|
||||||
self.gas = gas
|
|
||||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
|
||||||
self.volume = self.area * self.length
|
|
||||||
self.compliance_volume = self.volume / 2.0
|
|
||||||
self.heat_transfer_area = pi * self.diameter * self.length
|
|
||||||
|
|
||||||
self.state_1 = self._initial_state(p1_0, T1_0)
|
|
||||||
self.state_2 = self._initial_state(p2_0, T2_0)
|
|
||||||
self.port_1 = PortState()
|
|
||||||
self.port_2 = PortState()
|
|
||||||
|
|
||||||
def _initial_state(self, pressure: float, temperature: float) -> VolumeState:
|
|
||||||
rho = self.gas.density(pressure, temperature)
|
|
||||||
mass = rho * self.compliance_volume
|
|
||||||
return VolumeState(
|
|
||||||
m=mass,
|
|
||||||
U=mass * self.gas.specific_internal_energy(temperature),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
return [*self.state_1.as_vector(), *self.state_2.as_vector()]
|
|
||||||
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
if len(values) != 4:
|
|
||||||
raise ValueError("PNL0003 state vector requires four values")
|
|
||||||
self.state_1 = VolumeState.from_vector(values[:2])
|
|
||||||
self.state_2 = VolumeState.from_vector(values[2:])
|
|
||||||
|
|
||||||
def properties_1(self) -> ThermodynamicProperties:
|
|
||||||
properties = self._properties(self.state_1)
|
|
||||||
self.port_1.p = properties.p
|
|
||||||
self.port_1.h_outflow = properties.h
|
|
||||||
return properties
|
|
||||||
|
|
||||||
def properties_2(self) -> ThermodynamicProperties:
|
|
||||||
properties = self._properties(self.state_2)
|
|
||||||
self.port_2.p = properties.p
|
|
||||||
self.port_2.h_outflow = properties.h
|
|
||||||
return properties
|
|
||||||
|
|
||||||
def _properties(self, state: VolumeState) -> ThermodynamicProperties:
|
|
||||||
if state.m <= 0.0:
|
|
||||||
raise ValueError("pipe mass must stay positive")
|
|
||||||
temperature = self.gas.temperature_from_internal_energy(state.U / state.m)
|
|
||||||
density = state.m / self.compliance_volume
|
|
||||||
pressure = self.gas.pressure(density, temperature)
|
|
||||||
return ThermodynamicProperties(
|
|
||||||
p=pressure,
|
|
||||||
T=temperature,
|
|
||||||
rho=density,
|
|
||||||
u=state.U / state.m,
|
|
||||||
h=self.gas.specific_enthalpy(temperature),
|
|
||||||
)
|
|
||||||
|
|
||||||
def gas_mass_g(self) -> float:
|
|
||||||
return (self.state_1.m + self.state_2.m) * 1.0e3
|
|
||||||
|
|
||||||
def resistance_mass_flow(self) -> float:
|
|
||||||
"""Return center mass flow from port 1 storage to port 2 storage."""
|
|
||||||
port_1 = self.properties_1()
|
|
||||||
port_2 = self.properties_2()
|
|
||||||
pressure_difference = port_1.p - port_2.p
|
|
||||||
if pressure_difference == 0.0:
|
|
||||||
return 0.0
|
|
||||||
upstream = port_1 if pressure_difference > 0.0 else port_2
|
|
||||||
magnitude = self._mass_flow_for_pressure_drop(
|
|
||||||
abs(pressure_difference),
|
|
||||||
density=upstream.rho,
|
|
||||||
temperature=upstream.T,
|
|
||||||
)
|
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
|
||||||
|
|
||||||
def diagnostics(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mass_flow_kg_s: float,
|
|
||||||
temperature_k: float | None = None,
|
|
||||||
) -> AmesimPnl0001Diagnostics:
|
|
||||||
port_1 = self.properties_1()
|
|
||||||
port_2 = self.properties_2()
|
|
||||||
temperature = temperature_k or (port_1.T if mass_flow_kg_s >= 0.0 else port_2.T)
|
|
||||||
density = port_1.rho if mass_flow_kg_s >= 0.0 else port_2.rho
|
|
||||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
|
||||||
friction_factor = self._friction_factor(reynolds)
|
|
||||||
velocity = mass_flow_kg_s / (density * self.area)
|
|
||||||
pressure_drop = self._darcy_pressure_drop(
|
|
||||||
mass_flow_kg_s,
|
|
||||||
density=density,
|
|
||||||
temperature=temperature,
|
|
||||||
)
|
|
||||||
return AmesimPnl0001Diagnostics(
|
|
||||||
mass_flow_kg_s=mass_flow_kg_s,
|
|
||||||
reynolds_number=reynolds,
|
|
||||||
gas_velocity_m_s=velocity,
|
|
||||||
friction_factor=friction_factor,
|
|
||||||
pressure_drop_pa=pressure_drop,
|
|
||||||
)
|
|
||||||
|
|
||||||
def derivatives_from_connections(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_m_flow: float,
|
|
||||||
connected_h_1: float,
|
|
||||||
port_2_m_flow: float,
|
|
||||||
connected_h_2: float,
|
|
||||||
) -> tuple[VolumeState, VolumeState]:
|
|
||||||
port_1 = self.properties_1()
|
|
||||||
port_2 = self.properties_2()
|
|
||||||
center_flow = self.resistance_mass_flow()
|
|
||||||
heat_flow_each = (
|
|
||||||
self.heat_transfer_coefficient
|
|
||||||
* self.heat_transfer_area
|
|
||||||
* (self.external_temperature - 0.5 * (port_1.T + port_2.T))
|
|
||||||
/ 2.0
|
|
||||||
)
|
|
||||||
port_1_external_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_1_m_flow,
|
|
||||||
connected_h=connected_h_1,
|
|
||||||
internal_h=port_1.h,
|
|
||||||
)
|
|
||||||
port_2_external_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_2_m_flow,
|
|
||||||
connected_h=connected_h_2,
|
|
||||||
internal_h=port_2.h,
|
|
||||||
)
|
|
||||||
port_1_center_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=-center_flow,
|
|
||||||
connected_h=port_2.h,
|
|
||||||
internal_h=port_1.h,
|
|
||||||
)
|
|
||||||
port_2_center_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=center_flow,
|
|
||||||
connected_h=port_1.h,
|
|
||||||
internal_h=port_2.h,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
VolumeState(
|
|
||||||
m=port_1_m_flow - center_flow,
|
|
||||||
U=(
|
|
||||||
port_1_m_flow * port_1_external_h
|
|
||||||
- center_flow * port_1_center_h
|
|
||||||
+ heat_flow_each
|
|
||||||
),
|
|
||||||
),
|
|
||||||
VolumeState(
|
|
||||||
m=port_2_m_flow + center_flow,
|
|
||||||
U=(
|
|
||||||
port_2_m_flow * port_2_external_h
|
|
||||||
+ center_flow * port_2_center_h
|
|
||||||
+ heat_flow_each
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnl0002Pipe(_DarcyPipeResistanceMixin, DynamicComponent):
|
|
||||||
"""First-pass AMESim ``PNL0002`` (R-C-R) pipe.
|
|
||||||
|
|
||||||
The center compliance owns the gas state. Positive connection mass flows
|
|
||||||
enter that center storage from each external port.
|
|
||||||
"""
|
|
||||||
|
|
||||||
state_size = 2
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
diameter_mm: float,
|
|
||||||
length_m: float,
|
|
||||||
relative_roughness: float,
|
|
||||||
polytropic_constant: float = 1.35,
|
|
||||||
heat_transfer_coefficient: float = 0.0,
|
|
||||||
external_temperature_k: float = 293.15,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
pctr_0: float = 101_325.0,
|
|
||||||
Tctr_0: float = 293.15,
|
|
||||||
) -> None:
|
|
||||||
if diameter_mm <= 0.0:
|
|
||||||
raise ValueError("diameter_mm must be positive")
|
|
||||||
if length_m <= 0.0:
|
|
||||||
raise ValueError("length_m must be positive")
|
|
||||||
if relative_roughness < 0.0:
|
|
||||||
raise ValueError("relative_roughness must be non-negative")
|
|
||||||
if polytropic_constant <= 0.0:
|
|
||||||
raise ValueError("polytropic_constant must be positive")
|
|
||||||
if heat_transfer_coefficient < 0.0:
|
|
||||||
raise ValueError("heat_transfer_coefficient must be non-negative")
|
|
||||||
if external_temperature_k <= 0.0:
|
|
||||||
raise ValueError("external_temperature_k must be positive")
|
|
||||||
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.diameter = diameter_mm * 1.0e-3
|
|
||||||
self.length = length_m
|
|
||||||
self.relative_roughness = relative_roughness
|
|
||||||
self.polytropic_constant = polytropic_constant
|
|
||||||
self.heat_transfer_coefficient = heat_transfer_coefficient
|
|
||||||
self.external_temperature = external_temperature_k
|
|
||||||
self.gas = gas
|
|
||||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
|
||||||
self.volume = self.area * self.length
|
|
||||||
self.heat_transfer_area = pi * self.diameter * self.length
|
|
||||||
self._resistance_length = self.length / 2.0
|
|
||||||
|
|
||||||
rho0 = gas.density(pctr_0, Tctr_0)
|
|
||||||
mass0 = rho0 * self.volume
|
|
||||||
self.state = VolumeState(
|
|
||||||
m=mass0,
|
|
||||||
U=mass0 * gas.specific_internal_energy(Tctr_0),
|
|
||||||
)
|
|
||||||
self.port_1 = PortState()
|
|
||||||
self.port_2 = PortState()
|
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
return self.state.as_vector()
|
|
||||||
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
self.state = VolumeState.from_vector(values)
|
|
||||||
|
|
||||||
def properties(self) -> ThermodynamicProperties:
|
|
||||||
if self.state.m <= 0.0:
|
|
||||||
raise ValueError("pipe mass must stay positive")
|
|
||||||
temperature = self.gas.temperature_from_internal_energy(
|
|
||||||
self.state.U / self.state.m
|
|
||||||
)
|
|
||||||
density = self.state.m / self.volume
|
|
||||||
pressure = self.gas.pressure(density, temperature)
|
|
||||||
properties = ThermodynamicProperties(
|
|
||||||
p=pressure,
|
|
||||||
T=temperature,
|
|
||||||
rho=density,
|
|
||||||
u=self.state.U / self.state.m,
|
|
||||||
h=self.gas.specific_enthalpy(temperature),
|
|
||||||
)
|
|
||||||
self.port_1.p = pressure
|
|
||||||
self.port_1.h_outflow = properties.h
|
|
||||||
self.port_2.p = pressure
|
|
||||||
self.port_2.h_outflow = properties.h
|
|
||||||
return properties
|
|
||||||
|
|
||||||
def gas_mass_g(self) -> float:
|
|
||||||
return self.state.m * 1.0e3
|
|
||||||
|
|
||||||
def port_mass_flow(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_pressure_pa: float,
|
|
||||||
port_temperature_k: float,
|
|
||||||
) -> float:
|
|
||||||
"""Return mass flow from an external port into the center storage."""
|
|
||||||
if port_pressure_pa <= 0.0:
|
|
||||||
raise ValueError("port_pressure_pa must be positive")
|
|
||||||
if port_temperature_k <= 0.0:
|
|
||||||
raise ValueError("port_temperature_k must be positive")
|
|
||||||
|
|
||||||
center = self.properties()
|
|
||||||
pressure_difference = port_pressure_pa - center.p
|
|
||||||
if pressure_difference == 0.0:
|
|
||||||
return 0.0
|
|
||||||
upstream_pressure = max(port_pressure_pa, center.p)
|
|
||||||
upstream_temperature = (
|
|
||||||
port_temperature_k if pressure_difference > 0.0 else center.T
|
|
||||||
)
|
|
||||||
density = self.gas.density(upstream_pressure, upstream_temperature)
|
|
||||||
magnitude = self._mass_flow_for_resistance_pressure_drop(
|
|
||||||
abs(pressure_difference),
|
|
||||||
density=density,
|
|
||||||
temperature=upstream_temperature,
|
|
||||||
)
|
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
|
||||||
|
|
||||||
def _mass_flow_for_resistance_pressure_drop(
|
|
||||||
self,
|
|
||||||
pressure_drop_pa: float,
|
|
||||||
*,
|
|
||||||
density: float,
|
|
||||||
temperature: float,
|
|
||||||
) -> float:
|
|
||||||
original_length = self.length
|
|
||||||
self.length = self._resistance_length
|
|
||||||
try:
|
|
||||||
return self._mass_flow_for_pressure_drop(
|
|
||||||
pressure_drop_pa,
|
|
||||||
density=density,
|
|
||||||
temperature=temperature,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self.length = original_length
|
|
||||||
|
|
||||||
def diagnostics(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mass_flow_kg_s: float,
|
|
||||||
temperature_k: float | None = None,
|
|
||||||
) -> AmesimPnl0001Diagnostics:
|
|
||||||
properties = self.properties()
|
|
||||||
temperature = temperature_k or properties.T
|
|
||||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature)
|
|
||||||
friction_factor = self._friction_factor(reynolds)
|
|
||||||
velocity = mass_flow_kg_s / (properties.rho * self.area)
|
|
||||||
original_length = self.length
|
|
||||||
self.length = self._resistance_length
|
|
||||||
try:
|
|
||||||
pressure_drop = self._darcy_pressure_drop(
|
|
||||||
mass_flow_kg_s,
|
|
||||||
density=properties.rho,
|
|
||||||
temperature=temperature,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self.length = original_length
|
|
||||||
return AmesimPnl0001Diagnostics(
|
|
||||||
mass_flow_kg_s=mass_flow_kg_s,
|
|
||||||
reynolds_number=reynolds,
|
|
||||||
gas_velocity_m_s=velocity,
|
|
||||||
friction_factor=friction_factor,
|
|
||||||
pressure_drop_pa=pressure_drop,
|
|
||||||
)
|
|
||||||
|
|
||||||
def derivatives_from_connections(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_m_flow: float,
|
|
||||||
connected_h_1: float,
|
|
||||||
port_2_m_flow: float,
|
|
||||||
connected_h_2: float,
|
|
||||||
) -> VolumeState:
|
|
||||||
center = self.properties()
|
|
||||||
inlet_h_1 = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_1_m_flow,
|
|
||||||
connected_h=connected_h_1,
|
|
||||||
internal_h=center.h,
|
|
||||||
)
|
|
||||||
inlet_h_2 = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_2_m_flow,
|
|
||||||
connected_h=connected_h_2,
|
|
||||||
internal_h=center.h,
|
|
||||||
)
|
|
||||||
heat_flow = (
|
|
||||||
self.heat_transfer_coefficient
|
|
||||||
* self.heat_transfer_area
|
|
||||||
* (self.external_temperature - center.T)
|
|
||||||
)
|
|
||||||
return VolumeState(
|
|
||||||
m=port_1_m_flow + port_2_m_flow,
|
|
||||||
U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnl00rPipe(_DarcyPipeResistanceMixin, AlgebraicComponent):
|
|
||||||
"""First-pass AMESim ``PNL00R`` (R) pipe resistance."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
diameter_mm: float,
|
|
||||||
length_m: float,
|
|
||||||
relative_roughness: float,
|
|
||||||
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
|
|
||||||
) -> None:
|
|
||||||
if diameter_mm <= 0.0:
|
|
||||||
raise ValueError("diameter_mm must be positive")
|
|
||||||
if length_m <= 0.0:
|
|
||||||
raise ValueError("length_m must be positive")
|
|
||||||
if relative_roughness < 0.0:
|
|
||||||
raise ValueError("relative_roughness must be non-negative")
|
|
||||||
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.diameter = diameter_mm * 1.0e-3
|
|
||||||
self.length = length_m
|
|
||||||
self.relative_roughness = relative_roughness
|
|
||||||
self.gas = gas
|
|
||||||
self.area = diameter_mm_to_area_m2(diameter_mm)
|
|
||||||
self.port_1 = PortState()
|
|
||||||
self.port_2 = PortState()
|
|
||||||
|
|
||||||
def mass_flow(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
port_1_pressure_pa: float,
|
|
||||||
port_1_temperature_k: float,
|
|
||||||
port_2_pressure_pa: float,
|
|
||||||
port_2_temperature_k: float,
|
|
||||||
) -> float:
|
|
||||||
"""Return mass flow from port 1 to port 2 in kg/s."""
|
|
||||||
if port_1_pressure_pa <= 0.0 or port_2_pressure_pa <= 0.0:
|
|
||||||
raise ValueError("port pressures must be positive")
|
|
||||||
if port_1_temperature_k <= 0.0 or port_2_temperature_k <= 0.0:
|
|
||||||
raise ValueError("port temperatures must be positive")
|
|
||||||
pressure_difference = port_1_pressure_pa - port_2_pressure_pa
|
|
||||||
if pressure_difference == 0.0:
|
|
||||||
return 0.0
|
|
||||||
upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa)
|
|
||||||
upstream_temperature = (
|
|
||||||
port_1_temperature_k
|
|
||||||
if pressure_difference > 0.0
|
|
||||||
else port_2_temperature_k
|
|
||||||
)
|
|
||||||
density = self.gas.density(upstream_pressure, upstream_temperature)
|
|
||||||
magnitude = self._mass_flow_for_pressure_drop(
|
|
||||||
abs(pressure_difference),
|
|
||||||
density=density,
|
|
||||||
temperature=upstream_temperature,
|
|
||||||
)
|
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
|
||||||
|
|
||||||
def diagnostics(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mass_flow_kg_s: float,
|
|
||||||
pressure_pa: float,
|
|
||||||
temperature_k: float,
|
|
||||||
) -> AmesimPnl0001Diagnostics:
|
|
||||||
density = self.gas.density(pressure_pa, temperature_k)
|
|
||||||
reynolds = self._reynolds_number(mass_flow_kg_s, temperature_k)
|
|
||||||
friction_factor = self._friction_factor(reynolds)
|
|
||||||
velocity = mass_flow_kg_s / (density * self.area)
|
|
||||||
pressure_drop = self._darcy_pressure_drop(
|
|
||||||
mass_flow_kg_s,
|
|
||||||
density=density,
|
|
||||||
temperature=temperature_k,
|
|
||||||
)
|
|
||||||
return AmesimPnl0001Diagnostics(
|
|
||||||
mass_flow_kg_s=mass_flow_kg_s,
|
|
||||||
reynolds_number=reynolds,
|
|
||||||
gas_velocity_m_s=velocity,
|
|
||||||
friction_factor=friction_factor,
|
|
||||||
pressure_drop_pa=pressure_drop,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def helium_dynamic_viscosity(temperature_k: float) -> float:
|
|
||||||
"""Sutherland approximation centered on the test_mql initial condition."""
|
|
||||||
if temperature_k <= 0.0:
|
|
||||||
raise ValueError("temperature_k must be positive")
|
|
||||||
reference_temperature = 293.15
|
|
||||||
reference_viscosity = 2.0e-5
|
|
||||||
sutherland_constant = 79.4
|
|
||||||
return (
|
|
||||||
reference_viscosity
|
|
||||||
* (temperature_k / reference_temperature) ** 1.5
|
|
||||||
* (reference_temperature + sutherland_constant)
|
|
||||||
/ (temperature_k + sutherland_constant)
|
|
||||||
)
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
|
|
||||||
from app.simulation.examples.test_mql.system import (
|
|
||||||
TestMqlRunConfig,
|
|
||||||
TestMqlSimulationResult,
|
|
||||||
TestMqlSystem,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PreparedTestMqlRun:
|
|
||||||
run_config: TestMqlRunConfig
|
|
||||||
repo_root: Path
|
|
||||||
output_dir: Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlRunResult:
|
|
||||||
run_config: TestMqlRunConfig
|
|
||||||
prepared_run: PreparedTestMqlRun
|
|
||||||
system: TestMqlSystem
|
|
||||||
result: TestMqlSimulationResult
|
|
||||||
summary_path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def format_test_mql_summary(system: TestMqlSystem) -> str:
|
|
||||||
snapshot = system.snapshot()
|
|
||||||
lines = [
|
|
||||||
"Model: test_mql",
|
|
||||||
f"Source archive: {system.archive_path}",
|
|
||||||
f"Components: {snapshot.component_count}",
|
|
||||||
f"Connections: {snapshot.connection_count}",
|
|
||||||
f"Continuous states in AMESim modelinfo: {snapshot.continuous_state_count}",
|
|
||||||
f"Discrete states in AMESim modelinfo: {snapshot.discrete_state_count}",
|
|
||||||
"Global parameters:",
|
|
||||||
]
|
|
||||||
for name, value in sorted(snapshot.global_parameters.items()):
|
|
||||||
lines.append(f" - {name}: {value}")
|
|
||||||
lines.append("Component submodels:")
|
|
||||||
for name, count in sorted(snapshot.submodel_counts.items()):
|
|
||||||
lines.append(f" - {name}: {count}")
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _default_run_output_dir() -> Path:
|
|
||||||
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
|
|
||||||
return SIMULATION_RUNS_DIR / timestamp
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_test_mql_run(
|
|
||||||
*,
|
|
||||||
run_config: TestMqlRunConfig | None = None,
|
|
||||||
output_dir: Path | None = None,
|
|
||||||
) -> PreparedTestMqlRun:
|
|
||||||
return PreparedTestMqlRun(
|
|
||||||
run_config=run_config or TestMqlRunConfig(),
|
|
||||||
repo_root=PROJECT_ROOT,
|
|
||||||
output_dir=output_dir or _default_run_output_dir(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_prepared_test_mql(prepared_run: PreparedTestMqlRun) -> TestMqlRunResult:
|
|
||||||
system = TestMqlSystem()
|
|
||||||
result = system.simulate(prepared_run.run_config)
|
|
||||||
prepared_run.output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
summary_path = prepared_run.output_dir / "test_mql_model_summary.txt"
|
|
||||||
summary_path.write_text(format_test_mql_summary(system), encoding="utf-8")
|
|
||||||
return TestMqlRunResult(
|
|
||||||
run_config=prepared_run.run_config,
|
|
||||||
prepared_run=prepared_run,
|
|
||||||
system=system,
|
|
||||||
result=result,
|
|
||||||
summary_path=summary_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql(
|
|
||||||
*,
|
|
||||||
run_config: TestMqlRunConfig | None = None,
|
|
||||||
output_dir: Path | None = None,
|
|
||||||
) -> TestMqlRunResult:
|
|
||||||
return run_prepared_test_mql(
|
|
||||||
prepare_test_mql_run(run_config=run_config, output_dir=output_dir)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
run = run_test_mql()
|
|
||||||
print(format_test_mql_summary(run.system), end="")
|
|
||||||
print(f"Samples: {len(run.result.t)}")
|
|
||||||
print(f"Output directory: {run.prepared_run.output_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.examples.test_mql.baseline import (
|
|
||||||
TestMqlBaselineRun,
|
|
||||||
run_test_mql_baseline_passthrough,
|
|
||||||
)
|
|
||||||
from app.simulation.paths import (
|
|
||||||
AMESIM_TEST_MQL_ARCHIVE_PATH,
|
|
||||||
SIMULATION_RUNS_DIR,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlBaselinePathConfig:
|
|
||||||
archive_path: Path = field(
|
|
||||||
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
|
|
||||||
)
|
|
||||||
output_dir: Path | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlBaselineExecutionConfig:
|
|
||||||
write_summary: bool = True
|
|
||||||
data_paths: tuple[str, ...] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlBaselineScriptConfig:
|
|
||||||
paths: TestMqlBaselinePathConfig = field(default_factory=TestMqlBaselinePathConfig)
|
|
||||||
execution: TestMqlBaselineExecutionConfig = field(default_factory=TestMqlBaselineExecutionConfig)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_output_dir() -> Path:
|
|
||||||
timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f")
|
|
||||||
return SIMULATION_RUNS_DIR / timestamp
|
|
||||||
|
|
||||||
|
|
||||||
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
|
|
||||||
return "\n".join(
|
|
||||||
[
|
|
||||||
"Model: test_mql",
|
|
||||||
"Mode: AMESim baseline passthrough",
|
|
||||||
f"Samples: {run.sample_count}",
|
|
||||||
f"Output schema signals: {run.output_schema.signal_count}",
|
|
||||||
f"Compared signals: {run.signal_count}",
|
|
||||||
f"Observation bindings: {run.observation_catalog.binding_count}",
|
|
||||||
f"Max absolute error: {run.comparison.max_abs_error}",
|
|
||||||
f"Max relative error: {run.comparison.max_rel_error}",
|
|
||||||
]
|
|
||||||
) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def run_test_mql_baseline(config: TestMqlBaselineScriptConfig | None = None):
|
|
||||||
config = config or TestMqlBaselineScriptConfig()
|
|
||||||
run = run_test_mql_baseline_passthrough(
|
|
||||||
config.paths.archive_path,
|
|
||||||
data_paths=config.execution.data_paths,
|
|
||||||
)
|
|
||||||
output_dir = config.paths.output_dir or _default_output_dir()
|
|
||||||
if config.execution.write_summary:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(output_dir / "test_mql_baseline_summary.txt").write_text(
|
|
||||||
format_test_mql_baseline_summary(run),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return run, output_dir
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
run, output_dir = run_test_mql_baseline()
|
|
||||||
print(format_test_mql_baseline_summary(run), end="")
|
|
||||||
print(f"Output directory: {output_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,18 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.solvers.solver import (
|
|
||||||
SolveIVPConfig as _BaseSolveIVPConfig,
|
|
||||||
integrate_ode,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SolveIVPConfig(_BaseSolveIVPConfig):
|
|
||||||
"""Solver defaults used by the calibrated ``test_mql`` example."""
|
|
||||||
|
|
||||||
atol: float = 1e-10
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["SolveIVPConfig", "integrate_ode"]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.core.base import Component, DynamicComponent
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Connection:
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationNetwork:
|
|
||||||
"""Container for components, topology, and state-vector bookkeeping."""
|
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name = name
|
|
||||||
self.components: dict[str, Component] = {}
|
|
||||||
self.connections: list[Connection] = []
|
|
||||||
|
|
||||||
def add_component(self, component: Component) -> None:
|
|
||||||
if component.name in self.components:
|
|
||||||
raise ValueError(f"Duplicate component name: {component.name}")
|
|
||||||
self.components[component.name] = component
|
|
||||||
|
|
||||||
def connect(
|
|
||||||
self,
|
|
||||||
source_component: str,
|
|
||||||
source_port: str,
|
|
||||||
target_component: str,
|
|
||||||
target_port: str,
|
|
||||||
) -> None:
|
|
||||||
self.connections.append(
|
|
||||||
Connection(
|
|
||||||
source_component=source_component,
|
|
||||||
source_port=source_port,
|
|
||||||
target_component=target_component,
|
|
||||||
target_port=target_port,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def dynamic_components(self) -> list[DynamicComponent]:
|
|
||||||
return [
|
|
||||||
component
|
|
||||||
for component in self.components.values()
|
|
||||||
if isinstance(component, DynamicComponent)
|
|
||||||
]
|
|
||||||
|
|
||||||
def initial_state_vector(self) -> list[float]:
|
|
||||||
values: list[float] = []
|
|
||||||
for component in self.dynamic_components():
|
|
||||||
values.extend(component.get_state_vector())
|
|
||||||
return values
|
|
||||||
|
|
||||||
def apply_state_vector(self, values: list[float]) -> None:
|
|
||||||
cursor = 0
|
|
||||||
for component in self.dynamic_components():
|
|
||||||
next_cursor = cursor + component.state_size
|
|
||||||
component.set_state_vector(values[cursor:next_cursor])
|
|
||||||
cursor = next_cursor
|
|
||||||
if cursor != len(values):
|
|
||||||
raise ValueError("State vector length does not match dynamic components.")
|
|
||||||
|
|
||||||
def summary(self) -> str:
|
|
||||||
lines = [f"Network: {self.name}", "Components:"]
|
|
||||||
for name, component in self.components.items():
|
|
||||||
lines.append(f" - {name}: {component.__class__.__name__}")
|
|
||||||
lines.append("Connections:")
|
|
||||||
for conn in self.connections:
|
|
||||||
lines.append(
|
|
||||||
f" - {conn.source_component}.{conn.source_port}"
|
|
||||||
f" -> {conn.target_component}.{conn.target_port}"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,118 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import tarfile
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComponentContact:
|
|
||||||
component_a: str
|
|
||||||
port_a: str
|
|
||||||
component_b: str
|
|
||||||
port_b: str
|
|
||||||
|
|
||||||
def other_endpoint(self, component_alias: str) -> tuple[str, str]:
|
|
||||||
if component_alias == self.component_a:
|
|
||||||
return self.component_b, self.port_b
|
|
||||||
if component_alias == self.component_b:
|
|
||||||
return self.component_a, self.port_a
|
|
||||||
raise KeyError(component_alias)
|
|
||||||
|
|
||||||
def port_for(self, component_alias: str) -> str:
|
|
||||||
if component_alias == self.component_a:
|
|
||||||
return self.port_a
|
|
||||||
if component_alias == self.component_b:
|
|
||||||
return self.port_b
|
|
||||||
raise KeyError(component_alias)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlCirTopology:
|
|
||||||
component_contacts: tuple[TestMqlComponentContact, ...]
|
|
||||||
|
|
||||||
def contacts_for(self, component_alias: str) -> tuple[TestMqlComponentContact, ...]:
|
|
||||||
return tuple(
|
|
||||||
contact
|
|
||||||
for contact in self.component_contacts
|
|
||||||
if component_alias in (contact.component_a, contact.component_b)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_test_mql_cir_topology(
|
|
||||||
archive_path: str | Path,
|
|
||||||
*,
|
|
||||||
cir_member: str = "test_mql_.cir",
|
|
||||||
) -> TestMqlCirTopology:
|
|
||||||
with tarfile.open(archive_path) as archive:
|
|
||||||
cir_file = archive.extractfile(cir_member)
|
|
||||||
if cir_file is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
|
|
||||||
cir_text = cir_file.read().decode("latin1")
|
|
||||||
|
|
||||||
root = ET.fromstring(_topology_only_xml(cir_text))
|
|
||||||
components = root.findall(".//COMPS_LIST/COMP")
|
|
||||||
aliases = tuple(_required_text(component, "ALIAS") for component in components)
|
|
||||||
contacts: dict[
|
|
||||||
tuple[tuple[int, int], tuple[int, int]],
|
|
||||||
TestMqlComponentContact,
|
|
||||||
] = {}
|
|
||||||
directed_contacts: set[tuple[tuple[int, int], tuple[int, int]]] = set()
|
|
||||||
|
|
||||||
for component_index, component in enumerate(components):
|
|
||||||
ports = component.findall("./COMP_PORTS_LIST/COMP_PORT")
|
|
||||||
for port_index, port in enumerate(ports):
|
|
||||||
if port.findtext("PORT_CONNECT") != "1":
|
|
||||||
continue
|
|
||||||
for connection in port.findall("./CONNECT_LIST/CONNECT"):
|
|
||||||
target_index = int(_required_text(connection, "CONNECT_ENTITY_NUM"))
|
|
||||||
target_port_index = int(_required_text(connection, "CONNECT_ENTITY_PORT"))
|
|
||||||
if target_index < 0 or target_index >= len(components):
|
|
||||||
raise ValueError(f"Component contact references unknown entity {target_index}")
|
|
||||||
target_ports = components[target_index].findall("./COMP_PORTS_LIST/COMP_PORT")
|
|
||||||
if target_port_index < 0 or target_port_index >= len(target_ports):
|
|
||||||
raise ValueError(
|
|
||||||
f"Component contact references unknown port {target_port_index} "
|
|
||||||
f"on {aliases[target_index]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
endpoint = (component_index, port_index)
|
|
||||||
target_endpoint = (target_index, target_port_index)
|
|
||||||
directed_contacts.add((endpoint, target_endpoint))
|
|
||||||
key = tuple(sorted((endpoint, target_endpoint)))
|
|
||||||
first, second = key
|
|
||||||
contacts[key] = TestMqlComponentContact(
|
|
||||||
component_a=aliases[first[0]],
|
|
||||||
port_a=f"port_{first[1] + 1}",
|
|
||||||
component_b=aliases[second[0]],
|
|
||||||
port_b=f"port_{second[1] + 1}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for endpoint, target_endpoint in directed_contacts:
|
|
||||||
if (target_endpoint, endpoint) not in directed_contacts:
|
|
||||||
raise ValueError(
|
|
||||||
"AMESim component contact is not reciprocal: "
|
|
||||||
f"{endpoint} -> {target_endpoint}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return TestMqlCirTopology(component_contacts=tuple(contacts.values()))
|
|
||||||
|
|
||||||
|
|
||||||
def _topology_only_xml(cir_text: str) -> str:
|
|
||||||
# AMESim expressions inside SUBMODEL contain unescaped && and <= operators.
|
|
||||||
# Topology lives outside those blocks, so omit them before XML parsing.
|
|
||||||
return re.sub(
|
|
||||||
r"<SUBMODEL>.*?</SUBMODEL>",
|
|
||||||
"<SUBMODEL />",
|
|
||||||
cir_text,
|
|
||||||
flags=re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_text(element: ET.Element, child_name: str) -> str:
|
|
||||||
value = element.findtext(child_name)
|
|
||||||
if value is None:
|
|
||||||
raise ValueError(f"Missing AMESim circuit element: {child_name}")
|
|
||||||
return value
|
|
||||||
@@ -1 +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,40 @@
|
|||||||
|
"""Whole-system numeric intermediate representation."""
|
||||||
|
|
||||||
|
from .compiler import compile_system_ir
|
||||||
|
from .schema import * # noqa: F403 - this package is the public schema facade.
|
||||||
|
from .schema import __dict__ as _schema_namespace
|
||||||
|
from .validation import (
|
||||||
|
IRValidationIssue,
|
||||||
|
IRValidationReport,
|
||||||
|
SystemIRValidationError,
|
||||||
|
require_valid_system_ir,
|
||||||
|
validate_system_ir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compile_system_ir",
|
||||||
|
"IRValidationIssue",
|
||||||
|
"IRValidationReport",
|
||||||
|
"SystemIRValidationError",
|
||||||
|
"require_valid_system_ir",
|
||||||
|
"validate_system_ir",
|
||||||
|
*sorted(
|
||||||
|
name
|
||||||
|
for name in _schema_namespace
|
||||||
|
if name.startswith("IR")
|
||||||
|
or name.startswith("SystemIR")
|
||||||
|
or name.startswith("SYSTEM_NUMERIC_IR")
|
||||||
|
or name
|
||||||
|
in {
|
||||||
|
"CURRENT_SYSTEM_IR_VERSION",
|
||||||
|
"NATIVE_NUMERIC_ABI_VERSION",
|
||||||
|
"canonical_json_bytes",
|
||||||
|
"native_artifact_key",
|
||||||
|
"operation_read_slots",
|
||||||
|
"operation_write_slots",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
del _schema_namespace
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Retired object-engine IR adapter; the independent v2 schema is retained.
|
||||||
|
|
||||||
|
Current native execution is generated by native_codegen.compile_native_program.
|
||||||
|
It does not yet emit the separate System IR v2 execution-plan format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def compile_system_ir(*args, **kwargs):
|
||||||
|
"""Fail explicitly for callers of the removed GenericFluidSystem adapter."""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"The GenericFluidSystem to System IR v2 exporter was retired with the Python "
|
||||||
|
"numerical engine. Use native_codegen.compiler.compile_native_program(network) "
|
||||||
|
"for the current C executable path. System IR v2 schema and validation remain available."
|
||||||
|
)
|
||||||
@@ -0,0 +1,973 @@
|
|||||||
|
"""Callback-free data contract for the whole-system numeric IR.
|
||||||
|
|
||||||
|
System IR v2 describes a compiled simulation model. It is intentionally a
|
||||||
|
pure, immutable data graph: Python functions, model objects, object addresses,
|
||||||
|
and run-local diagnostic state are not part of this module's wire contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, fields, is_dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from math import isfinite
|
||||||
|
from struct import pack
|
||||||
|
from typing import ClassVar
|
||||||
|
from unicodedata import normalize
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_NUMERIC_IR_SCHEMA_ID = "system-numeric-ir"
|
||||||
|
SYSTEM_NUMERIC_IR_SCHEMA_MAJOR = 2
|
||||||
|
SYSTEM_NUMERIC_IR_SCHEMA_MINOR = 0
|
||||||
|
SYSTEM_NUMERIC_IR_COMPILER_ID = "generic-fluid-system"
|
||||||
|
SYSTEM_NUMERIC_IR_COMPILER_VERSION = "2.0.0"
|
||||||
|
NATIVE_NUMERIC_ABI_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class IRDType(StrEnum):
|
||||||
|
FLOAT64 = "float64"
|
||||||
|
INT32 = "int32"
|
||||||
|
|
||||||
|
|
||||||
|
class IRBufferKind(StrEnum):
|
||||||
|
TIME = "time"
|
||||||
|
STATE_INPUT = "state_input"
|
||||||
|
DERIVATIVE_OUTPUT = "derivative_output"
|
||||||
|
LOCAL_STATE = "local_state"
|
||||||
|
LOCAL_DERIVATIVE = "local_derivative"
|
||||||
|
ALGEBRAIC = "algebraic"
|
||||||
|
SIGNAL = "signal"
|
||||||
|
PARAMETER = "parameter"
|
||||||
|
CONSTANT = "constant"
|
||||||
|
MODE = "mode"
|
||||||
|
WORK_FLOAT = "work_float"
|
||||||
|
WORK_INT = "work_int"
|
||||||
|
EVENT_OUTPUT = "event_output"
|
||||||
|
JACOBIAN_VALUE = "jacobian_value"
|
||||||
|
RESULT_OUTPUT = "result_output"
|
||||||
|
RUNTIME_INPUT = "runtime_input"
|
||||||
|
|
||||||
|
|
||||||
|
class IRKernelPhase(StrEnum):
|
||||||
|
PRIMAL = "primal"
|
||||||
|
RESIDUAL = "residual"
|
||||||
|
DERIVATIVE = "derivative"
|
||||||
|
PROPERTY = "property"
|
||||||
|
EVENT = "event"
|
||||||
|
RESET = "reset"
|
||||||
|
JACOBIAN = "jacobian"
|
||||||
|
OUTPUT = "output"
|
||||||
|
|
||||||
|
|
||||||
|
class IRKernelAvailability(StrEnum):
|
||||||
|
NATIVE = "native"
|
||||||
|
REFERENCE_ONLY = "reference_only"
|
||||||
|
|
||||||
|
|
||||||
|
class IRPortKind(StrEnum):
|
||||||
|
PHYSICAL = "physical"
|
||||||
|
SIGNAL = "signal"
|
||||||
|
|
||||||
|
|
||||||
|
class IRPortNominalRole(StrEnum):
|
||||||
|
INLET = "inlet"
|
||||||
|
OUTLET = "outlet"
|
||||||
|
BIDIRECTIONAL = "bidirectional"
|
||||||
|
INPUT = "input"
|
||||||
|
OUTPUT = "output"
|
||||||
|
|
||||||
|
|
||||||
|
class IRPositiveFlowDirection(StrEnum):
|
||||||
|
INTO_COMPONENT = "intoComponent"
|
||||||
|
|
||||||
|
|
||||||
|
class IRVariableRole(StrEnum):
|
||||||
|
EFFORT = "effort"
|
||||||
|
FLOW = "flow"
|
||||||
|
STREAM = "stream"
|
||||||
|
SIGNAL = "signal"
|
||||||
|
|
||||||
|
|
||||||
|
class IRConnectionRule(StrEnum):
|
||||||
|
EQUAL = "equal"
|
||||||
|
SUM_TO_ZERO = "sumToZero"
|
||||||
|
STREAM_MIX = "streamMix"
|
||||||
|
DIRECTED = "directed"
|
||||||
|
|
||||||
|
|
||||||
|
class IREquationOwner(StrEnum):
|
||||||
|
COMPONENT = "component"
|
||||||
|
CONNECTION = "connection"
|
||||||
|
|
||||||
|
|
||||||
|
class IREquationRelation(StrEnum):
|
||||||
|
EQUAL = "equal"
|
||||||
|
SUM_TO_ZERO = "sumToZero"
|
||||||
|
CONSTITUTIVE = "constitutive"
|
||||||
|
STATE = "state"
|
||||||
|
|
||||||
|
|
||||||
|
class IRPressureFlowScopeKind(StrEnum):
|
||||||
|
NETWORK = "network"
|
||||||
|
PHYSICAL_ISLAND = "physical_island"
|
||||||
|
EQUATION_BLOCK = "equation_block"
|
||||||
|
|
||||||
|
|
||||||
|
class IRStageKind(StrEnum):
|
||||||
|
STATE_REDUCE = "state_reduce"
|
||||||
|
SIGNAL = "signal"
|
||||||
|
MECHANICAL_EQUIVALENCE = "mechanical_equivalence"
|
||||||
|
DYNAMIC_VOLUME = "dynamic_volume"
|
||||||
|
PROPERTY = "property"
|
||||||
|
PRESSURE_FLOW = "pressure_flow"
|
||||||
|
STREAM = "stream"
|
||||||
|
TEMPERATURE_REFERENCE = "temperature_reference"
|
||||||
|
THERMOFLUID_FIXED_POINT = "thermofluid_fixed_point"
|
||||||
|
MECHANICAL_ACCELERATION = "mechanical_acceleration"
|
||||||
|
DERIVATIVE_REDUCE = "derivative_reduce"
|
||||||
|
EVENT = "event"
|
||||||
|
JACOBIAN = "jacobian"
|
||||||
|
OUTPUT = "output"
|
||||||
|
RESET = "reset"
|
||||||
|
|
||||||
|
|
||||||
|
class IREntryPointKind(StrEnum):
|
||||||
|
RHS = "rhs"
|
||||||
|
EVENTS = "events"
|
||||||
|
JACOBIAN = "jacobian"
|
||||||
|
OUTPUTS = "outputs"
|
||||||
|
|
||||||
|
|
||||||
|
class IRBlockKind(StrEnum):
|
||||||
|
SEQUENCE = "sequence"
|
||||||
|
FIXED_POINT = "fixed_point"
|
||||||
|
STREAM_SCC = "stream_scc"
|
||||||
|
|
||||||
|
|
||||||
|
class IRStepKind(StrEnum):
|
||||||
|
STAGE = "stage"
|
||||||
|
BLOCK = "block"
|
||||||
|
|
||||||
|
|
||||||
|
class IRStateMapKind(StrEnum):
|
||||||
|
SCATTER = "scatter"
|
||||||
|
DERIVATIVE_GATHER = "derivative_gather"
|
||||||
|
|
||||||
|
|
||||||
|
class IREventDirection(StrEnum):
|
||||||
|
DECREASING = "decreasing"
|
||||||
|
ANY = "any"
|
||||||
|
INCREASING = "increasing"
|
||||||
|
|
||||||
|
|
||||||
|
class IRFailurePolicy(StrEnum):
|
||||||
|
FAIL = "fail"
|
||||||
|
RETRY_SMALLER_STEP = "retry_smaller_step"
|
||||||
|
|
||||||
|
|
||||||
|
class IRCacheKind(StrEnum):
|
||||||
|
PROPERTY = "property"
|
||||||
|
PRESSURE_FLOW = "pressure_flow"
|
||||||
|
STREAM = "stream"
|
||||||
|
JACOBIAN = "jacobian"
|
||||||
|
OUTPUT = "output"
|
||||||
|
|
||||||
|
|
||||||
|
class IRCapabilityLevel(StrEnum):
|
||||||
|
NATIVE = "native"
|
||||||
|
REFERENCE_ONLY = "reference_only"
|
||||||
|
UNSUPPORTED = "unsupported"
|
||||||
|
|
||||||
|
|
||||||
|
class IRDiagnosticSeverity(StrEnum):
|
||||||
|
INFO = "info"
|
||||||
|
WARNING = "warning"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class IROpcode(StrEnum):
|
||||||
|
FILL = "fill"
|
||||||
|
COPY = "copy"
|
||||||
|
SCATTER = "scatter"
|
||||||
|
LINEAR_COMBINATION = "linear_combination"
|
||||||
|
STATE_MAP = "state_map"
|
||||||
|
KERNEL_CALL = "kernel_call"
|
||||||
|
EFFORT_BROADCAST = "effort_broadcast"
|
||||||
|
FLOW_ASSIGN = "flow_assign"
|
||||||
|
CHECK_FINITE = "check_finite"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRSchemaVersion:
|
||||||
|
schema_id: str = SYSTEM_NUMERIC_IR_SCHEMA_ID
|
||||||
|
major: int = SYSTEM_NUMERIC_IR_SCHEMA_MAJOR
|
||||||
|
minor: int = SYSTEM_NUMERIC_IR_SCHEMA_MINOR
|
||||||
|
|
||||||
|
|
||||||
|
CURRENT_SYSTEM_IR_VERSION = IRSchemaVersion()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRSlotRef:
|
||||||
|
buffer: IRBufferKind
|
||||||
|
index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRBufferSpec:
|
||||||
|
kind: IRBufferKind
|
||||||
|
dtype: IRDType
|
||||||
|
size: int
|
||||||
|
initial_float_values: tuple[float, ...] = ()
|
||||||
|
initial_int_values: tuple[int, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRValueSpec:
|
||||||
|
value_id: str
|
||||||
|
slot: IRSlotRef
|
||||||
|
semantic: str
|
||||||
|
role: str
|
||||||
|
quantity: str
|
||||||
|
unit: str
|
||||||
|
scale: float
|
||||||
|
lower_bound: float | None = None
|
||||||
|
upper_bound: float | None = None
|
||||||
|
owner_component_index: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRKernelPhaseSpec:
|
||||||
|
"""Phase capability tag; each call's ordered slot lists define its C-01 ABI.
|
||||||
|
|
||||||
|
Fixed component-kernel signatures deliberately belong to the C-02 contract.
|
||||||
|
Recording placeholder arities here would make the current reference-only
|
||||||
|
kernels look more strictly specified than they are.
|
||||||
|
"""
|
||||||
|
|
||||||
|
phase: IRKernelPhase
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRKernelSpec:
|
||||||
|
kernel_id: str
|
||||||
|
model_type: str
|
||||||
|
model_version: str
|
||||||
|
implementation_version: str
|
||||||
|
availability: IRKernelAvailability
|
||||||
|
unavailable_reason: str | None
|
||||||
|
phases: tuple[IRKernelPhaseSpec, ...]
|
||||||
|
parameter_count: int
|
||||||
|
state_count: int
|
||||||
|
mode_count: int
|
||||||
|
workspace_float_count: int
|
||||||
|
workspace_int_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRComponentInstance:
|
||||||
|
instance_id: str
|
||||||
|
kernel_index: int
|
||||||
|
parameter_slots: tuple[IRSlotRef, ...]
|
||||||
|
state_slots: tuple[IRSlotRef, ...]
|
||||||
|
derivative_slots: tuple[IRSlotRef, ...]
|
||||||
|
mode_slots: tuple[IRSlotRef, ...]
|
||||||
|
port_indices: tuple[int, ...]
|
||||||
|
port_slots: tuple[IRSlotRef, ...]
|
||||||
|
output_indices: tuple[int, ...]
|
||||||
|
workspace_float_slots: tuple[IRSlotRef, ...]
|
||||||
|
workspace_int_slots: tuple[IRSlotRef, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRPortVariable:
|
||||||
|
variable_id: str
|
||||||
|
name: str
|
||||||
|
role: IRVariableRole
|
||||||
|
connection_rule: IRConnectionRule
|
||||||
|
quantity: str
|
||||||
|
unit: str
|
||||||
|
result_visible: bool
|
||||||
|
slot: IRSlotRef
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRPortSpec:
|
||||||
|
port_id: str
|
||||||
|
component_index: int
|
||||||
|
name: str
|
||||||
|
kind: IRPortKind
|
||||||
|
domain: str
|
||||||
|
nominal_role: IRPortNominalRole
|
||||||
|
positive_flow_direction: IRPositiveFlowDirection | None
|
||||||
|
variables: tuple[IRPortVariable, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRConnectionVariable:
|
||||||
|
name: str
|
||||||
|
rule: IRConnectionRule
|
||||||
|
endpoint_a_slot: IRSlotRef
|
||||||
|
endpoint_b_slot: IRSlotRef
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRConnectionSpec:
|
||||||
|
connection_id: str
|
||||||
|
kind: IRPortKind
|
||||||
|
domain: str
|
||||||
|
endpoint_a_port_index: int
|
||||||
|
endpoint_b_port_index: int
|
||||||
|
variables: tuple[IRConnectionVariable, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRMediumSpec:
|
||||||
|
medium_id: str
|
||||||
|
name: str
|
||||||
|
implementation_id: str
|
||||||
|
implementation_version: str
|
||||||
|
parameter_slots: tuple[IRSlotRef, ...]
|
||||||
|
component_indices: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCSRPattern:
|
||||||
|
row_count: int
|
||||||
|
column_count: int
|
||||||
|
row_pointers: tuple[int, ...]
|
||||||
|
column_indices: tuple[int, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nonzero_count(self) -> int:
|
||||||
|
return len(self.column_indices)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCSRMatrix:
|
||||||
|
pattern: IRCSRPattern
|
||||||
|
values: tuple[float, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStateReducer:
|
||||||
|
solver_state_count: int
|
||||||
|
local_state_slots: tuple[IRSlotRef, ...]
|
||||||
|
raw_derivative_slots: tuple[IRSlotRef, ...]
|
||||||
|
state_scatter: IRCSRMatrix
|
||||||
|
derivative_gather: IRCSRMatrix
|
||||||
|
initial_state: tuple[float, ...]
|
||||||
|
absolute_tolerances: tuple[float, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRFillOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.FILL
|
||||||
|
target_slots: tuple[IRSlotRef, ...]
|
||||||
|
value: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCopyOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.COPY
|
||||||
|
source_slot: IRSlotRef
|
||||||
|
target_slot: IRSlotRef
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRScatterOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.SCATTER
|
||||||
|
source_slot: IRSlotRef
|
||||||
|
target_slots: tuple[IRSlotRef, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRLinearCombinationOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.LINEAR_COMBINATION
|
||||||
|
source_slots: tuple[IRSlotRef, ...]
|
||||||
|
weights: tuple[float, ...]
|
||||||
|
target_slot: IRSlotRef
|
||||||
|
bias: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStateMapOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.STATE_MAP
|
||||||
|
map_kind: IRStateMapKind
|
||||||
|
source_slots: tuple[IRSlotRef, ...]
|
||||||
|
target_slots: tuple[IRSlotRef, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRKernelCallOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.KERNEL_CALL
|
||||||
|
kernel_index: int
|
||||||
|
component_index: int | None
|
||||||
|
phase: IRKernelPhase
|
||||||
|
read_slots: tuple[IRSlotRef, ...]
|
||||||
|
write_slots: tuple[IRSlotRef, ...]
|
||||||
|
equation_indices: tuple[int, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IREffortBroadcastOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.EFFORT_BROADCAST
|
||||||
|
variable: str
|
||||||
|
anchor_slot: IRSlotRef
|
||||||
|
residual_slot: IRSlotRef
|
||||||
|
result_slot: IRSlotRef
|
||||||
|
scatter_slots: tuple[IRSlotRef, ...]
|
||||||
|
equation_id: str
|
||||||
|
lower_bound: float | None = None
|
||||||
|
upper_bound: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRFlowAssignmentOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.FLOW_ASSIGN
|
||||||
|
value_slot: IRSlotRef
|
||||||
|
result_slot: IRSlotRef
|
||||||
|
scatter_slots: tuple[IRSlotRef, ...]
|
||||||
|
equation_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCheckFiniteOperation:
|
||||||
|
opcode: ClassVar[IROpcode] = IROpcode.CHECK_FINITE
|
||||||
|
slots: tuple[IRSlotRef, ...]
|
||||||
|
error_code: str
|
||||||
|
|
||||||
|
|
||||||
|
IROperation = (
|
||||||
|
IRFillOperation
|
||||||
|
| IRCopyOperation
|
||||||
|
| IRScatterOperation
|
||||||
|
| IRLinearCombinationOperation
|
||||||
|
| IRStateMapOperation
|
||||||
|
| IRKernelCallOperation
|
||||||
|
| IREffortBroadcastOperation
|
||||||
|
| IRFlowAssignmentOperation
|
||||||
|
| IRCheckFiniteOperation
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def operation_read_slots(operation: IROperation) -> tuple[IRSlotRef, ...]:
|
||||||
|
if isinstance(operation, IRFillOperation):
|
||||||
|
return ()
|
||||||
|
if isinstance(operation, (IRCopyOperation, IRScatterOperation)):
|
||||||
|
return (operation.source_slot,)
|
||||||
|
if isinstance(operation, (IRLinearCombinationOperation, IRStateMapOperation)):
|
||||||
|
return operation.source_slots
|
||||||
|
if isinstance(operation, IRKernelCallOperation):
|
||||||
|
return operation.read_slots
|
||||||
|
if isinstance(operation, IREffortBroadcastOperation):
|
||||||
|
return (operation.anchor_slot, operation.residual_slot)
|
||||||
|
if isinstance(operation, IRFlowAssignmentOperation):
|
||||||
|
return (operation.value_slot,)
|
||||||
|
if isinstance(operation, IRCheckFiniteOperation):
|
||||||
|
return operation.slots
|
||||||
|
raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.")
|
||||||
|
|
||||||
|
|
||||||
|
def operation_write_slots(operation: IROperation) -> tuple[IRSlotRef, ...]:
|
||||||
|
if isinstance(operation, IRFillOperation):
|
||||||
|
return operation.target_slots
|
||||||
|
if isinstance(operation, IRCopyOperation):
|
||||||
|
return (operation.target_slot,)
|
||||||
|
if isinstance(operation, IRScatterOperation):
|
||||||
|
return operation.target_slots
|
||||||
|
if isinstance(operation, IRLinearCombinationOperation):
|
||||||
|
return (operation.target_slot,)
|
||||||
|
if isinstance(operation, IRStateMapOperation):
|
||||||
|
return operation.target_slots
|
||||||
|
if isinstance(operation, IRKernelCallOperation):
|
||||||
|
return operation.write_slots
|
||||||
|
if isinstance(operation, IREffortBroadcastOperation):
|
||||||
|
return (operation.result_slot, *operation.scatter_slots)
|
||||||
|
if isinstance(operation, IRFlowAssignmentOperation):
|
||||||
|
return (operation.result_slot, *operation.scatter_slots)
|
||||||
|
if isinstance(operation, IRCheckFiniteOperation):
|
||||||
|
return ()
|
||||||
|
raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStage:
|
||||||
|
stage_id: str
|
||||||
|
kind: IRStageKind
|
||||||
|
operations: tuple[IROperation, ...]
|
||||||
|
declared_read_slots: tuple[IRSlotRef, ...]
|
||||||
|
declared_write_slots: tuple[IRSlotRef, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStepRef:
|
||||||
|
kind: IRStepKind
|
||||||
|
index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRConvergenceSpec:
|
||||||
|
monitor_slots: tuple[IRSlotRef, ...]
|
||||||
|
absolute_tolerance: float
|
||||||
|
relative_tolerance: float
|
||||||
|
max_iterations: int
|
||||||
|
relaxation: float
|
||||||
|
rollback_slots: tuple[IRSlotRef, ...]
|
||||||
|
failure_policy: IRFailurePolicy
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRExecutionBlock:
|
||||||
|
block_id: str
|
||||||
|
kind: IRBlockKind
|
||||||
|
steps: tuple[IRStepRef, ...]
|
||||||
|
convergence: IRConvergenceSpec | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IREntryPoint:
|
||||||
|
kind: IREntryPointKind
|
||||||
|
steps: tuple[IRStepRef, ...]
|
||||||
|
input_slots: tuple[IRSlotRef, ...]
|
||||||
|
output_slots: tuple[IRSlotRef, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCausalEffortStageRef:
|
||||||
|
variable: str
|
||||||
|
stage_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCausalPlan:
|
||||||
|
plan_id: str
|
||||||
|
scope_component_indices: tuple[int, ...]
|
||||||
|
source_schema_version: int
|
||||||
|
source_structural_signature: str | None
|
||||||
|
fallback_reason: str | None
|
||||||
|
canonical_slots: tuple[IRSlotRef, ...]
|
||||||
|
compatibility_slots: tuple[IRSlotRef, ...]
|
||||||
|
reset_slots: tuple[IRSlotRef, ...]
|
||||||
|
external_effort_slots: tuple[IRSlotRef, ...]
|
||||||
|
effort_stages: tuple[IRCausalEffortStageRef, ...]
|
||||||
|
flow_stage_indices: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRAlgebraicUnknown:
|
||||||
|
unknown_id: str
|
||||||
|
component_index: int
|
||||||
|
port_index: int
|
||||||
|
variable: str
|
||||||
|
role: IRVariableRole
|
||||||
|
slot: IRSlotRef
|
||||||
|
scale: float
|
||||||
|
lower_bound: float | None = None
|
||||||
|
upper_bound: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRPressureFlowEquation:
|
||||||
|
equation_id: str
|
||||||
|
owner: IREquationOwner
|
||||||
|
owner_index: int
|
||||||
|
relation: IREquationRelation
|
||||||
|
role: IRVariableRole | None
|
||||||
|
variable_slots: tuple[IRSlotRef, ...]
|
||||||
|
residual_slot: IRSlotRef
|
||||||
|
scale: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRPressureFlowBlock:
|
||||||
|
block_id: str
|
||||||
|
unknown_indices: tuple[int, ...]
|
||||||
|
equation_indices: tuple[int, ...]
|
||||||
|
jacobian_pattern: IRCSRPattern
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRPressureFlowScope:
|
||||||
|
scope_id: str
|
||||||
|
kind: IRPressureFlowScopeKind
|
||||||
|
component_indices: tuple[int, ...]
|
||||||
|
unknown_indices: tuple[int, ...]
|
||||||
|
equation_indices: tuple[int, ...]
|
||||||
|
block_indices: tuple[int, ...]
|
||||||
|
causal_plan_index: int | None
|
||||||
|
residual_tolerance: float
|
||||||
|
max_evaluations: int
|
||||||
|
sparse_pattern_trusted: bool
|
||||||
|
sparse_fallback_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRPressureFlowPlan:
|
||||||
|
unknowns: tuple[IRAlgebraicUnknown, ...]
|
||||||
|
equations: tuple[IRPressureFlowEquation, ...]
|
||||||
|
blocks: tuple[IRPressureFlowBlock, ...]
|
||||||
|
scopes: tuple[IRPressureFlowScope, ...]
|
||||||
|
global_scope_index: int
|
||||||
|
secondary_scope_indices: tuple[int, ...]
|
||||||
|
pressure_lower_bound: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStreamSCC:
|
||||||
|
scc_id: str
|
||||||
|
node_slots: tuple[IRSlotRef, ...]
|
||||||
|
block_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStreamEdge:
|
||||||
|
source_scc_index: int
|
||||||
|
target_scc_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRStreamPlan:
|
||||||
|
plan_id: str
|
||||||
|
node_slots: tuple[IRSlotRef, ...]
|
||||||
|
strongly_connected_components: tuple[IRStreamSCC, ...]
|
||||||
|
condensed_edges: tuple[IRStreamEdge, ...]
|
||||||
|
topological_scc_indices: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRThermofluidPlan:
|
||||||
|
physical_port_indices: tuple[int, ...]
|
||||||
|
global_component_indices: tuple[int, ...]
|
||||||
|
stream_plan_index: int
|
||||||
|
secondary_pressure_scope_indices: tuple[int, ...]
|
||||||
|
sensitive_component_indices: tuple[int, ...]
|
||||||
|
maximum_iterations: int
|
||||||
|
flow_relative_tolerance: float
|
||||||
|
uses_conservative_global_solver: bool
|
||||||
|
conservative_fallback_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRTransactionPlan:
|
||||||
|
snapshot_slots: tuple[IRSlotRef, ...]
|
||||||
|
flow_slots: tuple[IRSlotRef, ...]
|
||||||
|
cache_component_indices: tuple[int, ...]
|
||||||
|
cache_attribute_ids: tuple[str, ...]
|
||||||
|
diagnostic_owner_ids: tuple[str, ...]
|
||||||
|
restores_on_recoverable_failure: bool
|
||||||
|
restores_on_fatal_failure: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRModeValueSpec:
|
||||||
|
value: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRModeSpec:
|
||||||
|
mode_id: str
|
||||||
|
slot: IRSlotRef
|
||||||
|
owner_component_indices: tuple[int, ...]
|
||||||
|
values: tuple[IRModeValueSpec, ...]
|
||||||
|
initial_value: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRFiniteDifferenceColumn:
|
||||||
|
column_index: int
|
||||||
|
value_indices: tuple[int, ...]
|
||||||
|
relative_step: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRJacobianPlan:
|
||||||
|
pattern: IRCSRPattern
|
||||||
|
value_slots: tuple[IRSlotRef, ...]
|
||||||
|
color_groups: tuple[tuple[int, ...], ...]
|
||||||
|
fill_steps: tuple[IRStepRef, ...]
|
||||||
|
analytic_value_indices: tuple[int, ...]
|
||||||
|
local_finite_difference_columns: tuple[IRFiniteDifferenceColumn, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRModeGuard:
|
||||||
|
mode_slot: IRSlotRef
|
||||||
|
allowed_values: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IREventSpec:
|
||||||
|
event_id: str
|
||||||
|
event_kind: str
|
||||||
|
owner_component_indices: tuple[int, ...]
|
||||||
|
root_slot: IRSlotRef
|
||||||
|
direction: IREventDirection
|
||||||
|
terminal: bool
|
||||||
|
priority: int
|
||||||
|
mode_guards: tuple[IRModeGuard, ...]
|
||||||
|
reset_steps: tuple[IRStepRef, ...]
|
||||||
|
invalidated_caches: tuple[IRCacheKind, ...]
|
||||||
|
restarts_integrator: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IROutputSpec:
|
||||||
|
output_id: str
|
||||||
|
component_index: int
|
||||||
|
scope: str
|
||||||
|
port_name: str | None
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
category: str
|
||||||
|
quantity: str
|
||||||
|
unit: str
|
||||||
|
order: int
|
||||||
|
source_slot: IRSlotRef
|
||||||
|
output_slot: IRSlotRef
|
||||||
|
scale: float = 1.0
|
||||||
|
offset: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRComponentCapability:
|
||||||
|
component_index: int
|
||||||
|
level: IRCapabilityLevel
|
||||||
|
supported_phases: tuple[IRKernelPhase, ...]
|
||||||
|
missing_features: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCapabilityIssue:
|
||||||
|
code: str
|
||||||
|
severity: IRDiagnosticSeverity
|
||||||
|
scope_id: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRCapabilityReport:
|
||||||
|
system_level: IRCapabilityLevel
|
||||||
|
components: tuple[IRComponentCapability, ...]
|
||||||
|
issues: tuple[IRCapabilityIssue, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SystemIR:
|
||||||
|
version: IRSchemaVersion
|
||||||
|
model_id: str
|
||||||
|
model_version: str
|
||||||
|
compiler_id: str
|
||||||
|
compiler_version: str
|
||||||
|
numeric_dtype: IRDType
|
||||||
|
buffers: tuple[IRBufferSpec, ...]
|
||||||
|
values: tuple[IRValueSpec, ...]
|
||||||
|
kernels: tuple[IRKernelSpec, ...]
|
||||||
|
components: tuple[IRComponentInstance, ...]
|
||||||
|
mediums: tuple[IRMediumSpec, ...]
|
||||||
|
ports: tuple[IRPortSpec, ...]
|
||||||
|
connections: tuple[IRConnectionSpec, ...]
|
||||||
|
state_reducer: IRStateReducer
|
||||||
|
causal_plans: tuple[IRCausalPlan, ...]
|
||||||
|
pressure_flow: IRPressureFlowPlan
|
||||||
|
stream_plans: tuple[IRStreamPlan, ...]
|
||||||
|
thermofluid: IRThermofluidPlan
|
||||||
|
stages: tuple[IRStage, ...]
|
||||||
|
blocks: tuple[IRExecutionBlock, ...]
|
||||||
|
entry_points: tuple[IREntryPoint, ...]
|
||||||
|
transaction: IRTransactionPlan
|
||||||
|
modes: tuple[IRModeSpec, ...]
|
||||||
|
jacobian: IRJacobianPlan
|
||||||
|
events: tuple[IREventSpec, ...]
|
||||||
|
outputs: tuple[IROutputSpec, ...]
|
||||||
|
capabilities: IRCapabilityReport
|
||||||
|
required_features: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def canonical_json_bytes(self) -> bytes:
|
||||||
|
return canonical_json_bytes(self)
|
||||||
|
|
||||||
|
def calculate_structural_signature(self) -> str:
|
||||||
|
return sha256(self.canonical_json_bytes()).hexdigest()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def structural_signature(self) -> str:
|
||||||
|
return self.calculate_structural_signature()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IRNativeBuildIdentity:
|
||||||
|
abi_version: int
|
||||||
|
target_triple: str
|
||||||
|
compiler_id: str
|
||||||
|
compiler_version: str
|
||||||
|
compile_flags: tuple[str, ...]
|
||||||
|
floating_point_policy: str
|
||||||
|
kernel_library_signature: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _IRNativeArtifactKeyInput:
|
||||||
|
program_signature: str
|
||||||
|
build: IRNativeBuildIdentity
|
||||||
|
|
||||||
|
|
||||||
|
_CANONICAL_TYPE_NAMES: tuple[tuple[type[object], str], ...] = (
|
||||||
|
(IRSchemaVersion, "schema_version"),
|
||||||
|
(IRSlotRef, "slot_ref"),
|
||||||
|
(IRBufferSpec, "buffer"),
|
||||||
|
(IRValueSpec, "value"),
|
||||||
|
(IRKernelPhaseSpec, "kernel_phase"),
|
||||||
|
(IRKernelSpec, "kernel"),
|
||||||
|
(IRComponentInstance, "component"),
|
||||||
|
(IRPortVariable, "port_variable"),
|
||||||
|
(IRPortSpec, "port"),
|
||||||
|
(IRConnectionVariable, "connection_variable"),
|
||||||
|
(IRConnectionSpec, "connection"),
|
||||||
|
(IRMediumSpec, "medium"),
|
||||||
|
(IRCSRPattern, "csr_pattern"),
|
||||||
|
(IRCSRMatrix, "csr_matrix"),
|
||||||
|
(IRStateReducer, "state_reducer"),
|
||||||
|
(IRFillOperation, "operation"),
|
||||||
|
(IRCopyOperation, "operation"),
|
||||||
|
(IRScatterOperation, "operation"),
|
||||||
|
(IRLinearCombinationOperation, "operation"),
|
||||||
|
(IRStateMapOperation, "operation"),
|
||||||
|
(IRKernelCallOperation, "operation"),
|
||||||
|
(IREffortBroadcastOperation, "operation"),
|
||||||
|
(IRFlowAssignmentOperation, "operation"),
|
||||||
|
(IRCheckFiniteOperation, "operation"),
|
||||||
|
(IRStage, "stage"),
|
||||||
|
(IRStepRef, "step_ref"),
|
||||||
|
(IRConvergenceSpec, "convergence"),
|
||||||
|
(IRExecutionBlock, "block"),
|
||||||
|
(IREntryPoint, "entry_point"),
|
||||||
|
(IRCausalEffortStageRef, "causal_effort_stage"),
|
||||||
|
(IRCausalPlan, "causal_plan"),
|
||||||
|
(IRAlgebraicUnknown, "algebraic_unknown"),
|
||||||
|
(IRPressureFlowEquation, "pressure_flow_equation"),
|
||||||
|
(IRPressureFlowBlock, "pressure_flow_block"),
|
||||||
|
(IRPressureFlowScope, "pressure_flow_scope"),
|
||||||
|
(IRPressureFlowPlan, "pressure_flow_plan"),
|
||||||
|
(IRStreamSCC, "stream_scc"),
|
||||||
|
(IRStreamEdge, "stream_edge"),
|
||||||
|
(IRStreamPlan, "stream_plan"),
|
||||||
|
(IRThermofluidPlan, "thermofluid_plan"),
|
||||||
|
(IRTransactionPlan, "transaction_plan"),
|
||||||
|
(IRModeValueSpec, "mode_value"),
|
||||||
|
(IRModeSpec, "mode"),
|
||||||
|
(IRFiniteDifferenceColumn, "finite_difference_column"),
|
||||||
|
(IRJacobianPlan, "jacobian_plan"),
|
||||||
|
(IRModeGuard, "mode_guard"),
|
||||||
|
(IREventSpec, "event"),
|
||||||
|
(IROutputSpec, "output"),
|
||||||
|
(IRComponentCapability, "component_capability"),
|
||||||
|
(IRCapabilityIssue, "capability_issue"),
|
||||||
|
(IRCapabilityReport, "capability_report"),
|
||||||
|
(SystemIR, "system_ir"),
|
||||||
|
(IRNativeBuildIdentity, "native_build"),
|
||||||
|
(_IRNativeArtifactKeyInput, "native_artifact_key_input"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_OPERATION_TYPES = (
|
||||||
|
IRFillOperation,
|
||||||
|
IRCopyOperation,
|
||||||
|
IRScatterOperation,
|
||||||
|
IRLinearCombinationOperation,
|
||||||
|
IRStateMapOperation,
|
||||||
|
IRKernelCallOperation,
|
||||||
|
IREffortBroadcastOperation,
|
||||||
|
IRFlowAssignmentOperation,
|
||||||
|
IRCheckFiniteOperation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_type_name(value: object) -> str:
|
||||||
|
value_type = type(value)
|
||||||
|
for candidate, name in _CANONICAL_TYPE_NAMES:
|
||||||
|
if value_type is candidate:
|
||||||
|
return name
|
||||||
|
raise TypeError(f"Unsupported IR schema object: {value_type.__name__}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_float(value: float) -> object:
|
||||||
|
numeric = float(value)
|
||||||
|
if not isfinite(numeric):
|
||||||
|
raise ValueError("Canonical IR JSON does not permit NaN or infinity.")
|
||||||
|
if numeric == 0.0:
|
||||||
|
numeric = 0.0
|
||||||
|
return {"$float64": pack(">d", numeric).hex()}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_value(value: object) -> object:
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, StrEnum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return _canonical_float(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return normalize("NFC", value)
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return [_canonical_value(item) for item in value]
|
||||||
|
if is_dataclass(value) and not isinstance(value, type):
|
||||||
|
payload: dict[str, object] = {"$type": _canonical_type_name(value)}
|
||||||
|
if isinstance(value, _OPERATION_TYPES):
|
||||||
|
payload["opcode"] = value.opcode.value
|
||||||
|
for item in fields(value):
|
||||||
|
payload[item.name] = _canonical_value(getattr(value, item.name))
|
||||||
|
return payload
|
||||||
|
raise TypeError(
|
||||||
|
"Canonical IR JSON accepts only schema dataclasses, tuples, enums, and "
|
||||||
|
f"scalar values; received {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: object) -> bytes:
|
||||||
|
"""Return the exact platform-independent canonical JSON byte sequence."""
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
_canonical_value(value),
|
||||||
|
ensure_ascii=True,
|
||||||
|
allow_nan=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def native_artifact_key(
|
||||||
|
program: SystemIR,
|
||||||
|
build: IRNativeBuildIdentity,
|
||||||
|
) -> str:
|
||||||
|
"""Build cache key; target details never contaminate the program hash."""
|
||||||
|
|
||||||
|
if build.abi_version != NATIVE_NUMERIC_ABI_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Native build ABI does not match NATIVE_NUMERIC_ABI_VERSION."
|
||||||
|
)
|
||||||
|
for field_name, value in (
|
||||||
|
("target_triple", build.target_triple),
|
||||||
|
("compiler_id", build.compiler_id),
|
||||||
|
("compiler_version", build.compiler_version),
|
||||||
|
("floating_point_policy", build.floating_point_policy),
|
||||||
|
):
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"Native build {field_name} must not be empty.")
|
||||||
|
if any(not flag for flag in build.compile_flags):
|
||||||
|
raise ValueError("Native build flags must not contain empty entries.")
|
||||||
|
if (
|
||||||
|
len(build.kernel_library_signature) != 64
|
||||||
|
or any(
|
||||||
|
character not in "0123456789abcdef"
|
||||||
|
for character in build.kernel_library_signature
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Native kernel library signature must be lowercase SHA-256 hex."
|
||||||
|
)
|
||||||
|
payload = _IRNativeArtifactKeyInput(program.structural_signature, build)
|
||||||
|
return sha256(canonical_json_bytes(payload)).hexdigest()
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 / "model.exe"),
|
||||||
|
"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,119 @@
|
|||||||
|
"""Reproducible native builds and a checked, model-specific executable cache."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .compiler import NativeProgram
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
NATIVE = ROOT / "native"
|
||||||
|
CACHE = ROOT / "app/data/native-builds"
|
||||||
|
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NativeBuild:
|
||||||
|
executable: Path
|
||||||
|
manifest: dict
|
||||||
|
cache_hit: bool
|
||||||
|
seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(path: Path) -> str:
|
||||||
|
return sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def toolchain() -> tuple[str, Path, str]:
|
||||||
|
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.")
|
||||||
|
base = Path(os.environ.get("SUNDIALS_ROOT", str(Path(sys.base_prefix) / "Library")))
|
||||||
|
if not (base / "include/cvode/cvode.h").is_file():
|
||||||
|
raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.")
|
||||||
|
version = subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True, timeout=15).stdout.splitlines()[0]
|
||||||
|
return compiler, base, version
|
||||||
|
|
||||||
|
|
||||||
|
def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild:
|
||||||
|
start = time.perf_counter()
|
||||||
|
compiler, sundials, compiler_version = toolchain()
|
||||||
|
runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").glob("*.h"))
|
||||||
|
flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"]
|
||||||
|
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]
|
||||||
|
vc_runtime = sundials / "bin/vcruntime140.dll"
|
||||||
|
if vc_runtime.is_file():
|
||||||
|
dlls.append(vc_runtime)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Native v1 build packaging currently supports Windows x64; Linux packaging is pending.")
|
||||||
|
sources = {str(p.relative_to(ROOT)): _hash(p) for p in runtime}
|
||||||
|
sources["native/THIRD_PARTY_NOTICES.txt"] = _hash(NATIVE / "THIRD_PARTY_NOTICES.txt")
|
||||||
|
dependencies = {str(p.name): _hash(p) for p in libraries + dlls}
|
||||||
|
# Header hashes include precision/index ABI settings as well as library APIs.
|
||||||
|
for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol"):
|
||||||
|
for path in sorted((sundials / "include" / directory).glob("*.h")):
|
||||||
|
dependencies[f"{directory}/{path.name}"] = _hash(path)
|
||||||
|
identity = dict(source=program.source, header=program.header, contract=program.manifest(), sources=sources,
|
||||||
|
dependencies=dependencies, compiler=compiler_version, flags=flags,
|
||||||
|
platform=sys.platform, abi=1)
|
||||||
|
signature = sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
||||||
|
cache = (cache_dir or CACHE).resolve()
|
||||||
|
target = cache / signature
|
||||||
|
manifest_path = target / "manifest.json"
|
||||||
|
if manifest_path.is_file():
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
if all((target / name).is_file() and _hash(target / name) == digest
|
||||||
|
for name, digest in manifest["artifacts"].items()):
|
||||||
|
return NativeBuild(target / "model.exe", manifest, True, time.perf_counter()-start)
|
||||||
|
raise RuntimeError(f"Native cache integrity check failed: {target}")
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
stage = Path(tempfile.mkdtemp(prefix="building-", dir=cache))
|
||||||
|
(stage / "model.c").write_text(program.source, encoding="utf-8")
|
||||||
|
(stage / "model.h").write_text(program.header, encoding="utf-8")
|
||||||
|
command = [compiler, *flags, "-I", str(stage), "-I", str(NATIVE / "include"),
|
||||||
|
"-I", str(sundials / "include"), str(stage / "model.c"),
|
||||||
|
*[str(p) for p in runtime if p.suffix == ".c"],
|
||||||
|
*map(str, libraries), "-lm", "-o", str(stage / "model.exe")]
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
||||||
|
(stage / "build.log").write_text(result.stdout + result.stderr, encoding="utf-8")
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"Native compilation failed; see {stage / 'build.log'}: {result.stderr[-3000:]}")
|
||||||
|
for library in dlls:
|
||||||
|
shutil.copy2(library, stage / library.name)
|
||||||
|
shutil.copy2(NATIVE / "THIRD_PARTY_NOTICES.txt", stage / "THIRD_PARTY_NOTICES.txt")
|
||||||
|
manifest = {
|
||||||
|
**program.manifest(), "buildKey": signature, "compiler": compiler_version,
|
||||||
|
"compilerFlags": flags, "sourceHashes": sources, "dependencyHashes": dependencies,
|
||||||
|
"artifacts": {p.name: _hash(p) for p in stage.iterdir() if p.name != "build.log"},
|
||||||
|
}
|
||||||
|
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
stage.rename(target)
|
||||||
|
except OSError:
|
||||||
|
# A concurrent compiler may have published the identical cache first.
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
raise
|
||||||
|
published = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
# PE linker timestamps can differ between concurrent equivalent builds.
|
||||||
|
# Validate the winning build against its own hashes and our identity.
|
||||||
|
if published.get("buildKey") != signature or not all(
|
||||||
|
(target / name).is_file() and _hash(target / name) == digest
|
||||||
|
for name, digest in published["artifacts"].items()
|
||||||
|
):
|
||||||
|
raise RuntimeError("Concurrent native build did not produce the expected artifacts.")
|
||||||
|
manifest = published
|
||||||
|
if stage.resolve().parent != cache:
|
||||||
|
raise RuntimeError("Unexpected native build staging directory.")
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
return NativeBuild(target / "model.exe", manifest, False, time.perf_counter()-start)
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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, ...]
|
||||||
|
|
||||||
|
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": "CVODE default; no custom Jacobian",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
from .extended import catalog_contracts
|
||||||
|
contracts = catalog_contracts()
|
||||||
|
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')
|
||||||
|
# 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 ())
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
put(c, "dvol", f"{get(c, 'vol')} <= {_number(c.cvol0 / 100)} ? 0.0 : ({rate})")
|
||||||
|
volume, rate = get(c, "vol"), get(c, "dvol")
|
||||||
|
volume_expressions[c.name] = (volume, rate)
|
||||||
|
# Match the Python constructor: m/U use configured storage volume;
|
||||||
|
# connected moving volumes subsequently change recovered p/T.
|
||||||
|
initial_volume = max(base, c.cvol0 / 100) if c.model_type == "amesim_pnch012" else base
|
||||||
|
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(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({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]
|
||||||
|
# A resistance-to-resistance stream path requires a fuller IR.
|
||||||
|
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]
|
||||||
|
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}));")
|
||||||
|
source = '\n'.join([
|
||||||
|
'#include "model.h"', '#include <math.h>', *declarations,
|
||||||
|
f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};",
|
||||||
|
f"const double model_atol[NSTATES] = {{{','.join('1e-12' if k.rsplit('.',1)[1] in ('v','x') else '1e-8' for k in 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; }",
|
||||||
|
"int model_eval(double t, const double *y, double *dy, double *w) {", "(void)t;", *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; }",
|
||||||
|
"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 NSTATES {len(state_keys)}
|
||||||
|
#define NOUTPUTS {len(variables)}
|
||||||
|
#define NSTOPS {len(stops)}
|
||||||
|
extern const NativeStop model_stops[{max(1,len(stops))}];
|
||||||
|
extern const double model_atol[NSTATES];
|
||||||
|
extern const char *const model_output_keys[NOUTPUTS];
|
||||||
|
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})))
|
||||||
@@ -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,561 @@
|
|||||||
|
"""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
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
|
||||||
|
from .contracts import SUPPORTED_VERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
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 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_schedule(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'double 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(f'{key} = {expr};')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def compile_extended_program(network):
|
||||||
|
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')
|
||||||
|
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:
|
||||||
|
fields = ('m1', 'U1', 'm2', 'U2') if c.model_type == 'amesim_pnl0003' else ('m', 'U')
|
||||||
|
add_states(c, fields, [0.0]*len(fields))
|
||||||
|
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]
|
||||||
|
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]
|
||||||
|
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};')
|
||||||
|
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]}]'
|
||||||
|
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 = [d for d in pistons if adjacent[ep(d,'port_1')][0] == c.name]
|
||||||
|
volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached)
|
||||||
|
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)
|
||||||
|
put(c, 'dvol', f'{w(c,"vol")} <= {num(c.cvol0/100)} ? 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)
|
||||||
|
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;')
|
||||||
|
lines.append(f'if(!native_medium_gas({medium(c)}, {y(c,"m"+suffix)}, {y(c,"U"+suffix)}, {V}, &{gas})) return 0;')
|
||||||
|
for field in ('m', 'U'):
|
||||||
|
put(c, field+suffix, y(c, field+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')
|
||||||
|
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)
|
||||||
|
project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',energy='+ '+'.join(f'y[{i+1}]' for i in offsets)+';')
|
||||||
|
for i,volume in zip(offsets,volumes):
|
||||||
|
project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=energy*{num(volume/total)};']
|
||||||
|
project.append('}')
|
||||||
|
coupled.append((root,offsets,volumes))
|
||||||
|
for root, gas in anchor.items():
|
||||||
|
lines.append(f'p[{pgi[root]}]={gas}.p;')
|
||||||
|
default_h = next(iter(gases.values()))+'.h' if gases else '0.0'
|
||||||
|
for endpoint, idx in pi.items():
|
||||||
|
lines.append(f'h[{idx}]={port_gas.get(endpoint,default_h.removesuffix(".h"))}.h;' if endpoint in port_gas else f'h[{idx}]={default_h};')
|
||||||
|
for c in components:
|
||||||
|
if c.model_type in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||||
|
# Closed-end ports retain their declared zero outflow enthalpy.
|
||||||
|
lines.append(f'{h(c,"port_1")}=0.0;')
|
||||||
|
|
||||||
|
flow_lines, flow_known, flow_eq = [], set(), []
|
||||||
|
def flow(c, name, expr):
|
||||||
|
target=q(c,name);flow_lines.append(f'{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({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({medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})'
|
||||||
|
flow(c,a,f'native_pipe_flow({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)
|
||||||
|
flow_lines.append(f'if(!native_medium_orifice({medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening},&{q(c,a)},&{w(c,"cm")},&{w(c,"gasvel")})) return 0;')
|
||||||
|
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):
|
||||||
|
T=gas+'.T'
|
||||||
|
if kind.endswith('2'):
|
||||||
|
T=f'({p(c,name)}>{gas}.p?native_temperature_ph({medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)'
|
||||||
|
flow(c,name,f'native_pipe_flow({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_lines += linear_schedule(reduced,unknownq,free_flows)
|
||||||
|
|
||||||
|
unknownp=[root for root in pgroups if root not in anchor]
|
||||||
|
residuals=[]
|
||||||
|
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')
|
||||||
|
residuals.append(' + '.join(terms))
|
||||||
|
# A monotone nodal mass-balance solve. This is an algebraic connection
|
||||||
|
# closure; the integration solver's Jacobian policy is unchanged.
|
||||||
|
pressure_lines=[]
|
||||||
|
if unknownp:
|
||||||
|
if not anchor:
|
||||||
|
raise NativeCapabilityError('Pneumatic pressure network has no storage pressure anchor')
|
||||||
|
pressure_lines += ['double plo=INFINITY,phi=0;', *[f'plo=fmin(plo,{g}.p);phi=fmax(phi,{g}.p);' for g in anchor.values()]]
|
||||||
|
pressure_lines += [f'p[{pgi[root]}]=.5*(plo+phi);' for root in unknownp]
|
||||||
|
pressure_lines += ['int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {']
|
||||||
|
for root,expr in zip(unknownp,residuals):
|
||||||
|
pressure_lines += ['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',f'p[{pgi[root]}]=.5*(lo+hi);',
|
||||||
|
'if(!model_flows(p,h,g,w,q)) return 0;', f'if(({expr})>0) hi=p[{pgi[root]}];else lo=p[{pgi[root]}];','}}']
|
||||||
|
pressure_lines += ['if(!model_flows(p,h,g,w,q)) return 0;', 'double residual=0;', *[f'residual=fmax(residual,fabs({expr}));' for expr in residuals],
|
||||||
|
'if(residual<1e-11) { pressure_ok=1;break; }','}', 'if(!pressure_ok) return 0;']
|
||||||
|
else:
|
||||||
|
pressure_lines=['if(!model_flows(p,h,g,w,q)) return 0;']
|
||||||
|
|
||||||
|
stream_lines=[]
|
||||||
|
for c in components:
|
||||||
|
names=pnames(c);kind=c.model_type
|
||||||
|
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||||
|
stream_lines.append(f'{h(c,"port_1")}={hin(c,"port_1")};')
|
||||||
|
elif kind in RESISTORS:
|
||||||
|
a,b=names
|
||||||
|
stream_lines += [f'{h(c,a)}={hin(c,b)};',f'{h(c,b)}={hin(c,a)};']
|
||||||
|
elif kind in NODES:
|
||||||
|
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,name)}=ref;' for name in names if name!='port_2'],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 += ['}']
|
||||||
|
if pneu:
|
||||||
|
lines += ['int closure_ok=0;',f'for(int closure=0;closure<{max(64,4*len(pneu))};closure++) {{',f'double previous[{len(pneu)}];',f'for(int i=0;i<{len(pneu)};i++) previous[i]=h[i];',*pressure_lines,*stream_lines,
|
||||||
|
'double change=0;',f'for(int i=0;i<{len(pneu)};i++) change=fmax(change,fabs(h[i]-previous[i])/fmax(1,fabs(h[i])));',
|
||||||
|
'if(change<1e-12) {closure_ok=1;break;}','}', 'if(!closure_ok) return 0;', 'if(!model_flows(p,h,g,w,q)) return 0;']
|
||||||
|
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(c.kcont)},{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',f'{v}>0?-{num(c.fcoul)}:({v}<0?{num(c.fcoul)}:0)' if c.use_friction else '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})'
|
||||||
|
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()]
|
||||||
|
lines += linear_schedule(feq,unknownf)
|
||||||
|
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")};']
|
||||||
|
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}]);')
|
||||||
|
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({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(f'{q(c,name)}*({q(c,name)}>0?{hin(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=f'.5*({gases[c.name,1]}.T+{gases[c.name,2]}.T)' if half else gas+'.T'
|
||||||
|
heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))'
|
||||||
|
lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,"U"+suffix]}]={energy}+({heat});']
|
||||||
|
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({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)'
|
||||||
|
temp=gases[c.name,0]+'.T' if kind=='amesim_pnl0001' else f'fmax(native_temperature_ph({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({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 in coupled:
|
||||||
|
lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',energy='+ '+'.join(f'dy[{i+1}]' for i in offsets)+';')
|
||||||
|
for i,volume in zip(offsets,volumes):
|
||||||
|
lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=energy*{num(volume/sum(volumes))};')
|
||||||
|
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;')
|
||||||
|
np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu))
|
||||||
|
source='\n'.join(['#include "model.h"','#include <math.h>',*declarations,
|
||||||
|
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}')+'};',
|
||||||
|
'const double model_atol[NSTATES] = {'+','.join('1e-12' if k.endswith(('.v','.x')) else '1e-8' for k in state_keys or ['dummy'])+'};',
|
||||||
|
'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};',
|
||||||
|
'static int model_flows(const double *p,const double *h,const NativeGas *g,double *w,double *q) {',
|
||||||
|
'(void)p;(void)h;(void)g;(void)w;(void)q;',*flow_lines,'return 1;}',
|
||||||
|
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
|
||||||
|
'int model_eval(double t,const double *y,double *dy,double *w) {',
|
||||||
|
*(['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}];',
|
||||||
|
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;(void)model_flows;',*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;}',
|
||||||
|
'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 NSTATES {nstates}
|
||||||
|
#define NOUTPUTS {max(1,len(variables))}
|
||||||
|
#define NSTOPS {len(stops)}
|
||||||
|
extern const NativeStop model_stops[{max(1,len(stops))}];
|
||||||
|
extern const double model_atol[NSTATES];
|
||||||
|
extern const char *const model_output_keys[NOUTPUTS];
|
||||||
|
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})))
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""CLI input adapter; XML stays the numerical backend's execution contract."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from copy import deepcopy
|
||||||
|
import json
|
||||||
|
from math import isfinite
|
||||||
|
import operator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.system_xml import validate_system_xml_document
|
||||||
|
|
||||||
|
# Matches the editor's displayed-unit conversions. Stored numeric values are
|
||||||
|
# already SI; only arithmetic expressions are evaluated in the selected unit.
|
||||||
|
_SCALES = {
|
||||||
|
"area": {"m2": 1, "cm2": 1e-4, "mm2": 1e-6},
|
||||||
|
"length": {"m": 1, "cm": .01, "mm": .001},
|
||||||
|
"pressure": {"Pa": 1, "kPa": 1e3, "MPa": 1e6, "bar": 1e5},
|
||||||
|
"volume": {"m3": 1, "L": .001, "mL": 1e-6},
|
||||||
|
}
|
||||||
|
_OPERATIONS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||||
|
ast.Div: operator.truediv, ast.Pow: operator.pow}
|
||||||
|
|
||||||
|
|
||||||
|
def arithmetic_value(source: str) -> float:
|
||||||
|
"""Bounded arithmetic only: never evaluate code, names or function calls."""
|
||||||
|
source = source.strip().removeprefix("=").strip()
|
||||||
|
if len(source) > 512:
|
||||||
|
raise ValueError("Parameter expression exceeds 512 characters.")
|
||||||
|
tree = ast.parse(source, mode="eval")
|
||||||
|
if sum(1 for _ in ast.walk(tree)) > 256:
|
||||||
|
raise ValueError("Parameter expression is too complex.")
|
||||||
|
def visit(node, depth=0):
|
||||||
|
if depth > 32:
|
||||||
|
raise ValueError("Parameter expression is nested too deeply.")
|
||||||
|
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
|
||||||
|
result = float(node.value)
|
||||||
|
elif isinstance(node, ast.UnaryOp) and type(node.op) in (ast.UAdd, ast.USub):
|
||||||
|
result = visit(node.operand, depth+1) * (-1 if isinstance(node.op, ast.USub) else 1)
|
||||||
|
elif isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS:
|
||||||
|
a, b = visit(node.left, depth+1), visit(node.right, depth+1)
|
||||||
|
if isinstance(node.op, ast.Pow) and abs(b) > 16:
|
||||||
|
raise ValueError("CLI parameter exponents must be between -16 and 16.")
|
||||||
|
result = _OPERATIONS[type(node.op)](a, b)
|
||||||
|
else:
|
||||||
|
raise ValueError("CLI supports arithmetic expressions only; export complex expressions as XML from the editor.")
|
||||||
|
if not isinstance(result, (int, float)) or not isfinite(result):
|
||||||
|
raise ValueError("Parameter expression must produce a finite real number.")
|
||||||
|
return result
|
||||||
|
return float(visit(tree.body))
|
||||||
|
|
||||||
|
|
||||||
|
def project_xml(data: dict) -> bytes:
|
||||||
|
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
|
||||||
|
from app.simulation.registry import get_component_model_spec
|
||||||
|
normalized = deepcopy(data)
|
||||||
|
for node in normalized.get("nodes", []):
|
||||||
|
model = node["data"]
|
||||||
|
spec = get_component_model_spec(model["modelType"])
|
||||||
|
for name, value in list(model.get("parameters", {}).items()):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except ValueError:
|
||||||
|
definition = spec.parameter_by_name[name]
|
||||||
|
if definition.editor:
|
||||||
|
raise ValueError(f"{node['id']}.{name}: a discrete parameter cannot be an expression.")
|
||||||
|
number = arithmetic_value(value)
|
||||||
|
unit = model.get("parameterUnits", {}).get(name, definition.unit)
|
||||||
|
if definition.quantity in _SCALES:
|
||||||
|
if unit not in _SCALES[definition.quantity]:
|
||||||
|
raise ValueError(f"Unsupported parameter unit: {unit}.")
|
||||||
|
number *= _SCALES[definition.quantity][unit]
|
||||||
|
elif definition.quantity == "temperature" and unit == "degC":
|
||||||
|
number += 273.15
|
||||||
|
elif unit != definition.unit:
|
||||||
|
raise ValueError(f"Unsupported parameter unit: {unit}.")
|
||||||
|
if not isfinite(number):
|
||||||
|
raise ValueError(f"{node['id']}.{name}: parameter must be finite.")
|
||||||
|
model["parameters"][name] = number
|
||||||
|
return build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(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,128 @@
|
|||||||
|
"""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 .build import NativeBuild, build_native
|
||||||
|
from .compiler import NativeCapabilityError, compile_native_program
|
||||||
|
|
||||||
|
|
||||||
|
def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
|
||||||
|
run_dir: Path, record_samples=True, cancel_check=None,
|
||||||
|
progress_callback=None, activity_tracker=None, timeout=300.0) -> dict:
|
||||||
|
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 v1 uses the existing default gas/mechanical absolute tolerances and automatic initial step.")
|
||||||
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output = run_dir / "result.json"
|
||||||
|
cancel_path = run_dir / "cancel.request"
|
||||||
|
if output.exists() or cancel_path.exists():
|
||||||
|
raise ValueError("Native execution requires a fresh run directory.")
|
||||||
|
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 not record_samples:
|
||||||
|
command.append("--solve-only")
|
||||||
|
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
||||||
|
started = time.perf_counter()
|
||||||
|
process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||||
|
text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
|
||||||
|
messages: queue.Queue[str] = queue.Queue()
|
||||||
|
def read_stderr():
|
||||||
|
assert process.stderr is not None
|
||||||
|
for line in process.stderr:
|
||||||
|
messages.put(line)
|
||||||
|
reader = threading.Thread(target=read_stderr, daemon=True)
|
||||||
|
reader.start()
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.start_integration(config.t_start)
|
||||||
|
cancelled_at = None
|
||||||
|
last_time = config.t_start
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
if now-started > timeout+5 or (cancelled_at is not None and now-cancelled_at > 5):
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
raise RuntimeError("Native worker was terminated after failing to return within its time limit.")
|
||||||
|
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") == "integrating":
|
||||||
|
last_time = max(last_time, min(config.t_stop, float(event["time"])))
|
||||||
|
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.join(timeout=2)
|
||||||
|
if process.stderr is not None:
|
||||||
|
process.stderr.close()
|
||||||
|
if not output.is_file():
|
||||||
|
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
|
||||||
|
payload = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
if process.returncode not in (0, 2):
|
||||||
|
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
|
||||||
|
payload["processWallSeconds"] = time.perf_counter()-started
|
||||||
|
payload["buildKey"] = build.manifest["buildKey"]
|
||||||
|
payload["cacheHit"] = build.cache_hit
|
||||||
|
payload["buildSeconds"] = build.seconds
|
||||||
|
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,
|
||||||
|
cancel_check=None, activity_tracker=None):
|
||||||
|
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, "initializing")
|
||||||
|
program = compile_native_program(network)
|
||||||
|
build = build_native(program)
|
||||||
|
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,
|
||||||
|
activity_tracker=activity_tracker)
|
||||||
|
totals = {
|
||||||
|
"nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"],
|
||||||
|
"acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"],
|
||||||
|
"stateTransitionCount": data["stateTransitions"], "solverStartCount": data["solverStarts"],
|
||||||
|
}
|
||||||
|
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", "native": {k: v for k, v in data.items()
|
||||||
|
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "totals": totals},
|
||||||
|
"stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])},
|
||||||
|
)
|
||||||
+61
-562
@@ -1,32 +1,19 @@
|
|||||||
"""Low-overhead, run-local performance instrumentation for simulations.
|
"""Run-local timing of Python orchestration; numeric timings come from C."""
|
||||||
|
|
||||||
The profiling mode is intentionally read once when this module is imported.
|
|
||||||
``standard`` records low-frequency pipeline stages, while ``audit`` also wraps
|
|
||||||
hot RHS/property operations and computes exact-input reuse metrics. With
|
|
||||||
profiling disabled, decorators return the original callable while classes are
|
|
||||||
being defined, so ordinary simulation calls do not pass through a wrapper.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Generator, Mapping
|
from collections.abc import Callable, Generator, Mapping
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import struct
|
|
||||||
from time import perf_counter_ns
|
from time import perf_counter_ns
|
||||||
from typing import Any, Literal, ParamSpec, TypeVar, cast
|
from typing import Any, Literal, ParamSpec, TypeVar, cast
|
||||||
|
|
||||||
|
ProfileMode = Literal['off', 'standard', 'audit']
|
||||||
ProfileMode = Literal["off", "standard", "audit"]
|
_P = ParamSpec('_P')
|
||||||
|
_R = TypeVar('_R')
|
||||||
_P = ParamSpec("_P")
|
_MODE_RANK: Mapping[ProfileMode, int] = {'off': 0, 'standard': 1, 'audit': 2}
|
||||||
_R = TypeVar("_R")
|
|
||||||
_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2}
|
|
||||||
|
|
||||||
|
|
||||||
def _read_startup_mode() -> ProfileMode:
|
def _read_startup_mode() -> ProfileMode:
|
||||||
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
|
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
|
||||||
@@ -50,17 +37,12 @@ def _read_startup_mode() -> ProfileMode:
|
|||||||
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
|
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
PROFILE_MODE: ProfileMode = _read_startup_mode()
|
|
||||||
|
|
||||||
|
|
||||||
def _minimum_mode(value: str) -> ProfileMode:
|
def _minimum_mode(value: str) -> ProfileMode:
|
||||||
normalized = value.strip().lower()
|
normalized = value.strip().lower()
|
||||||
if normalized not in _MODE_RANK:
|
if normalized not in _MODE_RANK:
|
||||||
raise ValueError("minimum_mode must be one of: off, standard, audit.")
|
raise ValueError("minimum_mode must be one of: off, standard, audit.")
|
||||||
return cast(ProfileMode, normalized)
|
return cast(ProfileMode, normalized)
|
||||||
|
|
||||||
|
|
||||||
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
||||||
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
|
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
|
||||||
# ``minimum_mode="off"`` by mistake.
|
# ``minimum_mode="off"`` by mistake.
|
||||||
@@ -69,7 +51,6 @@ def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
|||||||
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
|
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _TimingStats:
|
class _TimingStats:
|
||||||
calls: int = 0
|
calls: int = 0
|
||||||
@@ -95,311 +76,10 @@ class _TimingStats:
|
|||||||
"errors": self.errors,
|
"errors": self.errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _PropertyStats(_TimingStats):
|
|
||||||
operation: str = ""
|
|
||||||
layer: str = "semantic"
|
|
||||||
medium: str = "unknown"
|
|
||||||
exact_input_unique: int = 0
|
|
||||||
exact_input_repeats: int = 0
|
|
||||||
iteration_calls: int = 0
|
|
||||||
iteration_total: int = 0
|
|
||||||
iteration_max: int = 0
|
|
||||||
iteration_converged: int = 0
|
|
||||||
iteration_nonconverged: int = 0
|
|
||||||
cache_lookups: int = 0
|
|
||||||
cache_hits: int = 0
|
|
||||||
cache_misses: int = 0
|
|
||||||
|
|
||||||
def snapshot(self, *, audit: bool) -> dict[str, object]:
|
|
||||||
result: dict[str, object] = super().snapshot()
|
|
||||||
result.update(
|
|
||||||
{
|
|
||||||
"operation": self.operation,
|
|
||||||
"layer": self.layer,
|
|
||||||
"medium": self.medium,
|
|
||||||
"cacheLookups": self.cache_lookups,
|
|
||||||
"cacheHits": self.cache_hits,
|
|
||||||
"cacheMisses": self.cache_misses,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if audit:
|
|
||||||
result.update(
|
|
||||||
{
|
|
||||||
"exactInputUnique": self.exact_input_unique,
|
|
||||||
"exactInputRepeats": self.exact_input_repeats,
|
|
||||||
"iterationCalls": self.iteration_calls,
|
|
||||||
"iterationTotal": self.iteration_total,
|
|
||||||
"iterationMax": self.iteration_max,
|
|
||||||
"iterationConverged": self.iteration_converged,
|
|
||||||
"iterationNonconverged": self.iteration_nonconverged,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _ActiveSpan:
|
|
||||||
trace: PerformanceTrace
|
|
||||||
name: str
|
|
||||||
started_ns: int
|
|
||||||
property_key: str | None = None
|
|
||||||
property_operation: str | None = None
|
|
||||||
property_outermost: bool = False
|
|
||||||
child_ns: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PerformanceTrace:
|
|
||||||
"""Mutable counters owned by exactly one :func:`profile_run` context."""
|
|
||||||
|
|
||||||
mode: ProfileMode
|
|
||||||
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
|
|
||||||
_properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False)
|
|
||||||
_property_outermost_ns: int = field(default=0, repr=False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
return self.mode != "off"
|
|
||||||
|
|
||||||
def _property_stats(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
operation: str,
|
|
||||||
layer: str,
|
|
||||||
medium: str,
|
|
||||||
) -> _PropertyStats:
|
|
||||||
stats = self._properties.get(key)
|
|
||||||
if stats is None:
|
|
||||||
stats = _PropertyStats(
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
)
|
|
||||||
self._properties[key] = stats
|
|
||||||
return stats
|
|
||||||
|
|
||||||
def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None:
|
|
||||||
self_ns = max(0, elapsed_ns - frame.child_ns)
|
|
||||||
if frame.property_key is None:
|
|
||||||
stats = self._phases.setdefault(frame.name, _TimingStats())
|
|
||||||
else:
|
|
||||||
layer, medium, operation = frame.property_key.split("|", 2)
|
|
||||||
stats = self._property_stats(
|
|
||||||
frame.property_key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
)
|
|
||||||
if frame.property_outermost:
|
|
||||||
self._property_outermost_ns += elapsed_ns
|
|
||||||
stats.record(elapsed_ns, self_ns, error)
|
|
||||||
|
|
||||||
def _record_exact_input(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
operation: str,
|
|
||||||
layer: str,
|
|
||||||
medium: str,
|
|
||||||
fingerprint: object,
|
|
||||||
) -> None:
|
|
||||||
stats = self._property_stats(
|
|
||||||
key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
)
|
|
||||||
shadow_key = (key, fingerprint)
|
|
||||||
shadow = _PROPERTY_SHADOW.get()
|
|
||||||
if shadow is None:
|
|
||||||
# A trace normally installs its own set in ``profile_run``. Keep
|
|
||||||
# the ContextVar default immutable so no task can accidentally
|
|
||||||
# share a process-global shadow set.
|
|
||||||
shadow = set()
|
|
||||||
_PROPERTY_SHADOW.set(shadow)
|
|
||||||
if shadow_key in shadow:
|
|
||||||
stats.exact_input_repeats += 1
|
|
||||||
else:
|
|
||||||
shadow.add(shadow_key)
|
|
||||||
stats.exact_input_unique += 1
|
|
||||||
|
|
||||||
def _record_iterations(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
operation: str,
|
|
||||||
layer: str,
|
|
||||||
medium: str,
|
|
||||||
iterations: int,
|
|
||||||
converged: bool,
|
|
||||||
) -> None:
|
|
||||||
stats = self._property_stats(
|
|
||||||
key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
)
|
|
||||||
iteration_count = max(0, int(iterations))
|
|
||||||
stats.iteration_calls += 1
|
|
||||||
stats.iteration_total += iteration_count
|
|
||||||
stats.iteration_max = max(stats.iteration_max, iteration_count)
|
|
||||||
if converged:
|
|
||||||
stats.iteration_converged += 1
|
|
||||||
else:
|
|
||||||
stats.iteration_nonconverged += 1
|
|
||||||
|
|
||||||
def _record_cache(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
operation: str,
|
|
||||||
layer: str,
|
|
||||||
medium: str,
|
|
||||||
hits: int,
|
|
||||||
misses: int,
|
|
||||||
) -> None:
|
|
||||||
stats = self._property_stats(
|
|
||||||
key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
)
|
|
||||||
hit_delta = max(0, hits)
|
|
||||||
miss_delta = max(0, misses)
|
|
||||||
stats.cache_hits += hit_delta
|
|
||||||
stats.cache_misses += miss_delta
|
|
||||||
stats.cache_lookups += hit_delta + miss_delta
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
|
||||||
"""Return a detached, JSON-serializable copy of all counters."""
|
|
||||||
|
|
||||||
audit = self.mode == "audit"
|
|
||||||
return {
|
|
||||||
"mode": self.mode,
|
|
||||||
"phases": {
|
|
||||||
name: self._phases[name].snapshot()
|
|
||||||
for name in sorted(self._phases)
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
_public_property_key(key): self._properties[key].snapshot(audit=audit)
|
|
||||||
for key in sorted(self._properties)
|
|
||||||
},
|
|
||||||
"propertyOutermostNs": self._property_outermost_ns,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar(
|
|
||||||
"simulation_performance_trace",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar(
|
|
||||||
"simulation_performance_spans",
|
|
||||||
default=(),
|
|
||||||
)
|
|
||||||
_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar(
|
|
||||||
"simulation_property_shadow",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _public_property_key(key: str) -> str:
|
|
||||||
layer, medium, operation = key.split("|", 2)
|
|
||||||
return f"{layer}.{medium}.{operation}"
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _tracked_span(
|
|
||||||
trace: PerformanceTrace,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
property_key: str | None = None,
|
|
||||||
property_operation: str | None = None,
|
|
||||||
reset_property_shadow: bool = False,
|
|
||||||
) -> Generator[None, None, None]:
|
|
||||||
stack = _ACTIVE_SPANS.get()
|
|
||||||
property_outermost = property_key is not None and not any(
|
|
||||||
item.property_key is not None for item in stack
|
|
||||||
)
|
|
||||||
frame = _ActiveSpan(
|
|
||||||
trace=trace,
|
|
||||||
name=name,
|
|
||||||
started_ns=perf_counter_ns(),
|
|
||||||
property_key=property_key,
|
|
||||||
property_operation=property_operation,
|
|
||||||
property_outermost=property_outermost,
|
|
||||||
)
|
|
||||||
stack_token = _ACTIVE_SPANS.set((*stack, frame))
|
|
||||||
shadow_token: Token[set[object] | None] | None = None
|
|
||||||
if reset_property_shadow and trace.mode == "audit":
|
|
||||||
shadow_token = _PROPERTY_SHADOW.set(set())
|
|
||||||
error = False
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
except BaseException:
|
|
||||||
error = True
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
elapsed_ns = max(0, perf_counter_ns() - frame.started_ns)
|
|
||||||
_ACTIVE_SPANS.reset(stack_token)
|
|
||||||
if shadow_token is not None:
|
|
||||||
_PROPERTY_SHADOW.reset(shadow_token)
|
|
||||||
if stack:
|
|
||||||
stack[-1].child_ns += elapsed_ns
|
|
||||||
trace._record_span(frame, elapsed_ns, error)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def profile_run() -> Generator[PerformanceTrace, None, None]:
|
|
||||||
"""Create and bind an isolated trace for one simulation run.
|
|
||||||
|
|
||||||
The yielded trace remains usable after the context exits, which lets the
|
|
||||||
caller attach ``trace.snapshot()`` to a result without exposing live state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
trace = PerformanceTrace(mode=PROFILE_MODE)
|
|
||||||
trace_token = _CURRENT_TRACE.set(trace)
|
|
||||||
spans_token = _ACTIVE_SPANS.set(())
|
|
||||||
shadow_token = _PROPERTY_SHADOW.set(set())
|
|
||||||
try:
|
|
||||||
if trace.enabled:
|
|
||||||
with _tracked_span(trace, "simulation.total"):
|
|
||||||
yield trace
|
|
||||||
else:
|
|
||||||
yield trace
|
|
||||||
finally:
|
|
||||||
_PROPERTY_SHADOW.reset(shadow_token)
|
|
||||||
_ACTIVE_SPANS.reset(spans_token)
|
|
||||||
_CURRENT_TRACE.reset(trace_token)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def performance_span(
|
|
||||||
name: str,
|
|
||||||
minimum_mode: str = "standard",
|
|
||||||
reset_property_shadow: bool = False,
|
|
||||||
) -> Generator[None, None, None]:
|
|
||||||
"""Time a block in the current run, or act as a no-op outside one."""
|
|
||||||
|
|
||||||
minimum = _minimum_mode(minimum_mode)
|
|
||||||
trace = _CURRENT_TRACE.get()
|
|
||||||
if trace is None or not _mode_enabled(minimum):
|
|
||||||
yield
|
|
||||||
return
|
|
||||||
with _tracked_span(
|
|
||||||
trace,
|
|
||||||
name,
|
|
||||||
reset_property_shadow=reset_property_shadow,
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def profile_phase(
|
def profile_phase(
|
||||||
name: str,
|
name: str,
|
||||||
minimum_mode: str = "standard",
|
minimum_mode: str = "standard",
|
||||||
reset_property_shadow: bool = False,
|
|
||||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
"""Decorate a simulation phase while preserving the off-mode callable."""
|
"""Decorate a simulation phase while preserving the off-mode callable."""
|
||||||
|
|
||||||
@@ -419,7 +99,7 @@ def profile_phase(
|
|||||||
with _tracked_span(
|
with _tracked_span(
|
||||||
trace,
|
trace,
|
||||||
name,
|
name,
|
||||||
reset_property_shadow=reset_property_shadow,
|
|
||||||
):
|
):
|
||||||
return await function(*args, **kwargs)
|
return await function(*args, **kwargs)
|
||||||
|
|
||||||
@@ -433,7 +113,7 @@ def profile_phase(
|
|||||||
with _tracked_span(
|
with _tracked_span(
|
||||||
trace,
|
trace,
|
||||||
name,
|
name,
|
||||||
reset_property_shadow=reset_property_shadow,
|
|
||||||
):
|
):
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
@@ -441,249 +121,68 @@ def profile_phase(
|
|||||||
|
|
||||||
return decorate
|
return decorate
|
||||||
|
|
||||||
|
PROFILE_MODE = _read_startup_mode()
|
||||||
|
|
||||||
def _medium_name(args: tuple[object, ...]) -> str:
|
@dataclass
|
||||||
if not args:
|
class _ActiveSpan:
|
||||||
return "unknown"
|
name: str
|
||||||
owner = args[0]
|
started_ns: int
|
||||||
configured_name = getattr(owner, "name", None)
|
child_ns: int = 0
|
||||||
if isinstance(configured_name, str) and configured_name:
|
|
||||||
return configured_name
|
|
||||||
return type(owner).__name__
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PerformanceTrace:
|
||||||
|
mode: ProfileMode
|
||||||
|
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
def _fingerprint(value: object) -> object:
|
@property
|
||||||
"""Build a hashable, bit-exact token without retaining arbitrary objects."""
|
def enabled(self):
|
||||||
|
return self.mode != 'off'
|
||||||
|
|
||||||
if value is None or isinstance(value, (bool, int, str, bytes)):
|
def snapshot(self):
|
||||||
return (type(value).__name__, value)
|
return {'mode': self.mode, 'phases': {key: value.snapshot() for key, value in sorted(self._phases.items())}}
|
||||||
if isinstance(value, float):
|
|
||||||
return ("float64", struct.pack("!d", value))
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
return ("tuple", tuple(_fingerprint(item) for item in value))
|
|
||||||
if isinstance(value, list):
|
|
||||||
return ("list", tuple(_fingerprint(item) for item in value))
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()]
|
|
||||||
items.sort(key=repr)
|
|
||||||
return ("mapping", tuple(items))
|
|
||||||
return (
|
|
||||||
"object",
|
|
||||||
type(value).__module__,
|
|
||||||
type(value).__qualname__,
|
|
||||||
id(value),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
_CURRENT_TRACE = ContextVar('simulation_trace', default=None)
|
||||||
|
_ACTIVE_SPANS = ContextVar('simulation_spans', default=())
|
||||||
|
|
||||||
def _input_fingerprint(
|
@contextmanager
|
||||||
signature: inspect.Signature | None,
|
def _tracked_span(trace, name):
|
||||||
args: tuple[object, ...],
|
stack = _ACTIVE_SPANS.get()
|
||||||
kwargs: dict[str, object],
|
frame = _ActiveSpan(name, perf_counter_ns())
|
||||||
) -> object:
|
token = _ACTIVE_SPANS.set((*stack, frame))
|
||||||
if signature is not None:
|
error = False
|
||||||
try:
|
try:
|
||||||
bound = signature.bind(*args, **kwargs)
|
yield
|
||||||
bound.apply_defaults()
|
except BaseException:
|
||||||
return tuple(
|
error = True
|
||||||
(name, _fingerprint(value))
|
raise
|
||||||
for name, value in bound.arguments.items()
|
finally:
|
||||||
)
|
elapsed = max(0, perf_counter_ns()-frame.started_ns)
|
||||||
except TypeError:
|
_ACTIVE_SPANS.reset(token)
|
||||||
pass
|
if stack:
|
||||||
return (
|
stack[-1].child_ns += elapsed
|
||||||
_fingerprint(args),
|
trace._phases.setdefault(name, _TimingStats()).record(elapsed, max(0, elapsed-frame.child_ns), error)
|
||||||
tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None:
|
def profile_run():
|
||||||
cache_info = getattr(function, "cache_info", None)
|
trace = PerformanceTrace(PROFILE_MODE)
|
||||||
if not callable(cache_info):
|
token = _CURRENT_TRACE.set(trace)
|
||||||
return None
|
spans = _ACTIVE_SPANS.set(())
|
||||||
try:
|
try:
|
||||||
info = cache_info()
|
if trace.enabled:
|
||||||
return int(info.hits), int(info.misses)
|
with _tracked_span(trace, 'simulation.total'):
|
||||||
except (AttributeError, TypeError, ValueError):
|
yield trace
|
||||||
return None
|
else:
|
||||||
|
yield trace
|
||||||
|
finally:
|
||||||
def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None:
|
_ACTIVE_SPANS.reset(spans)
|
||||||
for attribute in ("cache_clear", "cache_info", "cache_parameters"):
|
_CURRENT_TRACE.reset(token)
|
||||||
value = getattr(source, attribute, None)
|
|
||||||
if value is not None:
|
|
||||||
setattr(target, attribute, value)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_property(
|
|
||||||
operation: str,
|
|
||||||
layer: str = "semantic",
|
|
||||||
minimum_mode: str = "audit",
|
|
||||||
capture_inputs: bool = True,
|
|
||||||
track_cache: bool = False,
|
|
||||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
|
||||||
"""Decorate one thermodynamic property operation."""
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def performance_span(name: str, minimum_mode: str = 'standard'):
|
||||||
minimum = _minimum_mode(minimum_mode)
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
|
||||||
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
|
||||||
if not _mode_enabled(minimum):
|
|
||||||
return function
|
|
||||||
try:
|
|
||||||
signature: inspect.Signature | None = inspect.signature(function)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
signature = None
|
|
||||||
|
|
||||||
def prepare(
|
|
||||||
args: tuple[object, ...],
|
|
||||||
kwargs: dict[str, object],
|
|
||||||
) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]:
|
|
||||||
trace = _CURRENT_TRACE.get()
|
trace = _CURRENT_TRACE.get()
|
||||||
medium = _medium_name(args)
|
if trace is None or not _mode_enabled(minimum):
|
||||||
key = f"{layer}|{medium}|{operation}"
|
yield
|
||||||
if trace is not None and trace.mode == "audit" and capture_inputs:
|
|
||||||
trace._record_exact_input(
|
|
||||||
key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
fingerprint=_input_fingerprint(signature, args, kwargs),
|
|
||||||
)
|
|
||||||
before = _cache_counts(function) if trace is not None and track_cache else None
|
|
||||||
return trace, medium, key, before
|
|
||||||
|
|
||||||
def finish_cache(
|
|
||||||
trace: PerformanceTrace | None,
|
|
||||||
medium: str,
|
|
||||||
key: str,
|
|
||||||
before: tuple[int, int] | None,
|
|
||||||
) -> None:
|
|
||||||
if trace is None or before is None:
|
|
||||||
return
|
return
|
||||||
after = _cache_counts(function)
|
with _tracked_span(trace, name):
|
||||||
if after is None:
|
yield
|
||||||
return
|
|
||||||
trace._record_cache(
|
|
||||||
key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
hits=after[0] - before[0],
|
|
||||||
misses=after[1] - before[1],
|
|
||||||
)
|
|
||||||
|
|
||||||
if inspect.iscoroutinefunction(function):
|
|
||||||
|
|
||||||
@wraps(function)
|
|
||||||
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
|
||||||
object_args = cast(tuple[object, ...], args)
|
|
||||||
object_kwargs = cast(dict[str, object], kwargs)
|
|
||||||
trace, medium, key, before = prepare(object_args, object_kwargs)
|
|
||||||
try:
|
|
||||||
if trace is None:
|
|
||||||
return await function(*args, **kwargs)
|
|
||||||
with _tracked_span(
|
|
||||||
trace,
|
|
||||||
f"property.{_public_property_key(key)}",
|
|
||||||
property_key=key,
|
|
||||||
property_operation=operation,
|
|
||||||
):
|
|
||||||
return await function(*args, **kwargs)
|
|
||||||
finally:
|
|
||||||
finish_cache(trace, medium, key, before)
|
|
||||||
|
|
||||||
_copy_cache_api(function, async_wrapper)
|
|
||||||
return cast(Callable[_P, _R], async_wrapper)
|
|
||||||
|
|
||||||
@wraps(function)
|
|
||||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
||||||
object_args = cast(tuple[object, ...], args)
|
|
||||||
object_kwargs = cast(dict[str, object], kwargs)
|
|
||||||
trace, medium, key, before = prepare(object_args, object_kwargs)
|
|
||||||
try:
|
|
||||||
if trace is None:
|
|
||||||
return function(*args, **kwargs)
|
|
||||||
with _tracked_span(
|
|
||||||
trace,
|
|
||||||
f"property.{_public_property_key(key)}",
|
|
||||||
property_key=key,
|
|
||||||
property_operation=operation,
|
|
||||||
):
|
|
||||||
return function(*args, **kwargs)
|
|
||||||
finally:
|
|
||||||
finish_cache(trace, medium, key, before)
|
|
||||||
|
|
||||||
_copy_cache_api(function, wrapper)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorate
|
|
||||||
|
|
||||||
|
|
||||||
def record_property_iterations(
|
|
||||||
operation: str,
|
|
||||||
iterations: int,
|
|
||||||
converged: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Record inverse-property solver iterations in audit mode."""
|
|
||||||
|
|
||||||
trace = _CURRENT_TRACE.get()
|
|
||||||
if trace is None or trace.mode != "audit":
|
|
||||||
return
|
|
||||||
for frame in reversed(_ACTIVE_SPANS.get()):
|
|
||||||
if (
|
|
||||||
frame.property_key is not None
|
|
||||||
and frame.property_operation == operation
|
|
||||||
):
|
|
||||||
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
|
||||||
trace._record_iterations(
|
|
||||||
frame.property_key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
iterations=iterations,
|
|
||||||
converged=converged,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
key = f"semantic|unknown|{operation}"
|
|
||||||
trace._record_iterations(
|
|
||||||
key,
|
|
||||||
operation=operation,
|
|
||||||
layer="semantic",
|
|
||||||
medium="unknown",
|
|
||||||
iterations=iterations,
|
|
||||||
converged=converged,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def record_property_cache(operation: str, *, hit: bool) -> None:
|
|
||||||
"""Record one run-local property-cache lookup in audit mode."""
|
|
||||||
|
|
||||||
trace = _CURRENT_TRACE.get()
|
|
||||||
if trace is None or trace.mode != "audit":
|
|
||||||
return
|
|
||||||
for frame in reversed(_ACTIVE_SPANS.get()):
|
|
||||||
if (
|
|
||||||
frame.property_key is not None
|
|
||||||
and frame.property_operation == operation
|
|
||||||
):
|
|
||||||
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
|
||||||
trace._record_cache(
|
|
||||||
frame.property_key,
|
|
||||||
operation=operation,
|
|
||||||
layer=layer,
|
|
||||||
medium=medium,
|
|
||||||
hits=1 if hit else 0,
|
|
||||||
misses=0 if hit else 1,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"PROFILE_MODE",
|
|
||||||
"PerformanceTrace",
|
|
||||||
"performance_span",
|
|
||||||
"profile_phase",
|
|
||||||
"profile_property",
|
|
||||||
"profile_run",
|
|
||||||
"record_property_cache",
|
|
||||||
"record_property_iterations",
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,234 +0,0 @@
|
|||||||
"""Run-local, exact-key cache for expensive thermodynamic calculations.
|
|
||||||
|
|
||||||
The cache is deliberately bound to one simulation through ``ContextVar``.
|
|
||||||
That keeps concurrent runs isolated and releases all cached states when the
|
|
||||||
run finishes. Keys use the original Python values with no rounding or
|
|
||||||
tolerance-based reuse that could flatten numerical residuals seen by ODE and
|
|
||||||
nonlinear solvers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable, Generator
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import lru_cache, wraps
|
|
||||||
import os
|
|
||||||
from typing import ParamSpec, TypeVar
|
|
||||||
|
|
||||||
from app.simulation.performance import PROFILE_MODE, record_property_cache
|
|
||||||
|
|
||||||
|
|
||||||
_P = ParamSpec("_P")
|
|
||||||
_R = TypeVar("_R")
|
|
||||||
DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192
|
|
||||||
|
|
||||||
|
|
||||||
def _read_cache_enabled() -> bool:
|
|
||||||
raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower()
|
|
||||||
if raw_value in {"", "1", "true", "yes", "on"}:
|
|
||||||
return True
|
|
||||||
if raw_value in {"0", "false", "no", "off"}:
|
|
||||||
return False
|
|
||||||
raise ValueError(
|
|
||||||
"SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PROPERTY_CACHE_ENABLED = _read_cache_enabled()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PropertyCacheInfo:
|
|
||||||
hits: int
|
|
||||||
misses: int
|
|
||||||
max_entries_per_cache: int
|
|
||||||
cache_count: int
|
|
||||||
current_entries: int
|
|
||||||
evictions: int
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationPropertyCache:
|
|
||||||
"""Bounded C-level LRUs owned by one simulation run."""
|
|
||||||
|
|
||||||
def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None:
|
|
||||||
if max_entries <= 0:
|
|
||||||
raise ValueError("Property cache max_entries must be positive.")
|
|
||||||
self.max_entries_per_cache = int(max_entries)
|
|
||||||
self._functions: dict[
|
|
||||||
tuple[str, int, Callable[..., object]],
|
|
||||||
Callable[..., object],
|
|
||||||
] = {}
|
|
||||||
self._failed_misses: dict[
|
|
||||||
tuple[str, int, Callable[..., object]],
|
|
||||||
int,
|
|
||||||
] = {}
|
|
||||||
self._owners: dict[int, tuple[object, int]] = {}
|
|
||||||
self._next_owner_token = 0
|
|
||||||
|
|
||||||
def owner_token(self, owner: object) -> int:
|
|
||||||
"""Return a stable identity token and retain its owner for this run."""
|
|
||||||
|
|
||||||
identity = id(owner)
|
|
||||||
existing = self._owners.get(identity)
|
|
||||||
if existing is not None and existing[0] is owner:
|
|
||||||
return existing[1]
|
|
||||||
self._next_owner_token += 1
|
|
||||||
self._owners[identity] = (owner, self._next_owner_token)
|
|
||||||
return self._next_owner_token
|
|
||||||
|
|
||||||
def get_or_compute(
|
|
||||||
self,
|
|
||||||
operation: str,
|
|
||||||
owner: object,
|
|
||||||
function: Callable[..., _R],
|
|
||||||
args: tuple[object, ...],
|
|
||||||
kwargs: dict[str, object],
|
|
||||||
) -> _R:
|
|
||||||
cache_key = (operation, self.owner_token(owner), function)
|
|
||||||
cached_function = self._functions.get(cache_key)
|
|
||||||
if cached_function is None:
|
|
||||||
|
|
||||||
@lru_cache(maxsize=self.max_entries_per_cache, typed=True)
|
|
||||||
def invoke(*cached_args: object, **cached_kwargs: object) -> _R:
|
|
||||||
return function(owner, *cached_args, **cached_kwargs)
|
|
||||||
|
|
||||||
cached_function = invoke
|
|
||||||
self._functions[cache_key] = cached_function
|
|
||||||
|
|
||||||
if PROFILE_MODE != "audit":
|
|
||||||
try:
|
|
||||||
return cached_function(*args, **kwargs)
|
|
||||||
except Exception:
|
|
||||||
self._failed_misses[cache_key] = (
|
|
||||||
self._failed_misses.get(cache_key, 0) + 1
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
before = cached_function.cache_info() # type: ignore[attr-defined]
|
|
||||||
try:
|
|
||||||
value = cached_function(*args, **kwargs)
|
|
||||||
except Exception:
|
|
||||||
self._failed_misses[cache_key] = (
|
|
||||||
self._failed_misses.get(cache_key, 0) + 1
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
after = cached_function.cache_info() # type: ignore[attr-defined]
|
|
||||||
hit = after.hits > before.hits
|
|
||||||
record_property_cache(operation, hit=hit)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def info(self) -> PropertyCacheInfo:
|
|
||||||
cache_infos = {
|
|
||||||
key: cached.cache_info() # type: ignore[attr-defined]
|
|
||||||
for key, cached in self._functions.items()
|
|
||||||
}
|
|
||||||
return PropertyCacheInfo(
|
|
||||||
hits=sum(info.hits for info in cache_infos.values()),
|
|
||||||
misses=sum(info.misses for info in cache_infos.values()),
|
|
||||||
max_entries_per_cache=self.max_entries_per_cache,
|
|
||||||
cache_count=len(self._functions),
|
|
||||||
current_entries=sum(info.currsize for info in cache_infos.values()),
|
|
||||||
evictions=sum(
|
|
||||||
max(
|
|
||||||
0,
|
|
||||||
info.misses
|
|
||||||
- self._failed_misses.get(key, 0)
|
|
||||||
- info.currsize,
|
|
||||||
)
|
|
||||||
for key, info in cache_infos.items()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar(
|
|
||||||
"simulation_property_cache",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def current_property_cache() -> SimulationPropertyCache | None:
|
|
||||||
return _CURRENT_PROPERTY_CACHE.get()
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def property_cache_run(
|
|
||||||
*,
|
|
||||||
max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES,
|
|
||||||
) -> Generator[SimulationPropertyCache | None, None, None]:
|
|
||||||
"""Bind a fresh cache to one top-level simulation run.
|
|
||||||
|
|
||||||
Nested uses reuse the existing cache so lower-level simulation helpers can
|
|
||||||
safely opt in without replacing the cache created by the API entry point.
|
|
||||||
"""
|
|
||||||
|
|
||||||
existing = _CURRENT_PROPERTY_CACHE.get()
|
|
||||||
if existing is not None:
|
|
||||||
yield existing
|
|
||||||
return
|
|
||||||
if not PROPERTY_CACHE_ENABLED:
|
|
||||||
yield None
|
|
||||||
return
|
|
||||||
|
|
||||||
cache = SimulationPropertyCache(max_entries=max_entries)
|
|
||||||
token = _CURRENT_PROPERTY_CACHE.set(cache)
|
|
||||||
try:
|
|
||||||
yield cache
|
|
||||||
finally:
|
|
||||||
_CURRENT_PROPERTY_CACHE.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def cache_property_calculation(
|
|
||||||
operation: str,
|
|
||||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
|
||||||
"""Cache one pure property calculation with hashable arguments per run."""
|
|
||||||
|
|
||||||
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
|
||||||
if not PROPERTY_CACHE_ENABLED:
|
|
||||||
return function
|
|
||||||
|
|
||||||
@wraps(function)
|
|
||||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
||||||
cache = _CURRENT_PROPERTY_CACHE.get()
|
|
||||||
if cache is None:
|
|
||||||
return function(*args, **kwargs)
|
|
||||||
owner = args[0] if args else function
|
|
||||||
return cache.get_or_compute(
|
|
||||||
operation,
|
|
||||||
owner,
|
|
||||||
function,
|
|
||||||
tuple(args[1:] if args else ()),
|
|
||||||
dict(kwargs),
|
|
||||||
)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorate
|
|
||||||
|
|
||||||
|
|
||||||
def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
|
||||||
"""Ensure a simulation entry point has a run-local cache."""
|
|
||||||
|
|
||||||
if not PROPERTY_CACHE_ENABLED:
|
|
||||||
return function
|
|
||||||
|
|
||||||
@wraps(function)
|
|
||||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
||||||
with property_cache_run():
|
|
||||||
return function(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DEFAULT_PROPERTY_CACHE_MAX_ENTRIES",
|
|
||||||
"PROPERTY_CACHE_ENABLED",
|
|
||||||
"PropertyCacheInfo",
|
|
||||||
"SimulationPropertyCache",
|
|
||||||
"cache_property_calculation",
|
|
||||||
"current_property_cache",
|
|
||||||
"property_cache_run",
|
|
||||||
"with_property_cache",
|
|
||||||
]
|
|
||||||
@@ -1,23 +1 @@
|
|||||||
from app.simulation.reporting.testmodel_outputs import (
|
"""Readers for external simulation results."""
|
||||||
COMPARISON_KEYS,
|
|
||||||
MODELICA_COMPARISON_COLUMNS,
|
|
||||||
PRIMARY_KEYS,
|
|
||||||
TestModelArtifacts,
|
|
||||||
export_testmodel_artifacts,
|
|
||||||
format_testmodel_run_report,
|
|
||||||
load_modelica_series,
|
|
||||||
write_testmodel_run_report,
|
|
||||||
write_modelica_comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"COMPARISON_KEYS",
|
|
||||||
"MODELICA_COMPARISON_COLUMNS",
|
|
||||||
"PRIMARY_KEYS",
|
|
||||||
"TestModelArtifacts",
|
|
||||||
"export_testmodel_artifacts",
|
|
||||||
"format_testmodel_run_report",
|
|
||||||
"load_modelica_series",
|
|
||||||
"write_testmodel_run_report",
|
|
||||||
"write_modelica_comparison",
|
|
||||||
]
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Mapping
|
|
||||||
|
|
||||||
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
|
||||||
from app.simulation.reporting.amesim_results import (
|
|
||||||
AmesimResults,
|
|
||||||
load_test_mql_amesim_results,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Pnl0002ReplayPaths:
|
|
||||||
"""AMESim data paths needed to replay one PNL0002 resistance."""
|
|
||||||
|
|
||||||
center_pressure: str
|
|
||||||
center_temperature: str
|
|
||||||
port_1_pressure: str
|
|
||||||
port_1_temperature: str
|
|
||||||
port_1_mass_flow: str
|
|
||||||
port_2_pressure: str
|
|
||||||
port_2_temperature: str
|
|
||||||
port_2_mass_flow: str
|
|
||||||
reynolds: str
|
|
||||||
friction_factor: str
|
|
||||||
|
|
||||||
|
|
||||||
PNL83_REPLAY_PATHS = Pnl0002ReplayPaths(
|
|
||||||
center_pressure="pctr@pneumatic_83",
|
|
||||||
center_temperature="tctr@pneumatic_83",
|
|
||||||
port_1_pressure="press1@pnnode4_16",
|
|
||||||
port_1_temperature="temp1@pnnode4_16",
|
|
||||||
port_1_mass_flow="dm1@pneumatic_83",
|
|
||||||
port_2_pressure="press3@pnnode4_17",
|
|
||||||
port_2_temperature="temp3@pnnode4_17",
|
|
||||||
port_2_mass_flow="dm2@pneumatic_83",
|
|
||||||
reynolds="re@pneumatic_83",
|
|
||||||
friction_factor="ff@pneumatic_83",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_series(
|
|
||||||
results: AmesimResults,
|
|
||||||
path: str,
|
|
||||||
) -> tuple[float, ...]:
|
|
||||||
try:
|
|
||||||
values = results.series(path)
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(f"AMESim replay variable is not saved: {path}") from exc
|
|
||||||
if len(values) != len(results.times):
|
|
||||||
raise ValueError(f"AMESim replay variable has an invalid length: {path}")
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _metric_summary(
|
|
||||||
rows: list[dict[str, float]],
|
|
||||||
key: str,
|
|
||||||
) -> dict[str, float]:
|
|
||||||
values = [float(row[key]) for row in rows]
|
|
||||||
max_index = max(range(len(values)), key=lambda index: abs(values[index]))
|
|
||||||
return {
|
|
||||||
"maxAbs": abs(values[max_index]),
|
|
||||||
"maxAbsTime": rows[max_index]["time"],
|
|
||||||
"finalSigned": values[-1],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def replay_pnl0002_amesim_states(
|
|
||||||
pipe: AmesimPnl0002,
|
|
||||||
results: AmesimResults,
|
|
||||||
paths: Pnl0002ReplayPaths,
|
|
||||||
*,
|
|
||||||
amesim_mass_flow_scale: float = -1.0e-3,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Replay saved AMESim states through current PNL0002 flow functions.
|
|
||||||
|
|
||||||
This is a calibration-only, no-integration calculation. It does not write
|
|
||||||
states into the pipe or alter the production simulation path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
series_by_field = {
|
|
||||||
field: _required_series(results, getattr(paths, field))
|
|
||||||
for field in paths.__dataclass_fields__
|
|
||||||
}
|
|
||||||
rows: list[dict[str, float]] = []
|
|
||||||
for index, time_s in enumerate(results.times):
|
|
||||||
center_pressure = series_by_field["center_pressure"][index]
|
|
||||||
center_temperature = series_by_field["center_temperature"][index]
|
|
||||||
port_1_pressure = series_by_field["port_1_pressure"][index]
|
|
||||||
port_2_pressure = series_by_field["port_2_pressure"][index]
|
|
||||||
observed_flow_1 = (
|
|
||||||
amesim_mass_flow_scale
|
|
||||||
* series_by_field["port_1_mass_flow"][index]
|
|
||||||
)
|
|
||||||
observed_flow_2 = (
|
|
||||||
amesim_mass_flow_scale
|
|
||||||
* series_by_field["port_2_mass_flow"][index]
|
|
||||||
)
|
|
||||||
upstream_temperature_1 = (
|
|
||||||
series_by_field["port_1_temperature"][index]
|
|
||||||
if observed_flow_1 >= 0.0
|
|
||||||
else center_temperature
|
|
||||||
)
|
|
||||||
upstream_temperature_2 = (
|
|
||||||
series_by_field["port_2_temperature"][index]
|
|
||||||
if observed_flow_2 >= 0.0
|
|
||||||
else center_temperature
|
|
||||||
)
|
|
||||||
predicted_flow_1 = pipe.mass_flow(
|
|
||||||
port_1_pressure,
|
|
||||||
center_pressure,
|
|
||||||
upstream_temperature_1,
|
|
||||||
)
|
|
||||||
predicted_flow_2 = pipe.mass_flow(
|
|
||||||
port_2_pressure,
|
|
||||||
center_pressure,
|
|
||||||
upstream_temperature_2,
|
|
||||||
)
|
|
||||||
reynolds_1 = pipe.reynolds_number(
|
|
||||||
observed_flow_1,
|
|
||||||
upstream_temperature_1,
|
|
||||||
)
|
|
||||||
reynolds_2 = pipe.reynolds_number(
|
|
||||||
observed_flow_2,
|
|
||||||
upstream_temperature_2,
|
|
||||||
)
|
|
||||||
friction_1 = pipe.friction_factor(reynolds_1)
|
|
||||||
friction_2 = pipe.friction_factor(reynolds_2)
|
|
||||||
replay_reynolds = 0.5 * (reynolds_1 + reynolds_2)
|
|
||||||
replay_friction = 0.5 * (friction_1 + friction_2)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"time": float(time_s),
|
|
||||||
"centerPressure": center_pressure,
|
|
||||||
"centerTemperature": center_temperature,
|
|
||||||
"port1Pressure": port_1_pressure,
|
|
||||||
"port2Pressure": port_2_pressure,
|
|
||||||
"observedPort1MassFlow": observed_flow_1,
|
|
||||||
"observedPort2MassFlow": observed_flow_2,
|
|
||||||
"predictedPort1MassFlow": predicted_flow_1,
|
|
||||||
"predictedPort2MassFlow": predicted_flow_2,
|
|
||||||
"port1MassFlowError": predicted_flow_1 - observed_flow_1,
|
|
||||||
"port2MassFlowError": predicted_flow_2 - observed_flow_2,
|
|
||||||
"amesimReynolds": series_by_field["reynolds"][index],
|
|
||||||
"replayReynolds": replay_reynolds,
|
|
||||||
"reynoldsError": (
|
|
||||||
replay_reynolds - series_by_field["reynolds"][index]
|
|
||||||
),
|
|
||||||
"amesimFrictionFactor": series_by_field["friction_factor"][index],
|
|
||||||
"replayFrictionFactor": replay_friction,
|
|
||||||
"frictionFactorError": (
|
|
||||||
replay_friction
|
|
||||||
- series_by_field["friction_factor"][index]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
metric_keys = (
|
|
||||||
"port1MassFlowError",
|
|
||||||
"port2MassFlowError",
|
|
||||||
"reynoldsError",
|
|
||||||
"frictionFactorError",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"mode": "amesim-state-replay-no-integration",
|
|
||||||
"component": pipe.name,
|
|
||||||
"pointCount": len(rows),
|
|
||||||
"massFlowScale": amesim_mass_flow_scale,
|
|
||||||
"paths": {
|
|
||||||
field: getattr(paths, field)
|
|
||||||
for field in paths.__dataclass_fields__
|
|
||||||
},
|
|
||||||
"parameters": dict(pipe.parameter_values),
|
|
||||||
"summary": {
|
|
||||||
key: _metric_summary(rows, key)
|
|
||||||
for key in metric_keys
|
|
||||||
},
|
|
||||||
"rows": rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_project_pipe(
|
|
||||||
project_path: Path,
|
|
||||||
component_name: str,
|
|
||||||
) -> AmesimPnl0002:
|
|
||||||
from app.main import (
|
|
||||||
ReactFlowProjectPayload,
|
|
||||||
_compile_xml_document_or_422,
|
|
||||||
_validated_xml_document_or_422,
|
|
||||||
build_reactflow_system_xml,
|
|
||||||
validate_system_xml_document,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = ReactFlowProjectPayload.model_validate_json(
|
|
||||||
project_path.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
xml_bytes = build_reactflow_system_xml(payload)
|
|
||||||
document = _validated_xml_document_or_422(
|
|
||||||
validate_system_xml_document(xml_bytes)
|
|
||||||
)
|
|
||||||
_project, network = _compile_xml_document_or_422(document)
|
|
||||||
try:
|
|
||||||
component = network.components[component_name]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ValueError(f"Project component does not exist: {component_name}") from exc
|
|
||||||
if not isinstance(component, AmesimPnl0002):
|
|
||||||
raise ValueError(f"Project component is not PNL0002: {component_name}")
|
|
||||||
return component
|
|
||||||
|
|
||||||
|
|
||||||
def _paths_from_arguments(arguments: argparse.Namespace) -> Pnl0002ReplayPaths:
|
|
||||||
values: Mapping[str, str] = {
|
|
||||||
field: getattr(arguments, field)
|
|
||||||
for field in PNL83_REPLAY_PATHS.__dataclass_fields__
|
|
||||||
}
|
|
||||||
return Pnl0002ReplayPaths(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Replay saved AMESim p/T/m_flow through a PNL0002 model without integration."
|
|
||||||
)
|
|
||||||
parser.add_argument("project", type=Path)
|
|
||||||
parser.add_argument("amesim_archive", type=Path)
|
|
||||||
parser.add_argument("output", type=Path)
|
|
||||||
parser.add_argument("--component", default="pneumatic_83")
|
|
||||||
parser.add_argument("--mass-flow-scale", type=float, default=-1.0e-3)
|
|
||||||
for field in PNL83_REPLAY_PATHS.__dataclass_fields__:
|
|
||||||
parser.add_argument(
|
|
||||||
"--" + field.replace("_", "-"),
|
|
||||||
dest=field,
|
|
||||||
default=getattr(PNL83_REPLAY_PATHS, field),
|
|
||||||
)
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
|
|
||||||
pipe = _compile_project_pipe(arguments.project, arguments.component)
|
|
||||||
results = load_test_mql_amesim_results(arguments.amesim_archive)
|
|
||||||
report = replay_pnl0002_amesim_states(
|
|
||||||
pipe,
|
|
||||||
results,
|
|
||||||
_paths_from_arguments(arguments),
|
|
||||||
amesim_mass_flow_scale=arguments.mass_flow_scale,
|
|
||||||
)
|
|
||||||
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
arguments.output.write_text(
|
|
||||||
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
print(json.dumps({key: report[key] for key in (
|
|
||||||
"mode",
|
|
||||||
"component",
|
|
||||||
"pointCount",
|
|
||||||
"parameters",
|
|
||||||
"summary",
|
|
||||||
)}, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableBinding,
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.pneumatic import (
|
|
||||||
TestMqlPneumaticAssembly,
|
|
||||||
build_test_mql_pneumatic_assembly,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlChamberObservation:
|
|
||||||
time: float
|
|
||||||
pressure_pa: float
|
|
||||||
temperature_k: float
|
|
||||||
gas_mass_g: float
|
|
||||||
volume_cm3: float | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlChamberBinding:
|
|
||||||
alias: str
|
|
||||||
submodel: str
|
|
||||||
pressure_path: str
|
|
||||||
temperature_path: str
|
|
||||||
gas_mass_path: str
|
|
||||||
pressure_duplicate_paths: tuple[str, ...]
|
|
||||||
temperature_duplicate_paths: tuple[str, ...]
|
|
||||||
volume_path: str | None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_variable(self) -> bool:
|
|
||||||
return self.volume_path is not None
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlChamberObservation:
|
|
||||||
return TestMqlChamberObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
pressure_pa=results.series(self.pressure_path)[index],
|
|
||||||
temperature_k=results.series(self.temperature_path)[index],
|
|
||||||
gas_mass_g=results.series(self.gas_mass_path)[index],
|
|
||||||
volume_cm3=(
|
|
||||||
results.series(self.volume_path)[index]
|
|
||||||
if self.volume_path is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlChamberObservationCatalog:
|
|
||||||
bindings: tuple[TestMqlChamberBinding, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fixed_count(self) -> int:
|
|
||||||
return sum(1 for binding in self.bindings if binding.submodel == "PNCH023")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def variable_count(self) -> int:
|
|
||||||
return sum(1 for binding in self.bindings if binding.submodel == "PNCH012")
|
|
||||||
|
|
||||||
def by_alias(self, alias: str) -> TestMqlChamberBinding:
|
|
||||||
for binding in self.bindings:
|
|
||||||
if binding.alias == alias:
|
|
||||||
return binding
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_chamber_observation_catalog(
|
|
||||||
results: AmesimResults,
|
|
||||||
*,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
|
||||||
assembly: TestMqlPneumaticAssembly | None = None,
|
|
||||||
) -> TestMqlChamberObservationCatalog:
|
|
||||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
|
||||||
assembly = assembly or build_test_mql_pneumatic_assembly()
|
|
||||||
chamber_aliases = {
|
|
||||||
**{alias: "PNCH023" for alias in assembly.fixed_chambers},
|
|
||||||
**{alias: "PNCH012" for alias in assembly.variable_chambers},
|
|
||||||
}
|
|
||||||
bindings = []
|
|
||||||
for alias, submodel in chamber_aliases.items():
|
|
||||||
variables = tuple(
|
|
||||||
variable
|
|
||||||
for variable in variable_catalog.variables
|
|
||||||
if variable.owner_alias == alias
|
|
||||||
)
|
|
||||||
pressure = _primary_observable(variables, "press", expected_units="Pa")
|
|
||||||
temperature = _primary_observable(variables, "temp", expected_units="K")
|
|
||||||
gas_mass = _required_path(
|
|
||||||
variables,
|
|
||||||
"mgas1" if submodel == "PNCH012" else "mgas",
|
|
||||||
expected_units="g",
|
|
||||||
)
|
|
||||||
volume = _optional_path(variables, "vol", expected_units="cm**3")
|
|
||||||
bindings.append(
|
|
||||||
TestMqlChamberBinding(
|
|
||||||
alias=alias,
|
|
||||||
submodel=submodel,
|
|
||||||
pressure_path=pressure.data_path,
|
|
||||||
temperature_path=temperature.data_path,
|
|
||||||
gas_mass_path=gas_mass,
|
|
||||||
pressure_duplicate_paths=_duplicate_paths(variables, "press", expected_units="Pa"),
|
|
||||||
temperature_duplicate_paths=_duplicate_paths(variables, "temp", expected_units="K"),
|
|
||||||
volume_path=volume,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TestMqlChamberObservationCatalog(
|
|
||||||
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _primary_observable(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_prefix: str,
|
|
||||||
*,
|
|
||||||
expected_units: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
matches = tuple(
|
|
||||||
variable
|
|
||||||
for variable in variables
|
|
||||||
if variable.signal_name == signal_prefix
|
|
||||||
and "duplicate" not in variable.label
|
|
||||||
)
|
|
||||||
variable = _single(matches, f"primary {signal_prefix}")
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return variable
|
|
||||||
|
|
||||||
|
|
||||||
def _duplicate_paths(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_prefix: str,
|
|
||||||
*,
|
|
||||||
expected_units: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
matches = tuple(
|
|
||||||
variable
|
|
||||||
for variable in variables
|
|
||||||
if variable.signal_name.startswith(signal_prefix)
|
|
||||||
and variable.signal_name != signal_prefix
|
|
||||||
and "duplicate" in variable.label
|
|
||||||
)
|
|
||||||
for variable in matches:
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return tuple(variable.data_path for variable in matches)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_path(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
*,
|
|
||||||
expected_units: str,
|
|
||||||
) -> str:
|
|
||||||
variable = _single(
|
|
||||||
tuple(variable for variable in variables if variable.signal_name == signal_name),
|
|
||||||
signal_name,
|
|
||||||
)
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return variable.data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_path(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
*,
|
|
||||||
expected_units: str,
|
|
||||||
) -> str | None:
|
|
||||||
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
|
|
||||||
if not matches:
|
|
||||||
return None
|
|
||||||
variable = _single(matches, signal_name)
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return variable.data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _single(
|
|
||||||
matches: tuple[TestMqlVariableBinding, ...],
|
|
||||||
description: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_units(variable: TestMqlVariableBinding, expected_units: str) -> None:
|
|
||||||
if variable.units != expected_units:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected units for {variable.data_path}: "
|
|
||||||
f"{variable.units!r}, expected {expected_units!r}."
|
|
||||||
)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from bisect import bisect_left
|
|
||||||
import csv
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
|
|
||||||
"temp3@pn_c1_8",
|
|
||||||
"press3@pn_c1_8",
|
|
||||||
"vvol1@pn_brp2_8",
|
|
||||||
"vol1@pn_brp2_8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComparisonMetric:
|
|
||||||
data_path: str
|
|
||||||
sample_count: int
|
|
||||||
max_abs_error: float
|
|
||||||
mean_abs_error: float
|
|
||||||
max_rel_error: float
|
|
||||||
undefined_rel_error_count: int
|
|
||||||
near_zero_baseline_count: int
|
|
||||||
final_abs_error: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlComparisonResult:
|
|
||||||
metrics: tuple[TestMqlComparisonMetric, ...]
|
|
||||||
|
|
||||||
def metric(self, data_path: str) -> TestMqlComparisonMetric:
|
|
||||||
for metric in self.metrics:
|
|
||||||
if metric.data_path == data_path:
|
|
||||||
return metric
|
|
||||||
raise KeyError(data_path)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_abs_error(self) -> float:
|
|
||||||
return max((metric.max_abs_error for metric in self.metrics), default=0.0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_rel_error(self) -> float:
|
|
||||||
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def undefined_rel_error_count(self) -> int:
|
|
||||||
return sum(metric.undefined_rel_error_count for metric in self.metrics)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMqlComparisonError(ValueError):
|
|
||||||
"""Raised when Python and AMESim series cannot be aligned."""
|
|
||||||
|
|
||||||
|
|
||||||
def compare_test_mql_series(
|
|
||||||
*,
|
|
||||||
python_times: tuple[float, ...] | list[float],
|
|
||||||
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
|
||||||
relative_floor: float = 1.0e-12,
|
|
||||||
) -> TestMqlComparisonResult:
|
|
||||||
"""Compare current values directly with AMESim simulation values.
|
|
||||||
|
|
||||||
``relative_floor`` only identifies near-zero baselines for reporting. It is
|
|
||||||
never substituted into the relative-error denominator. An exact zero
|
|
||||||
AMESim baseline has undefined relative error and is counted separately;
|
|
||||||
absolute error remains available for judgement.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if relative_floor < 0.0:
|
|
||||||
raise TestMqlComparisonError("relative_floor cannot be negative.")
|
|
||||||
_validate_time_axis(python_times)
|
|
||||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
|
||||||
metrics = []
|
|
||||||
for data_path in selected_paths:
|
|
||||||
python_values = tuple(float(value) for value in python_series_by_data_path[data_path])
|
|
||||||
if len(python_values) != len(python_times):
|
|
||||||
raise TestMqlComparisonError(
|
|
||||||
f"Python series length mismatch for {data_path!r}: "
|
|
||||||
f"{len(python_values)} values for {len(python_times)} time samples."
|
|
||||||
)
|
|
||||||
amesim_values = amesim_results.series(data_path)
|
|
||||||
abs_errors = []
|
|
||||||
rel_errors = []
|
|
||||||
undefined_rel_error_count = 0
|
|
||||||
near_zero_baseline_count = 0
|
|
||||||
for time_value, python_value in zip(python_times, python_values):
|
|
||||||
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
|
||||||
abs_error = abs(python_value - amesim_value)
|
|
||||||
abs_errors.append(abs_error)
|
|
||||||
if abs(amesim_value) <= relative_floor:
|
|
||||||
near_zero_baseline_count += 1
|
|
||||||
if amesim_value == 0.0:
|
|
||||||
undefined_rel_error_count += 1
|
|
||||||
else:
|
|
||||||
rel_errors.append(abs_error / abs(amesim_value))
|
|
||||||
final_amesim_value = interpolate_series_value(
|
|
||||||
amesim_results.times,
|
|
||||||
amesim_values,
|
|
||||||
float(python_times[-1]),
|
|
||||||
)
|
|
||||||
metrics.append(
|
|
||||||
TestMqlComparisonMetric(
|
|
||||||
data_path=data_path,
|
|
||||||
sample_count=len(python_times),
|
|
||||||
max_abs_error=max(abs_errors, default=0.0),
|
|
||||||
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
|
||||||
max_rel_error=max(rel_errors, default=0.0),
|
|
||||||
undefined_rel_error_count=undefined_rel_error_count,
|
|
||||||
near_zero_baseline_count=near_zero_baseline_count,
|
|
||||||
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TestMqlComparisonResult(metrics=tuple(metrics))
|
|
||||||
|
|
||||||
|
|
||||||
def write_test_mql_amesim_baseline_csv(
|
|
||||||
output_dir: Path,
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str] = DEFAULT_TEST_MQL_ALIGNMENT_PATHS,
|
|
||||||
) -> Path:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
csv_path = output_dir / "test_mql_amesim_baseline.csv"
|
|
||||||
_validate_amesim_data_paths(amesim_results, data_paths)
|
|
||||||
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
||||||
writer = csv.writer(handle)
|
|
||||||
writer.writerow(["time_s", *data_paths])
|
|
||||||
for index, time_value in enumerate(amesim_results.times):
|
|
||||||
writer.writerow(
|
|
||||||
[time_value, *(amesim_results.series(data_path)[index] for data_path in data_paths)]
|
|
||||||
)
|
|
||||||
return csv_path
|
|
||||||
|
|
||||||
|
|
||||||
def write_test_mql_comparison_csv(
|
|
||||||
*,
|
|
||||||
output_dir: Path,
|
|
||||||
python_times: tuple[float, ...] | list[float],
|
|
||||||
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
|
||||||
) -> tuple[Path, Path, TestMqlComparisonResult]:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
|
||||||
comparison = compare_test_mql_series(
|
|
||||||
python_times=python_times,
|
|
||||||
python_series_by_data_path=python_series_by_data_path,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=selected_paths,
|
|
||||||
)
|
|
||||||
csv_path = output_dir / "test_mql_amesim_comparison.csv"
|
|
||||||
summary_path = output_dir / "test_mql_amesim_comparison_summary.txt"
|
|
||||||
|
|
||||||
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
||||||
writer = csv.writer(handle)
|
|
||||||
header = ["time_s"]
|
|
||||||
for data_path in selected_paths:
|
|
||||||
header.extend(
|
|
||||||
[
|
|
||||||
f"python.{data_path}",
|
|
||||||
f"amesim.{data_path}",
|
|
||||||
f"abs_error.{data_path}",
|
|
||||||
f"rel_error.{data_path}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
writer.writerow(header)
|
|
||||||
for index, time_value in enumerate(python_times):
|
|
||||||
row = [time_value]
|
|
||||||
for data_path in selected_paths:
|
|
||||||
python_value = float(python_series_by_data_path[data_path][index])
|
|
||||||
amesim_value = interpolate_series_value(
|
|
||||||
amesim_results.times,
|
|
||||||
amesim_results.series(data_path),
|
|
||||||
float(time_value),
|
|
||||||
)
|
|
||||||
abs_error = abs(python_value - amesim_value)
|
|
||||||
rel_error = (
|
|
||||||
None
|
|
||||||
if amesim_value == 0.0
|
|
||||||
else abs_error / abs(amesim_value)
|
|
||||||
)
|
|
||||||
row.extend(
|
|
||||||
[
|
|
||||||
python_value,
|
|
||||||
amesim_value,
|
|
||||||
abs_error,
|
|
||||||
"" if rel_error is None else rel_error,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
writer.writerow(row)
|
|
||||||
|
|
||||||
summary_lines = [
|
|
||||||
(
|
|
||||||
f"{metric.data_path}: samples={metric.sample_count}, "
|
|
||||||
f"max_abs_error={metric.max_abs_error:.12g}, "
|
|
||||||
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
|
||||||
f"max_rel_error={metric.max_rel_error:.12%}, "
|
|
||||||
f"undefined_rel_error_count={metric.undefined_rel_error_count}, "
|
|
||||||
f"final_abs_error={metric.final_abs_error:.12g}"
|
|
||||||
)
|
|
||||||
for metric in comparison.metrics
|
|
||||||
]
|
|
||||||
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
|
|
||||||
return csv_path, summary_path, comparison
|
|
||||||
|
|
||||||
|
|
||||||
def interpolate_series_value(
|
|
||||||
time_values: tuple[float, ...] | list[float],
|
|
||||||
values: tuple[float, ...] | list[float],
|
|
||||||
target_time: float,
|
|
||||||
) -> float:
|
|
||||||
if len(time_values) != len(values):
|
|
||||||
raise TestMqlComparisonError("time and value series lengths differ.")
|
|
||||||
if not time_values:
|
|
||||||
raise TestMqlComparisonError("cannot interpolate an empty series.")
|
|
||||||
if target_time <= time_values[0]:
|
|
||||||
return float(values[0])
|
|
||||||
if target_time >= time_values[-1]:
|
|
||||||
return float(values[-1])
|
|
||||||
|
|
||||||
right_index = bisect_left(time_values, target_time)
|
|
||||||
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1.0e-12:
|
|
||||||
return float(values[right_index])
|
|
||||||
|
|
||||||
left_index = right_index - 1
|
|
||||||
left_time = float(time_values[left_index])
|
|
||||||
right_time = float(time_values[right_index])
|
|
||||||
fraction = (target_time - left_time) / (right_time - left_time)
|
|
||||||
return float(values[left_index]) + fraction * (float(values[right_index]) - float(values[left_index]))
|
|
||||||
|
|
||||||
|
|
||||||
def _select_data_paths(
|
|
||||||
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
if data_paths is None:
|
|
||||||
data_paths = tuple(
|
|
||||||
data_path
|
|
||||||
for data_path in python_series_by_data_path
|
|
||||||
if data_path in amesim_results.series_by_data_path
|
|
||||||
)
|
|
||||||
selected_paths = tuple(data_paths)
|
|
||||||
if not selected_paths:
|
|
||||||
raise TestMqlComparisonError("no common Data_Path values are available for comparison.")
|
|
||||||
missing_python = [data_path for data_path in selected_paths if data_path not in python_series_by_data_path]
|
|
||||||
if missing_python:
|
|
||||||
raise TestMqlComparisonError(f"Python series missing Data_Path values: {missing_python}")
|
|
||||||
_validate_amesim_data_paths(amesim_results, selected_paths)
|
|
||||||
return selected_paths
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_amesim_data_paths(
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str],
|
|
||||||
) -> None:
|
|
||||||
missing_amesim = [data_path for data_path in data_paths if data_path not in amesim_results.series_by_data_path]
|
|
||||||
if missing_amesim:
|
|
||||||
raise TestMqlComparisonError(f"AMESim results missing Data_Path values: {missing_amesim}")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_time_axis(time_values: tuple[float, ...] | list[float]) -> None:
|
|
||||||
if not time_values:
|
|
||||||
raise TestMqlComparisonError("Python time axis is empty.")
|
|
||||||
previous = float(time_values[0])
|
|
||||||
for value in time_values[1:]:
|
|
||||||
value = float(value)
|
|
||||||
if value < previous:
|
|
||||||
raise TestMqlComparisonError("Python time axis must be monotonically increasing.")
|
|
||||||
previous = value
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableBinding,
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.lines import (
|
|
||||||
TestMqlLineAssembly,
|
|
||||||
build_test_mql_line_assembly,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
G_PER_S_TO_KG_PER_S = 1.0e-3
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlLineObservation:
|
|
||||||
time: float
|
|
||||||
mass_flows_kg_s: dict[str, float]
|
|
||||||
enthalpy_flows_w: dict[str, float]
|
|
||||||
pressures_pa: dict[str, float]
|
|
||||||
temperatures_k: dict[str, float]
|
|
||||||
gas_mass_g: float | None
|
|
||||||
reynolds_number: float
|
|
||||||
mass_flow_parameter: float
|
|
||||||
gas_velocity_m_s: float
|
|
||||||
friction_factor: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlLineObservationBinding:
|
|
||||||
alias: str
|
|
||||||
submodel: str
|
|
||||||
pattern: str
|
|
||||||
mass_flow_paths: tuple[str, ...]
|
|
||||||
enthalpy_flow_paths: tuple[str, ...]
|
|
||||||
pressure_paths: tuple[str, ...]
|
|
||||||
temperature_paths: tuple[str, ...]
|
|
||||||
gas_mass_path: str | None
|
|
||||||
reynolds_path: str
|
|
||||||
mass_flow_parameter_path: str
|
|
||||||
gas_velocity_path: str
|
|
||||||
friction_factor_path: str
|
|
||||||
|
|
||||||
def mass_flow_kg_s_series(
|
|
||||||
self,
|
|
||||||
results: AmesimResults,
|
|
||||||
data_path: str | None = None,
|
|
||||||
) -> tuple[float, ...]:
|
|
||||||
path = data_path or self.mass_flow_paths[0]
|
|
||||||
if path not in self.mass_flow_paths:
|
|
||||||
raise KeyError(path)
|
|
||||||
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(path))
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlLineObservation:
|
|
||||||
return TestMqlLineObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
mass_flows_kg_s={
|
|
||||||
path: results.series(path)[index] * G_PER_S_TO_KG_PER_S
|
|
||||||
for path in self.mass_flow_paths
|
|
||||||
},
|
|
||||||
enthalpy_flows_w={
|
|
||||||
path: results.series(path)[index]
|
|
||||||
for path in self.enthalpy_flow_paths
|
|
||||||
},
|
|
||||||
pressures_pa={
|
|
||||||
path: results.series(path)[index]
|
|
||||||
for path in self.pressure_paths
|
|
||||||
},
|
|
||||||
temperatures_k={
|
|
||||||
path: results.series(path)[index]
|
|
||||||
for path in self.temperature_paths
|
|
||||||
},
|
|
||||||
gas_mass_g=(
|
|
||||||
results.series(self.gas_mass_path)[index]
|
|
||||||
if self.gas_mass_path is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
reynolds_number=results.series(self.reynolds_path)[index],
|
|
||||||
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
|
|
||||||
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
|
|
||||||
friction_factor=results.series(self.friction_factor_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlLineObservationCatalog:
|
|
||||||
bindings: tuple[TestMqlLineObservationBinding, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def line_count(self) -> int:
|
|
||||||
return len(self.bindings)
|
|
||||||
|
|
||||||
def by_alias(self, alias: str) -> TestMqlLineObservationBinding:
|
|
||||||
for binding in self.bindings:
|
|
||||||
if binding.alias == alias:
|
|
||||||
return binding
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
def by_submodel(self, submodel: str) -> tuple[TestMqlLineObservationBinding, ...]:
|
|
||||||
return tuple(binding for binding in self.bindings if binding.submodel == submodel)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_line_observation_catalog(
|
|
||||||
results: AmesimResults,
|
|
||||||
*,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
|
||||||
line_assembly: TestMqlLineAssembly | None = None,
|
|
||||||
) -> TestMqlLineObservationCatalog:
|
|
||||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
|
||||||
line_assembly = line_assembly or build_test_mql_line_assembly(results, variable_catalog)
|
|
||||||
bindings = []
|
|
||||||
for line in line_assembly.lines:
|
|
||||||
variables = tuple(
|
|
||||||
variable
|
|
||||||
for variable in variable_catalog.variables
|
|
||||||
if variable.owner_alias == line.alias
|
|
||||||
)
|
|
||||||
bindings.append(
|
|
||||||
TestMqlLineObservationBinding(
|
|
||||||
alias=line.alias,
|
|
||||||
submodel=line.submodel,
|
|
||||||
pattern=line.pattern,
|
|
||||||
mass_flow_paths=_paths_with_prefix(variables, "dm", expected_units="g/s"),
|
|
||||||
enthalpy_flow_paths=_paths_with_prefix(variables, "dh", expected_units="J/s"),
|
|
||||||
pressure_paths=_paths_with_prefix(variables, "p", expected_units="Pa"),
|
|
||||||
temperature_paths=_paths_with_prefix(variables, "t", expected_units="K"),
|
|
||||||
gas_mass_path=_optional_path(variables, "mgas", expected_units="g"),
|
|
||||||
reynolds_path=_required_path(variables, "re", expected_units=None),
|
|
||||||
mass_flow_parameter_path=_required_path(
|
|
||||||
variables,
|
|
||||||
"cm",
|
|
||||||
expected_units="(kg*K/J)**(1/2)",
|
|
||||||
),
|
|
||||||
gas_velocity_path=_required_path(variables, "v", expected_units="m/s"),
|
|
||||||
friction_factor_path=_required_path(variables, "ff", expected_units=None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TestMqlLineObservationCatalog(bindings=tuple(bindings))
|
|
||||||
|
|
||||||
|
|
||||||
def _paths_with_prefix(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
prefix: str,
|
|
||||||
*,
|
|
||||||
expected_units: str | None,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
matches = tuple(
|
|
||||||
variable
|
|
||||||
for variable in variables
|
|
||||||
if variable.signal_name.startswith(prefix)
|
|
||||||
)
|
|
||||||
for variable in matches:
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return tuple(variable.data_path for variable in matches)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_path(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
*,
|
|
||||||
expected_units: str | None,
|
|
||||||
) -> str:
|
|
||||||
variable = _single_signal(variables, signal_name)
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return variable.data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_path(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
*,
|
|
||||||
expected_units: str | None,
|
|
||||||
) -> str | None:
|
|
||||||
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
|
|
||||||
if not matches:
|
|
||||||
return None
|
|
||||||
variable = _single(matches, signal_name)
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return variable.data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _single_signal(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
return _single(
|
|
||||||
tuple(variable for variable in variables if variable.signal_name == signal_name),
|
|
||||||
signal_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _single(
|
|
||||||
matches: tuple[TestMqlVariableBinding, ...],
|
|
||||||
description: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
|
|
||||||
if variable.units != expected_units:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected units for {variable.data_path}: "
|
|
||||||
f"{variable.units!r}, expected {expected_units!r}."
|
|
||||||
)
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableBinding,
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.mechanical import (
|
|
||||||
TestMqlMechanicalAssembly,
|
|
||||||
build_test_mql_mechanical_assembly,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPistonObservation:
|
|
||||||
time: float
|
|
||||||
chamber_volume_cm3: float
|
|
||||||
chamber_volume_rate_l_min: float
|
|
||||||
chamber_length_mm: float
|
|
||||||
force_port_2_n: float
|
|
||||||
force_port_3_n: float
|
|
||||||
displacement_port_2_m: float
|
|
||||||
velocity_port_2_m_s: float
|
|
||||||
displacement_port_3_m: float
|
|
||||||
velocity_port_3_m_s: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMassEndstopObservation:
|
|
||||||
time: float
|
|
||||||
displacement_m: float
|
|
||||||
velocity_m_s: float
|
|
||||||
acceleration_m_s2: float
|
|
||||||
lower_contact_force_n: float
|
|
||||||
upper_contact_force_n: float
|
|
||||||
viscous_friction_force_n: float
|
|
||||||
dry_friction_force_n: float
|
|
||||||
stick_flag: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlElasticEndstopObservation:
|
|
||||||
time: float
|
|
||||||
force_n: float
|
|
||||||
duplicate_force_n: float
|
|
||||||
gap_mm: float
|
|
||||||
stiffness_n_m: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlForceSourceObservation:
|
|
||||||
time: float
|
|
||||||
force_n: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlForceConnectorObservation:
|
|
||||||
time: float
|
|
||||||
force_n: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalNodeObservation:
|
|
||||||
time: float
|
|
||||||
velocities_m_s: dict[int, float]
|
|
||||||
displacements_m: dict[int, float]
|
|
||||||
total_force_n: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlPistonObservationBinding:
|
|
||||||
alias: str
|
|
||||||
volume_path: str
|
|
||||||
volume_rate_path: str
|
|
||||||
length_path: str
|
|
||||||
force_port_2_path: str
|
|
||||||
force_port_3_path: str
|
|
||||||
displacement_port_2_path: str
|
|
||||||
velocity_port_2_path: str
|
|
||||||
displacement_port_3_path: str
|
|
||||||
velocity_port_3_path: str
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation:
|
|
||||||
return TestMqlPistonObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
chamber_volume_cm3=results.series(self.volume_path)[index],
|
|
||||||
chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index],
|
|
||||||
chamber_length_mm=results.series(self.length_path)[index],
|
|
||||||
force_port_2_n=results.series(self.force_port_2_path)[index],
|
|
||||||
force_port_3_n=results.series(self.force_port_3_path)[index],
|
|
||||||
displacement_port_2_m=results.series(self.displacement_port_2_path)[index],
|
|
||||||
velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index],
|
|
||||||
displacement_port_3_m=results.series(self.displacement_port_3_path)[index],
|
|
||||||
velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMassEndstopObservationBinding:
|
|
||||||
alias: str
|
|
||||||
displacement_path: str
|
|
||||||
velocity_path: str
|
|
||||||
acceleration_path: str
|
|
||||||
displacement_duplicate_path: str
|
|
||||||
velocity_duplicate_path: str
|
|
||||||
acceleration_duplicate_path: str
|
|
||||||
lower_contact_force_path: str
|
|
||||||
upper_contact_force_path: str
|
|
||||||
viscous_friction_force_path: str
|
|
||||||
dry_friction_force_path: str
|
|
||||||
stick_flag_path: str
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation:
|
|
||||||
return TestMqlMassEndstopObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
displacement_m=results.series(self.displacement_path)[index],
|
|
||||||
velocity_m_s=results.series(self.velocity_path)[index],
|
|
||||||
acceleration_m_s2=results.series(self.acceleration_path)[index],
|
|
||||||
lower_contact_force_n=results.series(self.lower_contact_force_path)[index],
|
|
||||||
upper_contact_force_n=results.series(self.upper_contact_force_path)[index],
|
|
||||||
viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index],
|
|
||||||
dry_friction_force_n=results.series(self.dry_friction_force_path)[index],
|
|
||||||
stick_flag=results.series(self.stick_flag_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlElasticEndstopObservationBinding:
|
|
||||||
alias: str
|
|
||||||
force_path: str
|
|
||||||
duplicate_force_path: str
|
|
||||||
gap_path: str
|
|
||||||
stiffness_path: str
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation:
|
|
||||||
return TestMqlElasticEndstopObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
force_n=results.series(self.force_path)[index],
|
|
||||||
duplicate_force_n=results.series(self.duplicate_force_path)[index],
|
|
||||||
gap_mm=results.series(self.gap_path)[index],
|
|
||||||
stiffness_n_m=results.series(self.stiffness_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlForceSourceObservationBinding:
|
|
||||||
alias: str
|
|
||||||
force_path: str
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceSourceObservation:
|
|
||||||
return TestMqlForceSourceObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
force_n=results.series(self.force_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlForceConnectorObservationBinding:
|
|
||||||
alias: str
|
|
||||||
force_path: str
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceConnectorObservation:
|
|
||||||
return TestMqlForceConnectorObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
force_n=results.series(self.force_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalNodeObservationBinding:
|
|
||||||
alias: str
|
|
||||||
velocity_paths_by_port: dict[int, str]
|
|
||||||
displacement_paths_by_port: dict[int, str]
|
|
||||||
total_force_path: str
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMechanicalNodeObservation:
|
|
||||||
return TestMqlMechanicalNodeObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
velocities_m_s={
|
|
||||||
port: results.series(path)[index]
|
|
||||||
for port, path in self.velocity_paths_by_port.items()
|
|
||||||
},
|
|
||||||
displacements_m={
|
|
||||||
port: results.series(path)[index]
|
|
||||||
for port, path in self.displacement_paths_by_port.items()
|
|
||||||
},
|
|
||||||
total_force_n=results.series(self.total_force_path)[index],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlMechanicalObservationCatalog:
|
|
||||||
pistons: dict[str, TestMqlPistonObservationBinding]
|
|
||||||
masses: dict[str, TestMqlMassEndstopObservationBinding]
|
|
||||||
elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding]
|
|
||||||
zero_force_sources: dict[str, TestMqlForceSourceObservationBinding]
|
|
||||||
force_connectors: dict[str, TestMqlForceConnectorObservationBinding]
|
|
||||||
mechanical_nodes: dict[str, TestMqlMechanicalNodeObservationBinding]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def binding_count(self) -> int:
|
|
||||||
return (
|
|
||||||
len(self.pistons)
|
|
||||||
+ len(self.masses)
|
|
||||||
+ len(self.elastic_endstops)
|
|
||||||
+ len(self.zero_force_sources)
|
|
||||||
+ len(self.force_connectors)
|
|
||||||
+ len(self.mechanical_nodes)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_mechanical_observation_catalog(
|
|
||||||
results: AmesimResults,
|
|
||||||
*,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
|
||||||
mechanical_assembly: TestMqlMechanicalAssembly | None = None,
|
|
||||||
) -> TestMqlMechanicalObservationCatalog:
|
|
||||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
|
||||||
mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly(
|
|
||||||
amesim_results=results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
)
|
|
||||||
return TestMqlMechanicalObservationCatalog(
|
|
||||||
pistons={
|
|
||||||
alias: _build_piston_binding(alias, variable_catalog)
|
|
||||||
for alias in mechanical_assembly.pistons
|
|
||||||
},
|
|
||||||
masses={
|
|
||||||
alias: _build_mass_binding(alias, variable_catalog)
|
|
||||||
for alias in mechanical_assembly.masses
|
|
||||||
},
|
|
||||||
elastic_endstops={
|
|
||||||
alias: _build_elastic_endstop_binding(alias, variable_catalog)
|
|
||||||
for alias in mechanical_assembly.elastic_endstops
|
|
||||||
},
|
|
||||||
zero_force_sources={
|
|
||||||
alias: _build_zero_force_source_binding(alias, variable_catalog)
|
|
||||||
for alias in mechanical_assembly.zero_force_sources
|
|
||||||
},
|
|
||||||
force_connectors={
|
|
||||||
alias: _build_force_connector_binding(alias, variable_catalog)
|
|
||||||
for alias in mechanical_assembly.force_connectors
|
|
||||||
},
|
|
||||||
mechanical_nodes={
|
|
||||||
alias: _build_mechanical_node_binding(alias, variable_catalog)
|
|
||||||
for alias in mechanical_assembly.mechanical_nodes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_piston_binding(
|
|
||||||
alias: str,
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
) -> TestMqlPistonObservationBinding:
|
|
||||||
variables = _owner_variables(variable_catalog, alias)
|
|
||||||
return TestMqlPistonObservationBinding(
|
|
||||||
alias=alias,
|
|
||||||
volume_path=_required_path(variables, "vol1", expected_units="cm**3"),
|
|
||||||
volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"),
|
|
||||||
length_path=_required_path(variables, "length", expected_units="mm"),
|
|
||||||
force_port_2_path=_required_path(variables, "f2", expected_units="N"),
|
|
||||||
force_port_3_path=_required_path(variables, "f3", expected_units="N"),
|
|
||||||
displacement_port_2_path=_required_path(variables, "x5", expected_units="m"),
|
|
||||||
velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"),
|
|
||||||
displacement_port_3_path=_required_path(variables, "x4", expected_units="m"),
|
|
||||||
velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_mass_binding(
|
|
||||||
alias: str,
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
) -> TestMqlMassEndstopObservationBinding:
|
|
||||||
variables = _owner_variables(variable_catalog, alias)
|
|
||||||
return TestMqlMassEndstopObservationBinding(
|
|
||||||
alias=alias,
|
|
||||||
displacement_path=_required_path(variables, "x1", expected_units="m"),
|
|
||||||
velocity_path=_required_path(variables, "v1", expected_units="m/s"),
|
|
||||||
acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"),
|
|
||||||
displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"),
|
|
||||||
velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"),
|
|
||||||
acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"),
|
|
||||||
lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"),
|
|
||||||
upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"),
|
|
||||||
viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"),
|
|
||||||
dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"),
|
|
||||||
stick_flag_path=_required_path(variables, "stick", expected_units=None),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_elastic_endstop_binding(
|
|
||||||
alias: str,
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
) -> TestMqlElasticEndstopObservationBinding:
|
|
||||||
variables = _owner_variables(variable_catalog, alias)
|
|
||||||
return TestMqlElasticEndstopObservationBinding(
|
|
||||||
alias=alias,
|
|
||||||
force_path=_required_path(variables, "f1", expected_units="N"),
|
|
||||||
duplicate_force_path=_required_path(variables, "f2", expected_units="N"),
|
|
||||||
gap_path=_required_path(variables, "gap", expected_units="mm"),
|
|
||||||
stiffness_path=_required_path(variables, "kval", expected_units="N/m"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_zero_force_source_binding(
|
|
||||||
alias: str,
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
) -> TestMqlForceSourceObservationBinding:
|
|
||||||
variables = _owner_variables(variable_catalog, alias)
|
|
||||||
return TestMqlForceSourceObservationBinding(
|
|
||||||
alias=alias,
|
|
||||||
force_path=_required_path(variables, "fzero", expected_units="N"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_force_connector_binding(
|
|
||||||
alias: str,
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
) -> TestMqlForceConnectorObservationBinding:
|
|
||||||
variables = _owner_variables(variable_catalog, alias)
|
|
||||||
return TestMqlForceConnectorObservationBinding(
|
|
||||||
alias=alias,
|
|
||||||
force_path=_required_path(variables, "force", expected_units="N"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_mechanical_node_binding(
|
|
||||||
alias: str,
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
) -> TestMqlMechanicalNodeObservationBinding:
|
|
||||||
variables = _owner_variables(variable_catalog, alias)
|
|
||||||
velocity_paths_by_port = {}
|
|
||||||
displacement_paths_by_port = {}
|
|
||||||
for port in range(1, 9):
|
|
||||||
velocity_paths_by_port[port] = _required_path(
|
|
||||||
variables,
|
|
||||||
f"p{port}__vt",
|
|
||||||
expected_units="m/s",
|
|
||||||
)
|
|
||||||
displacement_paths_by_port[port] = _required_path(
|
|
||||||
variables,
|
|
||||||
f"p{port}__xt",
|
|
||||||
expected_units="m",
|
|
||||||
)
|
|
||||||
return TestMqlMechanicalNodeObservationBinding(
|
|
||||||
alias=alias,
|
|
||||||
velocity_paths_by_port=velocity_paths_by_port,
|
|
||||||
displacement_paths_by_port=displacement_paths_by_port,
|
|
||||||
total_force_path=_required_path(variables, "tforce", expected_units="N"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_variables(
|
|
||||||
variable_catalog: TestMqlVariableCatalog,
|
|
||||||
alias: str,
|
|
||||||
) -> tuple[TestMqlVariableBinding, ...]:
|
|
||||||
return tuple(
|
|
||||||
variable
|
|
||||||
for variable in variable_catalog.variables
|
|
||||||
if variable.owner_alias == alias
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_path(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
*,
|
|
||||||
expected_units: str | None,
|
|
||||||
) -> str:
|
|
||||||
variable = _single(
|
|
||||||
tuple(variable for variable in variables if variable.signal_name == signal_name),
|
|
||||||
signal_name,
|
|
||||||
)
|
|
||||||
_assert_units(variable, expected_units)
|
|
||||||
return variable.data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _single(
|
|
||||||
matches: tuple[TestMqlVariableBinding, ...],
|
|
||||||
description: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
|
|
||||||
if variable.units != expected_units:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected units for {variable.data_path}: "
|
|
||||||
f"{variable.units!r}, expected {expected_units!r}."
|
|
||||||
)
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_chamber_observations import (
|
|
||||||
TestMqlChamberObservationCatalog,
|
|
||||||
build_test_mql_chamber_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_line_observations import (
|
|
||||||
TestMqlLineObservationCatalog,
|
|
||||||
build_test_mql_line_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_mechanical_observations import (
|
|
||||||
TestMqlMechanicalObservationCatalog,
|
|
||||||
build_test_mql_mechanical_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_orifice_observations import (
|
|
||||||
TestMqlOrificeObservationCatalog,
|
|
||||||
build_test_mql_orifice_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlObservationCatalog:
|
|
||||||
variable_catalog: TestMqlVariableCatalog
|
|
||||||
chambers: TestMqlChamberObservationCatalog
|
|
||||||
orifices: TestMqlOrificeObservationCatalog
|
|
||||||
lines: TestMqlLineObservationCatalog
|
|
||||||
mechanical: TestMqlMechanicalObservationCatalog
|
|
||||||
|
|
||||||
@property
|
|
||||||
def binding_count(self) -> int:
|
|
||||||
return (
|
|
||||||
len(self.chambers.bindings)
|
|
||||||
+ len(self.orifices.bindings)
|
|
||||||
+ self.lines.line_count
|
|
||||||
+ self.mechanical.binding_count
|
|
||||||
)
|
|
||||||
|
|
||||||
def data_paths_by_domain(self) -> dict[str, tuple[str, ...]]:
|
|
||||||
return {
|
|
||||||
"chambers": _sorted_unique(_chamber_data_paths(self.chambers)),
|
|
||||||
"orifices": _sorted_unique(_orifice_data_paths(self.orifices)),
|
|
||||||
"lines": _sorted_unique(_line_data_paths(self.lines)),
|
|
||||||
"mechanical": _sorted_unique(_mechanical_data_paths(self.mechanical)),
|
|
||||||
}
|
|
||||||
|
|
||||||
def data_paths(self) -> tuple[str, ...]:
|
|
||||||
paths = []
|
|
||||||
for domain_paths in self.data_paths_by_domain().values():
|
|
||||||
paths.extend(domain_paths)
|
|
||||||
return _sorted_unique(paths)
|
|
||||||
|
|
||||||
def baseline_series_by_data_path(
|
|
||||||
self,
|
|
||||||
results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
|
||||||
) -> dict[str, tuple[float, ...]]:
|
|
||||||
selected_paths = tuple(data_paths) if data_paths is not None else self.data_paths()
|
|
||||||
_validate_observed_paths(self, selected_paths)
|
|
||||||
return {data_path: results.series(data_path) for data_path in selected_paths}
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_observation_catalog(results: AmesimResults) -> TestMqlObservationCatalog:
|
|
||||||
variable_catalog = build_test_mql_variable_catalog(results)
|
|
||||||
return TestMqlObservationCatalog(
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
chambers=build_test_mql_chamber_observation_catalog(
|
|
||||||
results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
),
|
|
||||||
orifices=build_test_mql_orifice_observation_catalog(
|
|
||||||
results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
),
|
|
||||||
lines=build_test_mql_line_observation_catalog(
|
|
||||||
results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
),
|
|
||||||
mechanical=build_test_mql_mechanical_observation_catalog(
|
|
||||||
results,
|
|
||||||
variable_catalog=variable_catalog,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _chamber_data_paths(catalog: TestMqlChamberObservationCatalog) -> tuple[str, ...]:
|
|
||||||
paths = []
|
|
||||||
for binding in catalog.bindings:
|
|
||||||
paths.extend(
|
|
||||||
[
|
|
||||||
binding.pressure_path,
|
|
||||||
binding.temperature_path,
|
|
||||||
binding.gas_mass_path,
|
|
||||||
*binding.pressure_duplicate_paths,
|
|
||||||
*binding.temperature_duplicate_paths,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if binding.volume_path is not None:
|
|
||||||
paths.append(binding.volume_path)
|
|
||||||
return tuple(paths)
|
|
||||||
|
|
||||||
|
|
||||||
def _orifice_data_paths(catalog: TestMqlOrificeObservationCatalog) -> tuple[str, ...]:
|
|
||||||
paths = []
|
|
||||||
for binding in catalog.bindings:
|
|
||||||
paths.extend(
|
|
||||||
[
|
|
||||||
binding.primary_mass_flow_path,
|
|
||||||
binding.primary_enthalpy_flow_path,
|
|
||||||
binding.reversed_mass_flow_path,
|
|
||||||
binding.reversed_enthalpy_flow_path,
|
|
||||||
binding.mass_flow_parameter_path,
|
|
||||||
binding.gas_velocity_path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if binding.opening_path is not None:
|
|
||||||
paths.append(binding.opening_path)
|
|
||||||
return tuple(paths)
|
|
||||||
|
|
||||||
|
|
||||||
def _line_data_paths(catalog: TestMqlLineObservationCatalog) -> tuple[str, ...]:
|
|
||||||
paths = []
|
|
||||||
for binding in catalog.bindings:
|
|
||||||
paths.extend(binding.mass_flow_paths)
|
|
||||||
paths.extend(binding.enthalpy_flow_paths)
|
|
||||||
paths.extend(binding.pressure_paths)
|
|
||||||
paths.extend(binding.temperature_paths)
|
|
||||||
if binding.gas_mass_path is not None:
|
|
||||||
paths.append(binding.gas_mass_path)
|
|
||||||
paths.extend(
|
|
||||||
[
|
|
||||||
binding.reynolds_path,
|
|
||||||
binding.mass_flow_parameter_path,
|
|
||||||
binding.gas_velocity_path,
|
|
||||||
binding.friction_factor_path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return tuple(paths)
|
|
||||||
|
|
||||||
|
|
||||||
def _mechanical_data_paths(catalog: TestMqlMechanicalObservationCatalog) -> tuple[str, ...]:
|
|
||||||
paths = []
|
|
||||||
for binding in catalog.pistons.values():
|
|
||||||
paths.extend(
|
|
||||||
[
|
|
||||||
binding.volume_path,
|
|
||||||
binding.volume_rate_path,
|
|
||||||
binding.length_path,
|
|
||||||
binding.force_port_2_path,
|
|
||||||
binding.force_port_3_path,
|
|
||||||
binding.displacement_port_2_path,
|
|
||||||
binding.velocity_port_2_path,
|
|
||||||
binding.displacement_port_3_path,
|
|
||||||
binding.velocity_port_3_path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for binding in catalog.masses.values():
|
|
||||||
paths.extend(
|
|
||||||
[
|
|
||||||
binding.displacement_path,
|
|
||||||
binding.velocity_path,
|
|
||||||
binding.acceleration_path,
|
|
||||||
binding.displacement_duplicate_path,
|
|
||||||
binding.velocity_duplicate_path,
|
|
||||||
binding.acceleration_duplicate_path,
|
|
||||||
binding.lower_contact_force_path,
|
|
||||||
binding.upper_contact_force_path,
|
|
||||||
binding.viscous_friction_force_path,
|
|
||||||
binding.dry_friction_force_path,
|
|
||||||
binding.stick_flag_path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for binding in catalog.elastic_endstops.values():
|
|
||||||
paths.extend(
|
|
||||||
[
|
|
||||||
binding.force_path,
|
|
||||||
binding.duplicate_force_path,
|
|
||||||
binding.gap_path,
|
|
||||||
binding.stiffness_path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for binding in catalog.zero_force_sources.values():
|
|
||||||
paths.append(binding.force_path)
|
|
||||||
for binding in catalog.force_connectors.values():
|
|
||||||
paths.append(binding.force_path)
|
|
||||||
for binding in catalog.mechanical_nodes.values():
|
|
||||||
paths.extend(binding.velocity_paths_by_port.values())
|
|
||||||
paths.extend(binding.displacement_paths_by_port.values())
|
|
||||||
paths.append(binding.total_force_path)
|
|
||||||
return tuple(paths)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_observed_paths(
|
|
||||||
catalog: TestMqlObservationCatalog,
|
|
||||||
data_paths: tuple[str, ...],
|
|
||||||
) -> None:
|
|
||||||
observed_paths = set(catalog.data_paths())
|
|
||||||
missing = [data_path for data_path in data_paths if data_path not in observed_paths]
|
|
||||||
if missing:
|
|
||||||
raise KeyError(f"Data_Path values are not in the test_mql observation catalog: {missing}")
|
|
||||||
|
|
||||||
|
|
||||||
def _sorted_unique(data_paths: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
||||||
return tuple(sorted(set(data_paths)))
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_variables import (
|
|
||||||
TestMqlVariableBinding,
|
|
||||||
TestMqlVariableCatalog,
|
|
||||||
build_test_mql_variable_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.examples.test_mql.pneumatic import (
|
|
||||||
TestMqlPneumaticAssembly,
|
|
||||||
build_test_mql_pneumatic_assembly,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
G_PER_S_TO_KG_PER_S = 1.0e-3
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlOrificeObservation:
|
|
||||||
time: float
|
|
||||||
mass_flow_kg_s: float
|
|
||||||
enthalpy_flow_w: float
|
|
||||||
mass_flow_parameter: float
|
|
||||||
gas_velocity_m_s: float
|
|
||||||
opening: float
|
|
||||||
effective_area_m2: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlOrificeBinding:
|
|
||||||
alias: str
|
|
||||||
submodel: str
|
|
||||||
nominal_area_m2: float
|
|
||||||
flow_coefficient: float
|
|
||||||
primary_mass_flow_path: str
|
|
||||||
primary_enthalpy_flow_path: str
|
|
||||||
reversed_mass_flow_path: str
|
|
||||||
reversed_enthalpy_flow_path: str
|
|
||||||
mass_flow_parameter_path: str
|
|
||||||
gas_velocity_path: str
|
|
||||||
opening_path: str | None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_variable(self) -> bool:
|
|
||||||
return self.opening_path is not None
|
|
||||||
|
|
||||||
def opening_series(self, results: AmesimResults) -> tuple[float, ...]:
|
|
||||||
if self.opening_path is None:
|
|
||||||
return tuple(1.0 for _ in results.times)
|
|
||||||
return tuple(results.series(self.opening_path))
|
|
||||||
|
|
||||||
def mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
|
|
||||||
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.primary_mass_flow_path))
|
|
||||||
|
|
||||||
def reversed_mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
|
|
||||||
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.reversed_mass_flow_path))
|
|
||||||
|
|
||||||
def effective_area_series(self, results: AmesimResults) -> tuple[float, ...]:
|
|
||||||
return tuple(self.nominal_area_m2 * max(opening, 0.0) for opening in self.opening_series(results))
|
|
||||||
|
|
||||||
def observation_at(self, results: AmesimResults, index: int) -> TestMqlOrificeObservation:
|
|
||||||
opening = self.opening_series(results)[index]
|
|
||||||
return TestMqlOrificeObservation(
|
|
||||||
time=results.times[index],
|
|
||||||
mass_flow_kg_s=results.series(self.primary_mass_flow_path)[index] * G_PER_S_TO_KG_PER_S,
|
|
||||||
enthalpy_flow_w=results.series(self.primary_enthalpy_flow_path)[index],
|
|
||||||
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
|
|
||||||
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
|
|
||||||
opening=opening,
|
|
||||||
effective_area_m2=self.nominal_area_m2 * max(opening, 0.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlOrificeObservationCatalog:
|
|
||||||
bindings: tuple[TestMqlOrificeBinding, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fixed_count(self) -> int:
|
|
||||||
return sum(1 for binding in self.bindings if binding.submodel == "PNOR001")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def variable_count(self) -> int:
|
|
||||||
return sum(1 for binding in self.bindings if binding.submodel == "PNVO001")
|
|
||||||
|
|
||||||
def by_alias(self, alias: str) -> TestMqlOrificeBinding:
|
|
||||||
for binding in self.bindings:
|
|
||||||
if binding.alias == alias:
|
|
||||||
return binding
|
|
||||||
raise KeyError(alias)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_orifice_observation_catalog(
|
|
||||||
results: AmesimResults,
|
|
||||||
*,
|
|
||||||
variable_catalog: TestMqlVariableCatalog | None = None,
|
|
||||||
assembly: TestMqlPneumaticAssembly | None = None,
|
|
||||||
) -> TestMqlOrificeObservationCatalog:
|
|
||||||
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
|
|
||||||
assembly = assembly or build_test_mql_pneumatic_assembly()
|
|
||||||
bindings = []
|
|
||||||
for alias, orifice in {
|
|
||||||
**assembly.fixed_orifices,
|
|
||||||
**assembly.variable_orifices,
|
|
||||||
}.items():
|
|
||||||
owner_variables = tuple(
|
|
||||||
variable
|
|
||||||
for variable in variable_catalog.variables
|
|
||||||
if variable.owner_alias == alias
|
|
||||||
)
|
|
||||||
primary_mass_flow = _find_primary(owner_variables, signal_prefix="dm")
|
|
||||||
primary_enthalpy_flow = _find_primary(owner_variables, signal_prefix="dh")
|
|
||||||
reversed_mass_flow = _find_reversed(owner_variables, signal_prefix="dm")
|
|
||||||
reversed_enthalpy_flow = _find_reversed(owner_variables, signal_prefix="dh")
|
|
||||||
mass_flow_parameter = _find_by_signal(owner_variables, "cm")
|
|
||||||
gas_velocity = _find_by_signal(owner_variables, "gasvel")
|
|
||||||
opening = _find_optional_by_signal(owner_variables, "xv")
|
|
||||||
bindings.append(
|
|
||||||
TestMqlOrificeBinding(
|
|
||||||
alias=alias,
|
|
||||||
submodel=primary_mass_flow.submodel,
|
|
||||||
nominal_area_m2=orifice.area,
|
|
||||||
flow_coefficient=orifice.flow_coefficient,
|
|
||||||
primary_mass_flow_path=primary_mass_flow.data_path,
|
|
||||||
primary_enthalpy_flow_path=primary_enthalpy_flow.data_path,
|
|
||||||
reversed_mass_flow_path=reversed_mass_flow.data_path,
|
|
||||||
reversed_enthalpy_flow_path=reversed_enthalpy_flow.data_path,
|
|
||||||
mass_flow_parameter_path=mass_flow_parameter.data_path,
|
|
||||||
gas_velocity_path=gas_velocity.data_path,
|
|
||||||
opening_path=opening.data_path if opening is not None else None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TestMqlOrificeObservationCatalog(
|
|
||||||
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_primary(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
*,
|
|
||||||
signal_prefix: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
matches = [
|
|
||||||
variable
|
|
||||||
for variable in variables
|
|
||||||
if variable.signal_name.startswith(signal_prefix)
|
|
||||||
and "sign reversed duplicate" not in variable.label
|
|
||||||
]
|
|
||||||
return _single(matches, f"primary {signal_prefix}")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_reversed(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
*,
|
|
||||||
signal_prefix: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
matches = [
|
|
||||||
variable
|
|
||||||
for variable in variables
|
|
||||||
if variable.signal_name.startswith(signal_prefix)
|
|
||||||
and "sign reversed duplicate" in variable.label
|
|
||||||
]
|
|
||||||
return _single(matches, f"reversed {signal_prefix}")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_by_signal(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
return _single(
|
|
||||||
[variable for variable in variables if variable.signal_name == signal_name],
|
|
||||||
signal_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_optional_by_signal(
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...],
|
|
||||||
signal_name: str,
|
|
||||||
) -> TestMqlVariableBinding | None:
|
|
||||||
matches = [variable for variable in variables if variable.signal_name == signal_name]
|
|
||||||
if not matches:
|
|
||||||
return None
|
|
||||||
return _single(matches, signal_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _single(
|
|
||||||
matches: list[TestMqlVariableBinding],
|
|
||||||
description: str,
|
|
||||||
) -> TestMqlVariableBinding:
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
|
|
||||||
return matches[0]
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_observations import (
|
|
||||||
TestMqlObservationCatalog,
|
|
||||||
build_test_mql_observation_catalog,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_variables import TestMqlVariableBinding
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlOutputSignal:
|
|
||||||
data_path: str
|
|
||||||
domain: str
|
|
||||||
owner_alias: str
|
|
||||||
owner_kind: str
|
|
||||||
submodel: str
|
|
||||||
signal_name: str
|
|
||||||
units: str | None
|
|
||||||
amesim_index: int
|
|
||||||
saved: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlOutputSchema:
|
|
||||||
signals: tuple[TestMqlOutputSignal, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_count(self) -> int:
|
|
||||||
return len(self.signals)
|
|
||||||
|
|
||||||
def by_data_path(self, data_path: str) -> TestMqlOutputSignal:
|
|
||||||
for signal in self.signals:
|
|
||||||
if signal.data_path == data_path:
|
|
||||||
return signal
|
|
||||||
raise KeyError(data_path)
|
|
||||||
|
|
||||||
def data_paths(self) -> tuple[str, ...]:
|
|
||||||
return tuple(signal.data_path for signal in self.signals)
|
|
||||||
|
|
||||||
def data_paths_by_domain(self, domain: str) -> tuple[str, ...]:
|
|
||||||
return tuple(signal.data_path for signal in self.signals if signal.domain == domain)
|
|
||||||
|
|
||||||
def counts_by_domain(self) -> dict[str, int]:
|
|
||||||
return dict(Counter(signal.domain for signal in self.signals))
|
|
||||||
|
|
||||||
def counts_by_submodel(self) -> dict[str, int]:
|
|
||||||
return dict(Counter(signal.submodel for signal in self.signals))
|
|
||||||
|
|
||||||
def counts_by_owner_kind(self) -> dict[str, int]:
|
|
||||||
return dict(Counter(signal.owner_kind for signal in self.signals))
|
|
||||||
|
|
||||||
def counts_by_units(self) -> dict[str | None, int]:
|
|
||||||
return dict(Counter(signal.units for signal in self.signals))
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_output_schema(
|
|
||||||
results: AmesimResults,
|
|
||||||
*,
|
|
||||||
observation_catalog: TestMqlObservationCatalog | None = None,
|
|
||||||
) -> TestMqlOutputSchema:
|
|
||||||
observation_catalog = observation_catalog or build_test_mql_observation_catalog(results)
|
|
||||||
domain_by_data_path = _domain_by_data_path(observation_catalog)
|
|
||||||
signals = []
|
|
||||||
for data_path in sorted(domain_by_data_path):
|
|
||||||
variable = observation_catalog.variable_catalog.by_data_path(data_path)
|
|
||||||
signals.append(_signal_from_variable(variable, domain_by_data_path[data_path]))
|
|
||||||
return TestMqlOutputSchema(signals=tuple(signals))
|
|
||||||
|
|
||||||
|
|
||||||
def _domain_by_data_path(
|
|
||||||
observation_catalog: TestMqlObservationCatalog,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
domain_by_data_path = {}
|
|
||||||
for domain, data_paths in observation_catalog.data_paths_by_domain().items():
|
|
||||||
for data_path in data_paths:
|
|
||||||
if data_path in domain_by_data_path:
|
|
||||||
raise ValueError(f"Data_Path {data_path!r} is assigned to multiple domains.")
|
|
||||||
domain_by_data_path[data_path] = domain
|
|
||||||
return domain_by_data_path
|
|
||||||
|
|
||||||
|
|
||||||
def _signal_from_variable(
|
|
||||||
variable: TestMqlVariableBinding,
|
|
||||||
domain: str,
|
|
||||||
) -> TestMqlOutputSignal:
|
|
||||||
return TestMqlOutputSignal(
|
|
||||||
data_path=variable.data_path,
|
|
||||||
domain=domain,
|
|
||||||
owner_alias=variable.owner_alias,
|
|
||||||
owner_kind=variable.owner_kind,
|
|
||||||
submodel=variable.submodel,
|
|
||||||
signal_name=variable.signal_name,
|
|
||||||
units=variable.units,
|
|
||||||
amesim_index=variable.index,
|
|
||||||
saved=variable.saved,
|
|
||||||
)
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import isfinite
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults
|
|
||||||
from app.simulation.reporting.test_mql_comparison import (
|
|
||||||
TestMqlComparisonResult,
|
|
||||||
compare_test_mql_series,
|
|
||||||
)
|
|
||||||
from app.simulation.reporting.test_mql_output_schema import TestMqlOutputSchema
|
|
||||||
|
|
||||||
|
|
||||||
class TestMqlOutputValidationError(ValueError):
|
|
||||||
"""Raised when a Python test_mql output does not satisfy the AMESim output contract."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlValidatedOutput:
|
|
||||||
times: tuple[float, ...]
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...]]
|
|
||||||
data_paths: tuple[str, ...]
|
|
||||||
|
|
||||||
def series(self, data_path: str) -> tuple[float, ...]:
|
|
||||||
if data_path not in self.series_by_data_path:
|
|
||||||
raise KeyError(data_path)
|
|
||||||
return self.series_by_data_path[data_path]
|
|
||||||
|
|
||||||
|
|
||||||
def validate_test_mql_output(
|
|
||||||
*,
|
|
||||||
times: tuple[float, ...] | list[float],
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
|
||||||
schema: TestMqlOutputSchema,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
|
||||||
allow_extra_paths: bool = False,
|
|
||||||
require_all_schema_paths: bool = False,
|
|
||||||
) -> TestMqlValidatedOutput:
|
|
||||||
validated_times = _validate_time_axis(times)
|
|
||||||
selected_paths = _select_paths(
|
|
||||||
series_by_data_path=series_by_data_path,
|
|
||||||
schema=schema,
|
|
||||||
data_paths=data_paths,
|
|
||||||
allow_extra_paths=allow_extra_paths,
|
|
||||||
require_all_schema_paths=require_all_schema_paths,
|
|
||||||
)
|
|
||||||
validated_series = {
|
|
||||||
data_path: _validate_series(
|
|
||||||
data_path=data_path,
|
|
||||||
values=series_by_data_path[data_path],
|
|
||||||
expected_count=len(validated_times),
|
|
||||||
)
|
|
||||||
for data_path in selected_paths
|
|
||||||
}
|
|
||||||
return TestMqlValidatedOutput(
|
|
||||||
times=validated_times,
|
|
||||||
series_by_data_path=validated_series,
|
|
||||||
data_paths=selected_paths,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compare_validated_test_mql_output(
|
|
||||||
*,
|
|
||||||
times: tuple[float, ...] | list[float],
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
|
||||||
schema: TestMqlOutputSchema,
|
|
||||||
amesim_results: AmesimResults,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
|
||||||
allow_extra_paths: bool = False,
|
|
||||||
require_all_schema_paths: bool = False,
|
|
||||||
relative_floor: float = 1.0e-12,
|
|
||||||
) -> TestMqlComparisonResult:
|
|
||||||
validated = validate_test_mql_output(
|
|
||||||
times=times,
|
|
||||||
series_by_data_path=series_by_data_path,
|
|
||||||
schema=schema,
|
|
||||||
data_paths=data_paths,
|
|
||||||
allow_extra_paths=allow_extra_paths,
|
|
||||||
require_all_schema_paths=require_all_schema_paths,
|
|
||||||
)
|
|
||||||
return compare_test_mql_series(
|
|
||||||
python_times=validated.times,
|
|
||||||
python_series_by_data_path=validated.series_by_data_path,
|
|
||||||
amesim_results=amesim_results,
|
|
||||||
data_paths=validated.data_paths,
|
|
||||||
relative_floor=relative_floor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_time_axis(times: tuple[float, ...] | list[float]) -> tuple[float, ...]:
|
|
||||||
if not times:
|
|
||||||
raise TestMqlOutputValidationError("Python time axis is empty.")
|
|
||||||
validated = tuple(_finite_float("time", value) for value in times)
|
|
||||||
previous = validated[0]
|
|
||||||
for value in validated[1:]:
|
|
||||||
if value < previous:
|
|
||||||
raise TestMqlOutputValidationError("Python time axis must be monotonically increasing.")
|
|
||||||
previous = value
|
|
||||||
return validated
|
|
||||||
|
|
||||||
|
|
||||||
def _select_paths(
|
|
||||||
*,
|
|
||||||
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
|
|
||||||
schema: TestMqlOutputSchema,
|
|
||||||
data_paths: tuple[str, ...] | list[str] | None,
|
|
||||||
allow_extra_paths: bool,
|
|
||||||
require_all_schema_paths: bool,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
schema_paths = set(schema.data_paths())
|
|
||||||
provided_paths = set(series_by_data_path)
|
|
||||||
if not allow_extra_paths:
|
|
||||||
extra_paths = sorted(provided_paths - schema_paths)
|
|
||||||
if extra_paths:
|
|
||||||
raise TestMqlOutputValidationError(
|
|
||||||
f"Python output contains Data_Path values outside test_mql schema: {extra_paths}"
|
|
||||||
)
|
|
||||||
if require_all_schema_paths:
|
|
||||||
missing_schema_paths = sorted(schema_paths - provided_paths)
|
|
||||||
if missing_schema_paths:
|
|
||||||
raise TestMqlOutputValidationError(
|
|
||||||
f"Python output is missing required test_mql schema Data_Path values: {missing_schema_paths}"
|
|
||||||
)
|
|
||||||
selected_paths = tuple(data_paths) if data_paths is not None else tuple(sorted(provided_paths & schema_paths))
|
|
||||||
if not selected_paths:
|
|
||||||
raise TestMqlOutputValidationError("no test_mql schema Data_Path values are available.")
|
|
||||||
unknown_selected = [data_path for data_path in selected_paths if data_path not in schema_paths]
|
|
||||||
if unknown_selected:
|
|
||||||
raise TestMqlOutputValidationError(
|
|
||||||
f"Requested Data_Path values are outside test_mql schema: {unknown_selected}"
|
|
||||||
)
|
|
||||||
missing_selected = [data_path for data_path in selected_paths if data_path not in series_by_data_path]
|
|
||||||
if missing_selected:
|
|
||||||
raise TestMqlOutputValidationError(
|
|
||||||
f"Python output is missing selected Data_Path values: {missing_selected}"
|
|
||||||
)
|
|
||||||
return selected_paths
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_series(
|
|
||||||
*,
|
|
||||||
data_path: str,
|
|
||||||
values: tuple[float, ...] | list[float],
|
|
||||||
expected_count: int,
|
|
||||||
) -> tuple[float, ...]:
|
|
||||||
if len(values) != expected_count:
|
|
||||||
raise TestMqlOutputValidationError(
|
|
||||||
f"Python series length mismatch for {data_path!r}: "
|
|
||||||
f"{len(values)} values for {expected_count} time samples."
|
|
||||||
)
|
|
||||||
return tuple(_finite_float(data_path, value) for value in values)
|
|
||||||
|
|
||||||
|
|
||||||
def _finite_float(label: str, value: float) -> float:
|
|
||||||
try:
|
|
||||||
numeric_value = float(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise TestMqlOutputValidationError(f"{label!r} contains a non-numeric value: {value!r}") from exc
|
|
||||||
if not isfinite(numeric_value):
|
|
||||||
raise TestMqlOutputValidationError(f"{label!r} contains a non-finite value: {value!r}")
|
|
||||||
return numeric_value
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.simulation.reporting.amesim_results import AmesimResults, AmesimVariable
|
|
||||||
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, CONNECTION_SPECS
|
|
||||||
|
|
||||||
|
|
||||||
_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlVariableBinding:
|
|
||||||
index: int
|
|
||||||
data_path: str
|
|
||||||
signal_name: str
|
|
||||||
owner_alias: str
|
|
||||||
owner_kind: str
|
|
||||||
submodel: str
|
|
||||||
label: str
|
|
||||||
units: str | None
|
|
||||||
saved: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestMqlVariableCatalog:
|
|
||||||
variables: tuple[TestMqlVariableBinding, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_path_count(self) -> int:
|
|
||||||
return len(self.variables)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def saved_data_path_count(self) -> int:
|
|
||||||
return sum(1 for variable in self.variables if variable.saved)
|
|
||||||
|
|
||||||
def by_data_path(self, data_path: str) -> TestMqlVariableBinding:
|
|
||||||
for variable in self.variables:
|
|
||||||
if variable.data_path == data_path:
|
|
||||||
return variable
|
|
||||||
raise KeyError(data_path)
|
|
||||||
|
|
||||||
def counts_by_submodel(self) -> dict[str, int]:
|
|
||||||
return dict(Counter(variable.submodel for variable in self.variables))
|
|
||||||
|
|
||||||
def counts_by_owner_kind(self) -> dict[str, int]:
|
|
||||||
return dict(Counter(variable.owner_kind for variable in self.variables))
|
|
||||||
|
|
||||||
def data_paths_for_owner(self, owner_alias: str) -> tuple[str, ...]:
|
|
||||||
return tuple(
|
|
||||||
variable.data_path
|
|
||||||
for variable in self.variables
|
|
||||||
if variable.owner_alias == owner_alias
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_test_mql_variable_catalog(amesim_results: AmesimResults) -> TestMqlVariableCatalog:
|
|
||||||
owner_map = _build_owner_map()
|
|
||||||
saved_indices = set(amesim_results.saved_variable_indices)
|
|
||||||
bindings = []
|
|
||||||
for variable in amesim_results.variables:
|
|
||||||
if variable.data_path is None:
|
|
||||||
continue
|
|
||||||
signal_name, owner_alias = split_data_path(variable.data_path)
|
|
||||||
owner_kind, submodel = owner_map[owner_alias]
|
|
||||||
bindings.append(
|
|
||||||
TestMqlVariableBinding(
|
|
||||||
index=variable.index,
|
|
||||||
data_path=variable.data_path,
|
|
||||||
signal_name=signal_name,
|
|
||||||
owner_alias=owner_alias,
|
|
||||||
owner_kind=owner_kind,
|
|
||||||
submodel=submodel,
|
|
||||||
label=variable.label,
|
|
||||||
units=_extract_units(variable),
|
|
||||||
saved=variable.index in saved_indices,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TestMqlVariableCatalog(variables=tuple(bindings))
|
|
||||||
|
|
||||||
|
|
||||||
def split_data_path(data_path: str) -> tuple[str, str]:
|
|
||||||
if "@" not in data_path:
|
|
||||||
raise ValueError(f"AMESim Data_Path does not contain an owner alias: {data_path!r}")
|
|
||||||
signal_name, owner_alias = data_path.rsplit("@", 1)
|
|
||||||
if not signal_name or not owner_alias:
|
|
||||||
raise ValueError(f"Invalid AMESim Data_Path: {data_path!r}")
|
|
||||||
return signal_name, owner_alias
|
|
||||||
|
|
||||||
|
|
||||||
def _build_owner_map() -> dict[str, tuple[str, str]]:
|
|
||||||
owner_map = {
|
|
||||||
str(spec["alias"]): ("component", str(spec["submodel"]))
|
|
||||||
for spec in COMPONENT_SPECS
|
|
||||||
}
|
|
||||||
owner_map.update(
|
|
||||||
{
|
|
||||||
str(spec["alias"]): ("connection", str(spec["submodel"]))
|
|
||||||
for spec in CONNECTION_SPECS
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return owner_map
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_units(variable: AmesimVariable) -> str | None:
|
|
||||||
match = _UNIT_RE.search(variable.label)
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
return match.group(1)
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from bisect import bisect_left
|
|
||||||
import csv
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
PRIMARY_KEYS = (
|
|
||||||
"mytank.p",
|
|
||||||
"mytank.T",
|
|
||||||
"mycylinder.p",
|
|
||||||
"mycylinder.T",
|
|
||||||
)
|
|
||||||
|
|
||||||
MODELICA_COMPARISON_COLUMNS = {
|
|
||||||
"mytank.p": "mytank.p",
|
|
||||||
"mytank.T": "mytank.T",
|
|
||||||
"mycylinder.p": "mycylinder.p",
|
|
||||||
"mycylinder.T": "mycylinder.T",
|
|
||||||
"branch.upper_branch.p": "mypipe.p",
|
|
||||||
"branch.upper_branch.in": "myorifice.port_a.m_flow",
|
|
||||||
"branch.upper_branch.out": "mytee1.port_out2.m_flow",
|
|
||||||
"branch.lower_branch.p": "mypipe1.p",
|
|
||||||
"branch.lower_branch.in": "myorifice1.port_a.m_flow",
|
|
||||||
"branch.lower_branch.out": "mytee1.port_out1.m_flow",
|
|
||||||
}
|
|
||||||
|
|
||||||
COMPARISON_KEYS = tuple(MODELICA_COMPARISON_COLUMNS.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def _branch_series_values(
|
|
||||||
series: dict[str, list[float]],
|
|
||||||
branch_name: str,
|
|
||||||
legacy_key: str,
|
|
||||||
) -> list[float]:
|
|
||||||
generic_key = f"branch.{branch_name}.{legacy_key.split('.')[-1]}"
|
|
||||||
if generic_key in series:
|
|
||||||
return series[generic_key]
|
|
||||||
return series[legacy_key]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestModelArtifacts:
|
|
||||||
primary_csv_path: Path
|
|
||||||
temperature_csv_path: Path
|
|
||||||
temperature_svg_path: Path
|
|
||||||
run_report_path: Path
|
|
||||||
comparison_csv_path: Path | None = None
|
|
||||||
comparison_summary_path: Path | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def format_testmodel_run_report(
|
|
||||||
*,
|
|
||||||
network_summary: str,
|
|
||||||
initialization: Any,
|
|
||||||
raw_initial_state: tuple[float, ...],
|
|
||||||
consistent_initial_state: tuple[float, ...],
|
|
||||||
solution: Any,
|
|
||||||
series: dict[str, list[float]],
|
|
||||||
solve_diagnostics: Any,
|
|
||||||
artifacts: TestModelArtifacts,
|
|
||||||
comparison_summary: dict[str, tuple[float, float]] | None,
|
|
||||||
) -> str:
|
|
||||||
lines = [
|
|
||||||
network_summary,
|
|
||||||
"",
|
|
||||||
f"Initialization converged: {initialization.converged}",
|
|
||||||
f"Initialization iterations: {initialization.iterations}",
|
|
||||||
f"Initialization max state delta: {initialization.max_state_delta:.6e}",
|
|
||||||
f"Initialization max flow delta: {initialization.max_flow_delta:.6e}",
|
|
||||||
f"Initialization max enthalpy delta: {initialization.max_enthalpy_delta:.6e}",
|
|
||||||
(
|
|
||||||
"Initialization downstream pressure spread: "
|
|
||||||
f"{initialization.downstream_pressure_spread:.6e}"
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
"Raw initial state vector:",
|
|
||||||
str(list(raw_initial_state)),
|
|
||||||
"",
|
|
||||||
"Constraint-consistent initial state vector:",
|
|
||||||
str(list(consistent_initial_state)),
|
|
||||||
"",
|
|
||||||
f"Solver success: {solution.success}",
|
|
||||||
f"Solver message: {solution.message}",
|
|
||||||
f"Final time: {solution.t[-1]:.2f} s",
|
|
||||||
f"Final tank pressure: {series['mytank.p'][-1]:.3f} Pa",
|
|
||||||
f"Final tank temperature: {series['mytank.T'][-1]:.3f} K",
|
|
||||||
f"Final cylinder pressure: {series['mycylinder.p'][-1]:.3f} Pa",
|
|
||||||
(
|
|
||||||
"Final branch inflow: "
|
|
||||||
f"{_branch_series_values(series, 'upper_branch', 'branch_upper.in')[-1] + _branch_series_values(series, 'lower_branch', 'branch_lower.in')[-1]:.6f} kg/s"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
if solve_diagnostics is not None:
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
"Final closure solve diagnostics:",
|
|
||||||
(
|
|
||||||
"Upper branch inlet solve: "
|
|
||||||
f"converged={solve_diagnostics.upper_branch_inlet.converged}, "
|
|
||||||
f"iterations={solve_diagnostics.upper_branch_inlet.iterations}, "
|
|
||||||
f"residual={solve_diagnostics.upper_branch_inlet.residual:.6e}"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Lower branch inlet solve: "
|
|
||||||
f"converged={solve_diagnostics.lower_branch_inlet.converged}, "
|
|
||||||
f"iterations={solve_diagnostics.lower_branch_inlet.iterations}, "
|
|
||||||
f"residual={solve_diagnostics.lower_branch_inlet.residual:.6e}"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if solve_diagnostics.downstream_pressure_projection is not None:
|
|
||||||
lines.append(
|
|
||||||
"Downstream pressure projection: "
|
|
||||||
f"converged={solve_diagnostics.downstream_pressure_projection.converged}, "
|
|
||||||
f"iterations={solve_diagnostics.downstream_pressure_projection.iterations}, "
|
|
||||||
f"residual={solve_diagnostics.downstream_pressure_projection.residual:.6e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f"Primary series CSV: {artifacts.primary_csv_path}",
|
|
||||||
f"Temperature CSV: {artifacts.temperature_csv_path}",
|
|
||||||
f"Temperature plot: {artifacts.temperature_svg_path}",
|
|
||||||
f"Run report TXT: {artifacts.run_report_path}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
artifacts.comparison_csv_path is not None
|
|
||||||
and artifacts.comparison_summary_path is not None
|
|
||||||
):
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f"Modelica comparison CSV: {artifacts.comparison_csv_path}",
|
|
||||||
f"Modelica comparison summary: {artifacts.comparison_summary_path}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if comparison_summary is not None:
|
|
||||||
for key, (max_abs_error, max_rel_error) in comparison_summary.items():
|
|
||||||
lines.append(
|
|
||||||
f"{key} max abs error: {max_abs_error:.6f}, "
|
|
||||||
f"max rel error: {max_rel_error:.6%}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def write_testmodel_run_report(output_dir: Path, report_text: str) -> Path:
|
|
||||||
report_path = output_dir / "testmodel_run_report.txt"
|
|
||||||
report_path.write_text(report_text, encoding="utf-8")
|
|
||||||
return report_path
|
|
||||||
|
|
||||||
|
|
||||||
def _write_primary_series_csv(output_dir: Path, series: dict[str, list[float]]) -> Path:
|
|
||||||
csv_path = output_dir / "testmodel_primary_series.csv"
|
|
||||||
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
||||||
writer = csv.writer(handle)
|
|
||||||
writer.writerow(["time_s", *PRIMARY_KEYS])
|
|
||||||
for index, time_value in enumerate(series["time"]):
|
|
||||||
writer.writerow([time_value, *(series[key][index] for key in PRIMARY_KEYS)])
|
|
||||||
return csv_path
|
|
||||||
|
|
||||||
|
|
||||||
def _write_temperature_csv(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path:
|
|
||||||
csv_path = output_dir / "testmodel_tank_temperature.csv"
|
|
||||||
with csv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
||||||
writer = csv.writer(handle)
|
|
||||||
writer.writerow(["time_s", "mytank_T_K"])
|
|
||||||
writer.writerows(zip(time_values, temperatures))
|
|
||||||
return csv_path
|
|
||||||
|
|
||||||
|
|
||||||
def _write_temperature_svg(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path:
|
|
||||||
svg_path = output_dir / "testmodel_tank_temperature.svg"
|
|
||||||
|
|
||||||
width = 900
|
|
||||||
height = 520
|
|
||||||
left = 90
|
|
||||||
right = 40
|
|
||||||
top = 60
|
|
||||||
bottom = 70
|
|
||||||
plot_width = width - left - right
|
|
||||||
plot_height = height - top - bottom
|
|
||||||
|
|
||||||
min_time = min(time_values)
|
|
||||||
max_time = max(time_values)
|
|
||||||
min_temp = min(temperatures)
|
|
||||||
max_temp = max(temperatures)
|
|
||||||
temp_padding = max(1.0, (max_temp - min_temp) * 0.08)
|
|
||||||
min_temp -= temp_padding
|
|
||||||
max_temp += temp_padding
|
|
||||||
|
|
||||||
def scale_x(value: float) -> float:
|
|
||||||
return left + (value - min_time) / max(max_time - min_time, 1e-12) * plot_width
|
|
||||||
|
|
||||||
def scale_y(value: float) -> float:
|
|
||||||
return top + (max_temp - value) / max(max_temp - min_temp, 1e-12) * plot_height
|
|
||||||
|
|
||||||
points = " ".join(
|
|
||||||
f"{scale_x(time_value):.2f},{scale_y(temperature):.2f}"
|
|
||||||
for time_value, temperature in zip(time_values, temperatures)
|
|
||||||
)
|
|
||||||
|
|
||||||
x_ticks = 5
|
|
||||||
y_ticks = 5
|
|
||||||
x_tick_markup = []
|
|
||||||
y_tick_markup = []
|
|
||||||
|
|
||||||
for index in range(x_ticks + 1):
|
|
||||||
fraction = index / x_ticks
|
|
||||||
time_value = min_time + fraction * (max_time - min_time)
|
|
||||||
x = left + fraction * plot_width
|
|
||||||
x_tick_markup.append(
|
|
||||||
f'<line x1="{x:.2f}" y1="{top}" x2="{x:.2f}" y2="{top + plot_height}" '
|
|
||||||
'stroke="#d9e2ec" stroke-width="1" />'
|
|
||||||
)
|
|
||||||
x_tick_markup.append(
|
|
||||||
f'<text x="{x:.2f}" y="{height - 30}" text-anchor="middle" '
|
|
||||||
'font-size="14" fill="#102a43">'
|
|
||||||
f"{time_value:.1f}</text>"
|
|
||||||
)
|
|
||||||
|
|
||||||
for index in range(y_ticks + 1):
|
|
||||||
fraction = index / y_ticks
|
|
||||||
temp_value = min_temp + fraction * (max_temp - min_temp)
|
|
||||||
y = top + plot_height - fraction * plot_height
|
|
||||||
y_tick_markup.append(
|
|
||||||
f'<line x1="{left}" y1="{y:.2f}" x2="{left + plot_width}" y2="{y:.2f}" '
|
|
||||||
'stroke="#d9e2ec" stroke-width="1" />'
|
|
||||||
)
|
|
||||||
y_tick_markup.append(
|
|
||||||
f'<text x="{left - 12}" y="{y + 5:.2f}" text-anchor="end" '
|
|
||||||
'font-size="14" fill="#102a43">'
|
|
||||||
f"{temp_value:.1f}</text>"
|
|
||||||
)
|
|
||||||
|
|
||||||
svg_content = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
|
||||||
<rect width="{width}" height="{height}" fill="#f7fafc" rx="18" ry="18" />
|
|
||||||
<text x="{width / 2:.0f}" y="32" text-anchor="middle" font-size="24" fill="#102a43">Python Testmodel Tank Temperature</text>
|
|
||||||
<text x="{width / 2:.0f}" y="{height - 8}" text-anchor="middle" font-size="16" fill="#486581">Time (s)</text>
|
|
||||||
<text x="26" y="{height / 2:.0f}" text-anchor="middle" font-size="16" fill="#486581" transform="rotate(-90 26 {height / 2:.0f})">Temperature (K)</text>
|
|
||||||
<rect x="{left}" y="{top}" width="{plot_width}" height="{plot_height}" fill="#ffffff" stroke="#bcccdc" stroke-width="1.5" />
|
|
||||||
{''.join(x_tick_markup)}
|
|
||||||
{''.join(y_tick_markup)}
|
|
||||||
<polyline fill="none" stroke="#d64545" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" points="{points}" />
|
|
||||||
</svg>
|
|
||||||
"""
|
|
||||||
svg_path.write_text(svg_content, encoding="utf-8")
|
|
||||||
return svg_path
|
|
||||||
|
|
||||||
|
|
||||||
def load_modelica_series(csv_path: Path, variable_names: tuple[str, ...]) -> dict[str, list[float]]:
|
|
||||||
series = {"time": []}
|
|
||||||
for variable_name in variable_names:
|
|
||||||
series[variable_name] = []
|
|
||||||
|
|
||||||
with csv_path.open("r", newline="", encoding="utf-8") as handle:
|
|
||||||
reader = csv.DictReader(handle)
|
|
||||||
available_variable_names = tuple(
|
|
||||||
variable_name
|
|
||||||
for variable_name in variable_names
|
|
||||||
if MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) in (reader.fieldnames or ())
|
|
||||||
)
|
|
||||||
for row in reader:
|
|
||||||
series["time"].append(float(row["time"]))
|
|
||||||
for variable_name in available_variable_names:
|
|
||||||
modelica_column = MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name)
|
|
||||||
series[variable_name].append(float(row[modelica_column]))
|
|
||||||
|
|
||||||
return series
|
|
||||||
|
|
||||||
|
|
||||||
def _interpolate_series_value(time_values: list[float], values: list[float], target_time: float) -> float:
|
|
||||||
if target_time <= time_values[0]:
|
|
||||||
return values[0]
|
|
||||||
if target_time >= time_values[-1]:
|
|
||||||
return values[-1]
|
|
||||||
|
|
||||||
right_index = bisect_left(time_values, target_time)
|
|
||||||
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1e-12:
|
|
||||||
return values[right_index]
|
|
||||||
|
|
||||||
left_index = right_index - 1
|
|
||||||
left_time = time_values[left_index]
|
|
||||||
right_time = time_values[right_index]
|
|
||||||
fraction = (target_time - left_time) / (right_time - left_time)
|
|
||||||
return values[left_index] + fraction * (values[right_index] - values[left_index])
|
|
||||||
|
|
||||||
|
|
||||||
def write_modelica_comparison(
|
|
||||||
output_dir: Path,
|
|
||||||
python_series: dict[str, list[float]],
|
|
||||||
modelica_series: dict[str, list[float]],
|
|
||||||
) -> tuple[Path, Path, dict[str, tuple[float, float]]]:
|
|
||||||
comparison_csv_path = output_dir / "testmodel_modelica_comparison.csv"
|
|
||||||
summary_path = output_dir / "testmodel_modelica_comparison_summary.txt"
|
|
||||||
summary: dict[str, tuple[float, float]] = {}
|
|
||||||
|
|
||||||
with comparison_csv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
||||||
writer = csv.writer(handle)
|
|
||||||
header = ["time_s"]
|
|
||||||
comparison_keys = tuple(
|
|
||||||
key
|
|
||||||
for key in COMPARISON_KEYS
|
|
||||||
if key in python_series and key in modelica_series and modelica_series[key]
|
|
||||||
)
|
|
||||||
for key in comparison_keys:
|
|
||||||
header.extend(
|
|
||||||
[
|
|
||||||
f"python.{key}",
|
|
||||||
f"modelica.{key}",
|
|
||||||
f"abs_error.{key}",
|
|
||||||
f"rel_error.{key}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
writer.writerow(header)
|
|
||||||
|
|
||||||
max_abs_errors = {key: 0.0 for key in comparison_keys}
|
|
||||||
max_rel_errors = {key: 0.0 for key in comparison_keys}
|
|
||||||
|
|
||||||
for index, time_value in enumerate(python_series["time"]):
|
|
||||||
row = [time_value]
|
|
||||||
for key in comparison_keys:
|
|
||||||
python_value = python_series[key][index]
|
|
||||||
modelica_value = _interpolate_series_value(
|
|
||||||
modelica_series["time"],
|
|
||||||
modelica_series[key],
|
|
||||||
time_value,
|
|
||||||
)
|
|
||||||
abs_error = abs(python_value - modelica_value)
|
|
||||||
rel_error = abs_error / max(abs(modelica_value), 1e-9)
|
|
||||||
max_abs_errors[key] = max(max_abs_errors[key], abs_error)
|
|
||||||
max_rel_errors[key] = max(max_rel_errors[key], rel_error)
|
|
||||||
row.extend([python_value, modelica_value, abs_error, rel_error])
|
|
||||||
writer.writerow(row)
|
|
||||||
|
|
||||||
summary_lines = []
|
|
||||||
for key in comparison_keys:
|
|
||||||
summary[key] = (max_abs_errors[key], max_rel_errors[key])
|
|
||||||
summary_lines.append(
|
|
||||||
f"{key}: max_abs_error={max_abs_errors[key]:.6f}, "
|
|
||||||
f"max_rel_error={max_rel_errors[key]:.6%}"
|
|
||||||
)
|
|
||||||
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
|
|
||||||
return comparison_csv_path, summary_path, summary
|
|
||||||
|
|
||||||
|
|
||||||
def export_testmodel_artifacts(
|
|
||||||
*,
|
|
||||||
output_dir: Path,
|
|
||||||
series: dict[str, list[float]],
|
|
||||||
modelica_series: dict[str, list[float]] | None = None,
|
|
||||||
) -> tuple[TestModelArtifacts, dict[str, tuple[float, float]] | None]:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
primary_csv_path = _write_primary_series_csv(output_dir, series)
|
|
||||||
temperature_csv_path = _write_temperature_csv(
|
|
||||||
output_dir,
|
|
||||||
series["time"],
|
|
||||||
series["mytank.T"],
|
|
||||||
)
|
|
||||||
temperature_svg_path = _write_temperature_svg(
|
|
||||||
output_dir,
|
|
||||||
series["time"],
|
|
||||||
series["mytank.T"],
|
|
||||||
)
|
|
||||||
|
|
||||||
comparison_csv_path = None
|
|
||||||
comparison_summary_path = None
|
|
||||||
comparison_summary = None
|
|
||||||
if modelica_series is not None:
|
|
||||||
(
|
|
||||||
comparison_csv_path,
|
|
||||||
comparison_summary_path,
|
|
||||||
comparison_summary,
|
|
||||||
) = write_modelica_comparison(output_dir, series, modelica_series)
|
|
||||||
|
|
||||||
return (
|
|
||||||
TestModelArtifacts(
|
|
||||||
primary_csv_path=primary_csv_path,
|
|
||||||
temperature_csv_path=temperature_csv_path,
|
|
||||||
temperature_svg_path=temperature_svg_path,
|
|
||||||
run_report_path=output_dir / "testmodel_run_report.txt",
|
|
||||||
comparison_csv_path=comparison_csv_path,
|
|
||||||
comparison_summary_path=comparison_summary_path,
|
|
||||||
),
|
|
||||||
comparison_summary,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Backend-neutral simulation result and preparation error contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
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]]
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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."""
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,794 +0,0 @@
|
|||||||
"""Executable reference IR for compile-proven causal algebraic programs.
|
|
||||||
|
|
||||||
The IR eliminates duplicate *logical* effort coordinates, but intentionally
|
|
||||||
keeps a compatibility scatter map to existing ``PortState`` objects. Stream
|
|
||||||
propagation, derivatives, and result collection still consume those objects;
|
|
||||||
this is a reference for a future flat backend, not physical slot deletion.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable, Iterable
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from enum import StrEnum
|
|
||||||
from hashlib import sha256
|
|
||||||
import json
|
|
||||||
from math import isfinite
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
CAUSAL_NUMERIC_IR_SCHEMA_VERSION = 1
|
|
||||||
PRESSURE_LOWER_BOUND_PA = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class CausalIROpcode(StrEnum):
|
|
||||||
EFFORT_BROADCAST = "effort_broadcast"
|
|
||||||
EFFORT_DIRECT_RESIDUAL = "effort_direct_residual"
|
|
||||||
EFFORT_COMPONENT_RESIDUAL = "effort_component_residual"
|
|
||||||
FLOW_DIRECT = "flow_direct"
|
|
||||||
FLOW_COMPONENT_RESIDUAL = "flow_component_residual"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRCompatibilitySlot:
|
|
||||||
slot: int
|
|
||||||
id: str
|
|
||||||
variable: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRCanonicalSlot:
|
|
||||||
slot: int
|
|
||||||
id: str
|
|
||||||
variable: str
|
|
||||||
kind: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIREffortOperation:
|
|
||||||
opcode: CausalIROpcode
|
|
||||||
variable: str
|
|
||||||
result_slot: int
|
|
||||||
anchor_compatibility_slot: int
|
|
||||||
scatter_compatibility_slots: tuple[int, ...]
|
|
||||||
equation_id: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIREffortEvaluation:
|
|
||||||
opcode: CausalIROpcode
|
|
||||||
output_indices: tuple[int, ...]
|
|
||||||
equation_indices: tuple[int, ...]
|
|
||||||
equation_ids: tuple[str, ...]
|
|
||||||
evaluator_slot: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIREffortStage:
|
|
||||||
variable: str
|
|
||||||
operations: tuple[CausalIREffortOperation, ...]
|
|
||||||
evaluations: tuple[CausalIREffortEvaluation, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRFlowOperation:
|
|
||||||
opcode: CausalIROpcode
|
|
||||||
output_indices: tuple[int, ...]
|
|
||||||
equation_indices: tuple[int, ...]
|
|
||||||
equation_ids: tuple[str, ...]
|
|
||||||
evaluator_slot: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRFlowStage:
|
|
||||||
target_slots: tuple[int, ...]
|
|
||||||
scatter_compatibility_slots: tuple[int, ...]
|
|
||||||
equation_ids: tuple[str, ...]
|
|
||||||
operations: tuple[CausalIRFlowOperation, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRProgram:
|
|
||||||
"""Immutable callback-free structure used as the backend cache key."""
|
|
||||||
|
|
||||||
schema_version: int
|
|
||||||
canonical_slots: tuple[CausalIRCanonicalSlot, ...]
|
|
||||||
compatibility_slots: tuple[CausalIRCompatibilitySlot, ...]
|
|
||||||
reset_compatibility_slots: tuple[int, ...]
|
|
||||||
external_effort_compatibility_slots: tuple[int, ...]
|
|
||||||
effort_stages: tuple[CausalIREffortStage, ...]
|
|
||||||
flow_stages: tuple[CausalIRFlowStage, ...]
|
|
||||||
structural_signature: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def assignment_count(self) -> int:
|
|
||||||
return len(self.canonical_slots)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def effort_group_count(self) -> int:
|
|
||||||
return sum(len(stage.operations) for stage in self.effort_stages)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def flow_assignment_count(self) -> int:
|
|
||||||
return sum(len(stage.target_slots) for stage in self.flow_stages)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def effort_scatter_count(self) -> int:
|
|
||||||
return sum(
|
|
||||||
len(operation.scatter_compatibility_slots)
|
|
||||||
for stage in self.effort_stages
|
|
||||||
for operation in stage.operations
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def eliminated_effort_replica_count(self) -> int:
|
|
||||||
return self.effort_scatter_count - self.effort_group_count
|
|
||||||
|
|
||||||
@property
|
|
||||||
def maximum_effort_stage_width(self) -> int:
|
|
||||||
return max((len(stage.operations) for stage in self.effort_stages), default=0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def maximum_flow_stage_width(self) -> int:
|
|
||||||
return max((len(stage.target_slots) for stage in self.flow_stages), default=0)
|
|
||||||
|
|
||||||
def structural_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schemaVersion": self.schema_version,
|
|
||||||
"canonicalSlots": [
|
|
||||||
{
|
|
||||||
"slot": item.slot,
|
|
||||||
"id": item.id,
|
|
||||||
"variable": item.variable,
|
|
||||||
"kind": item.kind,
|
|
||||||
}
|
|
||||||
for item in self.canonical_slots
|
|
||||||
],
|
|
||||||
"compatibilitySlots": [
|
|
||||||
{"slot": item.slot, "id": item.id, "variable": item.variable}
|
|
||||||
for item in self.compatibility_slots
|
|
||||||
],
|
|
||||||
"resetCompatibilitySlots": list(self.reset_compatibility_slots),
|
|
||||||
"externalEffortCompatibilitySlots": list(
|
|
||||||
self.external_effort_compatibility_slots
|
|
||||||
),
|
|
||||||
"effortStages": [
|
|
||||||
{
|
|
||||||
"variable": stage.variable,
|
|
||||||
"operations": [
|
|
||||||
{
|
|
||||||
"opcode": operation.opcode.value,
|
|
||||||
"resultSlot": operation.result_slot,
|
|
||||||
"anchorCompatibilitySlot": (
|
|
||||||
operation.anchor_compatibility_slot
|
|
||||||
),
|
|
||||||
"scatterCompatibilitySlots": list(
|
|
||||||
operation.scatter_compatibility_slots
|
|
||||||
),
|
|
||||||
"equationId": operation.equation_id,
|
|
||||||
}
|
|
||||||
for operation in stage.operations
|
|
||||||
],
|
|
||||||
"evaluations": [
|
|
||||||
{
|
|
||||||
"opcode": evaluation.opcode.value,
|
|
||||||
"outputIndices": list(evaluation.output_indices),
|
|
||||||
"equationIndices": list(evaluation.equation_indices),
|
|
||||||
"equationIds": list(evaluation.equation_ids),
|
|
||||||
"evaluatorSlot": evaluation.evaluator_slot,
|
|
||||||
}
|
|
||||||
for evaluation in stage.evaluations
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for stage in self.effort_stages
|
|
||||||
],
|
|
||||||
"flowStages": [
|
|
||||||
{
|
|
||||||
"targetSlots": list(stage.target_slots),
|
|
||||||
"scatterCompatibilitySlots": list(
|
|
||||||
stage.scatter_compatibility_slots
|
|
||||||
),
|
|
||||||
"equationIds": list(stage.equation_ids),
|
|
||||||
"operations": [
|
|
||||||
{
|
|
||||||
"opcode": operation.opcode.value,
|
|
||||||
"outputIndices": list(operation.output_indices),
|
|
||||||
"equationIndices": list(operation.equation_indices),
|
|
||||||
"equationIds": list(operation.equation_ids),
|
|
||||||
"evaluatorSlot": operation.evaluator_slot,
|
|
||||||
}
|
|
||||||
for operation in stage.operations
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for stage in self.flow_stages
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def calculate_structural_signature(self) -> str:
|
|
||||||
payload = json.dumps(
|
|
||||||
self.structural_dict(),
|
|
||||||
ensure_ascii=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
sort_keys=True,
|
|
||||||
).encode("utf-8")
|
|
||||||
return sha256(payload).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRBindings:
|
|
||||||
readers: tuple[Callable[[], float], ...]
|
|
||||||
writers: tuple[Callable[[float], None], ...]
|
|
||||||
evaluators: tuple[Callable[[], object], ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class CausalIRWorkspace:
|
|
||||||
structural_signature: str
|
|
||||||
canonical_values: "np.ndarray[Any, Any]"
|
|
||||||
effort_residuals: "np.ndarray[Any, Any]"
|
|
||||||
effort_written: "np.ndarray[Any, Any]"
|
|
||||||
flow_values: "np.ndarray[Any, Any]"
|
|
||||||
flow_written: "np.ndarray[Any, Any]"
|
|
||||||
transaction_values: "np.ndarray[Any, Any]"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRExecutionResult:
|
|
||||||
success: bool
|
|
||||||
fallback_reason: str | None
|
|
||||||
structural_signature: str
|
|
||||||
effort_assignment_count: int
|
|
||||||
flow_assignment_count: int
|
|
||||||
completed_effort_stage_count: int
|
|
||||||
completed_flow_stage_count: int
|
|
||||||
rolled_back: bool
|
|
||||||
|
|
||||||
|
|
||||||
StageObserver = Callable[
|
|
||||||
[str, int, tuple[int, ...], tuple[float, ...]],
|
|
||||||
None,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalNumericIR:
|
|
||||||
"""Bound reference IR; its normal path performs no full snapshot."""
|
|
||||||
|
|
||||||
program: CausalIRProgram
|
|
||||||
bindings: CausalIRBindings
|
|
||||||
|
|
||||||
def create_workspace(self) -> CausalIRWorkspace:
|
|
||||||
try:
|
|
||||||
import numpy as np
|
|
||||||
except ImportError as exc: # pragma: no cover
|
|
||||||
raise RuntimeError("The causal numeric reference IR requires NumPy.") from exc
|
|
||||||
return CausalIRWorkspace(
|
|
||||||
structural_signature=self.program.structural_signature,
|
|
||||||
canonical_values=np.empty(
|
|
||||||
max(len(self.program.canonical_slots), 1), dtype=np.float64
|
|
||||||
),
|
|
||||||
effort_residuals=np.empty(
|
|
||||||
max(self.program.maximum_effort_stage_width, 1), dtype=np.float64
|
|
||||||
),
|
|
||||||
effort_written=np.empty(
|
|
||||||
max(self.program.maximum_effort_stage_width, 1), dtype=np.bool_
|
|
||||||
),
|
|
||||||
flow_values=np.empty(
|
|
||||||
max(self.program.maximum_flow_stage_width, 1), dtype=np.float64
|
|
||||||
),
|
|
||||||
flow_written=np.empty(
|
|
||||||
max(self.program.maximum_flow_stage_width, 1), dtype=np.bool_
|
|
||||||
),
|
|
||||||
transaction_values=np.empty(
|
|
||||||
max(len(self.program.compatibility_slots), 1), dtype=np.float64
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def execute(
|
|
||||||
self,
|
|
||||||
workspace: CausalIRWorkspace,
|
|
||||||
*,
|
|
||||||
effort_variables: tuple[str, ...] = ("p",),
|
|
||||||
transactional: bool = False,
|
|
||||||
stage_observer: StageObserver | None = None,
|
|
||||||
) -> CausalIRExecutionResult:
|
|
||||||
"""Interpret the IR; transactional snapshots are audit-only."""
|
|
||||||
|
|
||||||
program = self.program
|
|
||||||
bindings = self.bindings
|
|
||||||
signature = program.structural_signature
|
|
||||||
if workspace.structural_signature != signature:
|
|
||||||
raise ValueError("Causal IR workspace belongs to a different program.")
|
|
||||||
if len(bindings.readers) != len(program.compatibility_slots) or len(
|
|
||||||
bindings.writers
|
|
||||||
) != len(program.compatibility_slots):
|
|
||||||
raise ValueError("Causal IR compatibility binding count is inconsistent.")
|
|
||||||
if any(variable not in {"p", "x", "v"} for variable in effort_variables):
|
|
||||||
return CausalIRExecutionResult(
|
|
||||||
False, "unsupportedEffortVariable", signature, 0, 0, 0, 0, False
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot_count = 0
|
|
||||||
if transactional:
|
|
||||||
try:
|
|
||||||
for slot, reader in enumerate(bindings.readers):
|
|
||||||
workspace.transaction_values[slot] = float(reader())
|
|
||||||
snapshot_count += 1
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
|
|
||||||
return CausalIRExecutionResult(
|
|
||||||
False,
|
|
||||||
f"slotReadFailed:{type(exc).__name__}",
|
|
||||||
signature,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
effort_count = 0
|
|
||||||
flow_count = 0
|
|
||||||
completed_effort_stages = 0
|
|
||||||
completed_flow_stages = 0
|
|
||||||
|
|
||||||
def failed(reason: str) -> CausalIRExecutionResult:
|
|
||||||
rolled_back = False
|
|
||||||
if transactional:
|
|
||||||
for slot in range(snapshot_count):
|
|
||||||
bindings.writers[slot](float(workspace.transaction_values[slot]))
|
|
||||||
rolled_back = True
|
|
||||||
return CausalIRExecutionResult(
|
|
||||||
False,
|
|
||||||
reason,
|
|
||||||
signature,
|
|
||||||
effort_count,
|
|
||||||
flow_count,
|
|
||||||
completed_effort_stages,
|
|
||||||
completed_flow_stages,
|
|
||||||
rolled_back,
|
|
||||||
)
|
|
||||||
|
|
||||||
selected_efforts = frozenset(effort_variables)
|
|
||||||
for stage_index, stage in enumerate(program.effort_stages):
|
|
||||||
if stage.variable not in selected_efforts:
|
|
||||||
continue
|
|
||||||
width = len(stage.operations)
|
|
||||||
workspace.effort_written[:width] = False
|
|
||||||
for evaluation in stage.evaluations:
|
|
||||||
try:
|
|
||||||
evaluated = bindings.evaluators[evaluation.evaluator_slot]()
|
|
||||||
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL:
|
|
||||||
output = evaluation.output_indices[0]
|
|
||||||
workspace.effort_residuals[output] = float(evaluated)
|
|
||||||
workspace.effort_written[output] = True
|
|
||||||
continue
|
|
||||||
if not hasattr(evaluated, "__len__"):
|
|
||||||
raise TypeError("component evaluator returned no sequence")
|
|
||||||
for output, equation in zip(
|
|
||||||
evaluation.output_indices, evaluation.equation_indices
|
|
||||||
):
|
|
||||||
if equation >= len(evaluated):
|
|
||||||
raise IndexError("component equation disappeared")
|
|
||||||
workspace.effort_residuals[output] = float(evaluated[equation])
|
|
||||||
workspace.effort_written[output] = True
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
return failed(f"effortEvaluationFailed:{type(exc).__name__}")
|
|
||||||
if any(not bool(workspace.effort_written[index]) for index in range(width)):
|
|
||||||
return failed("effortEvaluationCoverageMismatch")
|
|
||||||
for output, operation in enumerate(stage.operations):
|
|
||||||
try:
|
|
||||||
anchor = float(bindings.readers[operation.anchor_compatibility_slot]())
|
|
||||||
target = anchor - float(workspace.effort_residuals[output])
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except (
|
|
||||||
ArithmeticError,
|
|
||||||
IndexError,
|
|
||||||
RuntimeError,
|
|
||||||
TypeError,
|
|
||||||
ValueError,
|
|
||||||
) as exc:
|
|
||||||
return failed(f"effortAssignmentFailed:{type(exc).__name__}")
|
|
||||||
if not isfinite(target) or (
|
|
||||||
stage.variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
|
|
||||||
):
|
|
||||||
return failed("nonFiniteOrInvalidEffortAssignment")
|
|
||||||
workspace.canonical_values[operation.result_slot] = target
|
|
||||||
for slot in operation.scatter_compatibility_slots:
|
|
||||||
bindings.writers[slot](target)
|
|
||||||
effort_count += 1
|
|
||||||
completed_effort_stages += 1
|
|
||||||
if stage_observer is not None:
|
|
||||||
try:
|
|
||||||
stage_observer(
|
|
||||||
f"effort:{stage.variable}",
|
|
||||||
stage_index,
|
|
||||||
tuple(item.result_slot for item in stage.operations),
|
|
||||||
tuple(
|
|
||||||
float(workspace.canonical_values[item.result_slot])
|
|
||||||
for item in stage.operations
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
return failed(f"stageObserverFailed:{type(exc).__name__}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
external_finite = all(
|
|
||||||
isfinite(float(bindings.readers[slot]()))
|
|
||||||
for slot in program.external_effort_compatibility_slots
|
|
||||||
)
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
|
|
||||||
return failed(f"externalEffortReadFailed:{type(exc).__name__}")
|
|
||||||
if not external_finite:
|
|
||||||
return failed("nonFiniteExternalEffort")
|
|
||||||
|
|
||||||
for slot in program.reset_compatibility_slots:
|
|
||||||
bindings.writers[slot](0.0)
|
|
||||||
for stage_index, stage in enumerate(program.flow_stages):
|
|
||||||
width = len(stage.target_slots)
|
|
||||||
workspace.flow_written[:width] = False
|
|
||||||
for operation in stage.operations:
|
|
||||||
try:
|
|
||||||
evaluated = bindings.evaluators[operation.evaluator_slot]()
|
|
||||||
if operation.opcode is CausalIROpcode.FLOW_DIRECT:
|
|
||||||
output = operation.output_indices[0]
|
|
||||||
workspace.flow_values[output] = float(evaluated)
|
|
||||||
workspace.flow_written[output] = True
|
|
||||||
continue
|
|
||||||
if not hasattr(evaluated, "__len__"):
|
|
||||||
raise TypeError("component evaluator returned no sequence")
|
|
||||||
for output, equation in zip(
|
|
||||||
operation.output_indices, operation.equation_indices
|
|
||||||
):
|
|
||||||
if equation >= len(evaluated):
|
|
||||||
raise IndexError("component equation disappeared")
|
|
||||||
# Targets are zero before the stage; preserve -residual.
|
|
||||||
workspace.flow_values[output] = -float(evaluated[equation])
|
|
||||||
workspace.flow_written[output] = True
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except (
|
|
||||||
ArithmeticError,
|
|
||||||
IndexError,
|
|
||||||
RuntimeError,
|
|
||||||
TypeError,
|
|
||||||
ValueError,
|
|
||||||
) as exc:
|
|
||||||
return failed(f"flowEvaluationFailed:{type(exc).__name__}")
|
|
||||||
if any(not bool(workspace.flow_written[index]) for index in range(width)):
|
|
||||||
return failed("flowAssignmentCoverageMismatch")
|
|
||||||
for output, (canonical, compatibility) in enumerate(
|
|
||||||
zip(stage.target_slots, stage.scatter_compatibility_slots)
|
|
||||||
):
|
|
||||||
target = float(workspace.flow_values[output])
|
|
||||||
if not isfinite(target):
|
|
||||||
return failed("nonFiniteFlowAssignment")
|
|
||||||
workspace.canonical_values[canonical] = target
|
|
||||||
bindings.writers[compatibility](target)
|
|
||||||
flow_count += 1
|
|
||||||
completed_flow_stages += 1
|
|
||||||
if stage_observer is not None:
|
|
||||||
try:
|
|
||||||
stage_observer(
|
|
||||||
"flow",
|
|
||||||
stage_index,
|
|
||||||
stage.target_slots,
|
|
||||||
tuple(float(workspace.flow_values[i]) for i in range(width)),
|
|
||||||
)
|
|
||||||
except MemoryError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
return failed(f"stageObserverFailed:{type(exc).__name__}")
|
|
||||||
|
|
||||||
return CausalIRExecutionResult(
|
|
||||||
True,
|
|
||||||
None,
|
|
||||||
signature,
|
|
||||||
effort_count,
|
|
||||||
flow_count,
|
|
||||||
completed_effort_stages,
|
|
||||||
completed_flow_stages,
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CausalIRCompilation:
|
|
||||||
ir: CausalNumericIR | None
|
|
||||||
fallback_reason: str | None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def supported(self) -> bool:
|
|
||||||
return self.ir is not None and self.fallback_reason is None
|
|
||||||
|
|
||||||
|
|
||||||
def _unsupported(reason: str) -> CausalIRCompilation:
|
|
||||||
return CausalIRCompilation(ir=None, fallback_reason=reason)
|
|
||||||
|
|
||||||
|
|
||||||
def _unique_slots(items: Iterable[int]) -> tuple[int, ...]:
|
|
||||||
return tuple(dict.fromkeys(int(item) for item in items))
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_effort_evaluations(
|
|
||||||
operations: tuple[CausalIREffortOperation, ...],
|
|
||||||
anchor_evaluators: tuple[Callable[[], float], ...],
|
|
||||||
direct_residuals: tuple[bool, ...],
|
|
||||||
component_locations: dict[
|
|
||||||
str, tuple[object, Callable[[], tuple[float, ...]], int]
|
|
||||||
],
|
|
||||||
evaluators: list[Callable[[], object]],
|
|
||||||
) -> tuple[CausalIREffortEvaluation, ...]:
|
|
||||||
grouped: dict[int, list[tuple[int, int, str]]] = {}
|
|
||||||
component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {}
|
|
||||||
direct: list[tuple[int, Callable[[], float], str]] = []
|
|
||||||
for output, (operation, anchor_evaluate, direct_residual) in enumerate(
|
|
||||||
zip(operations, anchor_evaluators, direct_residuals)
|
|
||||||
):
|
|
||||||
location = (
|
|
||||||
None
|
|
||||||
if direct_residual
|
|
||||||
else component_locations.get(operation.equation_id)
|
|
||||||
)
|
|
||||||
if location is None:
|
|
||||||
direct.append((output, anchor_evaluate, operation.equation_id))
|
|
||||||
continue
|
|
||||||
owner, evaluate, equation = location
|
|
||||||
key = id(owner)
|
|
||||||
component_callbacks[key] = evaluate
|
|
||||||
grouped.setdefault(key, []).append((output, equation, operation.equation_id))
|
|
||||||
|
|
||||||
compiled: list[CausalIREffortEvaluation] = []
|
|
||||||
for output, evaluate, equation_id in direct:
|
|
||||||
evaluator = len(evaluators)
|
|
||||||
evaluators.append(evaluate)
|
|
||||||
compiled.append(
|
|
||||||
CausalIREffortEvaluation(
|
|
||||||
CausalIROpcode.EFFORT_DIRECT_RESIDUAL,
|
|
||||||
(output,),
|
|
||||||
(),
|
|
||||||
(equation_id,),
|
|
||||||
evaluator,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for key, entries in grouped.items():
|
|
||||||
evaluator = len(evaluators)
|
|
||||||
evaluators.append(component_callbacks[key])
|
|
||||||
compiled.append(
|
|
||||||
CausalIREffortEvaluation(
|
|
||||||
CausalIROpcode.EFFORT_COMPONENT_RESIDUAL,
|
|
||||||
tuple(item[0] for item in entries),
|
|
||||||
tuple(item[1] for item in entries),
|
|
||||||
tuple(item[2] for item in entries),
|
|
||||||
evaluator,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(compiled)
|
|
||||||
|
|
||||||
|
|
||||||
def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
|
|
||||||
"""Lower a compile-proven global plan; unsupported plans fail closed."""
|
|
||||||
|
|
||||||
if not bool(getattr(solver, "_causal_fast_path_eligible", False)):
|
|
||||||
return _unsupported(
|
|
||||||
str(
|
|
||||||
getattr(solver, "_causal_fast_path_fallback_reason", None)
|
|
||||||
or "causalProofNotAvailable"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
unknowns = tuple(getattr(solver, "unknowns"))
|
|
||||||
effort_plan = getattr(solver, "_causal_effort_plan_by_variable")
|
|
||||||
flow_plan = tuple(getattr(solver, "_explicit_flow_plan"))
|
|
||||||
component_plan = tuple(getattr(solver, "_component_equation_plan"))
|
|
||||||
reset_unknowns = tuple(
|
|
||||||
getattr(solver, "_explicit_flow_unknowns_by_variables")[
|
|
||||||
frozenset(("f", "m_flow"))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
external_unknowns = tuple(
|
|
||||||
getattr(solver, "_causal_external_effort_unknowns")
|
|
||||||
)
|
|
||||||
except (AttributeError, KeyError, TypeError):
|
|
||||||
return _unsupported("unsupportedCausalSolverContract")
|
|
||||||
|
|
||||||
unknown_ids = tuple(str(item.id) for item in unknowns)
|
|
||||||
if len(set(unknown_ids)) != len(unknown_ids):
|
|
||||||
return _unsupported("duplicateAlgebraicUnknown")
|
|
||||||
compatibility_slot_by_id = {
|
|
||||||
unknown_id: slot for slot, unknown_id in enumerate(unknown_ids)
|
|
||||||
}
|
|
||||||
compatibility_slots = tuple(
|
|
||||||
CausalIRCompatibilitySlot(slot, unknown_id, str(unknown.variable))
|
|
||||||
for slot, (unknown_id, unknown) in enumerate(zip(unknown_ids, unknowns))
|
|
||||||
)
|
|
||||||
readers = tuple(item.read for item in unknowns)
|
|
||||||
writers = tuple(item.write for item in unknowns)
|
|
||||||
evaluators: list[Callable[[], object]] = []
|
|
||||||
canonical_slots: list[CausalIRCanonicalSlot] = []
|
|
||||||
|
|
||||||
component_locations: dict[
|
|
||||||
str, tuple[object, Callable[[], tuple[float, ...]], int]
|
|
||||||
] = {}
|
|
||||||
try:
|
|
||||||
for plan in component_plan:
|
|
||||||
for equation, template in enumerate(plan.templates):
|
|
||||||
component_locations[str(template.id)] = (
|
|
||||||
plan.component,
|
|
||||||
plan.evaluate,
|
|
||||||
equation,
|
|
||||||
)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return _unsupported("unsupportedComponentEvaluationContract")
|
|
||||||
|
|
||||||
effort_stages: list[CausalIREffortStage] = []
|
|
||||||
try:
|
|
||||||
for variable in ("p", "x", "v"):
|
|
||||||
operations: list[CausalIREffortOperation] = []
|
|
||||||
anchors: list[Callable[[], float]] = []
|
|
||||||
direct_residuals: list[bool] = []
|
|
||||||
for assignment in effort_plan[variable]:
|
|
||||||
result = len(canonical_slots)
|
|
||||||
equation_id = str(assignment.anchor.equation_id)
|
|
||||||
scatter = tuple(
|
|
||||||
compatibility_slot_by_id[item.id]
|
|
||||||
for item in assignment.members
|
|
||||||
)
|
|
||||||
if not scatter or len(set(scatter)) != len(scatter):
|
|
||||||
return _unsupported("invalidEffortScatterSlots")
|
|
||||||
canonical_slots.append(
|
|
||||||
CausalIRCanonicalSlot(
|
|
||||||
result,
|
|
||||||
f"effort:{variable}:{equation_id}",
|
|
||||||
variable,
|
|
||||||
"effort_group",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
operations.append(
|
|
||||||
CausalIREffortOperation(
|
|
||||||
CausalIROpcode.EFFORT_BROADCAST,
|
|
||||||
variable,
|
|
||||||
result,
|
|
||||||
compatibility_slot_by_id[assignment.anchor.unknown.id],
|
|
||||||
scatter,
|
|
||||||
equation_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
causal_evaluate = getattr(
|
|
||||||
assignment.anchor,
|
|
||||||
"causal_evaluate",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
anchors.append(
|
|
||||||
causal_evaluate
|
|
||||||
if causal_evaluate is not None
|
|
||||||
else assignment.anchor.evaluate
|
|
||||||
)
|
|
||||||
direct_residuals.append(causal_evaluate is not None)
|
|
||||||
operation_tuple = tuple(operations)
|
|
||||||
effort_stages.append(
|
|
||||||
CausalIREffortStage(
|
|
||||||
variable,
|
|
||||||
operation_tuple,
|
|
||||||
_compile_effort_evaluations(
|
|
||||||
operation_tuple,
|
|
||||||
tuple(anchors),
|
|
||||||
tuple(direct_residuals),
|
|
||||||
component_locations,
|
|
||||||
evaluators,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (AttributeError, KeyError, TypeError):
|
|
||||||
return _unsupported("unsupportedEffortPlanContract")
|
|
||||||
|
|
||||||
flow_stages: list[CausalIRFlowStage] = []
|
|
||||||
try:
|
|
||||||
for stage in flow_plan:
|
|
||||||
scatter = tuple(
|
|
||||||
compatibility_slot_by_id[item.unknown.id]
|
|
||||||
for item in stage.assignments
|
|
||||||
)
|
|
||||||
equation_ids = tuple(str(item.equation_id) for item in stage.assignments)
|
|
||||||
if len(set(scatter)) != len(scatter):
|
|
||||||
return _unsupported("duplicateFlowTargetInStage")
|
|
||||||
targets: list[int] = []
|
|
||||||
for assignment in stage.assignments:
|
|
||||||
target = len(canonical_slots)
|
|
||||||
targets.append(target)
|
|
||||||
canonical_slots.append(
|
|
||||||
CausalIRCanonicalSlot(
|
|
||||||
target,
|
|
||||||
f"flow:{assignment.unknown.id}",
|
|
||||||
str(assignment.unknown.variable),
|
|
||||||
"flow_assignment",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
covered: list[int] = []
|
|
||||||
operations: list[CausalIRFlowOperation] = []
|
|
||||||
for output, evaluate in stage.direct_evaluations:
|
|
||||||
output = int(output)
|
|
||||||
evaluator = len(evaluators)
|
|
||||||
evaluators.append(evaluate)
|
|
||||||
operations.append(
|
|
||||||
CausalIRFlowOperation(
|
|
||||||
CausalIROpcode.FLOW_DIRECT,
|
|
||||||
(output,),
|
|
||||||
(),
|
|
||||||
(equation_ids[output],),
|
|
||||||
evaluator,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
covered.append(output)
|
|
||||||
for evaluation in stage.component_evaluations:
|
|
||||||
evaluator = len(evaluators)
|
|
||||||
evaluators.append(evaluation.evaluate)
|
|
||||||
outputs = tuple(int(item) for item in evaluation.assignment_indices)
|
|
||||||
operations.append(
|
|
||||||
CausalIRFlowOperation(
|
|
||||||
CausalIROpcode.FLOW_COMPONENT_RESIDUAL,
|
|
||||||
outputs,
|
|
||||||
tuple(int(item) for item in evaluation.equation_indices),
|
|
||||||
tuple(str(item) for item in evaluation.equation_ids),
|
|
||||||
evaluator,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
covered.extend(outputs)
|
|
||||||
if sorted(covered) != list(range(len(scatter))):
|
|
||||||
return _unsupported("flowStageEvaluationCoverageMismatch")
|
|
||||||
flow_stages.append(
|
|
||||||
CausalIRFlowStage(
|
|
||||||
tuple(targets), scatter, equation_ids, tuple(operations)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (AttributeError, IndexError, KeyError, TypeError):
|
|
||||||
return _unsupported("unsupportedFlowPlanContract")
|
|
||||||
|
|
||||||
try:
|
|
||||||
reset_slots = _unique_slots(
|
|
||||||
compatibility_slot_by_id[item.id] for item in reset_unknowns
|
|
||||||
)
|
|
||||||
external_slots = _unique_slots(
|
|
||||||
compatibility_slot_by_id[item.id] for item in external_unknowns
|
|
||||||
)
|
|
||||||
except (AttributeError, KeyError):
|
|
||||||
return _unsupported("unknownCausalBoundarySlot")
|
|
||||||
flow_scatter = tuple(
|
|
||||||
item for stage in flow_stages for item in stage.scatter_compatibility_slots
|
|
||||||
)
|
|
||||||
if len(set(flow_scatter)) != len(flow_scatter):
|
|
||||||
return _unsupported("duplicateExplicitFlowAssignment")
|
|
||||||
if set(flow_scatter) != set(reset_slots):
|
|
||||||
return _unsupported("incompleteExplicitFlowCoverage")
|
|
||||||
|
|
||||||
program = CausalIRProgram(
|
|
||||||
CAUSAL_NUMERIC_IR_SCHEMA_VERSION,
|
|
||||||
tuple(canonical_slots),
|
|
||||||
compatibility_slots,
|
|
||||||
reset_slots,
|
|
||||||
external_slots,
|
|
||||||
tuple(effort_stages),
|
|
||||||
tuple(flow_stages),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
program = replace(
|
|
||||||
program, structural_signature=program.calculate_structural_signature()
|
|
||||||
)
|
|
||||||
return CausalIRCompilation(
|
|
||||||
CausalNumericIR(
|
|
||||||
program,
|
|
||||||
CausalIRBindings(readers, writers, tuple(evaluators)),
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,256 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Iterable, Sequence
|
|
||||||
|
|
||||||
from app.simulation.components.amesim.flow.pipes import (
|
|
||||||
AmesimPnl0001,
|
|
||||||
AmesimPnl0002,
|
|
||||||
AmesimPnl0003,
|
|
||||||
)
|
|
||||||
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
|
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.simulation.core.base import DynamicComponent
|
|
||||||
from app.simulation.solvers.mechanical import MechanicalStateReducer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PneumaticStoragePartition:
|
|
||||||
"""One fixed-volume ``[mass, internal energy]`` pressure-state partition."""
|
|
||||||
|
|
||||||
component: DynamicComponent
|
|
||||||
state_offset: int
|
|
||||||
volume: float
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key(self) -> tuple[str, int]:
|
|
||||||
return self.component.name, self.state_offset
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IdealPneumaticStorageGroup:
|
|
||||||
partitions: tuple[PneumaticStoragePartition, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def names(self) -> tuple[str, ...]:
|
|
||||||
return tuple(partition.component.name for partition in self.partitions)
|
|
||||||
|
|
||||||
|
|
||||||
def pneumatic_storage_partition(
|
|
||||||
network: SimulationNetwork,
|
|
||||||
endpoint: Endpoint,
|
|
||||||
) -> PneumaticStoragePartition | None:
|
|
||||||
"""Map an AMESim pressure-state port to its fixed gas-volume state slice.
|
|
||||||
|
|
||||||
PNL0001 exposes its C side at ``port_2``. PNL0003 exposes one compliance
|
|
||||||
at each end. PNL0002 has resistances at both external ports, so its center
|
|
||||||
compliance is intentionally not returned here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
component = network.components[endpoint.component]
|
|
||||||
if isinstance(component, AmesimPnl0003):
|
|
||||||
if endpoint.port == "port_1":
|
|
||||||
return PneumaticStoragePartition(
|
|
||||||
component=component,
|
|
||||||
state_offset=0,
|
|
||||||
volume=component.compliance_volume,
|
|
||||||
)
|
|
||||||
if endpoint.port == "port_2":
|
|
||||||
return PneumaticStoragePartition(
|
|
||||||
component=component,
|
|
||||||
state_offset=2,
|
|
||||||
volume=component.compliance_volume,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if isinstance(component, AmesimPnl0001) and not isinstance(
|
|
||||||
component, AmesimPnl0002
|
|
||||||
):
|
|
||||||
if endpoint.port == "port_2":
|
|
||||||
return PneumaticStoragePartition(
|
|
||||||
component=component,
|
|
||||||
state_offset=0,
|
|
||||||
volume=component.volume,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ideal_storage_group_is_reducible(
|
|
||||||
network: SimulationNetwork,
|
|
||||||
storage_endpoints: Iterable[Endpoint],
|
|
||||||
) -> bool:
|
|
||||||
partitions = [
|
|
||||||
pneumatic_storage_partition(network, endpoint)
|
|
||||||
for endpoint in storage_endpoints
|
|
||||||
]
|
|
||||||
if not partitions or any(partition is None for partition in partitions):
|
|
||||||
return False
|
|
||||||
unique = {partition.key: partition for partition in partitions if partition}
|
|
||||||
if len(unique) < 2:
|
|
||||||
return False
|
|
||||||
media = {id(partition.component.medium) for partition in unique.values()}
|
|
||||||
return len(media) == 1 and all(partition.volume > 0.0 for partition in unique.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _pressure_storage_endpoint_groups(
|
|
||||||
network: SimulationNetwork,
|
|
||||||
) -> tuple[tuple[Endpoint, ...], ...]:
|
|
||||||
pneumatic_endpoints = {
|
|
||||||
Endpoint(component.name, definition.name)
|
|
||||||
for component in network.components.values()
|
|
||||||
for definition in component.active_port_definitions
|
|
||||||
if definition.kind == "physical" and definition.domain == "pneumatic"
|
|
||||||
}
|
|
||||||
parent = {endpoint: endpoint for endpoint in pneumatic_endpoints}
|
|
||||||
|
|
||||||
def find(endpoint: Endpoint) -> Endpoint:
|
|
||||||
root = endpoint
|
|
||||||
while parent[root] != root:
|
|
||||||
root = parent[root]
|
|
||||||
while parent[endpoint] != endpoint:
|
|
||||||
next_endpoint = parent[endpoint]
|
|
||||||
parent[endpoint] = root
|
|
||||||
endpoint = next_endpoint
|
|
||||||
return root
|
|
||||||
|
|
||||||
def union(first: Endpoint, second: Endpoint) -> None:
|
|
||||||
first_root = find(first)
|
|
||||||
second_root = find(second)
|
|
||||||
if first_root != second_root:
|
|
||||||
parent[second_root] = first_root
|
|
||||||
|
|
||||||
for connection in network.connections:
|
|
||||||
first, second = connection.endpoints
|
|
||||||
if first in pneumatic_endpoints and second in pneumatic_endpoints:
|
|
||||||
union(first, second)
|
|
||||||
|
|
||||||
storage_endpoints: set[Endpoint] = set()
|
|
||||||
for component in network.components.values():
|
|
||||||
for equation in component.pressure_flow_equation_residuals():
|
|
||||||
pressure_endpoints = [
|
|
||||||
Endpoint(component.name, variable.rsplit(".", 2)[1])
|
|
||||||
for variable in equation.variables
|
|
||||||
if variable.startswith(f"{component.name}.") and variable.endswith(".p")
|
|
||||||
]
|
|
||||||
if equation.relation == "equal":
|
|
||||||
for endpoint in pressure_endpoints[1:]:
|
|
||||||
union(pressure_endpoints[0], endpoint)
|
|
||||||
elif equation.relation == "state":
|
|
||||||
storage_endpoints.update(pressure_endpoints)
|
|
||||||
|
|
||||||
by_root: dict[Endpoint, list[Endpoint]] = {}
|
|
||||||
for endpoint in storage_endpoints:
|
|
||||||
by_root.setdefault(find(endpoint), []).append(endpoint)
|
|
||||||
return tuple(tuple(endpoints) for endpoints in by_root.values())
|
|
||||||
|
|
||||||
|
|
||||||
class IdealPneumaticStorageReducer:
|
|
||||||
"""Project supported ideal C-C connections onto one thermodynamic state.
|
|
||||||
|
|
||||||
AMESim permits compatible pipe compliances to share an ideal pneumatic
|
|
||||||
junction. The public ODE solver keeps the original result states but
|
|
||||||
projects their mass and energy densities together and distributes the
|
|
||||||
group's total derivative by physical volume. This removes the redundant
|
|
||||||
pressure constraint without adding a fictitious resistance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
network: SimulationNetwork,
|
|
||||||
mechanical_state_reducer: MechanicalStateReducer,
|
|
||||||
) -> None:
|
|
||||||
self.network = network
|
|
||||||
self.mechanical_state_reducer = mechanical_state_reducer
|
|
||||||
self.groups = self._build_groups()
|
|
||||||
self._component_offsets = self._build_component_offsets()
|
|
||||||
|
|
||||||
def _build_groups(self) -> tuple[IdealPneumaticStorageGroup, ...]:
|
|
||||||
groups: list[IdealPneumaticStorageGroup] = []
|
|
||||||
for endpoints in _pressure_storage_endpoint_groups(self.network):
|
|
||||||
partitions = [
|
|
||||||
pneumatic_storage_partition(self.network, endpoint)
|
|
||||||
for endpoint in endpoints
|
|
||||||
]
|
|
||||||
unique = {
|
|
||||||
partition.key: partition
|
|
||||||
for partition in partitions
|
|
||||||
if partition is not None
|
|
||||||
}
|
|
||||||
if len(unique) > 1 and len(unique) == len(
|
|
||||||
{endpoint.component for endpoint in endpoints}
|
|
||||||
):
|
|
||||||
group = IdealPneumaticStorageGroup(tuple(unique.values()))
|
|
||||||
if ideal_storage_group_is_reducible(self.network, endpoints):
|
|
||||||
groups.append(group)
|
|
||||||
return tuple(groups)
|
|
||||||
|
|
||||||
def _build_component_offsets(self) -> dict[str, int]:
|
|
||||||
offsets: dict[str, int] = {}
|
|
||||||
cursor = 0
|
|
||||||
for entry in self.mechanical_state_reducer.state_entries:
|
|
||||||
if isinstance(entry, MechanicalConstraintGroup):
|
|
||||||
cursor += 2
|
|
||||||
else:
|
|
||||||
offsets[entry.name] = cursor
|
|
||||||
cursor += entry.state_size
|
|
||||||
return offsets
|
|
||||||
|
|
||||||
def _global_offset(self, partition: PneumaticStoragePartition) -> int:
|
|
||||||
return self._component_offsets[partition.component.name] + partition.state_offset
|
|
||||||
|
|
||||||
def synchronize_state_vector(
|
|
||||||
self,
|
|
||||||
values: Sequence[float],
|
|
||||||
*,
|
|
||||||
validate: bool = False,
|
|
||||||
) -> list[float]:
|
|
||||||
projected = [float(value) for value in values]
|
|
||||||
for group in self.groups:
|
|
||||||
volumes = [partition.volume for partition in group.partitions]
|
|
||||||
offsets = [self._global_offset(partition) for partition in group.partitions]
|
|
||||||
mass_densities = [
|
|
||||||
projected[offset] / volume
|
|
||||||
for offset, volume in zip(offsets, volumes)
|
|
||||||
]
|
|
||||||
energy_densities = [
|
|
||||||
projected[offset + 1] / volume
|
|
||||||
for offset, volume in zip(offsets, volumes)
|
|
||||||
]
|
|
||||||
if validate:
|
|
||||||
mass_scale = max([abs(value) for value in mass_densities] + [1.0])
|
|
||||||
energy_scale = max([abs(value) for value in energy_densities] + [1.0])
|
|
||||||
if (
|
|
||||||
max(mass_densities) - min(mass_densities) > 1.0e-9 * mass_scale
|
|
||||||
or max(energy_densities) - min(energy_densities)
|
|
||||||
> 1.0e-9 * energy_scale
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"Ideally coupled AMESim pipe compliances require consistent "
|
|
||||||
"initial pressure and temperature: " + ", ".join(group.names)
|
|
||||||
)
|
|
||||||
total_volume = sum(volumes)
|
|
||||||
mass_density = sum(projected[offset] for offset in offsets) / total_volume
|
|
||||||
energy_density = (
|
|
||||||
sum(projected[offset + 1] for offset in offsets) / total_volume
|
|
||||||
)
|
|
||||||
for offset, volume in zip(offsets, volumes):
|
|
||||||
projected[offset] = mass_density * volume
|
|
||||||
projected[offset + 1] = energy_density * volume
|
|
||||||
return projected
|
|
||||||
|
|
||||||
def coupled_derivatives(self, values: Sequence[float]) -> list[float]:
|
|
||||||
derivatives = [float(value) for value in values]
|
|
||||||
for group in self.groups:
|
|
||||||
volumes = [partition.volume for partition in group.partitions]
|
|
||||||
offsets = [self._global_offset(partition) for partition in group.partitions]
|
|
||||||
total_volume = sum(volumes)
|
|
||||||
total_mass_derivative = sum(derivatives[offset] for offset in offsets)
|
|
||||||
total_energy_derivative = sum(
|
|
||||||
derivatives[offset + 1] for offset in offsets
|
|
||||||
)
|
|
||||||
for offset, volume in zip(offsets, volumes):
|
|
||||||
fraction = volume / total_volume
|
|
||||||
derivatives[offset] = total_mass_derivative * fraction
|
|
||||||
derivatives[offset + 1] = total_energy_derivative * fraction
|
|
||||||
return derivatives
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import isfinite
|
|
||||||
|
|
||||||
from app.simulation.core.base import Component
|
|
||||||
from app.simulation.core.ports import PortState
|
|
||||||
from app.simulation.performance import profile_phase
|
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PneumaticVolumeDiagnostics:
|
|
||||||
propagated: int
|
|
||||||
output_ports: tuple[str, ...]
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"propagated": self.propagated,
|
|
||||||
"outputPorts": list(self.output_ports),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _PneumaticVolumeConnectionBinding:
|
|
||||||
connected_endpoint: Endpoint
|
|
||||||
connected_port: PortState
|
|
||||||
|
|
||||||
|
|
||||||
class PneumaticVolumeResolver:
|
|
||||||
"""Propagate AMESim pneumatic external-volume connector variables."""
|
|
||||||
|
|
||||||
def __init__(self, network: SimulationNetwork) -> None:
|
|
||||||
self.network = network
|
|
||||||
self._pneumatic_ports = tuple(
|
|
||||||
component.get_port(definition.name)
|
|
||||||
for component in network.components.values()
|
|
||||||
for definition in component.active_port_definitions
|
|
||||||
if definition.kind == "physical" and definition.domain == "pneumatic"
|
|
||||||
)
|
|
||||||
self._output_components = tuple(
|
|
||||||
component
|
|
||||||
for component in network.components.values()
|
|
||||||
if type(component).pneumatic_volume_outputs
|
|
||||||
is not Component.pneumatic_volume_outputs
|
|
||||||
)
|
|
||||||
self._connected_endpoint = self._build_connection_map()
|
|
||||||
self.last_diagnostics: PneumaticVolumeDiagnostics | None = None
|
|
||||||
|
|
||||||
def _build_connection_map(
|
|
||||||
self,
|
|
||||||
) -> dict[Endpoint, _PneumaticVolumeConnectionBinding]:
|
|
||||||
result: dict[Endpoint, _PneumaticVolumeConnectionBinding] = {}
|
|
||||||
for connection in self.network.connections:
|
|
||||||
if connection.kind != "physical" or connection.domain != "pneumatic":
|
|
||||||
continue
|
|
||||||
first, second = connection.endpoints
|
|
||||||
result[first] = _PneumaticVolumeConnectionBinding(
|
|
||||||
connected_endpoint=second,
|
|
||||||
connected_port=self.network.components[second.component].get_port(
|
|
||||||
second.port
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result[second] = _PneumaticVolumeConnectionBinding(
|
|
||||||
connected_endpoint=first,
|
|
||||||
connected_port=self.network.components[first.component].get_port(
|
|
||||||
first.port
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@profile_phase("simulation.pneumatic_volume", minimum_mode="audit")
|
|
||||||
def solve(self) -> PneumaticVolumeDiagnostics:
|
|
||||||
for port in self._pneumatic_ports:
|
|
||||||
port.volume = 0.0
|
|
||||||
port.volume_flow = 0.0
|
|
||||||
|
|
||||||
outputs: dict[Endpoint, tuple[float, float]] = {}
|
|
||||||
for component in self._output_components:
|
|
||||||
for port_name, raw_values in component.pneumatic_volume_outputs().items():
|
|
||||||
port = component.get_port(port_name)
|
|
||||||
definition = port.definition
|
|
||||||
if (
|
|
||||||
definition is None
|
|
||||||
or definition.kind != "physical"
|
|
||||||
or definition.domain != "pneumatic"
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
f"Component {component.name} declares pneumatic volume output "
|
|
||||||
f"on non-pneumatic port {port_name}."
|
|
||||||
)
|
|
||||||
volume, volume_flow = (float(raw_values[0]), float(raw_values[1]))
|
|
||||||
if not isfinite(volume) or not isfinite(volume_flow):
|
|
||||||
raise ValueError(
|
|
||||||
f"Component {component.name}.{port_name} produced a non-finite "
|
|
||||||
"pneumatic volume value."
|
|
||||||
)
|
|
||||||
endpoint = Endpoint(component.name, port_name)
|
|
||||||
outputs[endpoint] = (volume, volume_flow)
|
|
||||||
port.volume = volume
|
|
||||||
port.volume_flow = volume_flow
|
|
||||||
|
|
||||||
propagated = 0
|
|
||||||
for endpoint, values in outputs.items():
|
|
||||||
binding = self._connected_endpoint.get(endpoint)
|
|
||||||
if binding is None:
|
|
||||||
continue
|
|
||||||
if binding.connected_endpoint in outputs:
|
|
||||||
raise ValueError(
|
|
||||||
"A pneumatic connection cannot contain two external-volume "
|
|
||||||
f"sources: {endpoint} and {binding.connected_endpoint}."
|
|
||||||
)
|
|
||||||
binding.connected_port.volume, binding.connected_port.volume_flow = values
|
|
||||||
propagated += 1
|
|
||||||
|
|
||||||
diagnostics = PneumaticVolumeDiagnostics(
|
|
||||||
propagated=propagated,
|
|
||||||
output_ports=tuple(sorted(str(endpoint) for endpoint in outputs)),
|
|
||||||
)
|
|
||||||
self.last_diagnostics = diagnostics
|
|
||||||
return diagnostics
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from math import isfinite
|
|
||||||
from typing import Callable, Protocol
|
|
||||||
|
|
||||||
from app.simulation.core.base import Component
|
|
||||||
from app.simulation.core.ports import PortState
|
|
||||||
from app.simulation.performance import profile_phase
|
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
|
||||||
|
|
||||||
|
|
||||||
class SignalOutputComponent(Protocol):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class SignalEventSource(Protocol):
|
|
||||||
"""Optional contract for signal sources with known time discontinuities."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def signal_event_times(
|
|
||||||
self,
|
|
||||||
start_time: float,
|
|
||||||
stop_time: float,
|
|
||||||
) -> tuple[float, ...]:
|
|
||||||
"""Return event times strictly inside ``(start_time, stop_time)``."""
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SignalSolveDiagnostics:
|
|
||||||
propagated: int
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
|
||||||
return {"propagated": self.propagated}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _SignalOutputBinding:
|
|
||||||
component: Component
|
|
||||||
evaluate: Callable[[float], dict[str, float]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _SignalConnectionBinding:
|
|
||||||
source: PortState
|
|
||||||
target: PortState
|
|
||||||
|
|
||||||
|
|
||||||
class SignalResolver:
|
|
||||||
"""Propagate scalar signal connections from output ports to input ports."""
|
|
||||||
|
|
||||||
def __init__(self, network: SimulationNetwork) -> None:
|
|
||||||
self.network = network
|
|
||||||
self._output_bindings = tuple(
|
|
||||||
_SignalOutputBinding(component=component, evaluate=evaluate)
|
|
||||||
for component in network.components.values()
|
|
||||||
if (evaluate := getattr(component, "signal_output_values", None)) is not None
|
|
||||||
)
|
|
||||||
self._event_sources = tuple(
|
|
||||||
(component.name, source_event_times)
|
|
||||||
for component in network.components.values()
|
|
||||||
if (
|
|
||||||
source_event_times := getattr(
|
|
||||||
component,
|
|
||||||
"signal_event_times",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
self._connections = tuple(
|
|
||||||
self._connection_binding(connection.endpoints)
|
|
||||||
for connection in network.connections
|
|
||||||
if connection.kind == "signal"
|
|
||||||
)
|
|
||||||
self.last_diagnostics: SignalSolveDiagnostics | None = None
|
|
||||||
|
|
||||||
@profile_phase("simulation.signal", minimum_mode="audit")
|
|
||||||
def solve(self, time: float) -> SignalSolveDiagnostics:
|
|
||||||
for binding in self._output_bindings:
|
|
||||||
for port_name, value in binding.evaluate(time).items():
|
|
||||||
binding.component.get_port(port_name).signal = float(value)
|
|
||||||
|
|
||||||
propagated = 0
|
|
||||||
for binding in self._connections:
|
|
||||||
binding.target.signal = binding.source.signal
|
|
||||||
propagated += 1
|
|
||||||
|
|
||||||
diagnostics = SignalSolveDiagnostics(propagated=propagated)
|
|
||||||
self.last_diagnostics = diagnostics
|
|
||||||
return diagnostics
|
|
||||||
|
|
||||||
def event_times(self, start_time: float, stop_time: float) -> tuple[float, ...]:
|
|
||||||
"""Collect optional source events that can be used as integration splits.
|
|
||||||
|
|
||||||
Event discovery is deliberately duck typed so existing signal-output
|
|
||||||
components remain valid without implementing ``signal_event_times``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
start = float(start_time)
|
|
||||||
stop = float(stop_time)
|
|
||||||
if not isfinite(start) or not isfinite(stop):
|
|
||||||
raise ValueError("Signal event interval must be finite.")
|
|
||||||
if stop < start:
|
|
||||||
raise ValueError("Signal event interval stop must not precede start.")
|
|
||||||
if stop == start:
|
|
||||||
return ()
|
|
||||||
|
|
||||||
events: set[float] = set()
|
|
||||||
for component_name, source_event_times in self._event_sources:
|
|
||||||
for raw_time in source_event_times(start, stop):
|
|
||||||
event_time = float(raw_time)
|
|
||||||
if not isfinite(event_time):
|
|
||||||
raise ValueError(
|
|
||||||
f"Signal event time from component '{component_name}' must be finite."
|
|
||||||
)
|
|
||||||
if start < event_time < stop:
|
|
||||||
events.add(event_time)
|
|
||||||
return tuple(sorted(events))
|
|
||||||
|
|
||||||
def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]:
|
|
||||||
first, second = endpoints
|
|
||||||
first_port = self.network.components[first.component].get_port(first.port)
|
|
||||||
second_port = self.network.components[second.component].get_port(second.port)
|
|
||||||
if first_port.definition is not None and first_port.definition.nominal_role == "output":
|
|
||||||
return first, second
|
|
||||||
if second_port.definition is not None and second_port.definition.nominal_role == "output":
|
|
||||||
return second, first
|
|
||||||
raise ValueError("Signal connection must contain one output endpoint.")
|
|
||||||
|
|
||||||
def _connection_binding(
|
|
||||||
self,
|
|
||||||
endpoints: tuple[Endpoint, Endpoint],
|
|
||||||
) -> _SignalConnectionBinding:
|
|
||||||
source, target = self._source_target(endpoints)
|
|
||||||
return _SignalConnectionBinding(
|
|
||||||
source=self.network.components[source.component].get_port(source.port),
|
|
||||||
target=self.network.components[target.component].get_port(target.port),
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user