Compare commits

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

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

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

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

已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
2026-08-03 15:45:48 +08:00
huojiarong de265cdde6 调整Playwright默认浏览器策略 2026-08-03 07:03:13 +00:00
249 changed files with 447008 additions and 4703 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.bat text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
+180
View File
@@ -0,0 +1,180 @@
name: Solver regression
on:
push:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
pull_request:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
schedule:
- cron: "17 3 * * 1-6"
- cron: "17 3 * * 0"
workflow_dispatch:
inputs:
suite:
description: Regression tier
required: true
default: quick
type: choice
options:
- quick
- historical
- main-long
case:
description: Longest main-model horizon (predecessors run first)
required: true
default: 0.2s
type: choice
options:
- 0.2s
- 1s
- 5s
- 10s
lane:
description: Output sampling lane
required: true
default: production
type: choice
options:
- solver-only
- production
concurrency:
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
quick:
if: >-
github.event_name == 'push' ||
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run solver foundation tests
env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: |
python -W error::ResourceWarning -m unittest \
tests.test_dependency_constraints \
tests.test_benchmark_regression \
tests.test_physical_state_v21 \
tests.test_test_mql_ame_contract \
tests.test_test_mql_8_regression \
tests.test_mql_full_branches_regression \
tests.test_pressure_flow_causal_execution \
tests.test_stream_pressure_block_solver \
tests.test_core_solver \
tests.test_supported_piston_tangent \
tests.test_three_piston_tangent \
tests.test_sparse_secant_jacobian \
tests.test_generic_jacobian_sparsity \
tests.test_generic_system_xml_simulation
historical-nightly:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run 0.81 and 2.10 second historical regression
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
--lane production \
--output artifacts/test-mql-full-branches.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: historical-solver-regression
path: artifacts/*.json
if-no-files-found: warn
main-periodic:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
runs-on: ubuntu-24.04
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run bounded progressive main-model regression
env:
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
--lane "$REQUESTED_LANE" \
--case "$REQUESTED_CASE" \
--output artifacts/test-mql-8-progressive.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: main-model-progressive-regression
path: artifacts/*.json
if-no-files-found: warn
+4
View File
@@ -12,6 +12,10 @@ htmlcov/
# Local virtual environments
.venv/
.venv-win/
# Local Linux toolchain (downloaded for the startup scripts)
.tools/node-*-linux-x64/
app/data/
frontend/node_modules/
frontend/dist/
+1
View File
@@ -0,0 +1 @@
3.12.3
+91 -15
View File
@@ -2,15 +2,97 @@
ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 开发环境准备
后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
`3.12.3`。`requirements.txt` 保留支持范围,
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
`constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64
wheel、全部传递依赖及其 SHA-256。
Windows:
```powershell
py -3.12 -m venv .venv-win
.\.venv-win\Scripts\python.exe -m pip install `
-r requirements.txt `
-c constraints/python312-direct.txt
.\.venv-win\Scripts\python.exe -m pip check
```
Linux:
```bash
python3.12 -m venv .venv
./.venv/bin/python -m pip install \
-r constraints/python312-linux-x86_64.lock
./.venv/bin/python -m pip check
```
Linux 发布锁仅适用于兼容 manylinux_2_28 的 Linux x86_64 和 CPython 3.12。它启用
`--only-binary=:all:` 与 `--require-hashes`,因此不会静默改用源码包或未审计 wheel;
CI 和正式性能复测必须直接以 `-r` 安装该文件。Windows 或其他平台的开发环境继续
使用 `requirements.txt` 加 `constraints/python312-direct.txt`。若要测试
`requirements.txt` 声明的兼容范围,可显式省略约束,但这类结果不应与锁定环境的
性能数据直接比较。
升级参考版本时,应在干净的 Python 3.12 Linux x86_64 虚拟环境中解析范围文件,
仅下载兼容 wheel,逐个记录 wheel 的 SHA-256,再从空环境安装发布锁并运行
`pip check`、依赖契约测试与后端测试。不能只复制 `pip freeze`,因为它既不证明
依赖来源,也不校验安装产物。
前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。
Windows(PowerShell,使用仓库内的便携 Node.js):
```powershell
$nodeDir = Get-ChildItem .tools -Directory -Filter "node-*-win-x64" |
Where-Object { (Test-Path "$($_.FullName)\node.exe") -and (Test-Path "$($_.FullName)\npm.cmd") } |
Select-Object -First 1
& "$($nodeDir.FullName)\npm.cmd" --prefix frontend ci
```
Linux:
```bash
cd frontend
npm ci
cd ..
```
Windows 启动脚本会自动使用 `.tools/node-*-win-x64` 下兼容的便携 Node.js;Linux 启动脚本优先使用 `.tools/node-*-linux-x64` 下兼容的运行时(如果存在),否则使用 `PATH` 中的 `node` 和 `npm`。`start-all.sh` 需要 Bash 4.3 或更高版本。
## 启动项目
脚本统一存放在 `bat/` 目录。三个入口分别用于同时启动、只启动后端、只启动前端。
Windows:
```bat
bat\start-all.bat
bat\start-backend.bat
bat\start-reactflow.bat
```
Linux:
```bash
./bat/start-all.sh
./bat/start-backend.sh
./bat/start-reactflow.sh
```
后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。
## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
- `POST /api/reactflow/system-xml`:导出 System XML v2。
- `POST /api/reactflow/system-xml`:导出精简的 System XML v3。
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
- `POST /api/reactflow/simulate-test-mql`:返回固定拓扑 AMESim `test_mql` 的结构与采样摘要;132 状态数值对比使用独立 comparison 入口。AMESim 子模型已有 19 个第一版公开模型,但该接口本身不是任意拖拽拓扑求解器。
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
- `POST /api/system-xml/validate`:接收原始 System XML v3,返回 XML、XSD 和模型语义三层诊断。
- `POST /api/system-xml/parse`:校验 XML,并返回可直接编译、求解的规范化模型数据;它不还原 ReactFlow 画布布局。
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
- `POST /api/system-xml/simulate`:按 XML 中的组件、连接、参数和仿真设置运行当前支持的气动、标量信号及一维机械网络 MVP,并返回组件及端口时间序列。
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
@@ -19,20 +101,14 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
```powershell
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
```
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
## 文档
- [开发文档索引](docs/README.md)
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
- [System XML v2 协议](docs/system-xml-v2.md)
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
+447 -200
View File
File diff suppressed because it is too large. Load diff
+36 -4
View File
@@ -33,8 +33,8 @@ FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
当前关键文件:
@@ -42,6 +42,8 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
- `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 专用单阻容管道近似,入口压降 + 出口直连内容腔
@@ -58,6 +60,36 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
- `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 件重点工作,现在的状态如下:
@@ -186,7 +218,7 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
`testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
13. 面向 System XML v3 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
当前没有实现:
@@ -279,7 +311,7 @@ print(result.used_modelica_reference)
## 基线结果
当前基线对比摘要来自:
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
[`testmodel_modelica_comparison_summary.txt`](../../tests/data/testmodel/testmodel_modelica_comparison_summary.txt)
当前四个主变量的最大误差为:
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import os
import platform
import statistics
import sys
from datetime import UTC, datetime
from math import ceil
from pathlib import Path
from time import perf_counter_ns, process_time_ns
from typing import Any
def _named_value(value: str, *, option: str) -> tuple[str, str]:
name, separator, target = value.partition("=")
if not separator or not name.strip() or not target.strip():
raise ValueError(
f"{option} must use NAME=VALUE syntax, received {value!r}."
)
return name.strip(), target.strip()
def _percentile(values: list[float], percentile: float) -> float:
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1))
return ordered[index]
def _duration_summary(values: list[float]) -> dict[str, object]:
return {
"samplesMs": values,
"minimumMs": min(values),
"medianMs": statistics.median(values),
"p95Ms": _percentile(values, 0.95),
"maximumMs": max(values),
}
def _load_factory_xml(specification: str) -> bytes:
module_name, separator, member_name = specification.partition(":")
if not separator or not module_name or not member_name:
raise ValueError(
"Factory specifications must use module.path:callable syntax."
)
factory = getattr(importlib.import_module(module_name), member_name)
value = factory()
if isinstance(value, bytes):
return value
if isinstance(value, str):
return value.encode("utf-8")
from app.main import build_reactflow_system_xml
return build_reactflow_system_xml(value)
def _serialize_result_event(result: dict[str, object]) -> bytes:
"""Render the final NDJSON payload shape used by the streaming endpoint."""
status = str(result.get("status", "completed"))
event = {
"event": "result",
"progress": 100 if status == "completed" else 0,
"phase": status,
"message": "仿真完成" if status == "completed" else "仿真任务结束",
"simulatedTime": result.get("simulatedUntil"),
"totalTime": result.get("requestedStopTime"),
"result": result,
}
return (
json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
).encode("utf-8")
def _run_case(
name: str,
xml_bytes: bytes,
*,
warmups: int,
runs: int,
cancellable_path: bool,
allow_failures: bool,
) -> dict[str, object]:
from app.main import run_system_xml_simulation
cancel_check = (lambda: False) if cancellable_path else None
for _ in range(warmups):
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
if not bool(result.get("success")) and not allow_failures:
raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}")
wall_samples_ms: list[float] = []
cpu_samples_ms: list[float] = []
serialization_samples_ms: list[float] = []
serialized_sizes: list[int] = []
profiles: list[dict[str, object]] = []
final_result: dict[str, object] | None = None
for _ in range(runs):
wall_start = perf_counter_ns()
cpu_start = process_time_ns()
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0)
wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0)
if not bool(result.get("success")) and not allow_failures:
raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}")
diagnostics = result.get("diagnostics")
if isinstance(diagnostics, dict):
performance = diagnostics.get("performance")
if isinstance(performance, dict):
profiles.append(performance)
serialization_start = perf_counter_ns()
serialized_event = _serialize_result_event(result)
serialization_samples_ms.append(
(perf_counter_ns() - serialization_start) / 1_000_000.0
)
serialized_sizes.append(len(serialized_event))
final_result = result
assert final_result is not None
return {
"name": name,
"success": bool(final_result.get("success")),
"message": final_result.get("message"),
"inputBytes": len(xml_bytes),
"inputSha256": hashlib.sha256(xml_bytes).hexdigest(),
"status": final_result.get("status"),
"simulatedUntil": final_result.get("simulatedUntil"),
"requestedStopTime": final_result.get("requestedStopTime"),
"wall": _duration_summary(wall_samples_ms),
"cpu": _duration_summary(cpu_samples_ms),
"resultSerialization": _duration_summary(serialization_samples_ms),
"resultEventBytes": serialized_sizes,
"performanceRuns": profiles,
}
def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Benchmark the real System XML simulation path with optional profiling."
)
parser.add_argument(
"--mode",
choices=("off", "standard", "audit"),
default="audit",
help="Instrumentation depth selected before importing the simulation modules.",
)
parser.add_argument("--warmups", type=int, default=1)
parser.add_argument("--runs", type=int, default=5)
parser.add_argument(
"--xml",
action="append",
default=[],
metavar="NAME=PATH",
help="Add an XML file benchmark case.",
)
parser.add_argument(
"--factory",
action="append",
default=[],
metavar="NAME=MODULE:CALLABLE",
help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.",
)
parser.add_argument(
"--direct-path",
action="store_true",
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
)
parser.add_argument(
"--disable-property-cache",
action="store_true",
help="Disable the run-local exact property cache for an A/B comparison.",
)
parser.add_argument(
"--allow-failures",
action="store_true",
help="Record failed simulation runs instead of aborting the benchmark.",
)
parser.add_argument("--output", type=Path)
arguments = parser.parse_args(argv)
if arguments.warmups < 0:
parser.error("--warmups must not be negative.")
if arguments.runs <= 0:
parser.error("--runs must be positive.")
if not arguments.xml and not arguments.factory:
parser.error("At least one --xml or --factory case is required.")
return arguments
def main(argv: list[str] | None = None) -> int:
arguments = _parse_arguments(argv)
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = (
"off" if arguments.disable_property_cache else "on"
)
cases: list[tuple[str, bytes]] = []
for raw_case in arguments.xml:
name, raw_path = _named_value(raw_case, option="--xml")
cases.append((name, Path(raw_path).read_bytes()))
for raw_case in arguments.factory:
name, specification = _named_value(raw_case, option="--factory")
cases.append((name, _load_factory_xml(specification)))
report: dict[str, Any] = {
"generatedAt": datetime.now(UTC).isoformat(),
"profileMode": arguments.mode,
"cancellableSolverPath": not arguments.direct_path,
"propertyCacheEnabled": not arguments.disable_property_cache,
"allowFailures": bool(arguments.allow_failures),
"warmups": arguments.warmups,
"runs": arguments.runs,
"runtime": {
"python": sys.version,
"platform": platform.platform(),
"processor": platform.processor(),
},
"cases": [
_run_case(
name,
xml_bytes,
warmups=arguments.warmups,
runs=arguments.runs,
cancellable_path=not arguments.direct_path,
allow_failures=arguments.allow_failures,
)
for name, xml_bytes in cases
],
}
text = json.dumps(report, ensure_ascii=False, indent=2)
if arguments.output is not None:
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(text + "\n", encoding="utf-8")
print(f"Performance report written to {arguments.output.resolve()}")
else:
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large. Load diff
@@ -19,6 +19,7 @@ class AmesimPnpl01(AlgebraicComponent):
MODEL_TYPE = "amesim_pnpl01"
MODEL_VERSION = "0.1.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
PARAMETERS = ()
RESULT_VARIABLES = ()
@@ -46,6 +47,9 @@ class AmesimPnpl01(AlgebraicComponent):
) -> AmesimPnpl01:
return cls(name=name)
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return (self.port_1.m_flow,)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
+439 -81
View File
@@ -1,33 +1,68 @@
from __future__ import annotations
from functools import lru_cache
from collections.abc import Mapping
from math import isclose, sqrt
from math import isclose, log, sqrt, tanh
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
normalize_amesim_gas_index,
)
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.catalog import (
ComponentDisplaySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterCondition,
ParameterDefinition,
ParameterOption,
ResultVariableDefinition,
)
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
_FLOW_COEFFICIENT_OPTIONS = (
ParameterOption(1.0, "Cq"),
ParameterOption(2.0, "Cv"),
ParameterOption(3.0, "Kv"),
)
_FLOWSET_USES_CQ = (ParameterCondition("flowset", (1.0,)),)
_FLOWSET_USES_CV = (ParameterCondition("flowset", (2.0,)),)
_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),)
_PN_PRESSURE_RATIO_ACCURACY = 0.9999
_PN_LAMINAR_SMOOTHING_GAIN = 12.0
_PNVO001_CLOSED_OPENING_ABS_TOL = 1.0e-12
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
id="flow_coefficient",
label="流量系数",
parameters=("cq", "area", "Cv", "Kv"),
order=10,
)
_PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
id="flow_coefficient",
label="流量系数",
parameters=("cq", "area0", "Cv", "Kv"),
order=10,
)
class AmesimPnor001(AlgebraicComponent):
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
This public component preserves the PNOR001 catalog/XML contract and uses a
finite bidirectional compressible-orifice approximation. The Siemens
`pn2rcqfix_` details remain a later calibration target.
This public component preserves the PNOR001 catalog/XML contract and uses
real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing.
"""
MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -43,6 +78,7 @@ class AmesimPnor001(AlgebraicComponent):
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"area",
@@ -53,6 +89,7 @@ class AmesimPnor001(AlgebraicComponent):
minimum=0.0,
maximum=1.0,
description="选择 Cq/面积方式时用于流量计算的有效孔口面积。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"Cv",
@@ -62,6 +99,7 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的英制流量系数。",
visible_when=_FLOWSET_USES_CV,
),
ParameterDefinition(
"Kv",
@@ -71,6 +109,7 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=0.0,
description="选择 Kv 方式时使用的公制流量系数。",
visible_when=_FLOWSET_USES_KV,
),
ParameterDefinition(
"flowset",
@@ -80,6 +119,8 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=1.0,
maximum=3.0,
editor="choice",
options=_FLOW_COEFFICIENT_OPTIONS,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
)
@@ -111,6 +152,7 @@ class AmesimPnor001(AlgebraicComponent):
PortDisplaySpec("port_2", "right", order=20),
),
order=10,
parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,),
)
def __init__(
@@ -151,6 +193,7 @@ class AmesimPnor001(AlgebraicComponent):
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
@@ -206,12 +249,114 @@ class AmesimPnor001(AlgebraicComponent):
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
return self.medium.T_ref
inlet_h = self._connected_h.get(port_name, port.h_outflow)
return max(
self.medium.temperature_from_pressure_enthalpy(
max(port.p, 1.0),
inlet_h,
),
1.0,
)
@staticmethod
def _subsonic_mass_flow_parameter(
*,
pressure_ratio: float,
gamma_s: float,
density: float,
upstream_temperature: float,
upstream_pressure: float,
) -> float:
expansion = (
pressure_ratio ** (2.0 * gamma_s)
- pressure_ratio ** (1.0 + gamma_s)
)
return sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* upstream_temperature
/ upstream_pressure
* expansion,
0.0,
)
)
@lru_cache(maxsize=32768)
def _one_way_flow_characteristics(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> tuple[float, float]:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma_s = self.medium.isentropic_density_pressure_factor(
p_up,
T_up,
p_down,
)
gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9)
density = max(self.medium.density(p_up, T_up), 1.0e-12)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** (
1.0 / (1.0 - gamma_s)
)
if pressure_ratio <= critical_ratio:
effective_pressure_ratio = critical_ratio
mass_flow_parameter = (
sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up)
* (2.0 * gamma_s / (gamma_s + 1.0))
** (gamma_s / (1.0 - gamma_s))
)
gas_velocity = sqrt(2.0 / (1.0 + gamma_s) * p_up / density)
else:
effective_pressure_ratio = pressure_ratio
mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=pressure_ratio,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
gas_velocity = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* p_up
/ density
* (1.0 - pressure_ratio ** (1.0 - gamma_s)),
0.0,
)
)
reference = self._subsonic_mass_flow_parameter(
pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
if mass_flow_parameter > 0.0 and reference > 0.0:
argument = (
_PN_LAMINAR_SMOOTHING_GAIN
* abs(mass_flow_parameter / reference)
* log(effective_pressure_ratio)
/ log(_PN_PRESSURE_RATIO_ACCURACY)
)
smoothing_factor = tanh(max(argument, 0.0))
mass_flow_parameter *= smoothing_factor
gas_velocity *= smoothing_factor
return mass_flow_parameter, gas_velocity
def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2 or self.effective_area == 0.0:
if (
isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8)
or self.effective_area == 0.0
):
return 0.0
if p_1 > p_2:
return self._one_way_mass_flow(
@@ -233,45 +378,49 @@ class AmesimPnor001(AlgebraicComponent):
upstream_temperature: float,
) -> float:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
if pressure_ratio <= critical_ratio:
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
2.0 / (gamma + 1.0)
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
else:
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
(gamma + 1.0) / gamma
mass_flow_parameter, _ = self._one_way_flow_characteristics(
upstream_pressure=p_up,
downstream_pressure=downstream_pressure,
upstream_temperature=T_up,
)
flow_factor = sqrt(
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
return (
self.effective_cq
* self.effective_area
* p_up
* mass_flow_parameter
/ sqrt(T_up)
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]:
p_1 = max(self.port_1.p, 1.0)
p_2 = max(self.port_2.p, 1.0)
m_flow = abs(self.mass_flow(self.port_1.p, self.port_2.p))
upstream_pressure = max(p_1, p_2)
upstream_temperature = self._upstream_temperature(
"port_1" if p_1 >= p_2 else "port_2"
if p_1 >= p_2:
upstream_port_name = "port_1"
upstream_pressure = p_1
downstream_pressure = p_2
flow_direction = 1.0
else:
upstream_port_name = "port_2"
upstream_pressure = p_2
downstream_pressure = p_1
flow_direction = -1.0
mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=upstream_pressure,
downstream_pressure=downstream_pressure,
upstream_temperature=self._upstream_temperature(upstream_port_name),
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
return {
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
"cm": mass_flow_parameter,
"gasvel": flow_direction * gas_velocity,
}
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return (
self.port_1.m_flow + self.port_2.m_flow,
self.port_1.m_flow - self.mass_flow(self.port_1.p, self.port_2.p),
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
@@ -303,6 +452,7 @@ class AmesimPnor001(AlgebraicComponent):
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self._connected_h = dict(connected_h)
self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"]
@@ -316,7 +466,11 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""
MODEL_TYPE = "amesim_pnvo001_fixed"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
@@ -332,6 +486,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"area0",
@@ -342,6 +497,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
minimum=0.0,
maximum=1.0,
description="阀门完全开启时的最大有效孔口面积。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"Cv",
@@ -351,6 +507,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的最大英制流量系数。",
visible_when=_FLOWSET_USES_CV,
),
ParameterDefinition(
"Kv",
@@ -360,6 +517,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=0.0,
description="选择 Kv 方式时使用的最大公制流量系数。",
visible_when=_FLOWSET_USES_KV,
),
ParameterDefinition(
"flowset",
@@ -369,6 +527,8 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=1.0,
maximum=3.0,
editor="choice",
options=_FLOW_COEFFICIENT_OPTIONS,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
ParameterDefinition(
@@ -414,10 +574,12 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
category_id="flow",
symbol="amesim_pnvo001_fixed",
ports=(
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
# AMESim default geometry places port 2 right and port 3 left.
PortDisplaySpec("port_2", "right", order=10),
PortDisplaySpec("port_3", "left", order=20),
),
order=30,
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
)
def __init__(
@@ -461,6 +623,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
@@ -507,12 +670,47 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
return self.medium.T_ref
# A component port's h_outflow describes fluid leaving the valve; the
# upstream state comes from the connection on that same physical side.
inlet_h = self._connected_h.get(port_name, port.h_outflow)
return max(
self.medium.temperature_from_pressure_enthalpy(
max(port.p, 1.0),
inlet_h,
),
1.0,
)
@staticmethod
def _subsonic_mass_flow_parameter(
*,
pressure_ratio: float,
gamma_s: float,
density: float,
upstream_temperature: float,
upstream_pressure: float,
) -> float:
expansion = (
pressure_ratio ** (2.0 * gamma_s)
- pressure_ratio ** (1.0 + gamma_s)
)
return sqrt(
max(
2.0
/ (1.0 - gamma_s)
* density
* upstream_temperature
/ upstream_pressure
* expansion,
0.0,
)
)
def mass_flow(self, p_2: float, p_3: float) -> float:
if p_2 == p_3 or self.effective_area == 0.0:
if (
isclose(p_2, p_3, rel_tol=0.0, abs_tol=1.0e-8)
or self.effective_area == 0.0
):
return 0.0
if p_2 > p_3:
return self._one_way_mass_flow(
@@ -526,6 +724,82 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
upstream_temperature=self._upstream_temperature("port_3"),
)
@lru_cache(maxsize=32768)
def _one_way_flow_characteristics(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> tuple[float, float]:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma_s = self.medium.isentropic_density_pressure_factor(
p_up,
T_up,
p_down,
)
gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9)
density = max(self.medium.density(p_up, T_up), 1.0e-12)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** (
1.0 / (1.0 - gamma_s)
)
if pressure_ratio <= critical_ratio:
effective_pressure_ratio = critical_ratio
mass_flow_parameter = (
sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up)
* (2.0 * gamma_s / (gamma_s + 1.0))
** (gamma_s / (1.0 - gamma_s))
)
gas_velocity = sqrt(
2.0 / (1.0 + gamma_s) * p_up / density
)
else:
effective_pressure_ratio = pressure_ratio
mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=pressure_ratio,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
gas_velocity = sqrt(
max(
2.0
/ (1.0 - gamma_s)
* p_up
/ density
* (1.0 - pressure_ratio ** (1.0 - gamma_s)),
0.0,
)
)
# AMESim's gas_cm_prc_ applies this factor continuously over the
# complete pressure-ratio range. It is effectively one outside the
# near-equal-pressure region and makes Cm (and vena-contracta
# velocity) approach zero quadratically as the pressure ratio tends
# to one. The reference Cm intentionally reuses the current gamma_s.
reference_mass_flow_parameter = self._subsonic_mass_flow_parameter(
pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY,
gamma_s=gamma_s,
density=density,
upstream_temperature=T_up,
upstream_pressure=p_up,
)
if mass_flow_parameter > 0.0 and reference_mass_flow_parameter > 0.0:
smoothing_argument = (
_PN_LAMINAR_SMOOTHING_GAIN
* abs(mass_flow_parameter / reference_mass_flow_parameter)
* log(effective_pressure_ratio)
/ log(_PN_PRESSURE_RATIO_ACCURACY)
)
smoothing_factor = tanh(max(smoothing_argument, 0.0))
mass_flow_parameter *= smoothing_factor
gas_velocity *= smoothing_factor
return mass_flow_parameter, gas_velocity
def _one_way_mass_flow(
self,
*,
@@ -534,46 +808,63 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
upstream_temperature: float,
) -> float:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
if pressure_ratio <= critical_ratio:
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
2.0 / (gamma + 1.0)
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
else:
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
(gamma + 1.0) / gamma
mass_flow_parameter, _gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=p_up,
downstream_pressure=downstream_pressure,
upstream_temperature=T_up,
)
flow_factor = sqrt(
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
return (
self.effective_cq
* self.effective_area
* p_up
* mass_flow_parameter
/ sqrt(T_up)
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]:
p_2 = max(self.port_2.p, 1.0)
p_3 = max(self.port_3.p, 1.0)
m_flow = abs(self.mass_flow(self.port_2.p, self.port_3.p))
upstream_pressure = max(p_2, p_3)
if p_2 >= p_3:
upstream_port_name = "port_2"
upstream_pressure = p_2
downstream_pressure = p_3
flow_direction = 1.0
else:
upstream_port_name = "port_3"
upstream_pressure = p_3
downstream_pressure = p_2
flow_direction = -1.0
upstream_temperature = self._upstream_temperature(
"port_2" if p_2 >= p_3 else "port_3"
upstream_port_name
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics(
upstream_pressure=upstream_pressure,
downstream_pressure=downstream_pressure,
upstream_temperature=upstream_temperature,
)
# AMESim reports no vena-contracta velocity while the valve is closed.
# Signal propagation around a step can leave a round-off-sized opening,
# so apply the same numerical-zero convention to this diagnostic only.
if isclose(
self.opening,
0.0,
rel_tol=0.0,
abs_tol=_PNVO001_CLOSED_OPENING_ABS_TOL,
):
gas_velocity = 0.0
return {
"xv": self.opening,
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
"cm": mass_flow_parameter,
"gasvel": flow_direction * gas_velocity,
}
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return (
self.port_2.m_flow + self.port_3.m_flow,
self.port_2.m_flow - self.mass_flow(self.port_2.p, self.port_3.p),
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
@@ -605,6 +896,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self._connected_h = dict(connected_h)
self.port_2.h_outflow = connected_h["port_3"]
self.port_3.h_outflow = connected_h["port_2"]
@@ -613,7 +905,12 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
MODEL_TYPE = "amesim_pnvo001"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
# Repeat the exact-sum promise on this concrete subclass deliberately.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -621,12 +918,70 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition("cq", 0.72, label="流量系数 Cq", quantity="dimensionless", unit="", minimum=1.0e-10, maximum=1.0),
ParameterDefinition("area0", 5.0e-6, label="最大孔口面积", quantity="area", unit="m2", minimum=0.0, maximum=1.0),
ParameterDefinition("Cv", 0.5, label="最大流量系数 Cv", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("Kv", 0.4, label="最大流量系数 Kv", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("flowset", 1.0, label="流量系数设置", quantity="dimensionless", unit="", minimum=1.0, maximum=3.0),
ParameterDefinition("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
ParameterDefinition(
"cq",
0.72,
label="流量系数 Cq",
quantity="dimensionless",
unit="",
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"area0",
5.0e-6,
label="最大孔口面积",
quantity="area",
unit="m2",
minimum=0.0,
maximum=1.0,
description="阀门完全开启时的最大有效孔口面积。",
visible_when=_FLOWSET_USES_CQ,
),
ParameterDefinition(
"Cv",
0.5,
label="最大流量系数 Cv",
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的最大英制流量系数。",
visible_when=_FLOWSET_USES_CV,
),
ParameterDefinition(
"Kv",
0.4,
label="最大流量系数 Kv",
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Kv 方式时使用的最大公制流量系数。",
visible_when=_FLOWSET_USES_KV,
),
ParameterDefinition(
"flowset",
1.0,
label="流量系数设置",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=3.0,
editor="choice",
options=_FLOW_COEFFICIENT_OPTIONS,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
ParameterDefinition(
"opening0",
1.0,
label="初始开度",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=1.0,
description="信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。",
),
)
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
@@ -636,10 +991,12 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
symbol="amesim_pnvo001",
ports=(
PortDisplaySpec("res", "left", order=5),
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
# AMESim default geometry places port 2 right and port 3 left.
PortDisplaySpec("port_2", "right", order=10),
PortDisplaySpec("port_3", "left", order=20),
),
order=35,
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
)
def __init__(
@@ -684,6 +1041,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
self._connected_h: dict[str, float] = {}
@classmethod
def create(
File diff suppressed because it is too large. Load diff
@@ -9,17 +9,56 @@ from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortState
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels."""
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO = 0.05
def _regularized_inverse_outflow(flow: float, transition_flow: float) -> float:
"""Return a C1 inverse that tends to zero as a negative flow vanishes."""
if flow >= 0.0:
return 0.0
transition_flow = max(float(transition_flow), 1.0e-12)
if -flow >= transition_flow:
return 1.0 / flow
return (
flow
* (2.0 * transition_flow * transition_flow - flow * flow)
/ transition_flow**4
)
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels.
PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference.
Non-reference outlet ports use that reference temperature. When port 2 is
an outlet, its enthalpy is the residual that closes the junction energy
balance, matching the AMESim dh2 causality.
"""
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
REFERENCE_PORT = "port_2"
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.temperature_reference_h = 0.0
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
def pressure_flow_equation_values(self) -> tuple[float, ...]:
reference = self.get_port(self.REFERENCE_PORT)
return tuple(
self.get_port(definition.name).p - reference.p
for definition in self.PORTS
if definition.name != self.REFERENCE_PORT
) + (
sum(
self.get_port(definition.name).m_flow
for definition in self.PORTS
),
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.get_port(self.REFERENCE_PORT)
residuals: list[EquationResidual] = []
@@ -58,6 +97,10 @@ class _AmesimPneumaticNode(AlgebraicComponent):
return tuple(residuals)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.temperature_reference_h = connected_h.get(
self.REFERENCE_PORT,
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
)
incoming = [
(port.m_flow, connected_h[name])
for name, port in self.ports.items()
@@ -67,19 +110,62 @@ class _AmesimPneumaticNode(AlgebraicComponent):
if total_flow > 1e-12:
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
else:
mixed_h = connected_h.get(
self.REFERENCE_PORT,
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
mixed_h = self.temperature_reference_h
reference_port = self.get_port(self.REFERENCE_PORT)
for name, port in self.ports.items():
port.h_outflow = (
mixed_h
if name == self.REFERENCE_PORT
else self.temperature_reference_h
)
if reference_port.m_flow < 0.0:
energy_without_reference = sum(
port.m_flow
* (
connected_h[name]
if port.m_flow > 1e-12
else self.temperature_reference_h
)
for name, port in self.ports.items()
if name != self.REFERENCE_PORT
)
non_reference_flow_scale = sum(
abs(port.m_flow)
for name, port in self.ports.items()
if name != self.REFERENCE_PORT
)
transition_flow = (
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO
* non_reference_flow_scale
)
# Port 2 carries AMESim's residual-energy causality. Exact
# division is singular when its outflow reverses through zero, so
# use a C1 band that matches the exact balance at its boundary and
# tends to the mixed enthalpy at zero flow.
inverse_flow = _regularized_inverse_outflow(
reference_port.m_flow,
transition_flow,
)
energy_residual_at_mixed_h = (
energy_without_reference
+ reference_port.m_flow * mixed_h
)
reference_port.h_outflow = (
mixed_h - energy_residual_at_mixed_h * inverse_flow
)
for port in self.ports.values():
port.h_outflow = mixed_h
class AmesimPn3Node2(_AmesimPneumaticNode):
"""AMESim PN3NODE2 pneumatic three-port junction."""
MODEL_TYPE = "amesim_pn3node2"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -115,7 +201,11 @@ class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = "amesim_p4node2"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -1,7 +1,8 @@
from __future__ import annotations
from collections.abc import Mapping
from math import pi
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite, pi
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
@@ -18,6 +19,18 @@ from app.simulation.core.ports import PortDefinition
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
@dataclass(frozen=True)
class Pnrp17Linearization:
volume: float
volume_flow: float
pressure_force: float
volume_tangent: tuple[float, ...]
volume_flow_tangent: tuple[float, ...]
pressure_force_tangent: tuple[float, ...]
valid: bool = True
reason: str | None = None
class AmesimPnrp17(AlgebraicComponent):
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
@@ -28,6 +41,7 @@ class AmesimPnrp17(AlgebraicComponent):
MODEL_TYPE = "amesim_pnrp17"
MODEL_VERSION = "0.1.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.mechanical_translational("port_2"),
@@ -93,8 +107,8 @@ class AmesimPnrp17(AlgebraicComponent):
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_3", "left", order=20),
PortDisplaySpec("port_4", "left", order=30),
PortDisplaySpec("port_2", "right", order=40),
PortDisplaySpec("port_2", "left", order=30),
PortDisplaySpec("port_4", "right", order=40),
PortDisplaySpec("port_5", "right", order=50),
),
order=60,
@@ -154,6 +168,22 @@ class AmesimPnrp17(AlgebraicComponent):
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] = [
@@ -213,6 +243,53 @@ class AmesimPnrp17(AlgebraicComponent):
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",
File diff suppressed because it is too large. Load diff
@@ -1,11 +1,21 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from math import exp, isfinite, log
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)
@@ -36,18 +46,25 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
"""AMESim helium with a Peng-Robinson mechanical equation of state.
The pressure-density-temperature relation is evaluated by the shared
``HELIUM_PR`` fluid. The first public AMESim port keeps the committed
constant-heat-capacity caloric model so it can be consumed through the
same :class:`GasMedium` contract as ideal-gas air.
``HELIUM_PR`` fluid. The caloric reference follows the constant NASA
polynomial from Simcenter Amesim 2404 ``helium_cp_h_s.data``.
"""
SUBSTANCE_ID: ClassVar[str] = "helium"
PROPERTY_METHOD_ID: ClassVar[str] = "peng_robinson"
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
nasa_cp_over_R: ClassVar[float] = 2.5
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (
0.7501594,
35.76324,
-2212.129,
0.9212635,
)
name: str = "AMESimHeliumPengRobinson"
R_gas: float = HELIUM_PR.specific_gas_constant
cp_ref: float = 5193.0
cp_ref: float = nasa_cp_over_R * HELIUM_PR.specific_gas_constant
T_ref: float = 293.15
cp_slope: float = 0.0
viscosity_ref: float = 1.96e-5
@@ -56,20 +73,411 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
@property
def cv(self) -> float:
return 3116.0
return (self.nasa_cp_over_R - 1.0) * self.R_gas
def cv_at_temperature(self, T: float) -> float:
del T
return self.cv
def diagnostic_dynamic_viscosity(self, T: float) -> float:
"""Return the AMESim NASA-table viscosity used by pipe diagnostics.
pn2pipefr reports Reynolds number with sagum viscosity. Keep this
separate from dynamic_viscosity so matching that diagnostic cannot
alter the already-validated pipe flow or friction dynamics.
"""
if T <= 0.0:
raise ValueError("Temperature must be positive.")
a, b, c, d = self.nasa_viscosity_coefficients
return 1.0e-7 * exp(a * log(T) + b / T + c / (T * T) + d)
@profile_property("density")
@cache_property_calculation("density")
def density(self, p: float, T: float) -> float:
return self.fluid.density(p, T)
def _real_heat_capacities(
self,
p: float,
T: float,
) -> tuple[float, float, float, float, float]:
density = self.density(p, T)
pressure_density_derivative = (
self.fluid.pressure_density_derivative_at_temperature(
T,
density,
)
)
pressure_temperature_derivative = (
self.fluid.pressure_temperature_derivative_at_density(
T,
density,
)
)
cv = (
self.cv_at_temperature(T)
+ self.fluid.residual_isochoric_heat_capacity_at_density(T, density)
)
cp = (
cv
+ T
* pressure_temperature_derivative
* pressure_temperature_derivative
/ (density * density * pressure_density_derivative)
)
if cp <= 0.0 or cv <= 0.0:
raise ValueError("Real-gas heat capacities must be positive.")
return (
cp,
cv,
density,
pressure_density_derivative,
pressure_temperature_derivative,
)
def _local_isentropic_density_pressure_factor(
self,
p: float,
T: float,
) -> tuple[float, float]:
cp, cv, density, pressure_density_derivative, pressure_temperature_derivative = (
self._real_heat_capacities(p, T)
)
heat_capacity_ratio = cp / cv
factor = p / (
density * pressure_density_derivative * heat_capacity_ratio
)
exponent = (
p
* (heat_capacity_ratio - 1.0)
/ (
heat_capacity_ratio
* T
* pressure_temperature_derivative
)
)
return factor, exponent
@profile_property("isentropic_density_pressure_factor")
@cache_property_calculation("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor(
self,
p: float,
T: float,
downstream_pressure: float | None = None,
) -> float:
upstream_factor, isentropic_temperature_exponent = (
self._local_isentropic_density_pressure_factor(p, T)
)
if downstream_pressure is None or downstream_pressure >= p:
return upstream_factor
pressure_ratio = max(downstream_pressure / p, 1.0e-12)
isentropic_temperature = max(
T * pressure_ratio**isentropic_temperature_exponent,
2.2,
)
downstream_factor, _unused_exponent = (
self._local_isentropic_density_pressure_factor(
max(downstream_pressure, 1.0),
isentropic_temperature,
)
)
# AMESim 2404 saggs_ evaluates the local factor at the upstream
# state and at an approximate isentropic downstream state.
return 0.5 * (upstream_factor + downstream_factor)
def pressure(self, m: float, T: float, V: float) -> float:
if V <= 0.0:
raise ValueError("Volume must stay positive.")
return self.fluid.pressure_from_density(T, m / V)
@profile_property("specific_internal_energy")
def specific_internal_energy(self, T: float) -> float:
return self.R_gas * (
(self.nasa_cp_over_R - 1.0) * T
+ self.nasa_enthalpy_constant_K
)
@profile_property("specific_internal_energy_at_pressure")
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
density = self.density(p, T)
return (
self.specific_internal_energy(T)
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
)
@profile_property("specific_enthalpy")
def specific_enthalpy(self, T: float) -> float:
return self.R_gas * (
self.nasa_cp_over_R * T
+ self.nasa_enthalpy_constant_K
)
@profile_property("specific_enthalpy_at_pressure")
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
def temperature_from_internal_energy(self, u: float) -> float:
return (
u / self.R_gas - self.nasa_enthalpy_constant_K
) / (self.nasa_cp_over_R - 1.0)
def temperature_from_enthalpy(self, h: float) -> float:
return (
h / self.R_gas - self.nasa_enthalpy_constant_K
) / self.nasa_cp_over_R
@profile_property("temperature_from_pressure_enthalpy")
@cache_property_calculation("temperature_from_pressure_enthalpy")
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
temperature = max(self.temperature_from_enthalpy(h), 2.2)
for _iteration in range(16):
residual_enthalpy = self.fluid.residual_specific_enthalpy(p, temperature)
next_temperature = max(
self.temperature_from_enthalpy(h - residual_enthalpy),
2.2,
)
if abs(next_temperature - temperature) <= 1.0e-10 * max(
temperature,
1.0,
):
record_property_iterations(
"temperature_from_pressure_enthalpy",
_iteration + 1,
True,
)
return next_temperature
temperature = next_temperature
record_property_iterations(
"temperature_from_pressure_enthalpy",
16,
False,
)
return temperature
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
return self.temperature_from_internal_energy(U / m)
@profile_property("properties_from_mU")
@cache_property_calculation("properties_from_mU")
def properties_from_mU(
self,
m: float,
U: float,
V: float,
) -> ThermodynamicProperties:
"""Recover a real-gas state, reusing exact repeated evaluations.
Implicit integration asks several component interfaces for the same
``(m, U, V)`` state while closing one RHS evaluation and while building
finite-difference Jacobians. The calculation is pure and its result is
immutable, so an exact-key bounded cache avoids repeating the
Peng-Robinson temperature iteration without changing model semantics.
"""
if m <= 0.0:
raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
if V <= 0.0:
raise ValueError("Volume must stay positive.")
density = m / V
target_internal_energy = U / m
temperature = max(
self.temperature_from_internal_energy(target_internal_energy),
2.2,
)
converged = False
for _iteration in range(16):
residual_internal_energy = (
self.fluid.residual_specific_internal_energy_at_density(
temperature,
density,
)
)
next_temperature = max(
self.temperature_from_internal_energy(
target_internal_energy - residual_internal_energy
),
2.2,
)
if abs(next_temperature - temperature) <= 1.0e-10 * max(
temperature,
1.0,
):
temperature = next_temperature
converged = True
break
temperature = next_temperature
record_property_iterations(
"properties_from_mU",
_iteration + 1,
converged,
)
pressure = self.fluid.pressure_from_density(temperature, density)
return ThermodynamicProperties(
p=pressure,
T=temperature,
rho=density,
u=target_internal_energy,
h=self.specific_enthalpy_at_pressure(
pressure,
temperature,
),
)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Implicitly differentiate the Peng-Robinson m/U/V recovery."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
def invalid(reason: str) -> ThermodynamicPropertiesLinearization:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason=reason,
)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return invalid("properties_primal_mismatch")
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return invalid("non_finite_tangent_input")
if props.T <= 2.2 * (1.0 + 1.0e-10):
return invalid("temperature_floor_boundary")
pressure_temperature_derivative = (
self.fluid.pressure_temperature_derivative_at_density(
props.T,
props.rho,
)
)
pressure_density_derivative = (
self.fluid.pressure_density_derivative_at_temperature(
props.T,
props.rho,
)
)
cv = (
self.cv_at_temperature(props.T)
+ self.fluid.residual_isochoric_heat_capacity_at_density(
props.T,
props.rho,
)
)
recovered_internal_energy = (
self.specific_internal_energy(props.T)
+ self.fluid.residual_specific_internal_energy_at_density(
props.T,
props.rho,
)
)
recovery_scale = max(
abs(props.u),
abs(cv * props.T) if isfinite(cv) else 0.0,
1.0,
)
if (
not all(
isfinite(value)
for value in (
pressure_temperature_derivative,
pressure_density_derivative,
cv,
recovered_internal_energy,
)
)
or cv <= 0.0
):
return invalid("invalid_peng_robinson_derivative")
if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale:
return invalid("properties_recovery_not_converged")
internal_energy_density_derivative = (
props.p - props.T * pressure_temperature_derivative
) / (props.rho * props.rho)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = (
mass_tangent / V - m * volume_tangent / (V * V)
)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = (
internal_energy_tangent
- internal_energy_density_derivative * density_tangent
) / cv
pressure_tangent = (
pressure_temperature_derivative * temperature_tangent
+ pressure_density_derivative * density_tangent
)
enthalpy_tangent = (
internal_energy_tangent
+ pressure_tangent / props.rho
- props.p * density_tangent / (props.rho * props.rho)
)
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
if not all(isfinite(value) for value in tangent_values):
return invalid("non_finite_property_tangent")
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
)
@dataclass(frozen=True)
class AmesimGasPropertyModelSpec:
@@ -4,12 +4,71 @@ from collections.abc import Mapping
from math import floor
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.catalog import (
ComponentDisplaySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
ParameterCondition,
ParameterDefinition,
ParameterOption,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]:
visible_when = (
()
if index == 1
else (
ParameterCondition(
"nstages",
tuple(float(stage_count) for stage_count in range(index, 9)),
),
)
)
return (
ParameterDefinition(
f"start{index}",
0.0 if index == 1 else 1.0,
label=f"第 {index} 段起点",
quantity="dimensionless",
unit="",
description=f"第 {index} 段开始时的输出值。",
visible_when=visible_when,
),
ParameterDefinition(
f"end{index}",
1.0,
label=f"第 {index} 段终点",
quantity="dimensionless",
unit="",
description=f"第 {index} 段结束时的输出值。",
visible_when=visible_when,
),
ParameterDefinition(
f"t{index}",
1.0 if index == 1 else 0.0,
label=f"第 {index} 段时长",
quantity="time",
unit="s",
minimum=0.0,
description=f"第 {index} 段的持续时间。",
visible_when=visible_when,
),
)
_UD00_STAGE_PARAMETERS = tuple(
parameter
for stage_index in range(1, 9)
for parameter in _ud00_stage_parameters(stage_index)
)
class AmesimStep0(AlgebraicComponent):
"""AMESim STEP0 scalar step signal source."""
@@ -89,36 +148,48 @@ class AmesimUd00(AlgebraicComponent):
"""AMESim UD00 piecewise-linear scalar signal source."""
MODEL_TYPE = "amesim_ud00"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
PARAMETERS = (
ParameterDefinition("tstart", 0.0, label="启动时间", quantity="time", unit="s"),
ParameterDefinition("start1", 0.0, label="第 1 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end1", 1.0, label="第 1 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t1", 1.0, label="第 1 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start2", 1.0, label="第 2 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end2", 1.0, label="第 2 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t2", 0.0, label="第 2 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start3", 1.0, label="第 3 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end3", 1.0, label="第 3 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t3", 0.0, label="第 3 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start4", 1.0, label="第 4 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end4", 1.0, label="第 4 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t4", 0.0, label="第 4 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start5", 1.0, label="第 5 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end5", 1.0, label="第 5 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t5", 0.0, label="第 5 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start6", 1.0, label="第 6 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end6", 1.0, label="第 6 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t6", 0.0, label="第 6 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start7", 1.0, label="第 7 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end7", 1.0, label="第 7 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t7", 0.0, label="第 7 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start8", 1.0, label="第 8 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end8", 1.0, label="第 8 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t8", 0.0, label="第 8 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("nstages", 1.0, label="段数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
ParameterDefinition("iscyclic", 0.0, label="循环", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
ParameterDefinition(
"tstart",
0.0,
label="启动时间",
quantity="time",
unit="s",
description="分段信号开始输出第一段之前的等待时间。",
),
*_UD00_STAGE_PARAMETERS,
ParameterDefinition(
"nstages",
1.0,
label="段数",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=8.0,
editor="choice",
options=tuple(
ParameterOption(float(stage_count), str(stage_count))
for stage_count in range(1, 9)
),
description="参与输出计算的有效线性分段数量。",
),
ParameterDefinition(
"iscyclic",
0.0,
label="循环",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=1.0,
editor="choice",
options=(
ParameterOption(0.0, "否"),
ParameterOption(1.0, "是"),
),
description="当前公共协议编码:0 表示单次输出,1 表示循环输出。",
),
)
RESULT_VARIABLES = (
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
@@ -130,6 +201,16 @@ class AmesimUd00(AlgebraicComponent):
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__(
@@ -174,10 +255,17 @@ class AmesimUd00(AlgebraicComponent):
) -> "AmesimUd00":
nstages = parameters["nstages"]
iscyclic = parameters["iscyclic"]
if not float(nstages).is_integer():
raise ValueError("UD00 nstages must be an integer.")
if not float(iscyclic).is_integer():
raise ValueError("UD00 iscyclic must be 0 or 1.")
definitions = {definition.name: definition for definition in cls.PARAMETERS}
for parameter_name, value in (
("nstages", nstages),
("iscyclic", iscyclic),
):
numeric_value = float(value)
if not numeric_value.is_integer():
raise ValueError(f"UD00 {parameter_name} must be an integer.")
message = definitions[parameter_name].validation_message(numeric_value)
if message is not None:
raise ValueError(f"UD00 {parameter_name} {message}.")
return cls(
name=name,
medium=medium,
@@ -1,6 +1,8 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite
from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER,
@@ -14,11 +16,24 @@ from app.simulation.core.metadata import (
ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
from app.simulation.core.medium import (
GasMedium,
ThermodynamicProperties,
ThermodynamicPropertiesLinearization,
)
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
class Pnch012DerivativeLinearization:
derivative: tuple[float, float]
tangents: tuple[tuple[float, ...], tuple[float, ...]]
properties: ThermodynamicPropertiesLinearization
valid: bool = True
reason: str | None = None
class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
@@ -142,9 +157,9 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
self.p0 = float(p0)
self.T0 = float(T0)
m0 = medium.density(self.p0, self.T0) * self.cvol
U0 = m0 * medium.specific_internal_energy(self.T0)
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0)
self.port_1 = self.register_declared_port("port_1")
self.port_1.p = self.p0
self.port_1.h_outflow = initial_h
@@ -217,6 +232,17 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
)
return derivative.as_vector()
def pressure_flow_equation_values(self) -> tuple[float, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.cvol,
).p
return (
self.port_1.p - pressure,
self.port_2.p - pressure,
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
@@ -413,9 +439,9 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
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(self.T0)
U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0)
for port_name in ("port_1", "port_2", "port_3", "port_4"):
port = self.register_declared_port(port_name)
port.p = self.p0
@@ -507,6 +533,143 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
energy_derivative -= props.p * self.total_volume_rate()
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
def linearize_state_derivative(
self,
connected_h: Mapping[str, float],
*,
state_mass_tangent: Sequence[float],
state_energy_tangent: Sequence[float],
external_volume_tangent: Sequence[float],
external_volume_rate_tangent: Sequence[float],
port_mass_flow_tangents: Mapping[str, Sequence[float]],
connected_h_tangents: Mapping[str, Sequence[float]],
property_linearization: ThermodynamicPropertiesLinearization | None = None,
flow_boundary_tolerance: float = 1.0e-12,
) -> Pnch012DerivativeLinearization:
"""Linearize the chamber balance while keeping stream modes fixed."""
port_names = ("port_1", "port_2", "port_3", "port_4")
vectors = {
"state_mass": tuple(float(value) for value in state_mass_tangent),
"state_energy": tuple(float(value) for value in state_energy_tangent),
"volume": tuple(float(value) for value in external_volume_tangent),
"volume_rate": tuple(
float(value) for value in external_volume_rate_tangent
),
}
for port_name in port_names:
vectors[f"flow:{port_name}"] = tuple(
float(value) for value in port_mass_flow_tangents[port_name]
)
vectors[f"enthalpy:{port_name}"] = tuple(
float(value) for value in connected_h_tangents[port_name]
)
widths = {len(values) for values in vectors.values()}
if len(widths) != 1:
raise ValueError("PNCH012 tangent vectors must have equal lengths.")
width = len(vectors["state_mass"])
invalid_reason: str | None = None
if not all(isfinite(value) for values in vectors.values() for value in values):
invalid_reason = "non_finite_tangent_input"
raw_volume = (
self.cvol0
+ sum(self.external_volumes.values())
+ self.connected_external_volume()
)
minimum_volume = self.cvol0 / 100.0
volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18)
on_volume_boundary = (
abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale
)
supplied_volume_tangent = vectors["volume"]
if raw_volume < minimum_volume or on_volume_boundary:
used_volume_tangent = (0.0,) * width
used_volume_rate_tangent = (0.0,) * width
if on_volume_boundary and any(
value != 0.0
for value in (
*supplied_volume_tangent,
*vectors["volume_rate"],
)
):
invalid_reason = invalid_reason or "volume_floor_boundary"
else:
used_volume_tangent = supplied_volume_tangent
used_volume_rate_tangent = vectors["volume_rate"]
properties = property_linearization or self.medium.linearize_properties_from_mU(
self.state.m,
self.state.U,
self.total_volume(),
vectors["state_mass"],
vectors["state_energy"],
used_volume_tangent,
)
if properties.tangents.width != width:
raise ValueError(
"PNCH012 property tangent width must match balance tangents."
)
props = properties.properties
if not properties.valid:
invalid_reason = invalid_reason or properties.reason
mass_derivative = sum(
self.get_port(port_name).m_flow for port_name in port_names
)
volume_rate = self.total_volume_rate()
energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate
mass_tangent = [0.0] * width
energy_tangent = [
-self.kth * self.sth * properties.tangents.T[index]
- volume_rate * properties.tangents.p[index]
- props.p * used_volume_rate_tangent[index]
for index in range(width)
]
for port_name in port_names:
port = self.get_port(port_name)
flow_tangent = vectors[f"flow:{port_name}"]
if (
abs(port.m_flow) <= flow_boundary_tolerance
and any(value != 0.0 for value in flow_tangent)
):
invalid_reason = invalid_reason or (
f"flow_direction_boundary:{port_name}"
)
if port.m_flow > 0.0:
inlet_h = connected_h[port_name]
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
else:
inlet_h = props.h
inlet_h_tangent = properties.tangents.h
energy_derivative += port.m_flow * inlet_h
for index in range(width):
mass_tangent[index] += flow_tangent[index]
energy_tangent[index] += (
inlet_h * flow_tangent[index]
+ port.m_flow * inlet_h_tangent[index]
)
return Pnch012DerivativeLinearization(
derivative=(mass_derivative, energy_derivative),
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
properties=properties,
valid=invalid_reason is None,
reason=invalid_reason,
)
def pressure_flow_equation_values(self) -> tuple[float, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.total_volume(),
).p
return tuple(
self.get_port(port_name).p - pressure
for port_name in ("port_1", "port_2", "port_3", "port_4")
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
+2 -2
View File
@@ -1,7 +1,7 @@
# 元件建模规范与示例
规范的权威版本位于
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
修改中同步,不能让示例形成另一套规则。
@@ -279,4 +279,4 @@ models=(
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。
@@ -1,12 +1,3 @@
"""Temporary component library used to validate the model authoring contract."""
from app.simulation.components.experimental.library import LIBRARY
# Compatibility aliases for code written before the v1 library manifest.
LIBRARY_ID = LIBRARY.id
LIBRARY_LABEL = LIBRARY.label
LIBRARY_VERSION = LIBRARY.version
LIBRARY_ORDER = LIBRARY.order
LIBRARY_SOURCE_PACKAGE = LIBRARY.source_package
LIBRARY_TEMPORARY = LIBRARY.temporary
@@ -16,6 +16,10 @@ class Orifice(AlgebraicComponent):
MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -115,4 +119,3 @@ class Orifice(AlgebraicComponent):
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
@@ -16,6 +16,10 @@ class ResistivePipe(AlgebraicComponent):
MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -14,6 +14,10 @@ class Tee(AlgebraicComponent):
MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
+81 -3
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
@@ -21,6 +21,20 @@ if TYPE_CHECKING:
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
# ``True`` means that pressure/flow residuals read values written by
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
# ``False`` is an explicit promise that those residuals are independent of
# stream propagation. ``None`` keeps custom components conservative: when
# they override either stream hook, the closure planner retains the legacy
# full-network thermofluid fixed point.
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
# Exact residual suffixes whose declared variables are summed, in order,
# to form a ``sumToZero`` flow equation. The causal solver deliberately
# reads this capability from the concrete class ``__dict__``: subclasses
# must repeat the promise after changing any equation semantics.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
frozenset[str]
] = frozenset()
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
@@ -44,6 +58,31 @@ class Component(ABC):
if port.definition is not None
)
@classmethod
def active_port_definitions_for_parameters(
cls,
parameters: Mapping[str, float],
) -> tuple[PortDefinition, ...]:
"""Declared ports enabled by one normalized parameter set."""
return cls.PORTS
@property
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
"""Instance ports that participate in execution and result reporting."""
return self.port_definitions
@property
def required_connection_ports(self) -> tuple[str, ...]:
"""Physical ports that must have an external connection before simulation."""
return tuple(
definition.name
for definition in self.active_port_definitions
if definition.kind == "physical"
)
def register_port(self, port: PortState) -> PortState:
definition = port.definition
if definition is None:
@@ -124,7 +163,7 @@ class Component(ABC):
)
values[name] = float(component_values[name])
for port_definition in self.port_definitions:
for port_definition in self.active_port_definitions:
port = self.get_port(port_definition.name)
for variable in port_definition.variables:
if not variable.result_visible:
@@ -151,7 +190,7 @@ class Component(ABC):
for definition in self.RESULT_VARIABLES
if definition.visible
]
for port_definition in self.port_definitions:
for port_definition in self.active_port_definitions:
for variable in port_definition.variables:
if not variable.result_visible:
continue
@@ -199,11 +238,50 @@ class Component(ABC):
return ()
def pressure_flow_equation_values(self) -> tuple[float, ...]:
"""Return live residual values in the declared equation order.
Components with frequently evaluated equations can override this
method to avoid rebuilding immutable equation metadata during closure.
The default keeps third-party components compatible with the public
residual API.
"""
return tuple(
float(equation.value)
for equation in self.pressure_flow_equation_residuals()
)
def pressure_flow_equation_value_readers(
self,
) -> Mapping[str, Callable[[], float]]:
"""Return explicitly separable scalar residual readers.
The solver consumes this optional capability only when the concrete
component class declares the method itself. Subclasses therefore
cannot accidentally inherit an equation-purity promise.
"""
return {}
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
"""Update connector outflow properties from current flow directions."""
return None
def update_flow_temperature_references(
self,
connected_h: Mapping[str, float],
) -> None:
"""Update enthalpy references used only by pressure-flow laws.
Most components use the normal stream enthalpy for both energy
transport and upstream-property evaluation. AMESim node submodels can
expose a distinct temperature reference, so the default is a no-op.
"""
return None
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
"""Return directed ``volume``/``volume_flow`` values by pneumatic port.
+21
View File
@@ -33,6 +33,26 @@ class PortDisplaySpec:
order: int = 0
@dataclass(frozen=True)
class ParameterGroupDisplaySpec:
"""Ordered, collapsible presentation group for component parameters."""
id: str
label: str
parameters: tuple[str, ...]
order: int = 0
default_expanded: bool = False
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"parameters": list(self.parameters),
"order": self.order,
"defaultExpanded": self.default_expanded,
}
@dataclass(frozen=True)
class ComponentDisplaySpec:
"""Frontend metadata co-located with a component implementation."""
@@ -44,6 +64,7 @@ class ComponentDisplaySpec:
ports: tuple[PortDisplaySpec, ...]
order: int = 0
role: ComponentCatalogRole | None = None
parameter_groups: tuple[ParameterGroupDisplaySpec, ...] = ()
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
+1 -1
View File
@@ -10,7 +10,7 @@ EquationOwner = Literal["connection", "component"]
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class EquationResidual:
"""One executable scalar equation in the pressure-flow subsystem."""
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
class RecoverableTrialStateError(ValueError):
"""A physical-domain failure caused by an integrator trial state."""
+205 -2
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
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)
@@ -13,6 +17,36 @@ class ThermodynamicProperties:
h: float
@dataclass(frozen=True)
class ThermodynamicPropertyTangents:
"""Directional derivatives of a recovered thermodynamic state."""
p: tuple[float, ...]
T: tuple[float, ...]
rho: tuple[float, ...]
u: tuple[float, ...]
h: tuple[float, ...]
@property
def width(self) -> int:
return len(self.p)
@classmethod
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
values = (0.0,) * width
return cls(p=values, T=values, rho=values, u=values, h=values)
@dataclass(frozen=True)
class ThermodynamicPropertiesLinearization:
"""Primal properties and a validity-checked directional linearization."""
properties: ThermodynamicProperties
tangents: ThermodynamicPropertyTangents
valid: bool = True
reason: str | None = None
class GasMedium(Protocol):
"""Thermodynamic contract required by pneumatic components.
@@ -38,16 +72,31 @@ class GasMedium(Protocol):
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: ...
@@ -59,6 +108,18 @@ class GasMedium(Protocol):
V: float,
) -> ThermodynamicProperties: ...
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization: ...
@dataclass(frozen=True)
class IdealGasMedium:
@@ -93,9 +154,24 @@ class IdealGasMedium:
def cv_at_temperature(self, T: float) -> float:
return self.cp_at_temperature(T) - self.R_gas
@profile_property("density")
def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T)
@profile_property("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor(
self,
p: float,
T: float,
downstream_pressure: float | None = None,
) -> float:
del p
del downstream_pressure
cp = self.cp_at_temperature(T)
cv = self.cv_at_temperature(T)
return cv / cp
@profile_property("dynamic_viscosity")
def dynamic_viscosity(self, T: float) -> float:
"""Return dynamic viscosity using the default air Sutherland law."""
@@ -108,6 +184,17 @@ class IdealGasMedium:
/ (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 (
@@ -116,6 +203,12 @@ class IdealGasMedium:
+ 0.5 * self.cp_slope * delta_T * delta_T
)
@profile_property("specific_internal_energy_at_pressure")
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
del p
return self.specific_internal_energy(T)
@profile_property("specific_enthalpy")
def specific_enthalpy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
@@ -124,6 +217,11 @@ class IdealGasMedium:
+ 0.5 * self.cp_slope * delta_T * delta_T
)
@profile_property("specific_enthalpy_at_pressure")
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
del p
return self.specific_enthalpy(T)
def temperature_from_internal_energy(self, u: float) -> float:
reference_internal_energy = self.cv * self.T_ref
delta_u = u - reference_internal_energy
@@ -156,9 +254,16 @@ class IdealGasMedium:
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
return self.T_ref + delta_T
@profile_property("temperature_from_pressure_enthalpy")
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
del p
return self.temperature_from_enthalpy(h)
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.")
raise RecoverableTrialStateError(
"Mass must stay positive when recovering temperature."
)
return self.temperature_from_internal_energy(U / m)
def pressure(self, m: float, T: float, V: float) -> float:
@@ -166,6 +271,7 @@ class IdealGasMedium:
raise ValueError("Volume must stay positive.")
return m * self.R_gas * T / V
@profile_property("properties_from_mU")
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
T = self.temperature_from_mass_internal_energy(m, U)
p = self.pressure(m, T, V)
@@ -173,3 +279,100 @@ class IdealGasMedium:
u = U / m
h = self.specific_enthalpy(T)
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Linearize properties_from_mU for several seed directions."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="properties_primal_mismatch",
)
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_finite_tangent_input",
)
cv = self.cv_at_temperature(props.T)
cp = self.cp_at_temperature(props.T)
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_positive_heat_capacity",
)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = internal_energy_tangent / cv
pressure_tangent = self.R_gas * (
props.T * density_tangent + props.rho * temperature_tangent
)
enthalpy_tangent = cp * temperature_tangent
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
valid = all(isfinite(value) for value in tangent_values)
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
valid=valid,
reason=None if valid else "non_finite_property_tangent",
)
+25 -1
View File
@@ -9,6 +9,7 @@ ResultVariableScope = Literal["component", "port"]
ParameterEditor = Literal[
"amesimGasReference",
"amesimGasPropertyModel",
"choice",
]
@@ -52,9 +53,27 @@ class ParameterOption:
}
@dataclass(frozen=True)
class ParameterCondition:
"""Match when the controlling parameter equals any declared value."""
parameter: str
values: tuple[float, ...]
def as_interface_dict(self) -> dict[str, object]:
return {
"parameter": self.parameter,
"values": list(self.values),
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
"""User-configurable model input expressed in the backend SI contract.
Every ``visible_when`` condition must match for the catalog parameter to
be visible; each individual condition matches any one of its ``values``.
"""
name: str
default: float
@@ -67,6 +86,7 @@ class ParameterDefinition:
editor: ParameterEditor | None = None
options: tuple[ParameterOption, ...] = ()
description: str = ""
visible_when: tuple[ParameterCondition, ...] = ()
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
@@ -106,6 +126,10 @@ class ParameterDefinition:
]
if self.description:
payload["description"] = self.description
if self.visible_when:
payload["visibleWhen"] = [
condition.as_interface_dict() for condition in self.visible_when
]
if value is not None:
payload["value"] = value
return payload
+193 -6
View File
@@ -1,9 +1,17 @@
from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError
from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt
from app.simulation.performance import profile_property
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
PENG_ROBINSON_A_COEFFICIENT = 0.457235583
PENG_ROBINSON_B_COEFFICIENT = 0.07779607
@dataclass(frozen=True)
@@ -27,7 +35,7 @@ class PengRobinsonFluid:
@property
def a_parameter(self) -> float:
return (
0.45724
PENG_ROBINSON_A_COEFFICIENT
* UNIVERSAL_GAS_CONSTANT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
@@ -37,7 +45,12 @@ class PengRobinsonFluid:
@property
def b_parameter(self) -> float:
return 0.07780 * UNIVERSAL_GAS_CONSTANT * self.critical_temperature / self.critical_pressure
return (
PENG_ROBINSON_B_COEFFICIENT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
/ self.critical_pressure
)
@property
def kappa(self) -> float:
@@ -60,27 +73,112 @@ class PengRobinsonFluid:
/ (self.critical_temperature * sqrt_reduced_temperature)
)
def alpha_temperature_second_derivative(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
sqrt_reduced_temperature = sqrt(reduced_temperature)
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
return (
self.kappa
/ (2.0 * self.critical_temperature * self.critical_temperature)
* (
self.kappa / reduced_temperature
+ alpha_base / (reduced_temperature * sqrt_reduced_temperature)
)
)
def attractive_parameter(self, temperature: float) -> float:
return self.a_parameter * self.alpha(temperature)
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
return self.a_parameter * self.alpha_temperature_derivative(temperature)
def attractive_parameter_temperature_second_derivative(
self,
temperature: float,
) -> float:
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
@profile_property(
"pressure_from_molar_volume",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature)
if molar_volume <= self.b_parameter:
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.")
raise RecoverableTrialStateError("Molar volume must be larger than Peng-Robinson b parameter.")
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
return repulsive - attractive
@profile_property(
"pressure_from_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_density(self, temperature: float, density: float) -> float:
if density <= 0.0:
raise ValueError("Density must be positive.")
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
@profile_property(
"pressure_temperature_derivative_at_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_temperature_derivative_at_density(
self,
temperature: float,
density: float,
) -> float:
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
b = self.b_parameter
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
return (
UNIVERSAL_GAS_CONSTANT / (molar_volume - b)
- self.attractive_parameter_temperature_derivative(temperature) / denominator
)
@profile_property(
"pressure_density_derivative_at_temperature",
layer="kernel",
minimum_mode="audit",
)
def pressure_density_derivative_at_temperature(
self,
temperature: float,
density: float,
) -> float:
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
b = self.b_parameter
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
pressure_molar_volume_derivative = (
-UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) ** 2
+ self.attractive_parameter(temperature)
* 2.0
* (molar_volume + b)
/ denominator**2
)
molar_volume_density_derivative = -self.molar_mass / (density * density)
return pressure_molar_volume_derivative * molar_volume_density_derivative
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
self._validate_pressure_temperature(pressure, temperature)
a_alpha = self.attractive_parameter(temperature)
@@ -89,6 +187,11 @@ class PengRobinsonFluid:
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
return A, B
@profile_property(
"compressibility_roots",
layer="kernel",
minimum_mode="audit",
)
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
A, B = self.reduced_parameters(pressure, temperature)
coefficients = (
@@ -102,6 +205,11 @@ class PengRobinsonFluid:
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
return physical_roots
@profile_property(
"compressibility_factor",
layer="kernel",
minimum_mode="audit",
)
def compressibility_factor(
self,
pressure: float,
@@ -117,6 +225,11 @@ class PengRobinsonFluid:
return roots[-1]
raise ValueError(f"Unsupported phase selector: {phase!r}")
@profile_property(
"molar_volume",
layer="kernel",
minimum_mode="audit",
)
def molar_volume(
self,
pressure: float,
@@ -126,6 +239,7 @@ class PengRobinsonFluid:
z = self.compressibility_factor(pressure, temperature, phase=phase)
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
@profile_property("density", layer="kernel", minimum_mode="audit")
def density(
self,
pressure: float,
@@ -134,6 +248,11 @@ class PengRobinsonFluid:
) -> float:
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
@profile_property(
"residual_specific_enthalpy",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_enthalpy(
self,
pressure: float,
@@ -163,15 +282,82 @@ class PengRobinsonFluid:
)
return residual_molar_enthalpy / self.molar_mass
@profile_property(
"residual_specific_internal_energy_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_internal_energy_at_density(
self,
temperature: float,
density: float,
) -> float:
"""Return Peng-Robinson internal-energy departure, J/kg."""
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
b = self.b_parameter
if molar_volume <= b:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
attractive = self.attractive_parameter(temperature)
d_attractive_d_temperature = (
self.attractive_parameter_temperature_derivative(temperature)
)
log_argument = (
molar_volume + (1.0 + sqrt(2.0)) * b
) / (
molar_volume + (1.0 - sqrt(2.0)) * b
)
residual_molar_internal_energy = (
temperature * d_attractive_d_temperature - attractive
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
return residual_molar_internal_energy / self.molar_mass
@profile_property(
"residual_isochoric_heat_capacity_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_isochoric_heat_capacity_at_density(
self,
temperature: float,
density: float,
) -> float:
"""Return the constant-volume heat-capacity departure, J/kg/K."""
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
b = self.b_parameter
if molar_volume <= b:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
log_argument = (
molar_volume + (1.0 + sqrt(2.0)) * b
) / (
molar_volume + (1.0 - sqrt(2.0)) * b
)
residual_molar_cv = (
temperature
* self.attractive_parameter_temperature_second_derivative(temperature)
* log(log_argument)
/ (2.0 * sqrt(2.0) * b)
)
return residual_molar_cv / self.molar_mass
@staticmethod
def _validate_temperature(temperature: float) -> None:
if temperature <= 0.0:
raise ValueError("Temperature must be positive.")
raise RecoverableTrialStateError("Temperature must be positive.")
@classmethod
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
if pressure <= 0.0:
raise ValueError("Pressure must be positive.")
raise RecoverableTrialStateError("Pressure must be positive.")
cls._validate_temperature(temperature)
HELIUM_PR = PengRobinsonFluid(
@@ -179,7 +365,8 @@ HELIUM_PR = PengRobinsonFluid(
molar_mass=0.004002602,
critical_temperature=5.1953,
critical_pressure=227_460.0,
acentric_factor=-0.385,
# Simcenter Amesim 2404 helium_eos.data.
acentric_factor=-0.382,
)
NITROGEN_PR = PengRobinsonFluid(
@@ -433,7 +433,7 @@ def _build_mass(
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
rest_coeff=component.parameter_value("restcoeff"),
stribeck_constant_m_s=component.parameter_value("astrib"),
use_friction=bool(int(component.parameter_value("useFriction"))),
use_friction=int(component.parameter_value("useFriction")) == 2,
stop_type=int(component.parameter_value("stoptype")),
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -15,7 +15,7 @@ DATA_DIR = (
)
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "data"
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
+689
View File
@@ -0,0 +1,689 @@
"""Low-overhead, run-local performance instrumentation for simulations.
The profiling mode is intentionally read once when this module is imported.
``standard`` records low-frequency pipeline stages, while ``audit`` also wraps
hot RHS/property operations and computes exact-input reuse metrics. With
profiling disabled, decorators return the original callable while classes are
being defined, so ordinary simulation calls do not pass through a wrapper.
"""
from __future__ import annotations
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from functools import wraps
import inspect
import os
import struct
from time import perf_counter_ns
from typing import Any, Literal, ParamSpec, TypeVar, cast
ProfileMode = Literal["off", "standard", "audit"]
_P = ParamSpec("_P")
_R = TypeVar("_R")
_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2}
def _read_startup_mode() -> ProfileMode:
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
aliases: dict[str, ProfileMode] = {
"": "off",
"0": "off",
"false": "off",
"no": "off",
"off": "off",
"1": "standard",
"true": "standard",
"yes": "standard",
"on": "standard",
"standard": "standard",
"audit": "audit",
}
try:
return aliases[raw_mode]
except KeyError as exc:
raise ValueError(
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
) from exc
PROFILE_MODE: ProfileMode = _read_startup_mode()
def _minimum_mode(value: str) -> ProfileMode:
normalized = value.strip().lower()
if normalized not in _MODE_RANK:
raise ValueError("minimum_mode must be one of: off, standard, audit.")
return cast(ProfileMode, normalized)
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
# ``minimum_mode="off"`` by mistake.
return (
PROFILE_MODE != "off"
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
)
@dataclass
class _TimingStats:
calls: int = 0
inclusive_ns: int = 0
self_ns: int = 0
max_ns: int = 0
errors: int = 0
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
self.calls += 1
self.inclusive_ns += inclusive_ns
self.self_ns += self_ns
self.max_ns = max(self.max_ns, inclusive_ns)
if error:
self.errors += 1
def snapshot(self) -> dict[str, int]:
return {
"calls": self.calls,
"inclusiveNs": self.inclusive_ns,
"selfNs": self.self_ns,
"maxNs": self.max_ns,
"errors": self.errors,
}
@dataclass
class _PropertyStats(_TimingStats):
operation: str = ""
layer: str = "semantic"
medium: str = "unknown"
exact_input_unique: int = 0
exact_input_repeats: int = 0
iteration_calls: int = 0
iteration_total: int = 0
iteration_max: int = 0
iteration_converged: int = 0
iteration_nonconverged: int = 0
cache_lookups: int = 0
cache_hits: int = 0
cache_misses: int = 0
def snapshot(self, *, audit: bool) -> dict[str, object]:
result: dict[str, object] = super().snapshot()
result.update(
{
"operation": self.operation,
"layer": self.layer,
"medium": self.medium,
"cacheLookups": self.cache_lookups,
"cacheHits": self.cache_hits,
"cacheMisses": self.cache_misses,
}
)
if audit:
result.update(
{
"exactInputUnique": self.exact_input_unique,
"exactInputRepeats": self.exact_input_repeats,
"iterationCalls": self.iteration_calls,
"iterationTotal": self.iteration_total,
"iterationMax": self.iteration_max,
"iterationConverged": self.iteration_converged,
"iterationNonconverged": self.iteration_nonconverged,
}
)
return result
@dataclass
class _ActiveSpan:
trace: PerformanceTrace
name: str
started_ns: int
property_key: str | None = None
property_operation: str | None = None
property_outermost: bool = False
child_ns: int = 0
@dataclass
class PerformanceTrace:
"""Mutable counters owned by exactly one :func:`profile_run` context."""
mode: ProfileMode
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
_properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False)
_property_outermost_ns: int = field(default=0, repr=False)
@property
def enabled(self) -> bool:
return self.mode != "off"
def _property_stats(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
) -> _PropertyStats:
stats = self._properties.get(key)
if stats is None:
stats = _PropertyStats(
operation=operation,
layer=layer,
medium=medium,
)
self._properties[key] = stats
return stats
def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None:
self_ns = max(0, elapsed_ns - frame.child_ns)
if frame.property_key is None:
stats = self._phases.setdefault(frame.name, _TimingStats())
else:
layer, medium, operation = frame.property_key.split("|", 2)
stats = self._property_stats(
frame.property_key,
operation=operation,
layer=layer,
medium=medium,
)
if frame.property_outermost:
self._property_outermost_ns += elapsed_ns
stats.record(elapsed_ns, self_ns, error)
def _record_exact_input(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
fingerprint: object,
) -> None:
stats = self._property_stats(
key,
operation=operation,
layer=layer,
medium=medium,
)
shadow_key = (key, fingerprint)
shadow = _PROPERTY_SHADOW.get()
if shadow is None:
# A trace normally installs its own set in ``profile_run``. Keep
# the ContextVar default immutable so no task can accidentally
# share a process-global shadow set.
shadow = set()
_PROPERTY_SHADOW.set(shadow)
if shadow_key in shadow:
stats.exact_input_repeats += 1
else:
shadow.add(shadow_key)
stats.exact_input_unique += 1
def _record_iterations(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
iterations: int,
converged: bool,
) -> None:
stats = self._property_stats(
key,
operation=operation,
layer=layer,
medium=medium,
)
iteration_count = max(0, int(iterations))
stats.iteration_calls += 1
stats.iteration_total += iteration_count
stats.iteration_max = max(stats.iteration_max, iteration_count)
if converged:
stats.iteration_converged += 1
else:
stats.iteration_nonconverged += 1
def _record_cache(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
hits: int,
misses: int,
) -> None:
stats = self._property_stats(
key,
operation=operation,
layer=layer,
medium=medium,
)
hit_delta = max(0, hits)
miss_delta = max(0, misses)
stats.cache_hits += hit_delta
stats.cache_misses += miss_delta
stats.cache_lookups += hit_delta + miss_delta
def snapshot(self) -> dict[str, object]:
"""Return a detached, JSON-serializable copy of all counters."""
audit = self.mode == "audit"
return {
"mode": self.mode,
"phases": {
name: self._phases[name].snapshot()
for name in sorted(self._phases)
},
"properties": {
_public_property_key(key): self._properties[key].snapshot(audit=audit)
for key in sorted(self._properties)
},
"propertyOutermostNs": self._property_outermost_ns,
}
_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar(
"simulation_performance_trace",
default=None,
)
_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar(
"simulation_performance_spans",
default=(),
)
_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar(
"simulation_property_shadow",
default=None,
)
def _public_property_key(key: str) -> str:
layer, medium, operation = key.split("|", 2)
return f"{layer}.{medium}.{operation}"
@contextmanager
def _tracked_span(
trace: PerformanceTrace,
name: str,
*,
property_key: str | None = None,
property_operation: str | None = None,
reset_property_shadow: bool = False,
) -> Generator[None, None, None]:
stack = _ACTIVE_SPANS.get()
property_outermost = property_key is not None and not any(
item.property_key is not None for item in stack
)
frame = _ActiveSpan(
trace=trace,
name=name,
started_ns=perf_counter_ns(),
property_key=property_key,
property_operation=property_operation,
property_outermost=property_outermost,
)
stack_token = _ACTIVE_SPANS.set((*stack, frame))
shadow_token: Token[set[object] | None] | None = None
if reset_property_shadow and trace.mode == "audit":
shadow_token = _PROPERTY_SHADOW.set(set())
error = False
try:
yield
except BaseException:
error = True
raise
finally:
elapsed_ns = max(0, perf_counter_ns() - frame.started_ns)
_ACTIVE_SPANS.reset(stack_token)
if shadow_token is not None:
_PROPERTY_SHADOW.reset(shadow_token)
if stack:
stack[-1].child_ns += elapsed_ns
trace._record_span(frame, elapsed_ns, error)
@contextmanager
def profile_run() -> Generator[PerformanceTrace, None, None]:
"""Create and bind an isolated trace for one simulation run.
The yielded trace remains usable after the context exits, which lets the
caller attach ``trace.snapshot()`` to a result without exposing live state.
"""
trace = PerformanceTrace(mode=PROFILE_MODE)
trace_token = _CURRENT_TRACE.set(trace)
spans_token = _ACTIVE_SPANS.set(())
shadow_token = _PROPERTY_SHADOW.set(set())
try:
if trace.enabled:
with _tracked_span(trace, "simulation.total"):
yield trace
else:
yield trace
finally:
_PROPERTY_SHADOW.reset(shadow_token)
_ACTIVE_SPANS.reset(spans_token)
_CURRENT_TRACE.reset(trace_token)
@contextmanager
def performance_span(
name: str,
minimum_mode: str = "standard",
reset_property_shadow: bool = False,
) -> Generator[None, None, None]:
"""Time a block in the current run, or act as a no-op outside one."""
minimum = _minimum_mode(minimum_mode)
trace = _CURRENT_TRACE.get()
if trace is None or not _mode_enabled(minimum):
yield
return
with _tracked_span(
trace,
name,
reset_property_shadow=reset_property_shadow,
):
yield
def profile_phase(
name: str,
minimum_mode: str = "standard",
reset_property_shadow: bool = False,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Decorate a simulation phase while preserving the off-mode callable."""
minimum = _minimum_mode(minimum_mode)
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not _mode_enabled(minimum):
return function
if inspect.iscoroutinefunction(function):
@wraps(function)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
trace = _CURRENT_TRACE.get()
if trace is None:
return await function(*args, **kwargs)
with _tracked_span(
trace,
name,
reset_property_shadow=reset_property_shadow,
):
return await function(*args, **kwargs)
return cast(Callable[_P, _R], async_wrapper)
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
trace = _CURRENT_TRACE.get()
if trace is None:
return function(*args, **kwargs)
with _tracked_span(
trace,
name,
reset_property_shadow=reset_property_shadow,
):
return function(*args, **kwargs)
return wrapper
return decorate
def _medium_name(args: tuple[object, ...]) -> str:
if not args:
return "unknown"
owner = args[0]
configured_name = getattr(owner, "name", None)
if isinstance(configured_name, str) and configured_name:
return configured_name
return type(owner).__name__
def _fingerprint(value: object) -> object:
"""Build a hashable, bit-exact token without retaining arbitrary objects."""
if value is None or isinstance(value, (bool, int, str, bytes)):
return (type(value).__name__, value)
if isinstance(value, float):
return ("float64", struct.pack("!d", value))
if isinstance(value, tuple):
return ("tuple", tuple(_fingerprint(item) for item in value))
if isinstance(value, list):
return ("list", tuple(_fingerprint(item) for item in value))
if isinstance(value, Mapping):
items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()]
items.sort(key=repr)
return ("mapping", tuple(items))
return (
"object",
type(value).__module__,
type(value).__qualname__,
id(value),
)
def _input_fingerprint(
signature: inspect.Signature | None,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
if signature is not None:
try:
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
return tuple(
(name, _fingerprint(value))
for name, value in bound.arguments.items()
)
except TypeError:
pass
return (
_fingerprint(args),
tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())),
)
def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None:
cache_info = getattr(function, "cache_info", None)
if not callable(cache_info):
return None
try:
info = cache_info()
return int(info.hits), int(info.misses)
except (AttributeError, TypeError, ValueError):
return None
def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None:
for attribute in ("cache_clear", "cache_info", "cache_parameters"):
value = getattr(source, attribute, None)
if value is not None:
setattr(target, attribute, value)
def profile_property(
operation: str,
layer: str = "semantic",
minimum_mode: str = "audit",
capture_inputs: bool = True,
track_cache: bool = False,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Decorate one thermodynamic property operation."""
minimum = _minimum_mode(minimum_mode)
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not _mode_enabled(minimum):
return function
try:
signature: inspect.Signature | None = inspect.signature(function)
except (TypeError, ValueError):
signature = None
def prepare(
args: tuple[object, ...],
kwargs: dict[str, object],
) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]:
trace = _CURRENT_TRACE.get()
medium = _medium_name(args)
key = f"{layer}|{medium}|{operation}"
if trace is not None and trace.mode == "audit" and capture_inputs:
trace._record_exact_input(
key,
operation=operation,
layer=layer,
medium=medium,
fingerprint=_input_fingerprint(signature, args, kwargs),
)
before = _cache_counts(function) if trace is not None and track_cache else None
return trace, medium, key, before
def finish_cache(
trace: PerformanceTrace | None,
medium: str,
key: str,
before: tuple[int, int] | None,
) -> None:
if trace is None or before is None:
return
after = _cache_counts(function)
if after is None:
return
trace._record_cache(
key,
operation=operation,
layer=layer,
medium=medium,
hits=after[0] - before[0],
misses=after[1] - before[1],
)
if inspect.iscoroutinefunction(function):
@wraps(function)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
object_args = cast(tuple[object, ...], args)
object_kwargs = cast(dict[str, object], kwargs)
trace, medium, key, before = prepare(object_args, object_kwargs)
try:
if trace is None:
return await function(*args, **kwargs)
with _tracked_span(
trace,
f"property.{_public_property_key(key)}",
property_key=key,
property_operation=operation,
):
return await function(*args, **kwargs)
finally:
finish_cache(trace, medium, key, before)
_copy_cache_api(function, async_wrapper)
return cast(Callable[_P, _R], async_wrapper)
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
object_args = cast(tuple[object, ...], args)
object_kwargs = cast(dict[str, object], kwargs)
trace, medium, key, before = prepare(object_args, object_kwargs)
try:
if trace is None:
return function(*args, **kwargs)
with _tracked_span(
trace,
f"property.{_public_property_key(key)}",
property_key=key,
property_operation=operation,
):
return function(*args, **kwargs)
finally:
finish_cache(trace, medium, key, before)
_copy_cache_api(function, wrapper)
return wrapper
return decorate
def record_property_iterations(
operation: str,
iterations: int,
converged: bool,
) -> None:
"""Record inverse-property solver iterations in audit mode."""
trace = _CURRENT_TRACE.get()
if trace is None or trace.mode != "audit":
return
for frame in reversed(_ACTIVE_SPANS.get()):
if (
frame.property_key is not None
and frame.property_operation == operation
):
layer, medium, _unused_operation = frame.property_key.split("|", 2)
trace._record_iterations(
frame.property_key,
operation=operation,
layer=layer,
medium=medium,
iterations=iterations,
converged=converged,
)
return
key = f"semantic|unknown|{operation}"
trace._record_iterations(
key,
operation=operation,
layer="semantic",
medium="unknown",
iterations=iterations,
converged=converged,
)
def record_property_cache(operation: str, *, hit: bool) -> None:
"""Record one run-local property-cache lookup in audit mode."""
trace = _CURRENT_TRACE.get()
if trace is None or trace.mode != "audit":
return
for frame in reversed(_ACTIVE_SPANS.get()):
if (
frame.property_key is not None
and frame.property_operation == operation
):
layer, medium, _unused_operation = frame.property_key.split("|", 2)
trace._record_cache(
frame.property_key,
operation=operation,
layer=layer,
medium=medium,
hits=1 if hit else 0,
misses=0 if hit else 1,
)
return
__all__ = [
"PROFILE_MODE",
"PerformanceTrace",
"performance_span",
"profile_phase",
"profile_property",
"profile_run",
"record_property_cache",
"record_property_iterations",
]
File diff suppressed because it is too large. Load diff
+234
View File
@@ -0,0 +1,234 @@
"""Run-local, exact-key cache for expensive thermodynamic calculations.
The cache is deliberately bound to one simulation through ``ContextVar``.
That keeps concurrent runs isolated and releases all cached states when the
run finishes. Keys use the original Python values with no rounding or
tolerance-based reuse that could flatten numerical residuals seen by ODE and
nonlinear solvers.
"""
from __future__ import annotations
from collections.abc import Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from functools import lru_cache, wraps
import os
from typing import ParamSpec, TypeVar
from app.simulation.performance import PROFILE_MODE, record_property_cache
_P = ParamSpec("_P")
_R = TypeVar("_R")
DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192
def _read_cache_enabled() -> bool:
raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower()
if raw_value in {"", "1", "true", "yes", "on"}:
return True
if raw_value in {"0", "false", "no", "off"}:
return False
raise ValueError(
"SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0."
)
PROPERTY_CACHE_ENABLED = _read_cache_enabled()
@dataclass(frozen=True)
class PropertyCacheInfo:
hits: int
misses: int
max_entries_per_cache: int
cache_count: int
current_entries: int
evictions: int
class SimulationPropertyCache:
"""Bounded C-level LRUs owned by one simulation run."""
def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None:
if max_entries <= 0:
raise ValueError("Property cache max_entries must be positive.")
self.max_entries_per_cache = int(max_entries)
self._functions: dict[
tuple[str, int, Callable[..., object]],
Callable[..., object],
] = {}
self._failed_misses: dict[
tuple[str, int, Callable[..., object]],
int,
] = {}
self._owners: dict[int, tuple[object, int]] = {}
self._next_owner_token = 0
def owner_token(self, owner: object) -> int:
"""Return a stable identity token and retain its owner for this run."""
identity = id(owner)
existing = self._owners.get(identity)
if existing is not None and existing[0] is owner:
return existing[1]
self._next_owner_token += 1
self._owners[identity] = (owner, self._next_owner_token)
return self._next_owner_token
def get_or_compute(
self,
operation: str,
owner: object,
function: Callable[..., _R],
args: tuple[object, ...],
kwargs: dict[str, object],
) -> _R:
cache_key = (operation, self.owner_token(owner), function)
cached_function = self._functions.get(cache_key)
if cached_function is None:
@lru_cache(maxsize=self.max_entries_per_cache, typed=True)
def invoke(*cached_args: object, **cached_kwargs: object) -> _R:
return function(owner, *cached_args, **cached_kwargs)
cached_function = invoke
self._functions[cache_key] = cached_function
if PROFILE_MODE != "audit":
try:
return cached_function(*args, **kwargs)
except Exception:
self._failed_misses[cache_key] = (
self._failed_misses.get(cache_key, 0) + 1
)
raise
before = cached_function.cache_info() # type: ignore[attr-defined]
try:
value = cached_function(*args, **kwargs)
except Exception:
self._failed_misses[cache_key] = (
self._failed_misses.get(cache_key, 0) + 1
)
raise
finally:
after = cached_function.cache_info() # type: ignore[attr-defined]
hit = after.hits > before.hits
record_property_cache(operation, hit=hit)
return value
def info(self) -> PropertyCacheInfo:
cache_infos = {
key: cached.cache_info() # type: ignore[attr-defined]
for key, cached in self._functions.items()
}
return PropertyCacheInfo(
hits=sum(info.hits for info in cache_infos.values()),
misses=sum(info.misses for info in cache_infos.values()),
max_entries_per_cache=self.max_entries_per_cache,
cache_count=len(self._functions),
current_entries=sum(info.currsize for info in cache_infos.values()),
evictions=sum(
max(
0,
info.misses
- self._failed_misses.get(key, 0)
- info.currsize,
)
for key, info in cache_infos.items()
),
)
_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar(
"simulation_property_cache",
default=None,
)
def current_property_cache() -> SimulationPropertyCache | None:
return _CURRENT_PROPERTY_CACHE.get()
@contextmanager
def property_cache_run(
*,
max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES,
) -> Generator[SimulationPropertyCache | None, None, None]:
"""Bind a fresh cache to one top-level simulation run.
Nested uses reuse the existing cache so lower-level simulation helpers can
safely opt in without replacing the cache created by the API entry point.
"""
existing = _CURRENT_PROPERTY_CACHE.get()
if existing is not None:
yield existing
return
if not PROPERTY_CACHE_ENABLED:
yield None
return
cache = SimulationPropertyCache(max_entries=max_entries)
token = _CURRENT_PROPERTY_CACHE.set(cache)
try:
yield cache
finally:
_CURRENT_PROPERTY_CACHE.reset(token)
def cache_property_calculation(
operation: str,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Cache one pure property calculation with hashable arguments per run."""
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not PROPERTY_CACHE_ENABLED:
return function
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
cache = _CURRENT_PROPERTY_CACHE.get()
if cache is None:
return function(*args, **kwargs)
owner = args[0] if args else function
return cache.get_or_compute(
operation,
owner,
function,
tuple(args[1:] if args else ()),
dict(kwargs),
)
return wrapper
return decorate
def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]:
"""Ensure a simulation entry point has a run-local cache."""
if not PROPERTY_CACHE_ENABLED:
return function
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
with property_cache_run():
return function(*args, **kwargs)
return wrapper
__all__ = [
"DEFAULT_PROPERTY_CACHE_MAX_ENTRIES",
"PROPERTY_CACHE_ENABLED",
"PropertyCacheInfo",
"SimulationPropertyCache",
"cache_property_calculation",
"current_property_cache",
"property_cache_run",
"with_property_cache",
]
+243 -2
View File
@@ -12,10 +12,12 @@ from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentDisplaySpec,
ComponentLibrarySpec,
ParameterGroupDisplaySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
SI_UNIT_BY_QUANTITY,
ParameterCondition,
ParameterDefinition,
ParameterOption,
ResultVariableDefinition,
@@ -71,6 +73,16 @@ class ComponentModelSpec:
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
return {parameter.name: parameter for parameter in self.parameters}
def active_ports(
self,
values: Mapping[str, float],
) -> tuple[PortDefinition, ...]:
resolved = {
parameter.name: values.get(parameter.name, parameter.default)
for parameter in self.parameters
}
return self.component_class.active_port_definitions_for_parameters(resolved)
def as_catalog_dict(self) -> dict[str, object]:
category = self.library.category_by_id[self.display.category_id]
display_ports = self.display.port_by_name
@@ -97,6 +109,14 @@ class ComponentModelSpec:
}
if self.display.role is not None:
payload["role"] = self.display.role
if self.display.parameter_groups:
payload["parameterGroups"] = [
group.as_catalog_dict()
for group in sorted(
self.display.parameter_groups,
key=lambda item: (item.order, item.id),
)
]
return payload
def create(
@@ -280,6 +300,81 @@ def _validate_category(
)
def _validate_parameter_groups(
parameter_groups: object,
*,
model_type: str,
parameter_names: Sequence[str],
) -> None:
field = f"Component '{model_type}' display parameter groups"
if not isinstance(parameter_groups, tuple):
raise ValueError(f"{field} must be a tuple.")
group_ids: list[str] = []
grouped_parameter_names: list[str] = []
known_parameter_names = set(parameter_names)
for group in parameter_groups:
if not isinstance(group, ParameterGroupDisplaySpec):
raise ValueError(
f"{field} must use ParameterGroupDisplaySpec."
)
group_id = _validate_machine_id(
group.id,
field=f"{field} id",
)
_validate_label(
group.label,
field=f"{field} '{group_id}' label",
)
_validate_order(
group.order,
field=f"{field} '{group_id}' order",
)
if type(group.default_expanded) is not bool:
raise ValueError(
f"{field} '{group_id}' default_expanded must be a boolean."
)
if not isinstance(group.parameters, tuple) or not group.parameters:
raise ValueError(
f"{field} '{group_id}' parameters must use a non-empty tuple."
)
for parameter_name in group.parameters:
_validate_member_id(
parameter_name,
field=f"{field} '{group_id}' parameter name",
)
_validate_unique_names(
list(group.parameters),
field=f"{field} '{group_id}' parameters",
)
unknown_parameters = sorted(
set(group.parameters) - known_parameter_names
)
if unknown_parameters:
raise ValueError(
f"{field} '{group_id}' references unknown parameters: "
+ ", ".join(unknown_parameters)
+ "."
)
group_ids.append(group_id)
grouped_parameter_names.extend(group.parameters)
_validate_unique_names(group_ids, field=field)
duplicate_memberships = sorted(
{
name
for name in grouped_parameter_names
if grouped_parameter_names.count(name) > 1
}
)
if duplicate_memberships:
raise ValueError(
f"{field} assign parameters to multiple groups: "
+ ", ".join(duplicate_memberships)
+ "."
)
def validate_component_library(library: ComponentLibrarySpec) -> None:
if not isinstance(library, ComponentLibrarySpec):
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
@@ -449,6 +544,7 @@ def _validate_parameter(
None,
"amesimGasReference",
"amesimGasPropertyModel",
"choice",
}:
raise ValueError(
f"{field} '{parameter.name}' uses unsupported editor "
@@ -462,10 +558,17 @@ def _validate_parameter(
raise ValueError(
f"{field} '{parameter.name}' property-model editor must declare options."
)
if parameter.options and parameter.editor != "amesimGasPropertyModel":
if parameter.editor == "choice" and not parameter.options:
raise ValueError(
f"{field} '{parameter.name}' choice editor must declare options."
)
if parameter.options and parameter.editor not in {
"amesimGasPropertyModel",
"choice",
}:
raise ValueError(
f"{field} '{parameter.name}' options require the "
"'amesimGasPropertyModel' editor."
"'choice' or 'amesimGasPropertyModel' editor."
)
option_values: list[float] = []
for option in parameter.options:
@@ -513,6 +616,55 @@ def _validate_parameter(
f"{field} '{parameter.name}' contains duplicate option values: "
f"{duplicates}."
)
if not isinstance(parameter.visible_when, tuple):
raise ValueError(
f"{field} '{parameter.name}' visible_when must use a tuple."
)
for condition in parameter.visible_when:
if not isinstance(condition, ParameterCondition):
raise ValueError(
f"{field} '{parameter.name}' visible_when entries must use "
"ParameterCondition."
)
_validate_member_id(
condition.parameter,
field=(
f"{field} '{parameter.name}' visibility condition parameter"
),
)
if not isinstance(condition.values, tuple) or not condition.values:
raise ValueError(
f"{field} '{parameter.name}' visibility condition values "
"must use a non-empty tuple."
)
numeric_condition_values: list[float] = []
for value in condition.values:
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not isfinite(value)
):
raise ValueError(
f"{field} '{parameter.name}' visibility condition values "
"must be finite numbers."
)
numeric_condition_values.append(float(value))
duplicate_condition_values = sorted(
{
value
for value in numeric_condition_values
if numeric_condition_values.count(value) > 1
}
)
if duplicate_condition_values:
duplicates = ", ".join(
f"{value:g}" for value in duplicate_condition_values
)
raise ValueError(
f"{field} '{parameter.name}' visibility condition for "
f"'{condition.parameter}' contains duplicate values: "
f"{duplicates}."
)
if (
parameter.minimum is not None
and parameter.maximum is not None
@@ -528,6 +680,89 @@ def _validate_parameter(
)
def _validate_parameter_visibility(
parameters: tuple[ParameterDefinition, ...],
*,
model_type: str,
) -> None:
"""Validate cross-parameter references used by catalog visibility rules."""
parameter_by_name = {parameter.name: parameter for parameter in parameters}
dependencies: dict[str, tuple[str, ...]] = {}
for parameter in parameters:
controllers: list[str] = []
for condition in parameter.visible_when:
controller_name = condition.parameter
controller = parameter_by_name.get(controller_name)
if controller is None:
raise ValueError(
f"Component '{model_type}' parameter '{parameter.name}' "
"visibility condition references unknown parameter "
f"'{controller_name}'."
)
if controller_name == parameter.name:
raise ValueError(
f"Component '{model_type}' parameter '{parameter.name}' "
"visibility condition cannot reference itself."
)
if controller_name in controllers:
raise ValueError(
f"Component '{model_type}' parameter '{parameter.name}' "
"contains duplicate visibility controller "
f"'{controller_name}'."
)
if not controller.options:
raise ValueError(
f"Component '{model_type}' parameter '{parameter.name}' "
f"visibility controller '{controller_name}' must declare "
"options."
)
controller_values = {
float(option.value) for option in controller.options
}
unsupported_values = sorted(
{
float(value)
for value in condition.values
if float(value) not in controller_values
}
)
if unsupported_values:
values = ", ".join(f"{value:g}" for value in unsupported_values)
raise ValueError(
f"Component '{model_type}' parameter '{parameter.name}' "
f"visibility condition values for '{controller_name}' "
f"must belong to its options; unsupported: {values}."
)
controllers.append(controller_name)
dependencies[parameter.name] = tuple(controllers)
states: dict[str, int] = {}
stack: list[str] = []
def visit(parameter_name: str) -> None:
state = states.get(parameter_name, 0)
if state == 2:
return
if state == 1:
cycle_start = stack.index(parameter_name)
cycle = stack[cycle_start:] + [parameter_name]
raise ValueError(
f"Component '{model_type}' parameter visibility dependencies "
f"contain a cycle: {' -> '.join(cycle)}."
)
states[parameter_name] = 1
stack.append(parameter_name)
for controller_name in dependencies[parameter_name]:
visit(controller_name)
stack.pop()
states[parameter_name] = 2
for parameter in parameters:
visit(parameter.name)
def _validate_result_variable(
variable: ResultVariableDefinition,
*,
@@ -676,6 +911,12 @@ def validate_component_model_class(
parameter_names,
field=f"Component '{model_type}' parameters",
)
_validate_parameter_groups(
display.parameter_groups,
model_type=model_type,
parameter_names=parameter_names,
)
_validate_parameter_visibility(parameters, model_type=model_type)
_validate_unique_names(
result_names,
field=f"Component '{model_type}' result variables",
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.reporting.amesim_results import (
AmesimResults,
load_test_mql_amesim_results,
)
@dataclass(frozen=True, slots=True)
class Pnl0002ReplayPaths:
"""AMESim data paths needed to replay one PNL0002 resistance."""
center_pressure: str
center_temperature: str
port_1_pressure: str
port_1_temperature: str
port_1_mass_flow: str
port_2_pressure: str
port_2_temperature: str
port_2_mass_flow: str
reynolds: str
friction_factor: str
PNL83_REPLAY_PATHS = Pnl0002ReplayPaths(
center_pressure="pctr@pneumatic_83",
center_temperature="tctr@pneumatic_83",
port_1_pressure="press1@pnnode4_16",
port_1_temperature="temp1@pnnode4_16",
port_1_mass_flow="dm1@pneumatic_83",
port_2_pressure="press3@pnnode4_17",
port_2_temperature="temp3@pnnode4_17",
port_2_mass_flow="dm2@pneumatic_83",
reynolds="re@pneumatic_83",
friction_factor="ff@pneumatic_83",
)
def _required_series(
results: AmesimResults,
path: str,
) -> tuple[float, ...]:
try:
values = results.series(path)
except KeyError as exc:
raise ValueError(f"AMESim replay variable is not saved: {path}") from exc
if len(values) != len(results.times):
raise ValueError(f"AMESim replay variable has an invalid length: {path}")
return values
def _metric_summary(
rows: list[dict[str, float]],
key: str,
) -> dict[str, float]:
values = [float(row[key]) for row in rows]
max_index = max(range(len(values)), key=lambda index: abs(values[index]))
return {
"maxAbs": abs(values[max_index]),
"maxAbsTime": rows[max_index]["time"],
"finalSigned": values[-1],
}
def replay_pnl0002_amesim_states(
pipe: AmesimPnl0002,
results: AmesimResults,
paths: Pnl0002ReplayPaths,
*,
amesim_mass_flow_scale: float = -1.0e-3,
) -> dict[str, object]:
"""Replay saved AMESim states through current PNL0002 flow functions.
This is a calibration-only, no-integration calculation. It does not write
states into the pipe or alter the production simulation path.
"""
series_by_field = {
field: _required_series(results, getattr(paths, field))
for field in paths.__dataclass_fields__
}
rows: list[dict[str, float]] = []
for index, time_s in enumerate(results.times):
center_pressure = series_by_field["center_pressure"][index]
center_temperature = series_by_field["center_temperature"][index]
port_1_pressure = series_by_field["port_1_pressure"][index]
port_2_pressure = series_by_field["port_2_pressure"][index]
observed_flow_1 = (
amesim_mass_flow_scale
* series_by_field["port_1_mass_flow"][index]
)
observed_flow_2 = (
amesim_mass_flow_scale
* series_by_field["port_2_mass_flow"][index]
)
upstream_temperature_1 = (
series_by_field["port_1_temperature"][index]
if observed_flow_1 >= 0.0
else center_temperature
)
upstream_temperature_2 = (
series_by_field["port_2_temperature"][index]
if observed_flow_2 >= 0.0
else center_temperature
)
predicted_flow_1 = pipe.mass_flow(
port_1_pressure,
center_pressure,
upstream_temperature_1,
)
predicted_flow_2 = pipe.mass_flow(
port_2_pressure,
center_pressure,
upstream_temperature_2,
)
reynolds_1 = pipe.reynolds_number(
observed_flow_1,
upstream_temperature_1,
)
reynolds_2 = pipe.reynolds_number(
observed_flow_2,
upstream_temperature_2,
)
friction_1 = pipe.friction_factor(reynolds_1)
friction_2 = pipe.friction_factor(reynolds_2)
replay_reynolds = 0.5 * (reynolds_1 + reynolds_2)
replay_friction = 0.5 * (friction_1 + friction_2)
rows.append(
{
"time": float(time_s),
"centerPressure": center_pressure,
"centerTemperature": center_temperature,
"port1Pressure": port_1_pressure,
"port2Pressure": port_2_pressure,
"observedPort1MassFlow": observed_flow_1,
"observedPort2MassFlow": observed_flow_2,
"predictedPort1MassFlow": predicted_flow_1,
"predictedPort2MassFlow": predicted_flow_2,
"port1MassFlowError": predicted_flow_1 - observed_flow_1,
"port2MassFlowError": predicted_flow_2 - observed_flow_2,
"amesimReynolds": series_by_field["reynolds"][index],
"replayReynolds": replay_reynolds,
"reynoldsError": (
replay_reynolds - series_by_field["reynolds"][index]
),
"amesimFrictionFactor": series_by_field["friction_factor"][index],
"replayFrictionFactor": replay_friction,
"frictionFactorError": (
replay_friction
- series_by_field["friction_factor"][index]
),
}
)
metric_keys = (
"port1MassFlowError",
"port2MassFlowError",
"reynoldsError",
"frictionFactorError",
)
return {
"mode": "amesim-state-replay-no-integration",
"component": pipe.name,
"pointCount": len(rows),
"massFlowScale": amesim_mass_flow_scale,
"paths": {
field: getattr(paths, field)
for field in paths.__dataclass_fields__
},
"parameters": dict(pipe.parameter_values),
"summary": {
key: _metric_summary(rows, key)
for key in metric_keys
},
"rows": rows,
}
def _compile_project_pipe(
project_path: Path,
component_name: str,
) -> AmesimPnl0002:
from app.main import (
ReactFlowProjectPayload,
_compile_xml_document_or_422,
_validated_xml_document_or_422,
build_reactflow_system_xml,
validate_system_xml_document,
)
payload = ReactFlowProjectPayload.model_validate_json(
project_path.read_text(encoding="utf-8")
)
xml_bytes = build_reactflow_system_xml(payload)
document = _validated_xml_document_or_422(
validate_system_xml_document(xml_bytes)
)
_project, network = _compile_xml_document_or_422(document)
try:
component = network.components[component_name]
except KeyError as exc:
raise ValueError(f"Project component does not exist: {component_name}") from exc
if not isinstance(component, AmesimPnl0002):
raise ValueError(f"Project component is not PNL0002: {component_name}")
return component
def _paths_from_arguments(arguments: argparse.Namespace) -> Pnl0002ReplayPaths:
values: Mapping[str, str] = {
field: getattr(arguments, field)
for field in PNL83_REPLAY_PATHS.__dataclass_fields__
}
return Pnl0002ReplayPaths(**values)
def main() -> None:
parser = argparse.ArgumentParser(
description="Replay saved AMESim p/T/m_flow through a PNL0002 model without integration."
)
parser.add_argument("project", type=Path)
parser.add_argument("amesim_archive", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--component", default="pneumatic_83")
parser.add_argument("--mass-flow-scale", type=float, default=-1.0e-3)
for field in PNL83_REPLAY_PATHS.__dataclass_fields__:
parser.add_argument(
"--" + field.replace("_", "-"),
dest=field,
default=getattr(PNL83_REPLAY_PATHS, field),
)
arguments = parser.parse_args()
pipe = _compile_project_pipe(arguments.project, arguments.component)
results = load_test_mql_amesim_results(arguments.amesim_archive)
report = replay_pnl0002_amesim_states(
pipe,
results,
_paths_from_arguments(arguments),
amesim_mass_flow_scale=arguments.mass_flow_scale,
)
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps({key: report[key] for key in (
"mode",
"component",
"pointCount",
"parameters",
"summary",
)}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -23,6 +23,8 @@ class TestMqlComparisonMetric:
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
@@ -44,6 +46,10 @@ class TestMqlComparisonResult:
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."""
@@ -57,6 +63,16 @@ def compare_test_mql_series(
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 = []
@@ -70,11 +86,18 @@ def compare_test_mql_series(
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)
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
if abs(amesim_value) <= relative_floor:
near_zero_baseline_count += 1
if amesim_value == 0.0:
undefined_rel_error_count += 1
else:
rel_errors.append(abs_error / abs(amesim_value))
final_amesim_value = interpolate_series_value(
amesim_results.times,
amesim_values,
@@ -87,6 +110,8 @@ def compare_test_mql_series(
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),
)
)
@@ -153,8 +178,19 @@ def write_test_mql_comparison_csv(
float(time_value),
)
abs_error = abs(python_value - amesim_value)
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
row.extend([python_value, amesim_value, abs_error, rel_error])
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 = [
@@ -163,6 +199,7 @@ def write_test_mql_comparison_csv(
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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+794
View File
@@ -0,0 +1,794 @@
"""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
+256
View File
@@ -0,0 +1,256 @@
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
+45 -17
View File
@@ -3,6 +3,9 @@ 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
@@ -18,34 +21,62 @@ class PneumaticVolumeDiagnostics:
}
@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, Endpoint]:
result: dict[Endpoint, Endpoint] = {}
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] = second
result[second] = first
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 component in self.network.components.values():
for definition in component.port_definitions:
if definition.kind == "physical" and definition.domain == "pneumatic":
port = component.get_port(definition.name)
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.network.components.values():
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
@@ -71,18 +102,15 @@ class PneumaticVolumeResolver:
propagated = 0
for endpoint, values in outputs.items():
connected = self._connected_endpoint.get(endpoint)
if connected is None:
binding = self._connected_endpoint.get(endpoint)
if binding is None:
continue
if connected in outputs:
if binding.connected_endpoint in outputs:
raise ValueError(
"A pneumatic connection cannot contain two external-volume "
f"sources: {endpoint} and {connected}."
f"sources: {endpoint} and {binding.connected_endpoint}."
)
connected_port = self.network.components[connected.component].get_port(
connected.port
)
connected_port.volume, connected_port.volume_flow = values
binding.connected_port.volume, binding.connected_port.volume_flow = values
propagated += 1
diagnostics = PneumaticVolumeDiagnostics(
+56 -20
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Protocol
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
@@ -37,30 +40,56 @@ class SignalSolveDiagnostics:
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._connections = [
connection for connection in network.connections if connection.kind == "signal"
]
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 component in self.network.components.values():
signal_output_values = getattr(component, "signal_output_values", None)
if signal_output_values is None:
continue
for port_name, value in signal_output_values(time).items():
component.get_port(port_name).signal = float(value)
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 connection in self._connections:
source, target = self._source_target(connection.endpoints)
source_port = self.network.components[source.component].get_port(source.port)
target_port = self.network.components[target.component].get_port(target.port)
target_port.signal = source_port.signal
for binding in self._connections:
binding.target.signal = binding.source.signal
propagated += 1
diagnostics = SignalSolveDiagnostics(propagated=propagated)
@@ -84,15 +113,12 @@ class SignalResolver:
return ()
events: set[float] = set()
for component in self.network.components.values():
source_event_times = getattr(component, "signal_event_times", None)
if source_event_times is None:
continue
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."
f"Signal event time from component '{component_name}' must be finite."
)
if start < event_time < stop:
events.add(event_time)
@@ -107,3 +133,13 @@ class SignalResolver:
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
+118 -33
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import DynamicComponent
from app.simulation.systems.network import Endpoint, SimulationNetwork
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.ports import PortState
from app.simulation.performance import profile_phase
from app.simulation.systems.network import SimulationNetwork
class StreamSolveError(RuntimeError):
@@ -26,6 +28,14 @@ class StreamSolveDiagnostics:
}
@dataclass(frozen=True)
class _StreamConnectionBinding:
component_name: str
port_name: str
connected_component: Component
connected_port: PortState
class StreamResolver:
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
@@ -39,62 +49,137 @@ class StreamResolver:
self.network = network
self.relative_tolerance = relative_tolerance
self.max_iterations = max_iterations
self._connected_endpoint = self._build_connection_map()
self._components = tuple(network.components.values())
self._dynamic_components = tuple(
component
for component in self._components
if isinstance(component, DynamicComponent)
)
self._non_dynamic_components = tuple(
component
for component in self._components
if not isinstance(component, DynamicComponent)
)
# State ownership and pressure-flow stream sensitivity are independent
# classifications. Compile this hook by behavior so algebraic
# components such as PNL00R receive their upstream-temperature
# references without dispatching a no-op to every component at runtime.
self._flow_temperature_reference_components = tuple(
component
for component in self._components
if type(component).update_flow_temperature_references
is not Component.update_flow_temperature_references
)
self._ports = tuple(
(component.name, port_name, port)
for component in self._components
for port_name, port in component.ports.items()
)
self._connection_bindings = self._build_connection_bindings()
self.last_diagnostics: StreamSolveDiagnostics | None = None
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
result: dict[Endpoint, Endpoint] = {}
def _build_connection_bindings(self) -> tuple[_StreamConnectionBinding, ...]:
result: list[_StreamConnectionBinding] = []
for connection in self.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
result[first] = second
result[second] = first
return result
first_component = self.network.components[first.component]
second_component = self.network.components[second.component]
result.append(
_StreamConnectionBinding(
component_name=first.component,
port_name=first.port,
connected_component=second_component,
connected_port=second_component.get_port(second.port),
)
)
result.append(
_StreamConnectionBinding(
component_name=second.component,
port_name=second.port,
connected_component=first_component,
connected_port=first_component.get_port(first.port),
)
)
return tuple(result)
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
values: dict[str, dict[str, float]] = {
component.name: {} for component in self.network.components.values()
component.name: {} for component in self._components
}
for endpoint, connected in self._connected_endpoint.items():
connected_port = self.network.components[connected.component].get_port(
connected.port
for binding in self._connection_bindings:
values[binding.component_name][binding.port_name] = (
binding.connected_port.h_outflow
)
values[endpoint.component][endpoint.port] = connected_port.h_outflow
return values
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
dynamic_components = [
component
for component in self.network.components.values()
if isinstance(component, DynamicComponent)
]
for component in dynamic_components:
def connected_temperature_reference_enthalpies(
self,
) -> dict[str, dict[str, float]]:
"""Return connector references used for upstream temperature only."""
values: dict[str, dict[str, float]] = {
component.name: {} for component in self._components
}
for binding in self._connection_bindings:
values[binding.component_name][binding.port_name] = float(
getattr(
binding.connected_component,
"temperature_reference_h",
binding.connected_port.h_outflow,
)
)
return values
@profile_phase("simulation.refresh", minimum_mode="audit")
def refresh_flow_temperature_references(self) -> None:
"""Refresh pressure-flow property inputs without changing stream outflows."""
connected = self.connected_temperature_reference_enthalpies()
for component in self._flow_temperature_reference_components:
component.update_flow_temperature_references(
connected[component.name]
)
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(self) -> None:
for component in self._dynamic_components:
component.refresh_thermodynamic_ports()
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_stream_components(
self,
connected: dict[str, dict[str, float]],
) -> None:
for component in self._non_dynamic_components:
component.update_stream_outflows(connected[component.name])
@profile_phase("simulation.stream", minimum_mode="audit")
def solve(
self,
*,
dynamic_ports_are_current: bool = False,
) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
if not dynamic_ports_are_current:
self._refresh_dynamic_components()
max_delta = 0.0
for iteration in range(1, self.max_iterations + 1):
previous = {
(component.name, port_name): port.h_outflow
for component in self.network.components.values()
for port_name, port in component.ports.items()
(component_name, port_name): port.h_outflow
for component_name, port_name, port in self._ports
}
connected = self.connected_enthalpies()
for component in self.network.components.values():
if isinstance(component, DynamicComponent):
component.refresh_thermodynamic_ports()
else:
component.update_stream_outflows(connected[component.name])
self._refresh_stream_components(connected)
deltas = [
abs(port.h_outflow - previous[(component.name, port_name)])
for component in self.network.components.values()
for port_name, port in component.ports.items()
abs(port.h_outflow - previous[(component_name, port_name)])
for component_name, port_name, port in self._ports
]
magnitudes = [
abs(port.h_outflow)
for component in self.network.components.values()
for port in component.ports.values()
for _component_name, _port_name, port in self._ports
]
max_delta = max(deltas, default=0.0)
scale = max(magnitudes + [1.0])
File diff suppressed because it is too large. Load diff
+567
View File
@@ -0,0 +1,567 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from copy import copy
from dataclasses import dataclass, replace
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.ports import PortState
_STREAM_CACHE_ATTRIBUTE_NAMES = frozenset(
{
"_connected_h",
"temperature_reference_h",
}
)
def _is_stream_cache_attribute(name: str) -> bool:
"""Return whether an attribute belongs to the stream/temperature replay state.
Catalog components currently use ``_connected_h`` and
``temperature_reference_h``. The name-based extension keeps conservative
third-party caches recoverable without copying an entire component graph.
Components with opaque cache names can provide the explicit hooks documented
by :class:`ThermofluidTransactionPlan`.
"""
lowered = name.lower()
return (
name in _STREAM_CACHE_ATTRIBUTE_NAMES
or lowered.startswith("_stream_")
or "connected_h" in lowered
or "connected_enthalpy" in lowered
or "temperature_reference" in lowered
)
def _copy_cache_value(value: object) -> object:
"""Shallow-copy a stream cache without traversing the component graph."""
if isinstance(value, (dict, list, set, bytearray)):
return copy(value)
return value
@dataclass(frozen=True)
class ThermofluidWorstPort:
component: str
port: str
value: float
signed_delta: float
def as_dict(self) -> dict[str, object]:
return {
"component": self.component,
"port": self.port,
"value": self.value,
"signedDelta": self.signed_delta,
}
@dataclass(frozen=True)
class ThermofluidIterationDelta:
iteration: int
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
def as_dict(self) -> dict[str, object]:
return {
"iteration": self.iteration,
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
}
@dataclass(frozen=True)
class ThermofluidClosureSuccess:
rhs_time: float
iterations: int
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
@classmethod
def from_iteration(
cls,
rhs_time: float,
delta: ThermofluidIterationDelta,
) -> ThermofluidClosureSuccess:
return cls(
rhs_time=float(rhs_time),
iterations=delta.iteration,
max_delta=delta.max_delta,
scale=delta.scale,
tolerance=delta.tolerance,
worst_port=delta.worst_port,
)
def as_dict(self) -> dict[str, object]:
return {
"rhsTime": self.rhs_time,
"iterations": self.iterations,
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
}
@dataclass(frozen=True)
class ThermofluidClosureFailure:
failed_rhs_time: float
iterations: int
delta_tail: tuple[ThermofluidIterationDelta, ...]
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
failure_count: int = 0
@classmethod
def from_iterations(
cls,
failed_rhs_time: float,
deltas: Sequence[ThermofluidIterationDelta],
*,
tail_limit: int = 8,
) -> ThermofluidClosureFailure:
if not deltas:
raise ValueError("A thermofluid failure requires iteration diagnostics.")
final = deltas[-1]
return cls(
failed_rhs_time=float(failed_rhs_time),
iterations=final.iteration,
delta_tail=tuple(deltas[-tail_limit:]),
max_delta=final.max_delta,
scale=final.scale,
tolerance=final.tolerance,
worst_port=final.worst_port,
)
def as_dict(self) -> dict[str, object]:
return {
"failedRhsTime": self.failed_rhs_time,
"iterations": self.iterations,
"deltaTail": [item.as_dict() for item in self.delta_tail],
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
"failureCount": self.failure_count,
}
class ThermofluidClosureError(RecoverableTrialStateError):
"""Recoverable exhaustion of the stream/pressure-flow fixed point.
Stream propagation failures and algebraic-solver failures intentionally
retain their original exception types: rollback is still applied, but a
smaller ODE step is not known to repair those structural/numerical errors.
"""
def __init__(self, diagnostics: ThermofluidClosureFailure) -> None:
super().__init__(
"Stream enthalpy and pressure-flow coupling did not converge "
f"after {diagnostics.iterations} iterations at "
f"t={diagnostics.failed_rhs_time:.17g}."
)
self.diagnostics = diagnostics
class ThermofluidClosureDiagnostics:
"""Run-level RHS outcomes; maintenance/postprocessing calls do not write it."""
def __init__(self) -> None:
self.failure_count = 0
self.last_failure: ThermofluidClosureFailure | None = None
self.last_success: ThermofluidClosureSuccess | None = None
def record_success(self, success: ThermofluidClosureSuccess) -> None:
self.last_success = success
def record_failure(
self,
failure: ThermofluidClosureFailure,
) -> ThermofluidClosureFailure:
self.failure_count += 1
recorded = replace(failure, failure_count=self.failure_count)
self.last_failure = recorded
return recorded
def as_dict(self) -> dict[str, object]:
return {
"failureCount": self.failure_count,
"lastFailure": (
self.last_failure.as_dict()
if self.last_failure is not None
else None
),
"lastSuccess": (
self.last_success.as_dict()
if self.last_success is not None
else None
),
}
@dataclass(frozen=True)
class _PortValueBinding:
component_name: str
port_name: str
state: PortState
variable: str
@dataclass(frozen=True)
class _PortFieldPlan:
variable: str
states: tuple[PortState, ...]
@dataclass(frozen=True)
class _FlowBinding:
component_name: str
port_name: str
state: PortState
@dataclass(frozen=True)
class _ComponentCacheBinding:
component: object
attribute_names: tuple[str, ...]
attribute_name_set: frozenset[str]
snapshot_hook: Callable[[], object] | None
restore_hook: Callable[[object], None] | None
@dataclass
class ThermofluidTransactionSnapshot:
plan: ThermofluidTransactionPlan
port_values: tuple[list[float], ...]
component_cache_values: tuple[list[object], ...]
custom_cache_values: list[object | None]
diagnostic_values: list[object]
def restore(self) -> None:
plan = self.plan
plan._restore_port_values(self.port_values)
for binding, values, custom_value in zip(
plan.component_cache_bindings,
self.component_cache_values,
self.custom_cache_values,
):
component = binding.component
for name in tuple(getattr(component, "__dict__", {})):
if (
name.startswith("_causal_")
or _is_stream_cache_attribute(name)
) and name not in binding.attribute_name_set:
delattr(component, name)
for name, value in zip(binding.attribute_names, values):
setattr(component, name, _copy_cache_value(value))
if binding.restore_hook is not None:
binding.restore_hook(custom_value)
for owner, value in zip(
plan.diagnostic_owners,
self.diagnostic_values,
):
owner.last_diagnostics = value
class ThermofluidTransactionPlan:
"""Compiled, lightweight rollback boundary for one Generic RHS closure.
It snapshots active physical-port values, catalog stream-temperature caches,
component ``_causal_*`` seed fields, and resolver/solver last diagnostics.
A custom stream-aware component with an opaque mutable cache can implement
both ``snapshot_thermofluid_closure_cache()`` and
``restore_thermofluid_closure_cache(snapshot)``; these hooks are invoked in
addition to the standard name-based cache capture.
"""
def __init__(
self,
*,
port_value_bindings: tuple[_PortValueBinding, ...],
port_field_plans: tuple[_PortFieldPlan, ...],
flow_bindings: tuple[_FlowBinding, ...],
component_cache_bindings: tuple[_ComponentCacheBinding, ...],
component_count: int,
diagnostic_owners: tuple[object, ...],
) -> None:
self.port_value_bindings = port_value_bindings
self.port_field_plans = port_field_plans
self.flow_bindings = flow_bindings
self.component_cache_bindings = component_cache_bindings
self.component_count = component_count
self.diagnostic_owners = diagnostic_owners
self._snapshot = ThermofluidTransactionSnapshot(
plan=self,
port_values=tuple(
[0.0] * len(field.states)
for field in port_field_plans
),
component_cache_values=tuple(
[None] * len(binding.attribute_names)
for binding in component_cache_bindings
),
custom_cache_values=[None] * len(component_cache_bindings),
diagnostic_values=[None] * len(diagnostic_owners),
)
@classmethod
def compile(
cls,
network: object,
*,
diagnostic_owners: Sequence[object] = (),
) -> ThermofluidTransactionPlan:
components = tuple(getattr(network, "components").values())
port_value_bindings: list[_PortValueBinding] = []
port_states_by_variable: dict[str, list[PortState]] = {}
flow_bindings: list[_FlowBinding] = []
component_cache_bindings: list[_ComponentCacheBinding] = []
for component in components:
active_definitions = tuple(
definition
for definition in component.active_port_definitions
if definition.kind == "physical"
)
for definition in active_definitions:
state = component.get_port(definition.name)
flow_bindings.append(
_FlowBinding(component.name, definition.name, state)
)
for variable in definition.variables:
port_states_by_variable.setdefault(variable.name, []).append(state)
port_value_bindings.append(
_PortValueBinding(
component.name,
definition.name,
state,
variable.name,
)
)
attribute_names = tuple(
name
for name in getattr(component, "__dict__", {})
if name.startswith("_causal_")
or _is_stream_cache_attribute(name)
)
snapshot_hook = getattr(
component,
"snapshot_thermofluid_closure_cache",
None,
)
restore_hook = getattr(
component,
"restore_thermofluid_closure_cache",
None,
)
hooks_are_available = callable(snapshot_hook) and callable(restore_hook)
if attribute_names or hooks_are_available:
component_cache_bindings.append(
_ComponentCacheBinding(
component=component,
attribute_names=attribute_names,
attribute_name_set=frozenset(attribute_names),
snapshot_hook=(snapshot_hook if hooks_are_available else None),
restore_hook=(restore_hook if hooks_are_available else None),
)
)
owners = tuple(
dict.fromkeys(
owner
for owner in diagnostic_owners
if hasattr(owner, "last_diagnostics")
)
)
return cls(
port_value_bindings=tuple(port_value_bindings),
port_field_plans=tuple(
_PortFieldPlan(variable, tuple(states))
for variable, states in port_states_by_variable.items()
),
flow_bindings=tuple(flow_bindings),
component_cache_bindings=tuple(component_cache_bindings),
component_count=len(components),
diagnostic_owners=owners,
)
def capture(self) -> ThermofluidTransactionSnapshot:
# GenericFluidSystem executes one RHS serially. Reuse one compiled
# workspace rather than allocating a snapshot object and several outer
# tuples at every successful trial point.
snapshot = self._snapshot
self._capture_port_values(snapshot.port_values)
for binding, values in zip(
self.component_cache_bindings,
snapshot.component_cache_values,
):
for position, name in enumerate(binding.attribute_names):
values[position] = _copy_cache_value(
getattr(binding.component, name)
)
for position, binding in enumerate(self.component_cache_bindings):
snapshot.custom_cache_values[position] = (
binding.snapshot_hook()
if binding.snapshot_hook is not None
else None
)
for position, owner in enumerate(self.diagnostic_owners):
snapshot.diagnostic_values[position] = owner.last_diagnostics
return snapshot
def _capture_port_values(
self,
workspaces: tuple[list[float], ...],
) -> None:
for field, values in zip(self.port_field_plans, workspaces):
variable = field.variable
states = field.states
if variable == "p":
for position, state in enumerate(states):
values[position] = state.p
elif variable == "m_flow":
for position, state in enumerate(states):
values[position] = state.m_flow
elif variable == "h_outflow":
for position, state in enumerate(states):
values[position] = state.h_outflow
elif variable == "volume":
for position, state in enumerate(states):
values[position] = state.volume
elif variable == "volume_flow":
for position, state in enumerate(states):
values[position] = state.volume_flow
elif variable == "x":
for position, state in enumerate(states):
values[position] = state.x
elif variable == "v":
for position, state in enumerate(states):
values[position] = state.v
elif variable == "f":
for position, state in enumerate(states):
values[position] = state.f
else:
for position, state in enumerate(states):
values[position] = getattr(state, variable)
def _restore_port_values(
self,
workspaces: tuple[list[float], ...],
) -> None:
for field, values in zip(self.port_field_plans, workspaces):
variable = field.variable
states = field.states
if variable == "p":
for state, value in zip(states, values):
state.p = value
elif variable == "m_flow":
for state, value in zip(states, values):
state.m_flow = value
elif variable == "h_outflow":
for state, value in zip(states, values):
state.h_outflow = value
elif variable == "volume":
for state, value in zip(states, values):
state.volume = value
elif variable == "volume_flow":
for state, value in zip(states, values):
state.volume_flow = value
elif variable == "x":
for state, value in zip(states, values):
state.x = value
elif variable == "v":
for state, value in zip(states, values):
state.v = value
elif variable == "f":
for state, value in zip(states, values):
state.f = value
else:
for state, value in zip(states, values):
setattr(state, variable, value)
def flow_values(self) -> tuple[float, ...]:
return tuple(float(binding.state.m_flow) for binding in self.flow_bindings)
def measure_flow_delta(
self,
previous: Sequence[float],
*,
iteration: int,
relative_tolerance: float,
) -> ThermofluidIterationDelta:
current = self.flow_values()
scale = max(
(abs(value) for value in (*previous, *current)),
default=1.0,
)
scale = max(scale, 1.0)
worst_index = -1
worst_signed_delta = 0.0
max_delta = 0.0
for index, (old, new) in enumerate(zip(previous, current)):
signed_delta = new - old
magnitude = abs(signed_delta)
if magnitude > max_delta:
worst_index = index
worst_signed_delta = signed_delta
max_delta = magnitude
worst_port = None
if worst_index >= 0:
binding = self.flow_bindings[worst_index]
worst_port = ThermofluidWorstPort(
component=binding.component_name,
port=binding.port_name,
value=current[worst_index],
signed_delta=worst_signed_delta,
)
return ThermofluidIterationDelta(
iteration=int(iteration),
max_delta=max_delta,
scale=scale,
tolerance=float(relative_tolerance) * scale,
worst_port=worst_port,
)
def diagnostics(self) -> dict[str, int]:
stream_cache_slot_count = sum(
len(binding.attribute_names)
for binding in self.component_cache_bindings
)
return {
"physicalPortValueSlotCount": len(self.port_value_bindings),
"physicalFlowPortCount": len(self.flow_bindings),
"componentCount": self.component_count,
"cacheBindingCount": len(self.component_cache_bindings),
"streamAndCausalCacheSlotCount": stream_cache_slot_count,
"customCacheHookCount": sum(
binding.snapshot_hook is not None
for binding in self.component_cache_bindings
),
"diagnosticOwnerCount": len(self.diagnostic_owners),
}
File diff suppressed because it is too large. Load diff
+33 -7
View File
@@ -91,11 +91,13 @@ class SimulationNetwork:
) -> Connection:
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
if endpoint_a == endpoint_b:
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.")
if endpoint_a.component == endpoint_b.component:
raise ValueError(
f"Cannot connect component {endpoint_a.component} to itself."
)
first_port = self._port_for(endpoint_a)
second_port = self._port_for(endpoint_b)
first_port = self._active_port_for(endpoint_a)
second_port = self._active_port_for(endpoint_b)
first_definition = first_port.definition
second_definition = second_port.definition
if first_definition is None or second_definition is None:
@@ -131,6 +133,16 @@ class SimulationNetwork:
+ ", ".join(occupied)
+ ". Use a junction component for branching."
)
else:
signal_input = (
endpoint_a
if first_definition.nominal_role == "input"
else endpoint_b
)
if signal_input in occupied_endpoints:
raise ValueError(
f"Signal input {signal_input} already has a driver."
)
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
endpoint_a, endpoint_b = endpoint_b, endpoint_a
@@ -156,6 +168,20 @@ class SimulationNetwork:
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
return component.get_port(endpoint.port)
def _active_port_for(self, endpoint: Endpoint) -> PortState:
try:
component = self.components[endpoint.component]
except KeyError as exc:
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
active_names = {
definition.name for definition in component.active_port_definitions
}
if endpoint.port not in active_names:
raise ValueError(
f"Port {endpoint} is not active for the component's current parameters."
)
return component.get_port(endpoint.port)
def connection_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Evaluate connector equations that have a direct scalar residual.
@@ -217,7 +243,7 @@ class SimulationNetwork:
return tuple(
f"{component.name}.{definition.name}.{variable.name}"
for component in self.components.values()
for definition in component.port_definitions
for definition in component.active_port_definitions
if definition.kind == "physical"
for variable in definition.variables
if variable.role in {"effort", "flow"}
@@ -292,7 +318,7 @@ class SimulationNetwork:
"parameters": component.parameter_interface_dicts(),
"ports": [
definition.as_interface_dict()
for definition in component.port_definitions
for definition in component.active_port_definitions
],
"resultVariables": [
variable.as_dict()
@@ -308,7 +334,7 @@ class SimulationNetwork:
"unconnectedPorts": [
{"component": component.name, "port": definition.name}
for component in self.components.values()
for definition in component.port_definitions
for definition in component.active_port_definitions
if (component.name, definition.name) not in connected_endpoints
],
}
+161
View File
@@ -0,0 +1,161 @@
"""Process-local warm-up for the numerical simulation runtime."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import logging
import os
from threading import Lock
from time import perf_counter
from typing import Literal
LOGGER = logging.getLogger(__name__)
WarmupStatus = Literal["completed", "failed", "disabled"]
@dataclass(frozen=True)
class SimulationWarmupReport:
status: WarmupStatus
duration_ms: float
error: str | None = None
def as_dict(self) -> dict[str, object]:
return asdict(self)
_WARMUP_LOCK = Lock()
_WARMUP_REPORT: SimulationWarmupReport | None = None
def simulation_warmup_enabled() -> bool:
raw_value = os.getenv("SIMULATIONAPP_WARMUP", "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_WARMUP must be one of: on, off, true, false, 1, 0."
)
def _run_numerical_warmup() -> None:
"""Exercise only in-memory SciPy paths used by real simulations."""
import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau, solve_ivp
from scipy.optimize import brentq, least_squares
from scipy.optimize._numdiff import group_columns
from scipy.sparse import csc_matrix, csr_matrix
# Importing these classes is intentional even though the micro solve below
# uses BDF: the stepwise solver selects them dynamically at runtime.
solver_types = (BDF, DOP853, LSODA, RK23, RK45, Radau)
if len(solver_types) != 6:
raise RuntimeError("SciPy solver warm-up did not load every supported method.")
sparsity = csc_matrix(np.array([[1.0]], dtype=float))
groups = group_columns(sparsity)
if groups.shape != (1,):
raise RuntimeError("SciPy Jacobian grouping warm-up returned an invalid shape.")
integration = solve_ivp(
lambda _time, state: -state,
(0.0, 1.0e-4),
np.array([1.0], dtype=float),
method="BDF",
t_eval=np.array([0.0, 1.0e-4], dtype=float),
jac_sparsity=sparsity,
rtol=1.0e-6,
atol=1.0e-9,
)
if not integration.success or not np.isfinite(integration.y).all():
raise RuntimeError("SciPy integration warm-up did not complete successfully.")
algebraic_sparsity = csr_matrix(np.eye(2, dtype=bool))
algebraic = least_squares(
lambda state: np.array(
[state[0] - 1.0, state[1] - 2.0],
dtype=float,
),
np.array([0.5, 0.5], dtype=float),
bounds=(
np.array([0.0, 0.0], dtype=float),
np.array([3.0, 3.0], dtype=float),
),
jac_sparsity=algebraic_sparsity,
tr_solver="lsmr",
)
if (
not algebraic.success
or not np.isfinite(algebraic.x).all()
or not np.allclose(algebraic.x, np.array([1.0, 2.0]), atol=1.0e-8)
):
raise RuntimeError("SciPy algebraic warm-up did not complete successfully.")
root = brentq(lambda value: value - 0.5, 0.0, 1.0)
if abs(root - 0.5) > 1.0e-12:
raise RuntimeError("SciPy scalar root warm-up returned an invalid result.")
# Compile the cached v3 XSD through the same public validation path. The
# intentionally incomplete document is never accepted or persisted.
from app.system_xml import validate_system_xml_document
validate_system_xml_document(b"<System/>")
def warm_up_simulation_runtime() -> SimulationWarmupReport:
"""Warm one worker exactly once, returning a startup diagnostic report.
Ordinary warm-up failures are reported but do not prevent the editor and
non-simulation APIs from starting. ``MemoryError`` remains fatal because
continuing a worker under memory exhaustion is unsafe.
"""
global _WARMUP_REPORT
with _WARMUP_LOCK:
if _WARMUP_REPORT is not None:
return _WARMUP_REPORT
if not simulation_warmup_enabled():
_WARMUP_REPORT = SimulationWarmupReport(
status="disabled",
duration_ms=0.0,
)
return _WARMUP_REPORT
started = perf_counter()
try:
_run_numerical_warmup()
except MemoryError:
raise
except Exception as exc:
_WARMUP_REPORT = SimulationWarmupReport(
status="failed",
duration_ms=(perf_counter() - started) * 1000.0,
error=f"{type(exc).__name__}: {exc}",
)
LOGGER.exception("Simulation runtime warm-up failed; startup will continue.")
else:
_WARMUP_REPORT = SimulationWarmupReport(
status="completed",
duration_ms=(perf_counter() - started) * 1000.0,
)
LOGGER.info(
"Simulation runtime warm-up completed in %.1f ms.",
_WARMUP_REPORT.duration_ms,
)
return _WARMUP_REPORT
def _reset_simulation_warmup_for_tests() -> None:
global _WARMUP_REPORT
with _WARMUP_LOCK:
_WARMUP_REPORT = None
__all__ = [
"SimulationWarmupReport",
"simulation_warmup_enabled",
"warm_up_simulation_runtime",
]
+310 -582
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
title SystemSimulationApp Launcher
set "BACKEND_SCRIPT=%~dp0start-backend.bat"
set "FRONTEND_SCRIPT=%~dp0start-reactflow.bat"
if not exist "%BACKEND_SCRIPT%" (
echo [ERROR] start-backend.bat was not found:
echo %BACKEND_SCRIPT%
pause
exit /b 1
)
if not exist "%FRONTEND_SCRIPT%" (
echo [ERROR] start-reactflow.bat was not found:
echo %FRONTEND_SCRIPT%
pause
exit /b 1
)
echo Starting FastAPI and ReactFlow in separate windows...
set "LAUNCH_ERROR=0"
ver >nul
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%BACKEND_SCRIPT%"
if errorlevel 1 set "LAUNCH_ERROR=1"
ver >nul
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%FRONTEND_SCRIPT%"
if errorlevel 1 set "LAUNCH_ERROR=1"
if "%LAUNCH_ERROR%"=="1" (
echo.
echo [ERROR] One or more service windows could not be created.
pause
exit /b 1
)
exit /b 0
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -u
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
echo "[ERROR] start-all.sh requires Bash 4.3 or newer." >&2
exit 1
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
BACKEND_SCRIPT="$SCRIPT_DIR/start-backend.sh"
FRONTEND_SCRIPT="$SCRIPT_DIR/start-reactflow.sh"
if [[ ! -x "$BACKEND_SCRIPT" ]]; then
echo "[ERROR] Backend start script is missing or not executable:" >&2
echo " $BACKEND_SCRIPT" >&2
exit 1
fi
if [[ ! -x "$FRONTEND_SCRIPT" ]]; then
echo "[ERROR] Frontend start script is missing or not executable:" >&2
echo " $FRONTEND_SCRIPT" >&2
exit 1
fi
BACKEND_PID=""
FRONTEND_PID=""
signal_process_group() {
local signal="$1"
local pid="$2"
[[ -n "$pid" ]] || return 0
kill "-$signal" -- "-$pid" 2>/dev/null || kill "-$signal" "$pid" 2>/dev/null || true
}
process_group_is_running() {
local pid="$1"
[[ -n "$pid" ]] && kill -0 -- "-$pid" 2>/dev/null
}
cleanup() {
local backend_cleared=false
local force_kill=false
local frontend_cleared=false
local launcher_pid=$$
local timer_pid
trap '' INT TERM HUP
trap - EXIT
trap 'force_kill=true' ALRM
signal_process_group TERM "$BACKEND_PID"
signal_process_group TERM "$FRONTEND_PID"
(
sleep 5
kill -ALRM "$launcher_pid" 2>/dev/null || true
) &
timer_pid=$!
if [[ -n "$BACKEND_PID" ]]; then
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ "$force_kill" == false && -n "$FRONTEND_PID" ]]; then
wait "$FRONTEND_PID" 2>/dev/null || true
fi
while [[ "$force_kill" == false ]]; do
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
backend_cleared=true
fi
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
frontend_cleared=true
fi
if [[ "$backend_cleared" == true && "$frontend_cleared" == true ]]; then
break
fi
sleep 0.1
done
kill -KILL "$timer_pid" 2>/dev/null || true
wait "$timer_pid" 2>/dev/null || true
if [[ "$force_kill" == true ]]; then
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
backend_cleared=true
fi
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
frontend_cleared=true
fi
if [[ "$backend_cleared" == false ]]; then
signal_process_group KILL "$BACKEND_PID"
fi
if [[ "$frontend_cleared" == false ]]; then
signal_process_group KILL "$FRONTEND_PID"
fi
fi
if [[ -n "$BACKEND_PID" ]]; then
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "$FRONTEND_PID" ]]; then
wait "$FRONTEND_PID" 2>/dev/null || true
fi
trap - ALRM
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
echo "Starting FastAPI and ReactFlow..."
echo "Press Ctrl+C to stop both services."
echo
set -m
"$BACKEND_SCRIPT" &
BACKEND_PID=$!
"$FRONTEND_SCRIPT" &
FRONTEND_PID=$!
set +m
wait -n
EXIT_CODE=$?
exit "$EXIT_CODE"
+17 -3
View File
@@ -1,15 +1,28 @@
@echo off
setlocal
setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0"
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
title SystemSimulationApp FastAPI - 127.0.0.1:8000
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe"
set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe"
if not exist "%PYTHON_EXE%" (
echo [ERROR] Python virtual environment was not found:
echo %PYTHON_EXE%
echo.
echo Create it and install the backend dependencies first:
echo py -3 -m venv "%REPO_ROOT%\.venv-win"
echo "%PYTHON_EXE%" -m pip install -r "%REPO_ROOT%\requirements.txt"
echo.
pause
exit /b 1
)
pushd "%REPO_ROOT%" >nul
if errorlevel 1 (
echo [ERROR] Unable to enter the repository directory:
echo %REPO_ROOT%
echo.
pause
exit /b 1
)
@@ -27,4 +40,5 @@ if not "%EXIT_CODE%"=="0" (
pause
)
popd
exit /b %EXIT_CODE%
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
PYTHON_EXE="${SYSTEM_SIMULATION_PYTHON:-$REPO_ROOT/.venv/bin/python}"
if [[ "$PYTHON_EXE" != /* ]]; then
PYTHON_EXE="$REPO_ROOT/$PYTHON_EXE"
fi
if [[ ! -x "$PYTHON_EXE" ]]; then
echo "[ERROR] Python virtual environment was not found:" >&2
echo " $PYTHON_EXE" >&2
echo >&2
echo "Create it and install the backend dependencies first:" >&2
echo " python3 -m venv \"$REPO_ROOT/.venv\"" >&2
echo " \"$REPO_ROOT/.venv/bin/python\" -m pip install -r \"$REPO_ROOT/requirements.txt\"" >&2
exit 1
fi
cd "$REPO_ROOT"
echo "Starting FastAPI at http://127.0.0.1:8000"
echo "Press Ctrl+C to stop the service."
echo
exec "$PYTHON_EXE" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+77
View File
@@ -0,0 +1,77 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
set "FRONTEND_DIR=%REPO_ROOT%\frontend"
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
if not exist "%FRONTEND_DIR%\package.json" (
echo [ERROR] Frontend package.json was not found:
echo %FRONTEND_DIR%\package.json
echo.
pause
exit /b 1
)
set "NODE_DIR="
for /d %%D in ("%REPO_ROOT%\.tools\node-*-win-x64") do (
if not defined NODE_DIR if exist "%%~fD\node.exe" if exist "%%~fD\npm.cmd" (
"%%~fD\node.exe" -e "v=process.versions.node.split('.');M=+v[0];m=+v[1];process.exit((M===20&&m>=19)||(M===22&&m>=12)||M>=23?0:1)" >nul 2>&1
if not errorlevel 1 set "NODE_DIR=%%~fD"
)
)
if not defined NODE_DIR (
echo [ERROR] A compatible Node.js portable runtime was not found under:
echo %REPO_ROOT%\.tools
echo Vite requires Node.js 20.19+ or 22.12+.
echo.
pause
exit /b 1
)
set "NPM_EXE=%NODE_DIR%\npm.cmd"
if not exist "%NPM_EXE%" (
echo [ERROR] npm.cmd was not found:
echo %NPM_EXE%
echo.
pause
exit /b 1
)
if not exist "%FRONTEND_DIR%\node_modules\.bin\vite.cmd" (
echo [ERROR] Frontend dependencies are not installed.
echo Run the following command first:
echo cd /d "%FRONTEND_DIR%"
echo call "%NPM_EXE%" ci
echo.
pause
exit /b 1
)
pushd "%FRONTEND_DIR%" >nul
if errorlevel 1 (
echo [ERROR] Unable to enter the frontend directory:
echo %FRONTEND_DIR%
echo.
pause
exit /b 1
)
set "PATH=%NODE_DIR%;%PATH%"
echo Starting ReactFlow at http://127.0.0.1:5173
echo Press Ctrl+C to stop the service.
echo.
call "%NPM_EXE%" run dev -- --strictPort
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
pause
)
popd
exit /b %EXIT_CODE%
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
FRONTEND_DIR="$REPO_ROOT/frontend"
node_version_supported() {
local version="$1"
local major
local minor
[[ "$version" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
(( (major == 20 && minor >= 19) || (major == 22 && minor >= 12) || major >= 23 ))
}
if [[ ! -f "$FRONTEND_DIR/package.json" ]]; then
echo "[ERROR] Frontend package.json was not found:" >&2
echo " $FRONTEND_DIR/package.json" >&2
exit 1
fi
NODE_EXE=""
NPM_EXE=""
for candidate in "$REPO_ROOT"/.tools/node-*-linux-x64/bin; do
if [[ -x "$candidate/node" && -x "$candidate/npm" ]]; then
CANDIDATE_VERSION="$("$candidate/node" --version 2>/dev/null || true)"
if node_version_supported "$CANDIDATE_VERSION"; then
NODE_EXE="$candidate/node"
NPM_EXE="$candidate/npm"
PATH="$candidate:${PATH:-}"
break
fi
fi
done
export PATH
if [[ -z "$NODE_EXE" ]]; then
NODE_EXE="$(command -v node || true)"
NPM_EXE="$(command -v npm || true)"
fi
if [[ -z "$NODE_EXE" || -z "$NPM_EXE" ]]; then
echo "[ERROR] Node.js and npm were not found." >&2
echo "Install Node.js 20.19+ or 22.12+ and make node/npm available on PATH." >&2
exit 1
fi
NODE_VERSION="$("$NODE_EXE" --version 2>/dev/null || true)"
if ! node_version_supported "$NODE_VERSION"; then
echo "[ERROR] Unsupported Node.js version: $NODE_VERSION" >&2
echo "Vite requires Node.js 20.19+ or 22.12+ (Node.js 21 is not supported)." >&2
exit 1
fi
if [[ ! -x "$FRONTEND_DIR/node_modules/.bin/vite" ]]; then
echo "[ERROR] Frontend dependencies are not installed." >&2
echo "Run the following command first:" >&2
echo " cd \"$FRONTEND_DIR\" && \"$NPM_EXE\" ci" >&2
exit 1
fi
cd "$FRONTEND_DIR"
echo "Starting ReactFlow at http://127.0.0.1:5173"
echo "Press Ctrl+C to stop the service."
echo
exec "$NPM_EXE" run dev -- --strictPort
+12
View File
@@ -0,0 +1,12 @@
# Reference direct-dependency set for CPython 3.12.
#
# This intentionally pins only packages imported or invoked directly by the
# application. In particular, it does not pin uvicorn[standard]'s optional,
# platform-dependent transitive dependencies. Regenerate and validate these
# pins in a clean CPython 3.12 environment when intentionally upgrading them.
fastapi==0.141.1
lxml==6.1.1
numpy==2.5.2
pydantic==2.13.4
scipy==1.18.0
uvicorn==0.52.3
+31
View File
@@ -0,0 +1,31 @@
# Fully resolved release lock for CPython 3.12 on manylinux_2_28-compatible
# Linux x86_64. Resolved and wheel-verified on 2026-08-18.
#
# Install this file as a requirements file (with -r), not as a constraint
# (with -c). Each package is pinned to the wheel selected for the reference
# platform, and pip verifies that wheel against its SHA-256 digest.
--only-binary=:all:
--require-hashes
annotated-doc==0.0.5 --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101
annotated-types==0.8.0 --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
anyio==4.14.2 --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494
click==8.4.2 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
fastapi==0.141.1 --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3
h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
httptools==0.8.0 --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2
idna==3.18 --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2
lxml==6.1.1 --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818
numpy==2.5.2 --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8
pydantic==2.13.4 --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba
pydantic-core==2.46.4 --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce
python-dotenv==1.2.3 --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
scipy==1.18.0 --hash=sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2
starlette==1.6.0 --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c
typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
uvicorn[standard]==0.52.3 --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c
uvloop==0.22.1 --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4
watchfiles==1.2.0 --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5
websockets==17.0.1 --hash=sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a
+26 -41
View File
@@ -1,52 +1,37 @@
# 开发文档索引
# 文档目录说明
本目录是 SystemSimulationApp 协议和开发规范的统一入口。
本目录是 SystemSimulationApp 更新日志、现行标准和其他技术报告的统一入口。
## 模型开发
## 目录职责
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
用于创建或修改模型,包括端口、参数、结果、方程、版本、测试和 AI 修改协议。
2. [组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。
3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json)
`GET /api/components/catalog` 的机器可读结构。
4. [AMESim 子模型公开组件迁移矩阵](amesim-component-migration-matrix.md)
用于划分 `test_mql` 子模型族的公开组件、内部模型和暂不支持范围。
5. [AMESim 氦气 Peng-Robinson 介质模型](amesim-helium-peng-robinson.md)
记录本地 AMESim 资料、氦气参数、索引映射和首版计算边界。
| 目录 | 职责 |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [`update-log/`](update-log/) | 按日期保存每日更新日志,记录当天已经完成的上传、修改及其影响。 |
| [`standard/`](standard/) | 保存当前采用的标准、协议和开发规范。实现、评审和 AI 修改代码时,应优先以这里的文档为准。 |
| [`other/`](other/) | 保存不属于现行标准的其他文档,例如性能仿真报告、优化报告、调研记录和技术总结。这些文档主要用于分析和参考,不默认作为强制规范。 |
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
模型。不要从前端兜底数据反推后端物理契约。
新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
## System XML
## `update-log` 书写规范
- [System XML v2 协议](system-xml-v2.md)
- [System XML v2 XSD](../schemas/system-simulation-v2.xsd)
- [System XML v1 协议(旧版)](system-xml-v1.md)
- [System XML v1 XSD(旧版)](../schemas/system-simulation-v1.xsd)
以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。
新增模型时,模型类和组件库清单是后端事实来源;System XML 保存组件实例、参数和
连接。XML 解析器不能自行创造模型端口或参数。
1. 按日期填写日志。每个文件只记录一天的更新,文件名使用 `更新日志-YYYY-MM-DD.md`,标题使用 `# 更新日志 YYYY-MM-DD`。
2. 同一天内有多次上传或更新时,按各项工作的实际完成时间分段记录。每段使用北京时间、24 小时制的 `## HH:mm` 标注时间,并按完成时间从早到晚排列。
3. 不同时间完成的内容应分别记录,不要合并到同一时间段。一次上传或更新包含多项相关修改时,可以写在同一时间段内。
4. 语言应简短且信息充分,让 AI 和人都能快速理解。优先说明完成了什么、结果是什么、影响哪些范围,删除重复描述和无关过程。
5. 尽量使用通俗易懂的语言,减少难以理解的术语。必须使用专业术语时,应提供必要的简短说明。
## 当前代码入口
推荐格式:
| 目的 | 文件 |
| --- | --- |
| 组件基类 | [`app/simulation/core/base.py`](../app/simulation/core/base.py) |
| 端口契约 | [`app/simulation/core/ports.py`](../app/simulation/core/ports.py) |
| 参数与结果元数据 | [`app/simulation/core/metadata.py`](../app/simulation/core/metadata.py) |
| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) |
| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) |
| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) |
| AMESim 第一版公开临时库清单 | [`app/simulation/components/amesim/library.py`](../app/simulation/components/amesim/library.py) |
| `test_mql` 固定算例入口 | [`app/simulation/examples/test_mql/system.py`](../app/simulation/examples/test_mql/system.py) |
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
```markdown
# 更新日志 YYYY-MM-DD
## AI 使用原则
## HH:mm
- 先读规范和相邻模型,再改代码。
- 只从 `library.py` 受控登记公开模型。
- 不在前端复制后端端口、参数或默认值作为正式来源。
- 不覆盖用户已有改动。
- 不自行猜测缺失的物理方程。
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
- 完成的修改、结果及影响范围。
## HH:mm
- 完成的修改、结果及影响范围。
```
@@ -4,7 +4,7 @@
适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system`
配套规范:[`component-model-authoring-spec-v1.md`](component-model-authoring-spec-v1.md)
配套规范:[`component-model-authoring-spec-v1.md`](../standard/component-model-authoring-spec-v1.md)
## 目标
@@ -38,15 +38,15 @@
| `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入名为 `res` 的标量信号输入端口,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 |
| `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_pn3node2`,`junctions` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 |
| `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_p4node2`,`junctions` | 已接入四端等压、流量守恒基础版;仍需复核 port 2 参考温压和多支路混合。 |
| `PNL00R` | 4 | 管路纯阻性摩擦段 | 第一版公开 | `amesim_pnl00r`,`flow` | 已接入准稳态阻性管公开契约;仍需按 AMESim `PNL00R` 参数和摩擦公式复核。 |
| `PNL00R` | 4 | 管路纯阻性摩擦段 | 第一版公开 | `amesim_pnl00r`,`flow` | 已按 AMESim `pn2pipefr` 接入可压缩 `Cm/Cq` 关系、同侧上游温度和裸 `Cm` 诊断;使用未修改 AMESim 结果包直接保存的 `Re/ff` 标定 Re≈1400–3400 的 shifted-Hill C2 过渡曲线,并通过固定点、单调性和第八路 `0–0.81 s` 回归。 |
| `PNL0001` | 20 | C-R 动态管路 | 第一版公开 | `amesim_pnl0001`,`flow` | 已按公开契约接入两状态管内容积 + port 1 摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 |
| `PNL0002` | 8 | R-C-R 动态管路 | 第一版公开 | `amesim_pnl0002`,`flow` | 已按公开契约接入中心两状态容积 + 两端半长摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 |
| `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 |
| `PNPL01` | 16 | 零气动流源 | 第一版公开 | `amesim_pnpl01`,`boundary` | 当前实现一端零流边界,只约束端口质量流量为 0;压力源/外部边界语义留后续扩展。 |
| `PNGD00` | 1 | 氦气气体定义 | 氦气 Peng-Robinson 首版公开 | `amesim_helium_medium`,`media` | 已映射源模型 `fluidType=12/eosType=6`;密度和压力使用 PR EOS,热量学暂用手册参考点的定比热闭合。完整压力相关残余焓、比热和真实气体临界流仍待状态相关物性接口。 |
| `PNRP17` | 8 | 气动活塞与移动体耦合 | 第一版公开 | `amesim_pnrp17`,`mechanical` | 已接入 1 个气动端口和 4 个一维机械端口,按 `dp/dr/x0/gi` 计算环形有效面积、扫掠容积、容积变化率和表压作用力;已与 PNCH012、双质量块跑通 System XML 联合仿真,完整事件语义和 AMESim baseline 仍待校准。 |
| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;完整 AMESim 接触/事件语义仍留后续对齐。 |
| `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 9 端一维机械节点、端口位移/速度等值和节点力平衡,并跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML;端口朝向/符号细节留后续 AMESim baseline 对齐。 |
| `MECMAS21` | 10 | 一维平动质量 | 第一版公开 | `amesim_mecmas21`,`mechanical` | 已接入一维机械端口 `x/v/f`、双端质量状态和基本摩擦/限位项,并跑通零力源与信号力源最小 System XML;参数注册已对齐 AMESim 选项码与条件显示,高级静摩擦、Stribeck 公式和倾角重力分量仍未实现。 |
| `LMECHN1` | 2 | 动态线性机械节点 | 第一版公开 | `amesim_lmechn1`,`mechanical` | 已接入 1..20 个右侧端口和动态最大编号左侧参考端口;工作区画布、接口编号与节点方程会随端口数同步变化,并兼容迁移旧版固定 `port_9` 工程。已跑通 `FORC -> LMECHN1 -> MECMAS21` 最小 System XML。 |
| `LSTP00A` | 8 | 弹性接触/端止动 | 第一版公开 | `amesim_lstp00a`,`mechanical` | 已接入两端一维机械端口、相对位移/速度接触力和最小 System XML 仿真;当前是连续罚函数基础版,完整 AMESim 事件/非光滑接触语义留后续 baseline 对齐。 |
| `F000` | 16 | 零力源 | 第一版公开 | `amesim_f000`,`mechanical` | 已作为一端机械零力边界公开,约束端口力为 0。 |
| `FORC` | 2 | 信号转力 | 第一版公开 | `amesim_forc`,`mechanical` | 已接入信号输入 `res` 到机械端口力源,可由 `STEP0/UD00` 驱动质量组件。 |
@@ -89,6 +89,6 @@
## 下一步执行建议
1. 以 `app.simulation.components.amesim.library` 的 22 个模型为公开清单唯一来源。
2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2 / PNL00R` 与动态管路的 AMESim baseline 误差。
2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2` 与动态管路的 AMESim baseline 误差;`PNL00R` 保持现有层流、过渡区固定点和单调性回归。
3. 每次调整模型都同步补充目录校验、参数边界、System XML 编译和最小仿真测试。
4. 用完整或代表性的 `test_mql` 画布校准 `PNRP17 + PNCH012` 的压力、力、位移和容积轨迹,并补齐事件边界语义。
+285
View File
@@ -0,0 +1,285 @@
# SystemSimulationApp 仿真性能评估(2026-08-15)
> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。
> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。
> 2026-08-16 已按本报告建议实现“仿真内独立物性缓存”“worker 启动暖机”、高刚度试探压力边界修复、方程关联块闭合、代数稀疏回退、外部 volume 跨域 ODE Jacobian 修正和 dense output 惰性构造;原始基线数据保留用于对照,当前大型 XML 复验见第 11 节。
## 1. 结论
1. **压力—流量闭合是原始基线的首要热点。** 2026-08-15 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加;2026-08-16 已完成方程块与稀疏首轮,当前现状见第 11 节。
2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。
3. **评估基线已有的两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些数据描述 2026-08-15 的原始基线,后续实现见第 9 节。
4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。
5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。
6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。
7. **用户提供的高刚度 XML 已能完成 10 s 仿真。** 原始基线在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;原因是压力优化下界为 1 Pa,排除了 RK45 合法产生的、仍严格大于 0 Pa 的亚帕试探值。2026-08-16 将优化器压力下界放宽到 0 Pa 后,构成方程仍要求压力严格为正,完整 RK45 仿真通过且没有触发可恢复重试。
8. **闭合不再固定执行第二次全网压力求解,也不再把一个大物理岛等同于一个求解块。** 每次闭合仍先保证全网成立;stream 更新后,只重算声明为 stream-sensitive 的方程—未知量关联块。物理连通岛只是安全范围,当前 `secondaryBlockCount` 是真实方程块数;无法安全分类的自定义模型会保守回退原全网路径。
9. **历史物理岛版收益取决于模型拓扑。** `off` 模式配对测试中,空气链、空气分支、氦气阶跃、机械接触和高刚度短算例分别改善 7.9%、6.5%、0.6%、21.4% 和 14.6%。这些数据保留作纵向基线,但该版已由方程关联块实现取代。
10. **大型分支 XML 的 `0.69 s` 现象已定位并完整跑通。** 输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量、74 个 ODE 状态。根因是外部 volume 跨域耦合在 ODE Jacobian 依赖图中漏 12 个实测显著项,而不是线程死锁;修正后结构由 1092 非零/27 色变为 1284 非零/31 色。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009。
11. **代数非线性回退已有可信声明图上的稀疏保护链。** 先求本轮未闭合方程块的 union sparse;失败恢复原始 `x0` 后做 global sparse,再失败才做 dense。受控扰动微基准在相同 `max_nfev=20` 下把真实残差回调由 3796 降至 164、墙钟约 7.357 s 降至 0.634 s(约 11.6 倍);活动接触或不可信声明仍走兼容 dense 路径。
12. **首轮其他优化均按适用范围解释。** worker 暖机已覆盖 sparse `least_squares` 的 LSMR 路径;dense output 仅在跨采样点或需要状态事件时构造,但本次大型 XML 含状态事件,因此没有本案收益。机械 `atol` 的 `1e-12→1e-10` A/B 约快 16%,但会改变机械误差合同,未采用;外层 thermofluid 流量固定点相对容差的 `1e-12→1e-9` A/B 反而增加 BDF 步数并改变轨迹,也未采用。
## 2. 埋点实现与污染控制
性能开关由进程启动环境变量 `SIMULATIONAPP_PROFILE` 决定:
| 模式 | 用途 | 记录内容 | 适合场景 |
| --- | --- | --- | --- |
| `off` | 正常运行,默认值 | 不在响应中加入性能数据;装饰器在模块加载时直接返回原函数 | 正式仿真和最终性能对比 |
| `standard` | 低开销阶段统计 | XML 校验、网络编译、系统构造、初始化、积分、后处理、结果组装 | 日常定位“大阶段” |
| `audit` | 深度审计 | 再展开 RHS、代数闭合、压力流量、stream、刷新、导数和物性内核 | 短算例诊断、调用频率与缓存评估 |
一次运行使用一个 `ContextVar` 隔离的 `PerformanceTrace`,不会把不同仿真任务的阶段计数混在一起。成功或失败的求解结果在 profiling 模式下都会把快照放入 `diagnostics.performance`。主要字段为:
- 阶段:`calls`、`inclusiveNs`、`selfNs`、`maxNs`、`errors`;
- 物性:上述时间字段,以及介质、操作、缓存查询/命中/未命中;
- audit 专有:闭合内精确输入唯一数/重复数、逆解迭代总数/最大值/收敛与未收敛次数;
- `propertyOutermostNs`:只累计最外层物性调用,避免把嵌套 PR 内核时间重复相加。
标准模式只保留低频的大阶段计时。21 次氦气阶跃配对运行中,标准模式相对关闭模式的中位开销为 1.9%;四个短算例分开校准为 0.5%~2.9%。audit 会逐次生成精确指纹并计时,短算例可慢到约 2.5~5 倍,因此 audit 数据用于定位和计数,最终优化收益必须回到 `off` 模式复测。
将当前代码的 `off` 模式与备份提交 `6a06489` 同时运行 21 次氦气阶跃,墙钟中位数差为约 0.3%,处于本机噪声范围。也就是说,默认关闭时没有观察到稳定的热路径退化。
## 3. 测试方法
环境:Windows 11、Python 3.12.3、SciPy 1.18.0、64 位 Intel 处理器。仓库没有 PyInstaller/Nuitka 等可执行文件构建链,本次直接使用项目实际启动后端的 `.venv-win` 解释器。把同一 Python 代码再包成单文件只会混入解包和启动成本,不会使这里的求解内核更接近生产路径。
基准工具入口:
```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
```
工具默认传入取消检查回调,从而走与前端流式仿真相同的低层逐步积分路径。它记录墙钟、进程 CPU、最终 NDJSON 编码、输入 SHA-256 和完整性能快照。原始 JSON 写入被 Git 忽略的 `app/data/performance-evaluations/`,避免把机器相关的大量样本提交到仓库。
本次代表算例:
| 算例 | 内容 | 暖机后 `off` 墙钟中位数 | 重复次数 |
| --- | --- | ---: | ---: |
| `air_chain` | 空气气缸—节流孔—管路—储罐 | 62.4 ms | 9 |
| `air_branched` | 空气分支网络 | 130.3 ms | 9 |
| `helium_step` | 高压 PR 氦气、信号阶跃阀 | 65.2 ms | 9 |
| `mechanical_contact` | MECMAS21/LSTP00A 弹性接触 | 33.0 ms | 9 |
| `helium_long` | 10 s PR 氦气均压、501 个输出点 | 10.63 s | 1 |
短算例先暖机 2 次再测 9 次;缓存 A/B 使用两个同时启动的独立进程各暖机 5 次、测量 21 次,以尽量抵消瞬时系统负载。长算例只测 1 次,因此它只用于判断数量级与阶段占比。
## 4. 深度阶段结果
下表时间是 audit 中位数,会包含审计自身开销;调用数和相对热点比绝对时间更可靠。
| 算例 | RHS | 完整闭合 | 压力流量求解 | 压力流量包含时间占 audit 总时间 | 物性调用 | 闭合内精确重复率 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `air_chain` | 33 | 37 | 74 | 77.0% | 4,265 | 91.2% |
| `air_branched` | 23 | 26 | 56 | 84.6% | 7,378 | 96.5% |
| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% |
| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 |
这是 2026-08-15 原始基线的闭合数据:当时每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点,理论上最多 26 次;四个算例平均为 2.00、2.15、2.30 和 2.00 次/闭合。随后 2026-08-16 的第一版先把后续重算缩到 stream-sensitive 物理连通岛;该历史版本又被当前方程关联块版取代。当前做法是在安全物理范围内只重算敏感方程实际关联的块,没有敏感块时不强制第二次压力求解。这样裁剪的是无效重算,不是删除真实耦合。
## 5. 物性调用与缓存结果
氦气阶跃的冷缓存 audit 代表运行:
| 操作 | 调用 | 命中/未命中 | 命中率 | 真实逆解次数 | 平均迭代 | 最大迭代 | 未收敛 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 |
在原始基线中,暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当时的进程级精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,712 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。
## 6. 输出与失败样本
| 算例 | 最终结果大小 | NDJSON 编码中位数 |
| --- | ---: | ---: |
| `air_chain` | 29.2 KiB | 0.39 ms |
| `air_branched` | 59.1 KiB | 1.21 ms |
| `helium_step` | 55.5 KiB | 1.10 ms |
| `mechanical_contact` | 51.4 KiB | 0.62 ms |
| `helium_long` | 507.4 KiB | 18.48 ms |
用户高刚度 XML 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。原始 audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;其 1.08 s 只代表历史失败路径,不能当作完整模型性能。允许严格正的亚帕试探压力后,同一模型已完成 10 s,当前完整性能结果见第 10 节。
## 7. 后续优化顺序
1. **[2026-08-16 已落实方程块与稀疏首轮] 优化压力流量执行计划。** 压力/流量方程、显式赋值、热流依赖和方程—未知量关联图已预编译;每轮 stream 更新后只重算敏感方程块。非线性时先做可信未闭合块的 union sparse,失败从原 `x0` 做 global sparse→dense;自定义、活动接触或结构不安全的网络保守回退兼容路径。后续仍可评估 equality group 真正消元和解析 Jacobian。
2. **[2026-08-16 已落实] 减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,并缓存相同输入的密度和等熵因子;沿用精确键、有界容量和按仿真隔离原则。
3. **[2026-08-16 已落实] 处理冷启动。** worker 在 FastAPI lifespan 中完成无业务副作用的 SciPy/XSD 微型暖机后再接收请求;不要把约 0.65 s 冷启动归因到每次仿真。
4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。
5. **[2026-08-16 已落实] 修复高刚度 XML 的压力试探边界。** 优化器允许严格正的亚帕试探值,物理构成方程仍拒绝零压和负压;该模型已用 10 s RK45 回归验证,不需要把这类合法试探误报为可恢复拒步。
6. **[2026-08-16 已落实] 补全外部 volume 的跨域 ODE Jacobian 依赖。** 机械位移写入气室容积后,气动储能与机械力平衡必须在状态稀疏图中双向关联;大型分支 XML 据此完整跑通。机械 `atol` 放宽虽有约 16% 的单次改善但改变精度合同;flow 固定点容差放宽反而增加步数,均未采用。
7. **[2026-08-16 已落实] dense output 惰性构造。** 仅当已接受步跨越下一样本或需要状态事件时构造插值;含状态事件的模型每步仍需要,不将其宣传为大型分支 XML 的收益来源。
## 8. 本次评估边界
- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。
- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。
- 原始基线的 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告当时均为单任务运行。该并发统计限制已由第 9 节的仿真内独立缓存消除。
- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。
- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。
- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。
## 9. 2026-08-16 缓存与启动暖机复验
原先两个函数级 LRU 会在 Python 进程内跨仿真共享条目。现已改为每次仿真通过
`ContextVar` 创建独立缓存,并在运行结束后整体释放;并发任务不会共享缓存或
命中统计。每个“物性操作 + 介质实例”使用独立的 C 层有界 LRU,默认上限为
8192 项。当前只缓存四条有明确重复收益的氦气路径:密度、等熵密度—压力因子、
`properties_from_mU` 和 `temperature_from_pressure_enthalpy`。
同一个高压氦气阶跃算例的冷缓存 audit 结果为:
| 操作 | 调用 | 命中 / 未命中 | 命中率 |
| --- | ---: | ---: | ---: |
| `density` | 578 | 403 / 175 | 69.7% |
| `isentropic_density_pressure_factor` | 398 | 310 / 88 | 77.9% |
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% |
四项合计 2,869 次命中、435 次未命中、435 个最终条目,未发生驱逐。PR 三次根
计算从原审计的 1,712 次降到 689 次。关闭埋点、各自预热 5 次并测量 21 次时,
缓存开启/完全关闭的墙钟中位数在本机分别为 71.6 ms 和 154.0 ms。这个对比表示
“四项缓存整体”相对“完全不缓存”的收益,不能误解为相对旧版两个 LRU 又提升
53.5%。缓存开关两次运行的完整 `series` 和 `final` SHA-256 一致。
10 s、501 个输出点的氦气均压算例单次复验中,缓存开启和关闭分别用时
14.18 s 与 26.64 s;开启时命中 400,571 次、未命中 96,427 次。四个缓存均达到
各自 8192 项上限,共发生 63,659 次 LRU 驱逐,但仍完成到 10 s。该长算例每种
配置只测了一次,只能说明容量上限确实生效且仍有收益,不能作为稳定百分比承诺。
worker 暖机只执行内存中的一维 BDF、`least_squares`、`brentq`、稀疏 Jacobian
分组和 XSD 编译,不运行用户模型、不写文件、不填充氦气业务缓存。当前暖机还显式
覆盖携带 `jac_sparsity`、使用 sparse LSMR trust-region 子问题的代数路径,避免真实
用户任务第一次触发该 SciPy 分支时再承担惰性初始化。三个新进程的
中位数为:不暖机首个仿真 709.1 ms;启动暖机本身 637.1 ms;暖机后的首个仿真
69.8 ms,同进程第二次约 68~74 ms。也就是说总初始化成本没有消失,而是被
移到服务宣告就绪之前。
## 10. 2026-08-16 历史物理岛版闭合复验
> 本节保留方程关联块实现之前的物理岛版数据,用于纵向对照。它已不是当前执行计划;当前结果见第 11 节。
该轮把 signal 源/连接、stream 组件/端口/连接以及气动外部 volume 组件/连接的
静态查找移到系统构造阶段;运行时仍按每个状态执行实际传播和热力刷新。压力流量
闭合先进行一次全网求解,再根据预编译的依赖声明,只对 stream-sensitive 的物理
连通块做后续固定点重算。对未声明依赖的自定义 stream 组件、跨组件方程或非方阵
物理岛,执行计划保守回退到原全网求解,不以性能换取模型兼容性。
该历史版本的 `off` 模式配对结果如下。表中百分比是同一模型、同一数值配置下的墙钟改善,适合
判断优化方向,不是跨机器速度承诺:
| 算例 | 历史物理岛版相对原全网闭合的改善 |
| --- | ---: |
| `air_chain` | 7.9% |
| `air_branched` | 6.5% |
| `helium_step` | 0.6% |
| `mechanical_contact` | 21.4% |
| high-stiffness short | 14.6% |
`helium_step` 在该历史版本里只有一个需要在 stream 后继续求解的敏感物理岛,因此 0.6% 的改善处于
小幅范围;不能用其他无敏感岛模型的收益夸大氦气模型的效果。积分完成后的后处理
也没有跳过闭合:每个输出采样点仍重新应用状态、执行完整 `_close_current_state()`
并提取结果,只是闭合内部使用同一安全执行计划。
完整 high-stiffness 10 s 算例的历史 `off` 基线约为 28.126 s;本轮全部改动后的
多次运行中位数为 13.694 s。这个跨版本对比同时包含本轮多项改动,不能把差额全部
归因于物理岛裁剪。为了单独核对当时的闭合计划,在同版代码上做 optimized/forced-global
对照,墙钟分别为 14.676 s 和 16.929 s,完整 `series` 完全一致;这组受控对比才
直接反映该模型的执行计划收益。
当前诊断已经进一步消除旧命名歧义:`secondaryPhysicalIslandCount` 表示安全分类的
物理范围,`secondaryBlockCount` 表示方程—未知量关联图中的真实块数,
`secondaryUnknownCount` 表示这些方程块合计未知量。`solveCount` 统计实际求解器调用,
`closurePassCount` 单列发生过压力求解的闭合 pass;`last` 中还包含
`residualEvaluations`、`jacobianMode`、dense/方程块回退状态。不能再把
`secondaryBlockCount` 解释为物理岛数。
## 11. 2026-08-16 大型分支 XML 与方程块首轮复验
验证输入 `test_mql-full-branches-01-04.xml` 的 SHA-256 为
`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`。模型规模如下:
| 项目 | 数量 |
| --- | ---: |
| 组件 | 98 |
| ODE 状态 | 74 |
| 压力/流量/机械代数未知量与方程 | 472 / 472 |
| 代数声明图结构非零 | 919(约 0.413%) |
| 全部独立代数方程块 | 58 |
| stream 后续敏感方程块 | 9,合计 192 个未知量 |
### 11.1 `0.69 s` 慢区的根因与完整结果
旧代码在 `0.69 s` 左右不是线程死锁:它仍会缓慢前进,但 BDF 在刚性变化区大量
缩步、重建有限差分 Jacobian 和执行 LU。定位出的结构错误是气动外部 volume
跨域耦合没有完整进入 ODE 状态依赖图。机械位置先写入气室容积,气室压力又反馈到
机械力平衡;旧图只沿普通物理端口追踪,漏掉这条闭环中的 12 个实测显著导数项。
修正后,状态稀疏图由 1092 个非零项、27 个颜色组变为 1284 个非零项、31 个颜色组。
颜色数增加是因为补上了真实依赖,并非回退到更差算法;完整 Jacobian 让 BDF 少走
错误 Newton 方向和重复试步。功能收口过程中的较早阶段测量为 92.187 s;最终稳定
代码连续三次完整 `0~0.81 s` 分别用时 79.049 s、74.658 s 和 85.103 s,数值工作量一致:
| 指标 | 结果 |
| --- | ---: |
| `nfev` | 3393 |
| `njev` | 226 |
| `nlu` | 667 |
| 接受步 | 1009 |
| 求解器启动次数 | 3 |
| 压力流量求解 | 28008,全部 seeded |
| 方程块/dense 非线性回退 | 0 / 0 |
| 三次 full-response 规范 JSON SHA-256 | `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f` |
| `physical-solution-v1` SHA-256 | `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7` |
这里的 full-response 哈希覆盖完整响应,所以诊断字段增删也会改变它。为稳定比较物理
结果,`physical-solution-v1` 只把 `{status, simulatedUntil, requestedStopTime, series,
final}` 投影为待哈希对象;schema 名只是外部标签,不进入对象。两种口径都使用
`json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 后计算 SHA-256。
旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,响应结构不同,
不作为最终结果,也不能与当前口径直接比较。
容差 A/B 必须分开解释:机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次测试约快
16%,但会改变机械状态与事件的误差合同,当前未采用;外层 thermofluid 流量固定点
相对容差从 `1e-12` 放宽到 `1e-9` 后,BDF 内部步数反而增加并改变积分轨迹,也未
采用。完整成功来自依赖图修正和闭合优化,不是牺牲积分精度或闭合精度。
### 11.2 stream 方程块与受控 A/B
第一次压力流量求解仍承担“全网必须成立”的语义;但可信声明图允许它在非线性时只把
本轮未闭合的独立方程块合并成一个 union sparse 问题,而不是固定构造 472 变量的
dense 问题。stream 更新后的固定点进一步只处理 9 个敏感方程块、合计 192 个未知量,
不再因为机械总线把拓扑连成一个大物理岛,就重复求解全部 472 个未知量。
在相同当前代码、相同 `0~0.01 s` 区间做 optimized/forced-global 配对,墙钟分别为
16.200 s 和 18.584 s,物理解与 `series` 逐值一致。这组对照隔离的是后续 stream
闭合作用域;它不包含完整 `0.81 s` 慢区的全部收益,不能与最终完整运行直接换算百分比。
### 11.3 非线性稀疏回退与其他首轮项
全局非线性回退当前采用兼容保护链:可信声明图先求未闭合方程块的 union sparse;
若块解失败,先把所有共享端口未知量恢复到原始 `x0`,再做 global sparse;若 sparse
仍未达到既有残差合同,再次从原 `x0` 做 global dense。活动接触会改变坐标/活动集,
不可信自定义声明也可能漏依赖,这两类不冒险使用静态稀疏图,继续走 dense 兼容路径。
受控扰动微基准在相同 `max_nfev=20` 下得到:
| 路径 | 真实残差回调 | 墙钟 |
| --- | ---: | ---: |
| dense | 3796 | 7.357 s |
| sparse | 164 | 0.634 s |
同一评估预算下约为 11.6 倍的回退成本改善;两条路径在 20 次优化器评估内都没有收敛,
所以这是“数值 Jacobian 试算成本”微基准,不是整体仿真加速承诺。诊断用
`residualEvaluations` 记录真实残差回调,避免仅看 SciPy `nfev` 漏掉内部差分调用。
worker 暖机现已覆盖带 `jac_sparsity` 的 sparse LSMR `least_squares` 路径。逐步积分的
dense output 也改为只在当前步跨越下一采样点或需要状态事件定位时构造;本 XML 含
状态事件,所以每步仍需要插值,这项优化对最终 79.049 s/74.658 s/85.103 s 复验没有收益。
@@ -0,0 +1,597 @@
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
> 调研基线:2026-08-15(System XML v3 迁移后);2026-08-16 已补充压力边界、预编译闭合执行计划、方程关联图分块、代数稀疏回退、ODE Jacobian 修正和实测复验的当前状态。
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。机器相关的实测结果单独见[仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)。
## 0. 三分钟读懂
### 0.1 求解器到底在做什么
先不管 ODE、RHS、BDF 这些名字。把一次仿真想成制作一段工程动画:
1. **检查装配图。** 气管有没有漏接,控制线方向对不对,模型参数是否齐全。
2. **读取当前“存量”。** 例如气室里有多少气体和能量,质量块现在的位置和速度。
3. **让当前瞬间自洽。** 根据这些存量,把此刻的压力、流量和力反复对账,直到连接规则和组件方程同时满足。
4. **计算变化速度。** 得出“下一小段时间内,质量、能量、位置、速度将怎样变化”。
5. **内部小步前进。** 求解器自己决定每次走多小;变化剧烈时会缩短步长。
6. **按用户指定时刻留快照。** 内部可能算很多小步,但结果文件只在 XML v3 的 `sampleStep` 指定的时刻保存数值。
7. **把进度和结果交给前端。** 运行中发心跳/进度,结束时一次性发送完整曲线数据。
项目里的技术说法“**半显式 ODE + 每次变化率计算前做代数闭合**”,翻译成人话就是:**会积累的量用时间积分向前推;必须在当前瞬间成立的关系,每次都先对账求平衡。**
### 0.2 用仓库里的真实案例贯穿全文
`tests/test_generic_system_xml_simulation.py:86-145` 有一条完整回归气路:
```text
高压气缸 低压储气罐
0.01 m³、500 kPa 0.1 m³、100 kPa
──> 节流孔 ──> 1 m 管路 ──>
```
图中箭头只表示这个初始压差下**预计**的气流方向,不代表物理连线本身有 `source/target` 方向。回归测试检查了:
- 气缸压力下降;
- 储气罐压力上升;
- 总质量和总能量守恒;
- 把工程 JSON 中物理边的 `source/target` 对调,结果不变。
测试只运行 `0~0.01 s`,设置结果采样 `sample_step=0.005 s`、内部上限 `max_step=0.001 s`、`method=BDF`(`tests/test_generic_system_xml_simulation.py:86-145, 254-300, 354-372`)。在 System XML v3 中,前两个字段分别写成 `sampleStep` 和 `maxStep`。它们可以这样理解:
```text
结果快照: 0 s -------- 0.005 s -------- 0.01 s
内部计算: 0 s - 小步 - 小步 - 小步 - ... - 0.01 s
每个内部步最多 0.001 s,也可能更短或被重算
```
- `sampleStep`:相机隔多久保存一张结果快照;
- `max_step`:求解器一次内部前进最多能走多远;
- `BDF`:一种适合系统中“有的变化快、有的变化慢”的自适应算法。本文不需要展开它的公式。
另一个真实案例位于 `tests/test_amesim_pnvo001_signal_xml.py:87-204, 230-246`:高、低压氦气室之间有一个阀,阶跃信号在 `0.04 s` 从 0 跳到 1。求解器会像遇到“定时闹钟”一样,准确停到事件时刻,更新阀命令,再从该时刻继续积分。
### 0.3 常见术语翻译
| 技术词 | 先这样理解 | 本项目里的具体含义 |
| --- | --- | --- |
| 动态状态(state) | 会随时间积累的存量 | 气体质量/内能 `[m,U]`,或机械速度/位置 `[v,x]` |
| 代数量 | 当前瞬间的仪表读数 | 压力、流量、连接力等;由当前状态和约束求出 |
| 代数闭合(closure) | 把所有账对平 | 让组件方程、连接守恒和当前状态同时成立 |
| 变化率计算(RHS) | 算下一刻变化有多快 | 输入当前状态,输出 `dm/dt`、`dU/dt`、加速度等 |
| ODE 积分 | 根据变化率向时间前进 | SciPy 的 BDF、Radau、RK45 等 |
| DAE | 状态和瞬时约束一起交给专用求解器 | 当前主内核不是通用 DAE 求解器 |
| stream 焓 | 气体随流动携带的“能量标签” | `h_outflow` 按实际流向传播/混合,不是两端温度相等 |
| 非线性迭代(`least_squares`) | 直接算不出时反复试值 | 压力流量快速路径失败后的回退方案 |
| Jacobian | “改一个量会影响哪些方程”的灵敏度地图 | 可帮助 BDF/Radau 和非线性求解少做试算 |
| dense output | 两个内部步之间的插值尺 | 用来补采样点和定位机械事件 |
| NDJSON | 一行一个 JSON 消息 | 同一 HTTP 响应中依次发送心跳、进度、结果 |
| worker | 后台办事通道 | 当前脚本是一个 Uvicorn worker;流式任务另开求解线程 |
只想了解系统如何运行,可以读第 0、3、5、6、9、10、13 节;需要改求解器时,再阅读其余技术细节和第 15 节代码索引。
## 1. 结论先行
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
4. **[已优化] 每次完整闭合仍先做 1 次语义上的全网压力流量求解,再传播 stream;后续固定点只重算 stream-sensitive 方程实际关联的方程块。** 物理连通岛现在只是安全分类的第一层,真正执行的 `secondaryBlockCount` 是方程—未知量关联图中的块数;无法可信分类的自定义或异常结构仍保守回退原全网路径。积分结束后,每个输出采样点仍执行一次完整闭合并提取结果。
5. **[已实现] 代数求解已有因果化快路径、方程块和稀疏 `least_squares` 回退。** 对可信内置声明,求解器从方程—未知量关联图编译 `jac_sparsity`,先把本轮未闭合的独立方程块合并成一个 union sparse 问题;若失败,恢复到同一个原始 `x0`,再执行全局 sparse,仍失败才执行兼容的 dense 路径。活动接触或不可信自定义声明不会冒险稀疏化,保留 dense 兼容路径。当前没有解析 Jacobian,但不再是“完全未提供 `jac_sparsity`”。
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
7. **[推断] 优化应分两条线:**
- 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算;
- 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。
8. **[已修复] 高刚度 XML 不再因合法亚帕试探压力失败。** 压力优化下界由 1 Pa 放宽到 0 Pa,允许严格正的亚帕试探值,同时构成方程仍拒绝零压和负压;同一 10 s RK45 算例已完成且没有可恢复重试。
9. **[历史实测] 闭合裁剪的收益随拓扑明显变化。** 已被方程块版取代的物理岛版短算例 `off` 配对改善为 0.6%~21.4%;其 high-stiffness 10 s optimized/forced-global 对照为 14.676 s/16.929 s,完整 `series` 一致。数据保留作纵向基线,不能当作当前方程块版结果,也不能把其他拓扑的收益外推给单一氦气敏感岛。
10. **[实测] 98 组件的大型分支 XML 在 `0.69 s` 附近的长时间停留不是死锁。** 根因是气动外部 volume 跨域耦合在 ODE 有限差分 Jacobian 依赖图中漏了 12 个实测显著项,BDF 因此反复做小步和 Jacobian/LU 工作;修正后结构非零数由 1092 增至 1284、颜色组由 27 增至 31。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009;stream 后续闭合为 9 个方程块、合计 192 个未知量。
## 2. 证据标签与范围
- **[已实现]**:当前可执行代码或测试直接体现。
- **[约定]**:配置、Schema、类型或仓库说明规定,但不一定被每条入口完整执行。
- **[推断]**:从调用结构推导的资源或性能判断,尚无仓库内基准数据。
- **[发现]**:实现间的不一致、诊断缺口或潜在效率风险。
本次覆盖四条后端执行路径:
| 路径 | 是否为当前 UI 主路径 | 数值行为 |
| --- | --- | --- |
| `POST /api/system-xml/simulate-stream` | 是 | XML 校验、拓扑编译、通用系统积分;NDJSON 进度与最终结果 |
| `POST /api/system-xml/simulate` | 否 | 同一通用求解函数;同步返回、无任务登记/流式进度 |
| `POST /api/reactflow/simulate-testmodel` | 否 | 固定 TestModel 专用闭包与产物生成,不按任意 ReactFlow 边拓扑求解 |
| `POST /api/reactflow/simulate-test-mql` | 否 | 当前只返回结构/采样摘要和全零状态数组,不执行 132 状态时域积分 |
## 3. 主求解调用链
```mermaid
flowchart TD
A["前端校验参数并生成 System XML"] --> B["POST simulate-stream"]
B --> C["安全解析 + XSD v3 + 语义校验"]
C --> D["XML 执行模型直接编译 SimulationNetwork"]
D --> E["准备检查与求解器构造"]
E --> F["一致初始代数闭合"]
F --> G["自适应 ODE 逐步积分"]
G --> H["每个 RHS:信号/热力/代数/stream/机械闭合"]
G --> I["事件定位、状态重置、求解器重启"]
G --> J["逐采样点完整后处理闭合"]
J --> K["完整结果作为末条 NDJSON 返回"]
```
### 3.1 前端形成输入
**[已实现]** 前端先检查模型、表达式和仿真设置,再由 `buildSystemXml()` 在浏览器内构造精简 v3 XML。参数表达式会先求值,并按选定显示单位转成 SI 基准数值;求解期间这些组件参数保持静态,不存在前端实时调参或联合仿真输入通道(`frontend/src/App.tsx` 中的参数解析与 `buildSystemXml()`;`app/simulation/core/base.py:65-95`)。
前端生成 `simulationId`,通过 `fetch` 发送 XML,请求头携带该 ID;响应类型要求为 `application/x-ndjson`(`frontend/src/App.tsx` 中的运行入口与 `streamSystemSimulation()`)。
### 3.2 XML 校验、解析和网络编译
主序列见 `app/main.py` 的 `run_system_xml_simulation()`:
1. `validate_system_xml_document()` 执行大小/安全解析、v3 XSD 和语义校验;
2. XML 解析为只含求解信息的规范化执行模型,不重建 ReactFlow 画布;
3. `compile_system_xml_network()` 创建 `SimulationNetwork`;
4. 构造 `GenericFluidSystem` 与 `SolveIVPConfig`;
5. 调用通用 `simulate()`;
6. 汇总验证、模型接口、数值结果和错误诊断。
XML 安全层限制 5 MiB、禁用 DTD/实体和网络访问(`app/system_xml.py` 的安全解析与 `validate_system_xml_document()`)。
XML 编译最终进入 `_compile_solver_network()` 的两阶段装配(`app/main.py`):
- v3 语义层先核对组件 `modelVersion`、完整参数和端点引用;编译层再消费零端口介质定义节点;
- 按气动连通区域解析介质引用;
- 实例化实际组件,并根据注册模型恢复端口类型、角色和变量合同;
- 最后建立网络连接。
**[已实现]** v3 不携带坐标、旋转、镜像或端口显示侧,布局不会进入求解。`AmesimForc` 的物理正反由模型参数 `direction=+1/-1` 决定;旋转/镜像图标不会改变力方程(`app/simulation/components/amesim/mechanical/translational.py`)。
### 3.3 求解前结构检查
创建通用系统前,`generic_simulation_preparation_issues()` 检查(`app/simulation/systems/generic.py:93-201`):
- 所有物理端口已经连接;
- 压力流量系统的未知量数与方程数相等;
- 网络至少存在一个动态储能组件;
- 每个相连物理岛具有动态储能锚点;
- 不允许多个理想储能元件无阻力直接耦合。
这些检查是当前可求解结构的边界,不表示任意声明了 `PortDefinition` 的网络都能被通用求解器处理。
## 4. 数学结构与状态组织
本节的核心区别只有一个:**有些量要“记住过去并随时间积累”,有些量只要在当前瞬间满足约束。**
在高压气缸向低压储罐放气的案例里,气室内的气体质量和能量属于前者;端口压力和通过节流孔的瞬时流量属于后者。求解器先保存前者,再用它们求出后者。下面的“动态状态”“代数未知量”只是这两组量的技术名称。
### 4.1 动态状态
**[已实现]** 动态组件把自身状态拼成全局 ODE 向量:
- 固定/变容气室及部分动态管路通常使用质量与内能 `[m, U]`;导数由质量流、焓流和换热构成。PNCH023 示例见 `app/simulation/components/amesim/storage/chambers.py:111-218`。
- PNL0003 具有两个容积单元,使用四维 `[m1, U1, m2, U2]`(`app/simulation/components/amesim/flow/pipes.py:1043-1078`)。
- MECMAS21 的机械状态是 `[v, x]`,导数是 `[a, v]`(`app/simulation/components/amesim/mechanical/translational.py:569-700`)。
`MechanicalStateReducer` 会按机械连接中的 `x/v` 等值关系将刚性相连的质量归组,每组只保留一套 `[v, x]` 状态,从而避免重复积分同一个运动自由度(`app/simulation/solvers/mechanical.py:225-427`)。
### 4.2 代数未知量与方程
网络从物理端口合同和组件残差构造代数系统(`app/simulation/systems/network.py:159-237`):
- 气动端口的主要代数变量是 `p` 和 `m_flow`;
- 机械端口包含 `x`、`v`、`f`,其中已归属动态状态的坐标会被状态/等值关系约束;
- 连接对 `effort/equal` 生成两端差值,对 `flow/sumToZero` 生成两端和值;
- 组件再提供状态约束、构成关系、质量/力守恒等残差。
stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积也不进入同一通用连接残差,而由专用 resolver 处理。
### 4.3 压力流量因果化与非线性回退
`PressureFlowSolver` 初始化时预编译方程模板、effort 等值组、显式流/力赋值计划、未知量布局,以及方程声明到未知量的静态关联图。热流闭合先按物理连通关系划定安全范围,再在可信内置模型中按这张关联图拆成更细的方程块。每次 `solve()`:
1. 从上一解与当前动态状态播种端口未知量;
2. 传播状态拥有的压力、位移和速度;
3. 执行可显式求值的构成关系与守恒关系;
4. 处理单边接触约束;
5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`;
6. 否则找出残差未闭合或种子不可行的方程块,把这些互相独立的块合并成一次 sparse `least_squares`;
7. 方程块失败时恢复原始 `x0`,执行全局 sparse `least_squares`;全局 sparse 仍失败时再次恢复同一个 `x0`,最后执行 dense 兼容回退。
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`。可信结构会传入由声明图编译的 `jac_sparsity`;活动单边接触会改变坐标和活动集,自定义组件也可能没有完整依赖声明,因此这些情况继续使用 dense 路径。稀疏/分块是加速层,不会把失败候选带入兼容回退。诊断中的 `evaluations` 是 SciPy 报告的优化器 `nfev`,`residualEvaluations` 才包含数值 Jacobian 在内的真实残差回调数,并通过 `jacobianMode`、`denseFallbackUsed` 和块回退字段说明实际路径。
**[当前边界]** 第一次压力流量求解仍要保证全网所有方程成立,但“全网语义”不再等于“总是把全部未知量交给一个 dense 优化器”:可信声明图可只求本轮未闭合方程块的 union。后续 stream 固定点则进一步只重算敏感方程块。保守条件会恢复全网 sparse→dense 行为;没有可信依赖声明或活动接触时,仍可能直接恢复 dense,成本会随未知量数快速上升。
在用户提供的 `test_mql-full-branches-01-04.xml`(SHA-256 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`)中,472×472 代数关联矩阵只有 919 个结构非零项,密度约 0.413%,可拆为 58 个独立方程块。一个受控扰动实验在相同 `max_nfev=20` 下,dense 与 sparse 分别触发 3796 和 164 次真实残差回调,墙钟约为 7.357 s 和 0.634 s,即同一预算下约 11.6 倍的单次回退成本改善;这项实验只衡量回退开销,不代表两条路径在 20 次评估内已经收敛。
## 5. 每次导数计算的代数闭合
这一步就是第 0 节所说的“当前瞬间对账”。以高压气缸—节流孔—管路—低压储罐为例,求解器需要同时保证:
- 接头压力相容;
- 从一个组件流出的质量等于进入另一个组件的质量;
- 节流孔和管路自己的压差—流量关系成立;
- 气体携带的能量按实际流向交给下游;
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。
代码先建立全网初始压力解,再传播 stream 焓;只有组件方程明确依赖 stream 的范围,才让 stream 焓和其方程—未知量关联块继续迭代到同一个固定点。这样既让当前 RHS 不依赖上一次调用留下的焓/流量历史、保持有限差分 Jacobian 可重复,又避免无关方程重复对账。物理岛仍用于确认范围不会跨越未声明边界,但不再等同于实际求解块。
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py`:
| 顺序 | 操作 | 目的 |
| ---: | --- | --- |
| 1 | `SignalResolver.solve(time)` | 用构造时预绑定的信号源和连接更新时间信号 |
| 2 | 传播机械 `x/v` 等值关系 | 把当前机械状态同步到刚性连接端口 |
| 3 | `PneumaticVolumeResolver.solve()` | 用预绑定的气动端口、输出组件和连接传播外部 `volume/volume_flow` |
| 4 | 刷新动态组件热力端口 | 由当前 `m/U/V` 和最新体积恢复压力、温度、焓 |
| 5 | 第一次全网 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 |
| 6 | `StreamResolver.solve()`,必要时最多 25 轮重算敏感方程块 | 用预绑定的组件、端口和连接传播焓;让焓、温度引用和敏感构成流量同时收敛 |
| 7 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
随后 `rhs()` 才收集各动态组件的导数。
因此每次闭合固定有 **1 次全网语义**的压力流量求解;没有敏感方程块时,stream 传播一次后结束,不再有第二次压力求解。若有 `B` 个敏感方程块,每一轮外层热流固定点会把相应块作为一个 union 快路径求解,最多 25 轮;保守回退时恢复原全网求解器。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代,外层热流固定点最多 25 轮,两层上限不能混为一个数。上述大型 XML 的敏感 union 是 9 个方程块、合计 192 个未知量,而不是其单一巨大物理连通岛中的全部 472 个未知量。
signal、stream 和外部 volume resolver 已把静态组件列表、端口引用和连接绑定预编译到系统构造阶段;运行时仍执行真实的信号赋值、焓迭代、volume 传播和动态热力刷新。闭合计划只在组件以布尔能力声明明确说明压力流量方程是否依赖 stream、且方程变量声明可信时裁剪;未分类自定义 stream 组件、非法声明、跨组件残差或局部非方阵都会保守回退全网。这个边界避免把“少扫描”误做成“少算物理关系”。
## 6. 初始化、积分参数与推进方式
可以把初始化理解为“先摆好第 0 帧”,把积分理解为“根据每一帧的变化速度继续制作后续帧”。初始化并不会自动修改所有不合理的初始存量;它主要是在给定初始质量、能量、位置和速度后,求出与之匹配的端口压力、流量和力。
### 6.1 初始状态
`consistent_initial_state_vector()` 取得组件构造时形成的初值,应用该状态并执行一次完整代数闭合,然后原样返回状态向量(`app/simulation/systems/generic.py:292-296`)。
**[重要边界]** 这不是通用 DAE 一致初值求解:它不会联合调整微分状态及导数,只求“给定当前动态状态时”的端口代数变量。固定 TestModel 的专用压力投影/初始化逻辑不能外推为通用 XML 求解器能力。
### 6.2 参数来源及真实含义
初学者最需要先分清 XML v3 的 `sampleStep` 和 `maxStep`(进入 Python 后分别是 `sample_step` 和 `max_step`):
- `sampleStep` 只控制**结果多久记一条**;
- `max_step` 才限制**内部一次最多走多远**;
- 自适应求解器可以走得比 `max_step` 更短,也可能先试一步、发现误差过大后退回来重算。
所以,把 `sampleStep` 从 0.1 改成 0.01 通常会让输出曲线更密、结果占用更多内存,但它不等价于命令求解器固定每 0.01 秒算一步。
| 参数 | 当前来源/默认值 | 实际用途 |
| --- | --- | --- |
| `t_start / t_stop` | 前端与 Pydantic 默认 `0 / 2 s` | 积分区间 |
| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` |
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
| `rtol` | 通用 XML 路径硬编码 `1e-6` | 外层 ODE 相对误差;用户不可配置 |
| `atol` | `SolveIVPConfig` 标量默认 `1e-8`;机械状态收紧到 `min(default, 1e-12)` | 热力状态使用默认值,机械速度/位置使用更紧的分量容差;用户不可配置 |
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
| stream 容差/迭代 | `1e-9 / 100` | 焓传播固定点 |
| 采样数上限 | 无固定业务上限 | 输出规模受运行时可表示范围和可用资源约束,不限制 RHS 次数或事件数 |
前端/Pydantic 默认值见 `frontend/src/App.tsx` 的仿真默认配置和 `app/main.py:131-137`;通用路径构造 `SolveIVPConfig` 见 `app/main.py:684-701`;采样网格见 `app/simulation/systems/generic.py:204-225`。
**[已实现]** `sampleStep` 生成的采样网格会确保包含 `t_stop`,不再设置固定点数上限;仅在数值非有限、当前运行时无法表示点数或时间无法严格递增时预先拒绝。它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。
### 6.3 逐步推进分派
`integrate_ode()` 位于 `app/simulation/solvers/solver.py:917-1009`:
- 有取消检查、信号断点或机械状态事件时,使用 SciPy 的低层 BDF/DOP853/LSODA/RK23/RK45/Radau 类逐步推进;
- 无上述需求时,可一次调用常规 `solve_ivp`;
- 当前流式接口总会传 `cancel_check`,所以总走逐步路径;
- 同步 XML 接口只有在没有断点/状态事件时才可能走一次性 `solve_ivp`。
逐步路径在每个已接受步上:
1. 检查取消;
2. 推进一步;
3. 仅当本步跨越下一个 `t_eval` 样本或需要机械状态事件检测时构造 dense output,并插入样本;
4. 检测状态事件;
5. 报告进度;
6. 必要时重建求解器。
**[已优化]** 没有跨采样点、也没有状态事件时,不再为每个已接受步无条件构造插值对象。该优化对稀疏采样、无机械事件的模型有收益;用户大型分支 XML 含机械状态事件,所以仍必须在每步保留用于事件定位的 dense output,本轮实测中这项优化对该 XML 没有收益。
Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。
高刚度 XML 的历史失败不是“所有亚帕压力都不物理”,而是优化器原 1 Pa 下界过严:RK45 的内部自适应试探会短暂给出仍严格大于 0 Pa、但小于 1 Pa 的压力种子。当前 `PRESSURE_LOWER_BOUND_PA=0.0`,合法正压试探交回 RK45 自身判断,零压/负压仍由构成方程拒绝。该模型 10 s RK45 已通过且可恢复重试数为 0,因此这里没有用异常重启掩盖真实模型错误。
### 6.4 大型分支 XML 的 `0.69 s` 慢区
用户 XML 包含 98 个组件、472 个代数未知量和 74 个 ODE 状态。旧代码不是在 `0.69 s` 死锁,而是 BDF 到达这一刚性变化区后开始大量缩步、重建有限差分 Jacobian 并做 LU 分解。定位发现,气动外部 volume resolver 把机械位移写入气室容积,形成“机械位置 → 气室热力/压力 → 气动力”的跨域闭环;旧 ODE 稀疏依赖图只沿普通物理端口追踪,漏掉了这条外部 volume 边的 12 个实测显著导数项。
修正采用保守的双向跨域依赖:接收外部容积的气动储能状态依赖相关机械状态,机械力平衡也依赖被耦合的气动状态。结构非零数因此由 1092 增至 1284,有限差分颜色组由 27 增至 31。颜色组稍多不是退化:旧图更小是因为漏项,给 BDF 的 Jacobian 数值不完整,导致后续重复试步的总成本更高。
功能收口过程中的较早阶段测量为 92.187 s;最终稳定代码连续三次完整运行到 `0.81 s`,分别用时 79.049 s、74.658 s 和 85.103 s。三次积分统计均为 `nfev=3393`、`njev=226`、`nlu=667`、接受步 1009、求解器启动 3 次;压力流量求解 28008 次,全部由 seeded 快路径满足残差合同,没有触发方程块或 dense 非线性回退。
三次完整响应按规范 JSON 序列化后的 SHA-256 均为 `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f`。为避免诊断字段增删造成“物理结果没变但响应哈希变化”,另定义外部标签 `physical-solution-v1`:待哈希对象只投影 `{status, simulatedUntil, requestedStopTime, series, final}`,标签本身不放入对象;用 `json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 规范化后 SHA-256 为 `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7`。旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,受响应结构影响,不能与当前哈希直接比较。
本轮还区分验证了两种容易混淆的“容差”。机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次 A/B 约快 16%,但这会改变机械状态与事件的误差合同,当前证据不足,未采用。另一项是外层 thermofluid **流量固定点**相对容差从 `1e-12` 放宽到 `1e-9`;它反而增加 BDF 内部步数并改变积分轨迹,也未采用。这里的正式修复是补全依赖图,不是通过放宽精度或闭合容差掩盖问题。
仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。
## 7. 事件、取消与停止
### 7.1 信号离散时刻
STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件左侧的相邻浮点时刻,再在精确事件时间更新信号并重建求解器,连续动态状态保持不变(`app/simulation/solvers/signal.py:70-99`、`app/simulation/components/amesim/signals/sources.py:128-141`)。
### 7.2 机械端挡
机械状态事件在已接受步的 dense state 上检查端挡穿越,最多 60 次二分定位;命中后按塑性/恢复系数重置位置和速度并重启积分器。同一时刻最多允许 64 次链式状态重置(`app/simulation/solvers/mechanical.py:430-627`、`app/simulation/solvers/solver.py:92-166`)。
**[约定]** 这是为信号断点与机械端挡编写的专用事件框架,不是可由任意组件声明残差事件的通用高指数 DAE 框架。
### 7.3 协作取消
流式任务的取消端点只设置共享 `threading.Event`。积分器在已接受步、重试边界等检查点协作停止;若正在执行一次耗时的热物性、stream 或 `least_squares` 调用,取消不能立即抢占(`app/main.py:535-547`、`app/simulation/solvers/solver.py:917-1009`)。
停止后若至少已有两个有效采样点,通用系统可整理并返回部分结果;相关行为由 `tests/test_generic_system_xml_simulation.py:433-533` 覆盖。
## 8. 后处理与现有诊断
### 8.1 结果后处理
积分完成后,`GenericFluidSystem.simulate()` 重置机械约束模式,并对每个 `solution.t`:
1. 重新应用状态;
2. 再执行一次完整 `_close_current_state()`;
3. 提取所有组件级及端口级公开结果变量。
见 `app/simulation/systems/generic.py:397-474`。
**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。
**[当前边界]** 本轮优化没有复用积分期间的闭合快照,也没有跳过后处理对账。每个输出点仍执行完整 `_close_current_state()`;变化仅在于该闭合内部使用同一套预编译绑定和敏感方程块执行计划。
### 8.2 当前返回的诊断
**[已实现]** 结果包含:
- 状态数、采样数;
- 压力流量 `solveCount`、`closurePassCount`、`secondaryPhysicalIslandCount`、真实方程 `secondaryBlockCount`、`secondaryUnknownCount`、保守回退原因、最大残差、最大单次评估数和最后求解作用域;
- stream 最大迭代数;
- 停止状态及部分错误上下文。
**[已实现]** 积分诊断已经包含分段及汇总的 `nfev/njev/nlu`、已接受步、求解器启动、状态迁移和可恢复重试数。设置 `SIMULATIONAPP_PROFILE=standard|audit` 后,响应还会加入分阶段墙钟时间;audit 进一步记录物性调用、精确重复、缓存命中和逆解迭代。
**[已修复]** 闭合现在聚合所有实际压力求解的最大残差和最大单次评估数,`last` 与 `lastScope` 指向最后一个真实求解作用域,不再被一个未执行或较早 pass 的局部变量覆盖。`solveCount` 统计求解器调用次数,`closurePassCount` 单列发生过压力求解的固定点 pass;`secondaryPhysicalIslandCount` 只表示安全分类得到的物理范围,`secondaryBlockCount` 明确表示方程关联块数,不能再把两者混称为“块”。代数诊断还返回真实 `residualEvaluations`、`jacobianMode`、dense/方程块回退状态。子作用域失败时,API 返回 `scopeKind` 和 `scopeComponents`。仍缺少峰值 RSS、任务队列深度等服务级指标;结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。
## 9. 求解时前后端交流
主路径可以压缩为下图:
```text
浏览器 ──一次 POST:完整 System XML──────────────> 后端
浏览器 <──同一长连接:心跳、进度、心跳、进度──── 后端求解线程
浏览器 <──最后一个消息:完整结果 JSON─────────── 后端
浏览器 ──需要时另发取消 POST───────────────────> 后端
```
这里的“流式”主要是**进度消息流式**,不是每算出一段曲线就立刻传一段曲线。最终数值序列仍在末尾一次性返回。
### 9.1 当前协议
| 阶段 | 通信 | 当前行为 |
| --- | --- | --- |
| 提交 | 一个 HTTP POST | 请求体为完整 XML;`X-Simulation-Id` 标识任务 |
| 运行 | 同一响应上的 NDJSON | 进度事件、错误事件、5 秒心跳 |
| 完成 | 同一 NDJSON 流最后一行 | 一次性携带完整 `SimulationResult` |
| 用户取消 | 另一个短 POST | 设置协作取消事件;原流继续等待终态 |
| 流断开/停滞恢复 | GET 状态 | 每 500 ms 轮询,最多 30 秒 |
后端路由见 `app/main.py:589-634, 773-906`,前端解析、取消和恢复见 `frontend/src/App.tsx` 中的 `streamSystemSimulation()` 及相邻任务控制函数。
**[已实现]** 后端每 5 秒无队列事件时直接发送 heartbeat。通用系统按进度至少变化 0.25% 才发送积分进度,通常至多约 400 条积分进度事件(`app/simulation/systems/generic.py:318-343`)。
前端规则:
- 30 秒没有收到任何字节:连接超时;
- 15 分钟只收到心跳而没有真实积分进度:判定 stalled 并请求取消;
- 正常运行不是轮询,轮询仅用于异常恢复。
**[已缓解]** 合法但单个已接受步/闭合超过原 60 秒阈值时,前端会误判停滞;浏览器警钟现延长为 15 分钟,30 秒断流检测保持不变。该警钟仍以“最后一次非心跳积分进度”为依据,尚未细分 RHS、Jacobian 和闭合活动。后端结果事件的 `phase` 使用 `completed/stopped/stalled/failed`,前端事件类型却声明 `"complete"`;运行时当前没有按该字段做严格校验,所以契约漂移尚未直接报错(`app/main.py:825-840`、`frontend/src/App.tsx` 的流式事件类型)。
### 9.2 开发和部署连接数
**[已实现]** 开发态 Vite 将 `/api` 代理到 `127.0.0.1:8000`(`frontend/vite.config.ts:4-10`),所以一个流式仿真在开发态占用浏览器→Vite、Vite→FastAPI 两段长连接;若生产部署由 FastAPI/反向代理直接提供 API,则具体连接层数取决于部署。
仓库没有 WebSocket 路由、`EventSource` 或 `text/event-stream`;当前 NDJSON 只是普通 HTTP 分块响应。
## 10. CPU、线程、内存、网络和磁盘占用
先区分两个问题:
- **一个算例跑得快不快**:主要看每次变化率计算做了多少轮闭合、非线性试算和热物性计算;
- **多人同时运行稳不稳**:主要看并发任务是否有上限、是否能使用多个进程、每个结果在内存中保留多少份。
“每个任务开一个线程”不等于“每个任务独占一个 CPU 核”。Python 组件逻辑、SciPy 数值核和底层 BLAS 的实际并行程度取决于运行环境;仓库没有 CPU/RAM 实测数据,所以本节只给代码可证明的结构和数量级。
### 10.1 进程与线程
- `bat/start-all.bat` 与 `bat/start-all.sh` 分别启动 Vite 与 FastAPI。
- `bat/start-backend.bat` 与 `bat/start-backend.sh` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker。
- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。
- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。
- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。
**[推断]** 单个求解主要是串行 Python 全网扫描加 SciPy 数值核,常会持续消耗一个核心;多任务线程不保证线性利用多核,还可能出现 GIL 竞争、SciPy/BLAS 原生线程过度订阅和内存峰值相叠。仓库没有固定 BLAS 线程数,具体 CPU 占用必须在目标部署环境实测。
### 10.2 内存数量级
不计 Python 对象常数项,主峰值可写为:
```text
O(组件 + 连接 + 代数结构)
+ O(采样数 × 动态状态数)
+ O(采样数 × 公开结果变量数)
```
当前不设置固定采样点数上限,调用方必须根据模型输出变量数和可用内存选择 `sampleStep`。放大因素包括:
- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在;
- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行;
- 前端收到结果后执行 `structuredClone`,再 `JSON.stringify` 写入 `sessionStorage`(`frontend/src/App.tsx` 的结果快照与恢复逻辑);
- 图表会把数值数组映射为对象点数组,多个曲线窗口会产生更多前端副本;
- CSV 导出把完整结果再次上传,后端在 `StringIO` 中一次性构造完整 CSV(`frontend/src/SimulationResultsView.tsx:1041`、`app/main.py:299-377`)。
`SIMULATION_TASK_RETENTION_SECONDS=600`,但过期任务只在注册下一个任务时清理;没有新任务时,最后一批终态结果可能一直保留到进程退出(`app/main.py:491-520`)。
### 10.3 队列、网络和磁盘
- 任务队列是无界的,但进度被 0.25% 节流;正常单任务队列通常不大,客户端变慢或终态序列化时仍没有硬上限。
- 最终数值序列不分块,网络、后端 JSON 编码、前端字符串缓冲与 `JSON.parse` 会在完成时形成瞬时峰值。
- 通用 XML 求解本身不写仿真产物,结果主要驻留内存。
- 固定 TestModel 与 public Test MQL runner 会在 `app/data/simulation-runs` 下写时间戳产物;这不是主流式路径的磁盘行为。
## 11. 其他求解入口不能与主路径混同
### 11.1 固定 TestModel
`POST /api/reactflow/simulate-testmodel` 从所选类型/参数中提取固定数量的气瓶、贮箱、管/孔板来构造专用 `TestModelClosure`,不消费用户的任意节点边拓扑;它复用 `integrate_ode`,并写 CSV、SVG 和报告产物(`app/main.py:1363-1447`、`app/simulation/examples/testmodel/run.py`)。
它是回归/演示算例,不是通用 ReactFlow 网络求解器。
### 11.2 Test MQL
`POST /api/reactflow/simulate-test-mql` 当前忽略任意拓扑和主要积分配置;`TestMqlSystem.simulate()` 构造 132 状态的全零 `y`,只返回结构数量随时间的摘要(`app/main.py:1450-1481`、`app/simulation/examples/test_mql/system.py:6302-6318`)。
完整的 112 个气动状态与 20 个机械状态闭包存在于独立诊断/comparison 代码,但没有接入这个公开 API;仓库仍将其描述为校准阶段,不能宣称与 AMESim 全时域等价。
## 12. 当前明确的效率热点
| 热点 | 代码证据 | 影响范围 | 判断 |
| --- | --- | --- | --- |
| 每次闭合的压力流量重算 | `generic.py` 的预编译热流闭合计划 | 每个 RHS、初始化、每个结果采样点 | [已优化] 固定 1 次全网语义初解;后续只重算 stream-sensitive 方程关联块,不再把整个敏感物理岛重复求解 |
| stream 每轮复制/比较焓并刷新 | `stream.py` | 每个闭合,最多 100 轮 | [静态预编译已完成] 组件、端口和连接已预绑定;每轮必要的数值复制、比较和刷新仍保留 |
| 非线性回退使用有限差分 least-squares | `algebraic.py` | 快速路径失效时 | [首轮已优化] 可信声明图先做未闭合方程块 union sparse;失败恢复 `x0` 后做 global sparse→dense;接触/不可信结构保留 dense |
| 外层 ODE Jacobian 稀疏依赖图 | `generic.py`、`solver.py` | BDF/Radau 的每步/Newton | [已修复] 已传 `jac_sparsity`;补上外部 volume 的跨域双向依赖,用户 XML 为 1284 非零/31 色 |
| 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [已优化] 2026-08-16 已加入仿真隔离的四项氦气精确 LRU;代表算例 2,869/435 次命中/未命中,PR 三次根调用由 1,712 降至 689 |
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
| dense output 插值对象 | `solver.py` | 流式逐步路径 | [已优化] 仅跨样本或需要状态事件时构造;含状态事件的用户 XML 每步仍需要,因此无本案收益 |
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 |
下面是 2026-08-16 **物理岛版首轮实现的历史 `off` 基线**。这些数据仍可说明旧执行计划相对更早“每轮全网”的收益,但该实现已由方程关联块版取代,不能把表中的“块”解释为当前 `secondaryBlockCount`:
| 算例 | 墙钟改善 |
| --- | ---: |
| `air_chain` | 7.9% |
| `air_branched` | 6.5% |
| `helium_step` | 0.6% |
| `mechanical_contact` | 21.4% |
| high-stiffness short | 14.6% |
完整 high-stiffness 10 s 的历史基线约 28.126 s,本轮全部改动后的中位数为
13.694 s;这个跨版本差额不能归到某一项优化。当前代码上单独强制恢复全网后续
闭合的受控对照为 optimized 14.676 s、forced-global 16.929 s,完整 `series`
一致。`helium_step` 只有一个仍需重算的敏感岛,改善仅 0.6%;这说明收益取决于
可跳过多少无关网络,不能宣称单一氦岛也有两位数提升。后处理的逐采样点完整闭合
仍然保留。
当前方程关联块版另用用户大型分支 XML 做了受控短区间 A/B:输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量和 74 个 ODE 状态。stream 后续求解的 9 个方程块合计 192 个未知量;`0~0.01 s` optimized 与 forced-global 分别为 16.200 s 和 18.584 s,物理解与 `series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`。短区间 A/B 只归因于后续 stream 闭合作用域,完整运行同时包含 Jacobian 修正和最终执行路径收口,二者不能混算成一个百分比。
## 13. 优化建议排序
以下按**预期综合收益**排序;同档位优先低风险、低难度项。排序同时参考代码结构和 2026-08-15 的阶段/物性实测,但尚未覆盖大规模拓扑与多任务吞吐。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
### 13.1 先看人话版
在改算法前,应先给各阶段计时和计数;这本身不直接加速,但能防止优化错地方。之后可按下面顺序理解主要方案:
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
| ---: | --- | --- | --- |
| 1 | 少做重复“瞬时对账” | [方程块首轮已完成] 初解保持全网语义,后续只重算 stream-sensitive 的关联方程块 | 自定义/异常结构必须继续保守回退,不能漏掉真实耦合 |
| 2 | 先整理方程,再求解 | [稀疏首轮已完成] 可信声明图将未闭合块合并求解;大模型回退时减少数值 Jacobian 试算 | 接触活动集和不可信自定义声明必须走兼容回退 |
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 |
| 6 | 给并发任务设固定“办理窗口” | 有界进程 worker 可防止无限建线程,并更好利用多核 | 对单个算例未必更快,跨进程取消和结果传递更复杂 |
下面的完整表把这些方向进一步拆成 12 项,并明确收益、风险和实施难度。
| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 |
| ---: | --- | --- | --- | --- | --- |
| 1 | [方程块首轮已完成] 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:signal/stream/volume 预绑定;首次压力求解保留全网语义,仅在 stream 确实使构成关系变脏时重算相关方程关联块,并保留全网回退和耦合迭代上限。 | 单算例 | 历史物理岛版实测 0.6%~21.4%;大型 XML 方程块版短区间 16.200 s 对 18.584 s | 中:错误裁剪会破坏耦合一致性,需持续回归自定义模型 | 已完成首轮 |
| 2 | [稀疏首轮已完成] 可信方程声明图已用于 union block sparse 和 global sparse→dense 回退;后续继续评估 equality group 真正消元、解析 Jacobian,以及活动接触的安全分块。 | 单算例、大网络 | 扰动实验同预算残差回调 3796→164,约 11.6 倍;实际收益取决于是否触发非线性回退 | 高:接触活动集与错误声明会影响收敛 | 首轮已完成,继续深化 |
| 3 | [依赖图已修复] BDF/Radau 已使用状态 `jac_sparsity`,外部 volume 跨域漏边已补;后续再评估状态缩放和可配置分量级 `rtol/atol`,不要简单全局放宽容差。 | 单算例、刚性网络 | 大型 XML 已从 `0.69 s` 慢区定位并完整跑通;机械 `atol` A/B 虽约快 16%但改变精度合同,flow 固定点放宽则增加步数,均未采用 | 中高:会改变误差轨迹/事件时刻 | 稀疏图首轮完成,缩放待评估 |
| 4 | [已完成] 氦气高成本物性已按单次仿真做精确、有界缓存;dynamic components 及 signal/stream/volume 的静态组件、端口和连接也已预绑定。 | 单算例 | 已取得可见收益,且减少固定拓扑的重复查找 | 中:缓存失效错误会污染物理结果 | 已完成 |
| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 |
| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 |
| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 |
| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 |
| 9 | [首轮已完成] 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;后续记录并优化事件重启,大量周期 UD00 事件再评估惰性调度。 | 单算例、事件密集模型 | 无事件且采样稀疏时可减少插值对象;本次含状态事件 XML 无收益 | 低到中 | 首轮已完成 |
| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 |
| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 |
| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 |
结果访问器和每轮剩余临时容器不属于第 4 项已经完成的 signal/stream/volume 静态预绑定;前者应结合第 5 项后处理改造单独基准,不把尚未实测的小项混入已完成收益。
### 13.2 推荐落地顺序
低侵入阶段/物性观测、积分计数和代表算例首轮基准已经落地,但不把“加指标”误列为直接加速。下一步建议:
1. 继续汇总压力流量快路径命中、非线性 `nfev`、真实 `residualEvaluations` 和残差装配时间;单次诊断已能区分 block sparse、global sparse 和 dense 回退;
2. 用同一套基准把方程声明图 A/B 扩展到更多自定义组件、活动接触和保守回退路径;大型 98 组件 XML 与首轮短算例已经覆盖可信内置路径;
3. 补 1/8/32 单元规模曲线、1/2/4 并发吞吐和峰值 RSS;
4. 记录状态/结果数组字节、任务队列深度;结果 JSON 字节可继续由基准工具测量;
5. 再决定 equality group 真正消元/解析 Jacobian、结果按需计算和进程 worker 的实施深度。
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
## 14. 已实现、约定与推断的边界汇总
### 已实现
- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。
- 气动、机械和信号的专用闭合顺序。
- 压力流量显式因果化快路径,以及可信声明图上的 union block sparse、global sparse→dense `least_squares` 兼容回退;失败候选不会污染原始 `x0`。
- signal/stream/外部 volume 静态绑定,以及“全网语义初解 + stream-sensitive 方程块重算 + 保守全网回退”的闭合执行计划。
- 明确区分物理岛、方程块和方程块未知量的诊断,并记录实际残差回调、Jacobian 模式、最后作用域和子块失败作用域。
- BDF/Radau 状态 `jac_sparsity`,以及外部 volume 跨域双向依赖修正。
- dense output 按采样跨越/状态事件惰性构造;状态事件模型仍保持每步插值能力。
- 允许严格正亚帕试探压力的高刚度 RK45 路径;零压和负压仍不接受。
- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。
- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。
- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。
### 约定
- 内核定位为半显式 ODE/代数 MVP,而非任意 DAE。
- XML/组件运行参数使用 SI 基准值。
- 采样上限、超时、心跳和任务名义保留时长。
- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。
### 已有初步实测、仍需扩大样本
- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,仿真隔离的四项 PR 氦气缓存已取得可见端到端收益。
- 长氦气代表算例的积分阶段占约 90.5%,后处理约 5%。
- 物理岛版闭合执行计划的历史短算例配对收益为 0.6%~21.4%;完整 high-stiffness optimized/forced-global 历史对照为 14.676 s/16.929 s,数值序列一致。这些基线保留用于纵向比较,但已不是当前方程块实现。
- 当前大型分支 XML 的 stream 后续闭合为 9 个方程块/192 个未知量;`0~0.01 s` optimized/forced-global 为 16.200 s/18.584 s 且物理解/`series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s/74.658 s/85.103 s,积分统计一致。
- 代数 sparse 扰动实验将真实残差回调由 3796 降至 164(约 11.6 倍耗时改善);它是回退微基准,不能外推为所有仿真的整体加速倍数。
- 上述结论仍需在更大拓扑、更多真实工程和固定硬件环境复测。
### 推断及必须继续实测
- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。
- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。
- 各优化的实际收益;表中排序应在观测数据出现后更新。
## 15. 关键文件与符号索引
| 主题 | 文件与位置 | 关键符号 |
| --- | --- | --- |
| API 主入口与任务流 | `app/main.py` | `run_system_xml_simulation()`、`simulation_event_stream()` |
| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` |
| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` |
| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` |
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py` | `PressureFlowSolver.solve()`、方程关联图、sparse→dense 回退 |
| stream 方程块闭合 | `app/simulation/solvers/algebraic_blocks.py` | `StreamPressureBlockSolver` |
| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` |
| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` |
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
| 启动暖机 | `app/simulation/warmup.py` | BDF、稀疏分组、`least_squares` sparse LSMR 路径 |
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
| 启动方式 | `bat/start-backend.bat`、`bat/start-backend.sh` | Uvicorn 单 worker 命令 |
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
@@ -0,0 +1,641 @@
# SystemSimulationApp 接口类型与表示方式总结(通俗版)
> 调研基线:2026-08-12(System XML v3 接口基线)。本文依据当前仓库的代码、Schema、说明文档和测试编写。
> 这里的“接口”主要指组件上的端口(port/connector),不是只指 HTTP API。文末也单独列出了相关 HTTP API。
## 0. 三分钟读懂
先把整个系统想成一张“可以计算的工程图”:
- **组件**像气瓶、管路、阀门、质量块等设备;
- **端口**像设备上的接头或插座;
- **连接**像气管、机械连接杆或控制线;
- **编译**像正式计算前的接线检查:插头是否匹配、有没有漏接、方向是否正确;
- **求解**才是真正计算每个时刻的压力、流量、位移、速度等数值。
当前项目实际只有三类端口:
| 看到的类型 | 可以把它理解成 | 主要传递什么 |
| --- | --- | --- |
| `physical / pneumatic` | 气路接头 | 压力、质量流量、气体携带的能量 |
| `physical / mechanical` | 机械连接点 | 位移、速度、力 |
| `signal / signal` | 控制线 | 一个有方向的数值,例如阀门开度或目标力 |
最容易混淆的四种文件/数据,可以这样记:
| 数据 | 通俗比喻 | 它回答的问题 |
| --- | --- | --- |
| 组件目录 JSON | 产品说明书 | 某种组件天生有哪些端口、每个端口有哪些变量? |
| 工程 JSON | 画布存档 | 这张图上放了哪些组件、摆在哪里、怎样连? |
| System XML v3 | 交给后端的精简求解清单 | 只带组件、模型版本、SI 参数、连接和仿真设置,不负责保存画布 |
| 编译结果 JSON | 接线检查报告 | 后端恢复完整模型后,最终认出了哪些端口、连接和方程结构? |
贯穿全文的两个例子:
```text
案例 A:气路
储气容器/气室 A ── 节流孔或管路 ── 气室 B
气动端口 气动端口
案例 B:控制
阶跃信号源 step_1.out ──控制线──> 阀 valve_1.res
│
控制气路通断/开度
也可以是:step_1.out ──控制线──> 力源 force_1.res ──机械端口── 质量块
```
这两个示意图不是凭空编造的:仓库里已有对应的回归案例。`tests/test_generic_system_xml_simulation.py:86-145` 搭建了 `cylinder(500 kPa) → orifice → pipe → tank(100 kPa)` 气路;`tests/test_amesim_pnvo001_signal_xml.py:37-84` 搭建了 `step_1.out → valve_1.res`,同时让气缸、阀和气罐通过物理端口相连。
先记住六点就能继续阅读:
1. **物理端口必须同类相连。** 气动只能接气动,机械只能接机械,不能把“气管”插到“机械接头”上。
2. **信号线有方向。** 必须连接一个注册为 `output` 的端口和一个注册为 `input` 的端口;XML v3 不再另写 `source/target` 角色。
3. **物理线没有 source/target 的物理含义。** 画布虽然要写 `source/target`,后端会把它当作无方向的两个端点。
4. **流变量统一以“进入当前组件”为正。** 因此同一条气路两端的质量流量数值互为相反数。
5. **工程 JSON 和 XML 不重复保存完整变量表。** 后端依靠组件的 `modelType/type` 去注册表找回完整定义。
6. **`side/rotation/mirrored` 都只负责画面。** 力源是否反向由显式参数 `direction=+1/-1` 决定;转动或镜像图标不再改变方程。
只想看懂工程图,可以读第 0、2、3、5、6 节;需要开发或排查兼容问题时,再读第 1、4、7~11 节。
## 1. 本文中的标签和“谁说了算”
为了避免把“已经能运行”和“文档希望如此”混为一谈,本文使用四种标签:
- **[已实现]**:当前代码或测试直接体现的行为。
- **[约定]**:Schema、类型声明或说明文档规定的合同,但不一定所有入口都完整实现。
- **[推断]**:根据多处代码可以合理得到的判断,仓库没有直接承诺或实测数据。
- **[发现]**:代码层之间不一致、容易误解或存在兼容风险的地方。
如果不同层的说法不一致,优先相信更靠上的事实来源:
| 优先级 | 事实来源 | 关键文件/符号 | 通俗解释 |
| --- | --- | --- | --- |
| 1 | 端口核心定义 | `app/simulation/core/ports.py:7-207`:`PortVariableDefinition`、`PortDefinition`、`PortState` | 定义“插头标准”和运行时数值 |
| 2 | 具体组件模型类 | `MODEL_TYPE`、`PORTS`、`DISPLAY` | 声明某个产品实际装了哪些插头 |
| 3 | 模型注册器 | `app/simulation/registry.py:41-170, 416-490, 825-980` | 启动时核对产品声明,并生成目录 |
| 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 |
| 5 | JSON/XML | `ReactFlowPortDefinition`、System XML v3 XSD | JSON 搬运画布和端口显示快照;XML 只搬运可执行模型,端口合同由注册表恢复 |
**[约定]** [组件模型建模规范 v1](../standard/component-model-authoring-spec-v1.md)说明:组件模型类及受控库清单是后端事实来源。XML 或前端不能凭空创造一个模型没有声明的端口。
## 2. 常见术语翻译表
第一次阅读时,可以先把英文术语替换成右侧的日常说法。
| 术语 | 通俗说法 | 在本项目中的具体意思 |
| --- | --- | --- |
| component | 设备/元件 | 气室、节流孔、阀、质量块、信号源等 |
| port / connector | 接头/插座 | 组件可以与外界连接的位置 |
| interface contract | 接口说明书 | 端口名称、类型、变量、单位和连接规则的完整定义 |
| `kind` | 大类 | `physical` 物理连接,或 `signal` 控制信号 |
| `domain` | 专业类别 | 当前为 `pneumatic` 气动、`mechanical` 机械、`signal` 信号 |
| `nominalRole` | 名义用途 | 物理端口的入口/出口提示,或信号端口的输入/输出方向 |
| `effort` | 两端要相同的“势” | 气动压力 `p`;机械位移 `x`、速度 `v` |
| `flow` | 连接处要守恒的“流” | 气动质量流量 `m_flow`;机械力 `f` |
| `stream` | 随介质流动携带的性质 | 当前是气体流出比焓 `h_outflow` |
| `equal` | 两端相等 | 例如连接后 `p_A = p_B` |
| `sumToZero` | 两端相加为零 | 例如 `m_flow_A + m_flow_B = 0` |
| `streamMix` | 按实际流向传播/混合 | 不能简单令两端 `h_outflow` 相等 |
| `directed` | 按指定方向传值 | 例如阶跃源输出写入阀的信号输入 |
| registry / catalog | 型号登记表/产品目录 | 后端支持哪些模型,以及每种模型的完整定义 |
| compile | 接线检查和模型装配 | 根据 `modelType` 实例化组件并检查所有连接 |
| resolver | 专项计算器 | 分别处理信号、气体焓、移动容积等传播问题 |
| Schema / XSD | 格式规则 | 检查 JSON/XML 的字段和结构是否合规 |
| SI | 国际单位制 | Pa、kg/s、m、N 等;提交给求解器的值使用 SI 基准值 |
## 3. 用案例理解 physical 和 signal
### 3.1 案例 A:气室经节流孔连接
假设储气容器 A 的压力高于气室 B:
```text
tank_1.port_a ── orifice_1.port_a [节流孔] orifice_1.port_b ── chamber_1.port_1
```
仓库中的真实回归测试使用了一条更完整的链路:`cylinder(500 kPa) → orifice → pipe → tank(100 kPa)`(`tests/test_generic_system_xml_simulation.py:86-145`)。500 kPa 与 100 kPa 提供明显压差,便于检查压力和质量流量是否按预期推进。下面仍用 A、B 表示任意一对相连端口,规则与该测试相同。
这些都是 `physical / pneumatic` 端口。连接后,求解器关心三件主要事情:
1. 接头处的压力要相容;
2. 从一个组件流出的质量,必须流入另一个组件;
3. 气体携带的能量要按实际流向传递,发生汇合时还要混合。
这也是 `p`、`m_flow`、`h_outflow` 三个变量的来历:
| 变量 | 单位 | 人话解释 | 接线后的处理方式 |
| --- | --- | --- | --- |
| `p` | Pa | 接头处的绝对压力 | 两端相等:`p_A - p_B = 0` |
| `m_flow` | kg/s | 每秒有多少质量的气体流过 | 两端守恒:`m_flow_A + m_flow_B = 0` |
| `h_outflow` | J/kg | 如果气体从该组件流出,每公斤带走多少能量 | 按实际流向传播/混合,不直接令两端相等 |
| `volume` | m³ | 相邻移动机构提供的外部容积 | 内部辅助量,结果默认不展示 |
| `volume_flow` | m³/s | 上述外部容积每秒变化多少 | 内部辅助量,结果默认不展示 |
为什么两端的 `m_flow` 一正一负?项目统一规定“**进入当前组件为正**”。如果 0.01 kg/s 从 A 流进 B,那么从 A 的视角它在流出,约为 `-0.01`;从 B 的视角它在流入,约为 `+0.01`。这不是矛盾,只是观察对象不同。
`inlet`、`outlet`、`bidirectional` 是设计上的名义角色,不是止回阀。即使一个端口名义上叫 `outlet`,求解过程中仍可能出现反向流动。`PortState.actual_direction()` 使用约 `1e-12` 的死区判断 `in/out/stagnant`(`app/simulation/core/ports.py:218-231`)。
**[已实现]** `volume` 和 `volume_flow` 虽然使用 `signal/directed` 的变量规则,但它们仍装在气动物理端口里,不是画布上另一根信号线。`PneumaticVolumeResolver` 会沿现有气路传播它们(`app/simulation/solvers/pneumatic_volume.py:21-93`)。
### 3.2 案例 B:阶跃信号控制阀或力源
控制线与气管不同,它只把一个数值从发送方交给接收方:
```text
amesim_step0.out ───────────────> amesim_pnvo001.res
信号输出 output(发送方) 阀的信号输入 input(接收方)
```
当阶跃源在某个时刻从 0 跳到 1,`SignalResolver.solve()` 先更新信号源,再把输出值写到阀的 `res` 输入(`app/simulation/solvers/signal.py:40-68`)。阶跃发生的时刻还能作为积分断点,避免数值积分跨过突变点而不知情。
`tests/test_amesim_pnvo001_signal_xml.py:37-84` 正好演示了这个分工:`step_1.out → valve_1.res` 只传阀的控制命令,而 `cylinder → valve → tank` 的另外几条连接才传递气体的压力、质量流量和焓。控制线不会“变成气管”,阀组件负责在内部用命令改变气路行为。
同样的信号也能驱动力源:
```text
amesim_step0.out ──> amesim_forc.res [力源] amesim_forc.port_2 ── 机械网络
```
这里 `res` 是信号输入,`port_2` 是机械端口。信号与机械并没有直接相连,而是由 `amesim_forc` 组件内部方程把输入数值转换成力。
### 3.3 机械端口:把连接点当成同一个运动点
`physical / mechanical` 是一维平动机械连接,主要变量为:
| 变量 | 单位 | 人话解释 | 接线后的规则 |
| --- | --- | --- | --- |
| `x` | m | 连接点的位置 | 两端位移相等 |
| `v` | m/s | 连接点的速度 | 两端速度相等 |
| `f` | N | 组件在连接点承受的力 | 两端力相加为零 |
机械端口当前都标为 `bidirectional`。`MechanicalStateReducer` 会把刚性连接的一组惯性元件整理为一个共享的 `[v, x]` 状态坐标,避免同一运动被重复积分(`app/simulation/solvers/mechanical.py:225-248, 354-427`)。
### 3.4 跨域组件不是“不同插头直接相连”
当前有三种典型跨域组件:
- `amesim_forc`:信号输入 + 机械端口;
- `amesim_pnvo001`:信号输入 + 两个气动端口;
- `amesim_pnrp17`:一个气动端口 + 四个机械端口。
不同域之间的转换发生在组件内部方程中。网络层仍禁止把气动端口直接接到机械端口或信号端口。
## 4. 端口定义究竟包含什么
可以把 `PortDefinition` 看成端口铭牌。稳定定义在 `app/simulation/core/ports.py:38-47`:
| 字段 | 例子 | 通俗含义 |
| --- | --- | --- |
| `name` | `port_a`、`res` | 组件内部唯一的端口编号 |
| `kind` | `physical` / `signal` | 是物理接头还是控制线插座 |
| `domain` | `pneumatic` / `mechanical` / `signal` | 具体属于哪个专业类别 |
| `nominal_role` | `inlet`、`output` 等 | 名义用途;只有信号的 input/output 决定传播方向 |
| `positive_flow_direction` | `intoComponent` | 流和力的正号统一指向组件内部 |
| `variables` | `p`、`m_flow` 等 | 端口真正携带的变量、单位和连接规则 |
`side` 和 `order` 来自显示定义 `ComponentDisplaySpec.ports`,不是物理合同:
- `side`:端口图标画在节点左、右、上还是下;
- `order`:多个端口的显示顺序。
它们由 `ComponentModelSpec.as_catalog_dict()` 合并进目录响应(`app/simulation/registry.py:76-110`)。求解器不读取 `side`。
运行时的 `PortState` 是一只通用“数值盒子”,同时预留气动、机械和信号字段(`app/simulation/core/ports.py:194-207`)。不能因为盒子里有某个字段,就认定所有端口都支持该变量;真正要看的是 `PortDefinition.variables`。
## 5. JSON 和 XML 分别保存什么
本节继续用“储气容器连接节流孔”说明同一件事如何经过四层表示。
### 5.1 组件目录 JSON:产品说明书
`GET /api/components/catalog` 返回后端支持的全部型号(`app/main.py:283-287`、`app/simulation/registry.py:1002-1027`)。它受 `schemas/component-catalog-v1.schema.json` 约束,信息最完整。
下面是为了讲解而加了注释的 **JSONC**,不是可直接提交的严格 JSON:
```jsonc
{
"name": "port_a", // 端口编号
"kind": "physical", // 物理接头,不是控制线
"domain": "pneumatic", // 气动类别
"nominalRole": "bidirectional", // 设计上允许双向使用
"positiveFlowDirection": "intoComponent", // 正流量指向组件内部
"variables": [ // 完整变量表只在目录/编译结果中出现
{
"name": "p", // 压力
"role": "effort", // 连接后两端相等
"connectionRule": "equal",
"unit": "Pa",
"resultVisible": true
},
{
"name": "m_flow", // 质量流量
"role": "flow", // 连接后两端相加为零
"connectionRule": "sumToZero",
"unit": "kg/s",
"resultVisible": true
},
{
"name": "h_outflow", // 流出气体的比焓
"role": "stream", // 按流向传播/混合
"connectionRule": "streamMix",
"unit": "J/kg",
"resultVisible": true
}
],
"side": "left", // 只影响画面位置
"order": 10 // 只影响显示顺序
}
```
**[已实现]** 当前目录加载结果为 **27 个模型、61 个已声明端口**:38 个气动端口、19 个机械端口、4 个信号端口。两个介质定义模型没有端口,因此不计入 61 个端口。
### 5.2 ReactFlow 工程 JSON:画布存档
工程 JSON 保存“这次用了哪一个型号、端口快照和连线”,但不重复保存完整变量表。请求模型在 `app/main.py:86-155`;前端定义与生成逻辑见 `frontend/src/App.tsx` 中的 `PortDefinition`、`ReactFlowProjectPayload`、`buildProjectPayload()`。
下面仍是带说明的 JSONC:
为避免示例过长,这里只截取“储气容器接到节流孔”的局部画布;节流孔另一端尚未接出,所以它是**字段讲解片段**,不是可直接运行的完整工程。可运行的完整链路见 `tests/test_generic_system_xml_simulation.py:86-145`。
```jsonc
{
"projectSchemaVersion": 1, // 当前工程 JSON 的唯一格式版本
"name": "tank-orifice-demo",
"nodes": [
{
"id": "tank_1", // 本张图中的实例 ID
"type": "simulationComponent",
"position": {"x": 120, "y": 80}, // 画布位置
"data": {
"label": "储气容器 A",
"componentType": "tank",
"modelType": "tank", // 后端靠它回查完整模型定义
"modelVersion": "1.0.0", // 锁定保存时使用的模型合同
"ports": [
{
"name": "port_a",
"kind": "physical",
"domain": "pneumatic",
"nominalRole": "bidirectional",
"positiveFlowDirection": "intoComponent",
"side": "right"
}
],
"parameters": {} // 当前组件实例的参数值
}
},
{
"id": "orifice_1",
"type": "simulationComponent",
"position": {"x": 360, "y": 80},
"data": {
"label": "节流孔",
"componentType": "orifice",
"modelType": "orifice",
"modelVersion": "1.0.0",
"ports": [
{"name": "port_a", "kind": "physical", "domain": "pneumatic",
"nominalRole": "bidirectional", "positiveFlowDirection": "intoComponent", "side": "left"},
{"name": "port_b", "kind": "physical", "domain": "pneumatic",
"nominalRole": "bidirectional", "positiveFlowDirection": "intoComponent", "side": "right"}
],
"parameters": {}
}
}
],
"edges": [
{
"id": "edge-1",
"source": "tank_1", // ReactFlow 画线需要 source/target
"sourceHandle": "port_a",
"target": "orifice_1",
"targetHandle": "port_a", // 对物理线而言,不代表流动方向
"data": {"isContactEdge": false}
}
],
"simulation": {
"t_start": 0,
"t_stop": 2,
"step": 0.1,
"max_step": 0.005,
"method": "BDF"
}
}
```
后端会按 `modelType` 创建真实模型,并先要求节点 `modelVersion` 与注册版本完全一致,
再检查这里的端口名、类型、域、名义角色和正号是否与注册定义一致。
**[已实现] 前端工程和后端求解模型有意保持分工。**
- 目录 JSON 有完整 `variables`,前端只读取完成画布连接和即时提示所需的端口级字段;后端编译仍是物理合同的最终检查点。
- 工程 JSON 使用必填 `projectSchemaVersion: 1`,每个节点保存 `modelVersion`,端口必须是结构化对象,连接必须写明两端 Handle。结构版本不受支持时直接拒绝;节点模型版本缺失或不匹配时不得编译、导出 XML 或仿真。
- UI 继续通过浏览器 `localStorage` 和本地 JSON 文件保存工程。后端同时提供严格按工程 JSON v1 校验的工程列表、保存和读取接口;仿真主路径仍只向后端提交精简的 System XML v3。
### 5.3 System XML v3:交给后端的精简求解清单
XML v3 和工程 JSON 不再追求“保存同一份完整工程”。两者分工很明确:工程 JSON 保存怎样编辑和显示,XML v3 保存后端求解什么。当前结构由 `schemas/system-simulation-v3.xsd` 定义;完整规范见 `docs/standard/system-xml-v3.md`。
#### 5.3.1 生成出来的 XML 是什么结构
```text
System 整个可执行模型
├─ Simulation 恰好 1 个:仿真时间和算法
├─ Components 组件清单
│ └─ Component * 0~多个组件
│ └─ Parameter * 组件的完整 SI 参数
└─ Connections 接线清单
└─ Connection * 0~多条连接
├─ Endpoint 每条连接恰好两个端点
└─ Endpoint
```
XML 外形是一棵树,模型仍是一张连接图。组件平铺在 `<Components>` 中,`<Connections>` 再通过“组件 `id` + 注册端口名”把它们接起来。顶层顺序固定为 `Simulation → Components → Connections`。
与 v2 相比,v3 主动删掉了 `Port` 快照和画布字段。各字段来源如下:
| XML v3 内容 | 来源 | 通俗解释 |
| --- | --- | --- |
| `System/@name` | `project.name` | 可选的模型名称 |
| `schemaVersion/unitSystem` | 生成器固定写入 | 当前协议固定为 v3,参数使用 SI |
| `Simulation` | 求值后的仿真设置 | 起止时间、结果采样间隔、内部最大步长和算法 |
| `Component/@id` | `node.id` | 连接实际引用的稳定实例编号 |
| `Component/@type` | `modelType` | 用哪个后端模型类创建实例 |
| `Component/@modelVersion` | 组件目录/注册表 | 锁定本文件采用的模型合同版本 |
| `Parameter` | 参数表达式求值并换算后的值 | 写出该模型的全部注册参数,只留最终 SI 数值 |
| `Endpoint` | ReactFlow 边两端的 handle | 用“组件 ID + 端口名”重新接线 |
实际生成过程可以概括为:
1. 收集当前节点、连线和仿真设置;
2. 把工程 JSON 的 `simulation.step` 映射成 XML 的 `sampleStep`;
3. 根据组件目录写入 `modelVersion`,并用注册默认值补齐全部参数;
4. 把参数表达式求值、换算为 SI,只写 `Component/Parameter`;
5. 每条边只写两个 `Endpoint`,不复制端口类型或方向角色。
XML v3 不保存显示名称、`symbol`、坐标、`side`、旋转、镜像、参数显示单位、科学计数法偏好、撤销历史、当前选择和仿真结果。因此它适合校验、交换和求解,但不能无损还原前端画布;要继续编辑,应保存工程 JSON。
#### 5.3.2 一个最小 XML 片段
下面用“阶跃信号 → 力源 → 零力端”同时展示信号和机械连接:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="signal-force-demo" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="2"
sampleStep="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="20"/>
<Parameter name="time" value="0.04"/>
</Component>
<Component id="force_1" type="amesim_forc" modelVersion="0.2.0">
<!-- +1 为默认施力方向,-1 为反向 -->
<Parameter name="direction" value="1"/>
</Component>
<Component id="zero_1" type="amesim_f000" modelVersion="0.1.0"/>
</Components>
<Connections>
<Connection id="signal-1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="mechanical-1">
<Endpoint component="force_1" port="port_2"/>
<Endpoint component="zero_1" port="port_1"/>
</Connection>
</Connections>
</System>
```
这里没有写 `kind/domain/role`,也没有 `<Port>`。后端看到 `type="amesim_step0"` 后,会从注册表知道 `out` 是信号输出;看到 `amesim_forc.res` 后,会知道它是信号输入;同理还能恢复两个机械端口的变量合同。也就是说,端口名是“查说明书的索引”,不是由 XML 自己重新定义接口。
XML v3 的连接规则是:
- 物理和信号 `Connection` 都只写两个 `Endpoint`;
- 两端的 `kind/domain/nominalRole/variables` 都从当前注册模型恢复;
- 信号连接必须恰好包含一个注册输出端和一个注册输入端,但端点先后顺序不决定方向;一个输出可以扇出到多个输入,每个输入只能有一个驱动;
- 物理端点无序,流向由求解结果和统一正号约定决定;
- 参数必须完整、有限、使用 SI,并通过当前模型的范围或枚举校验。
**[已实现] 布局与物理方向已经分开。** `side/rotation/mirrored` 只保留在工程 JSON 中,XML v3 不包含它们。`amesim_forc` 从模型版本 `0.2.0` 起用显式 `direction=+1/-1` 控制力的正反:相同输入 20 N 时,`direction=+1` 要求机械端口平衡值为 `f=-20 N`,`direction=-1` 时为 `f=+20 N`。旋转或镜像图标不改变求解结果(`app/simulation/components/amesim/mechanical/translational.py`、`tests/test_amesim_mechanical_public_components.py`)。
### 5.4 编译结果 JSON:接线检查报告
`POST /api/reactflow/compile-model` 和 `POST /api/system-xml/compile-model` 最终调用 `SimulationNetwork.as_interface_dict()`(`app/simulation/systems/network.py:280-314`)。
它不是新的工程存档,而是告诉调用者:“后端实际装配出了什么”。下例也是带注释的结构示意:
```jsonc
{
"name": "tank-orifice-demo",
"components": [
{
"id": "tank_1",
"type": "tank",
"ports": [
{
"name": "port_a",
"kind": "physical",
"domain": "pneumatic",
"variables": [
// 工程 JSON 和 XML 都没保存的完整变量定义,在这里由注册表恢复
{"name": "p", "connectionRule": "equal", "unit": "Pa"},
{"name": "m_flow", "connectionRule": "sumToZero", "unit": "kg/s"},
{"name": "h_outflow", "connectionRule": "streamMix", "unit": "J/kg"}
]
}
]
}
],
"connections": [
{
"id": "edge-1",
"kind": "physical",
"domain": "pneumatic",
"endpoints": [
{"component": "tank_1", "port": "port_a"},
{"component": "orifice_1", "port": "port_a"}
] // 物理连接只有两个端点,不再保留 source/target 含义
}
],
"unconnectedPorts": [] // 若有漏接,会在这里或诊断中体现
}
```
### 5.5 四层字段对照
先说结论:v3 XML 只携带连接实际用到的端口名,端口大类和完整变量表都由后端注册表恢复。这样不会同时维护“模型类中的端口”和“XML 中的端口副本”。
| 模型含义 | 目录 JSON | 工程 JSON | System XML v3 | 编译结果 JSON |
| --- | --- | --- | --- | --- |
| 端口编号 | `name` | `name` | 只在 `Endpoint/@port` 出现 | `name` |
| 物理/信号 | `kind` | `kind` | 不保存,由 `type + port` 恢复 | `kind` |
| 气动/机械/信号 | `domain` | `domain` | 不保存,由注册表恢复 | `domain` |
| 名义角色 | `nominalRole` | `nominalRole` | 不保存,由注册表恢复 | `nominalRole` |
| 正号约定 | `positiveFlowDirection` | 同名字段 | 不保存,由注册表恢复 | 同名字段 |
| 完整变量和规则 | `variables[]` | 不保存 | 不保存 | `variables[]`,由注册表恢复 |
| 显示侧 | `side` | `side` | 不保存 | 不是求解合同 |
| 画布连线 | 不适用 | `source/.../targetHandle` | 两个 `Endpoint` | 规范化后的连接端点 |
| 模型合同版本 | `modelVersion` | 节点显式保存并与目录核对 | `Component/@modelVersion` 必填 | 按当前注册模型编译 |
## 6. 接线时后端具体检查什么
可以把 `SimulationNetwork.connect()` 当作最后一道“防止插错线”的检查(`app/simulation/systems/network.py:83-150`)。它会依次确认:
1. 组件和端口确实存在,且不是自己接自己;
2. 两端 `kind` 一样;
3. 两端 `domain` 一样;
4. 完整变量表一致;
5. 信号线是一端 input、一端 output;
6. 一个物理端口最多接一条线;需要分支时必须放入 `tee`、`amesim_pn3node2`、`amesim_p4node2` 等分支组件;
7. 连接 ID 和端点组合没有重复。
对于案例 A 的一条气动连接,网络直接形成:
```text
p_A - p_B = 0 # 接头两侧压力一致
m_flow_A + m_flow_B = 0 # 流出一侧的质量等于流入另一侧的质量
```
不会形成 `h_outflow_A = h_outflow_B`。焓要在压力、流量确定后,由 `StreamResolver` 根据真实流向传播或混合。物理连接端点顺序不影响结果,相关测试包括 `tests/test_component_interfaces.py:51-60`、`tests/test_system_xml_protocol.py:146-176` 和 `tests/test_generic_system_xml_simulation.py:354-372`。
XML v3 还会先经过:
- 安全解析和 5 MiB 大小限制(`app/system_xml.py:21, 243-280`);
- XSD 格式检查;
- 组件类型、`modelVersion`、参数完整性和值域检查;
- 端点引用检查,并从注册表恢复端口类型、角色、变量和正号约定;
- 端点占用、信号输入/输出配对、物理域和变量合同检查。
XML 语义检查会把未连接端口记为 warning;真正进入通用求解前,未连接的**物理端口**会成为 `PORT_UNCONNECTED` error(`app/simulation/systems/generic.py:93-125`)。未连接信号端口不会阻止求解。
## 7. 当前容易踩坑的跨层差异
这些问题不妨碍理解主流程,但开发或制作 XML 时必须注意。
### 7.1 信号多接的规则前后端不一致
**[发现]** 后端网络和 XML 只限制物理端口单连接,没有禁止多个信号源同时连接到一个 input。`SignalResolver.solve()` 会按连接顺序依次写入,因此最后一个值覆盖前面的值。
前端 `canConnectPorts()` 却对所有端口实行一对一:既阻止多个源写同一输入,也阻止一个输出连到多个输入(`frontend/src/App.tsx`)。所以:
- 在浏览器里通常画不出这种多接;
- 直接调用 XML/API 却可能构造出来;
- 最后覆盖不是稳定的“求和”或“仲裁”规则,不应依赖。
后续最好统一为“每个信号输入只能有一个驱动”,或者显式增加求和、选择、总线组件。
### 7.2 XML 不写端口类型,不等于后端不知道类型
**[已实现]** v3 有意删除 `Port`、连接的 `kind/domain` 和端点 `role`。这不是允许调用者“随便省略”,而是把端口合同收回到唯一事实来源:后端注册模型。语义检查会用 `Component/@type + Endpoint/@port` 查出 `kind/domain/nominalRole/variables`;端口不存在或两端不兼容仍会报错。手写 v3 时不要把 v2 的这些字段加回来,XSD 会拒绝它们。
### 7.3 JSON 和 XML 对缺省参数的处理不同
**[发现]** ReactFlow JSON 编译和 JSON→XML 导出会用注册默认值补齐缺失参数;System XML v3 语义检查会对缺少的注册参数报告 `PARAMETER_REQUIRED_MISSING`。因此“工程 JSON 可以省略默认参数”不等于“手写 XML 也可以省略”。正规导出器会替你写全,人工制作 v3 时必须列全。
### 7.4 当前 API 只接受 v3
仓库只保留当前的 v3 协议和 XSD。v1/v2 不属于受支持输入,旧版本号只出现在拒绝边界测试和“旧格式不受支持”的说明中。
**[已实现]** 当前 `validate_system_xml_document()` 固定加载 `schemas/system-simulation-v3.xsd`,不会按 `schemaVersion` 自动切换或迁移 v1/v2;新文件必须使用 v3。
### 7.5 XML v3 会锁定模型版本
**[已实现]** `Component/@modelVersion` 是 v3 必填属性,语义检查要求它与当前注册模型版本完全一致。例如 `amesim_forc` 当前是 `0.2.0`,旧版本号不会被静默当成新方程求解。工程 JSON v1 的每个节点也保存创建时的 `modelVersion`,编译和导出 XML 前必须先与当前目录核对。这种设计采取“发现不一致就拒绝”的策略,不表示系统已经提供自动模型迁移器。
### 7.6 不从旧格式推断当前行为
当前格式只看 `docs/standard/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。旧格式中的 `Port`、布局字段、端点 `role`、`Simulation/@step` 和“旋转改变力方向”都不能继续套用到 v3。
## 8. 当前模型覆盖范围
不需要记住所有型号,只需知道它们仍归入前面三种插头标准。
| 用途 | 当前模型 |
| --- | --- |
| 实验气动 | `cylinder`、`tank`、`pipe`、`orifice`、`tee` |
| AMESim 气动 | `amesim_pnpl01`、`amesim_pnrp17`、`amesim_pnch023`、`amesim_pnch012`、`amesim_pnor001`、`amesim_pnvo001_fixed`、`amesim_pnvo001`、`amesim_pnl00r`、`amesim_pnl0001`、`amesim_pnl0002`、`amesim_pnl0003`、`amesim_pn3node2`、`amesim_p4node2` |
| AMESim 机械 | `amesim_f000`、`amesim_forc`、`amesim_mecmas21`、`amesim_lstp00a`、`amesim_lmechn1`、`amesim_pnrp17` |
| AMESim 信号 | `amesim_step0`、`amesim_ud00`、`amesim_forc`、`amesim_pnvo001` |
| 零端口介质定义 | `amesim_ideal_air_medium`、`amesim_helium_medium` |
两个介质模型虽然出现在组件目录中,却没有连接端口。它们在编译第一阶段登记 `gi=1..99` 的介质定义,之后不进入方程网络(`app/main.py:1206-1253`)。它们是配置节点,不是第四类接口。
## 9. 与接口表示相关的 HTTP API
这里的 API 可以理解为围绕上述四层数据提供的“入口按钮”。
| API | 人话解释 | 当前前端是否直接使用 |
| --- | --- | --- |
| `GET /api/components/catalog` | 获取后端产品说明书 | 是 |
| `GET /api/reactflow/projects` | 列出后端保存的工程 JSON v1 | 当前 UI 主路径不用 |
| `GET /api/reactflow/projects/{id}` | 读取并校验一个后端工程 | 当前 UI 主路径不用 |
| `POST /api/reactflow/projects/{id}` | 保存一个工程并保留单位/科学计数显示信息 | 当前 UI 主路径不用 |
| `POST /api/reactflow/system-xml` | 把工程 JSON 转成精简 XML v3 | UI 当前也能在浏览器内生成同一结构 |
| `POST /api/reactflow/compile-model` | 检查工程 JSON 并返回装配结果 | 可用于诊断 |
| `POST /api/system-xml/validate` | 只检查 XML 格式和语义 | 可用于诊断 |
| `POST /api/system-xml/parse` | 把 XML 变成规范化执行模型;不还原坐标、旋转等画布信息 | 可用于检查求解输入,不是无损工程导入 |
| `POST /api/system-xml/compile-model` | 检查 XML 并装配网络 | 求解前使用 |
| `POST /api/system-xml/simulate-stream` | 提交 XML 并持续接收进度/最终结果 | 当前 UI 的通用求解入口 |
| `POST /api/system-xml/simulate` | 同步返回完整结果 | 后端提供,UI 主路径不用 |
后端还提供 CSV 导出 API。当前 UI 的工程保存和读取主要发生在浏览器与用户选择的本地文件中,后端工程接口作为同一严格合同的可选持久化入口。
## 10. 已实现、约定和推断:最后再分一次边界
### [已实现]
- 当前实际有气动、机械、标量信号三类端口,模型加载快照为 27 个模型、61 个端口。
- 物理端口以进入组件为正,物理连接端点无序;信号方向由注册端口的 output/input 决定,XML 端点不写 source/target 角色。
- 注册器、JSON/XML 编译器、XML 语义层和网络层会分层检查接口。
- 气动压力/质量流量约束、焓传播、外部容积传播、机械连接约束和标量信号传播已有可执行代码。
- 工程 JSON 可导出 System XML v3;XML v3 可解析为执行模型并编译为带完整端口合同的网络。
- `amesim_forc` 用 `direction=+1/-1` 决定力方向;旋转/镜像只影响显示。
- MECMAS21 工程、目录、XML 和模型统一使用 AMESim 原生 `1/2` 选项编码,不再猜测或转换旧 `0/1` 值。
### [约定]
- 组件模型类的 `PORTS` 是权威端口合同;前端目录只是读取视图。
- XML 和执行参数使用 SI 基准值。
- 物理端口的 `nominalRole` 不限制实际流向。
- 新文件使用 System XML v3;每个组件显式写当前 `modelVersion`,仿真采样字段写 `sampleStep`。
### [推断/需要另行实测]
- 类型字段允许出现其他 `domain` 字符串,但新增液压、电气等域还需要变量定义、组件方程和专项求解器;只改字符串不能工作。
- 27 个模型和 61 个端口是本次加载快照。受控库清单改变后,数量也会改变。
- v1/v2 XML 明确不受支持;旧工程 JSON 也不会由当前前端自动猜测或迁移。
## 11. 关键文件与符号索引
读到具体疑问时,可从这里回到代码。前面的章节已经给出人话解释,本表用于精确定位。
| 主题 | 文件与位置 | 关键符号 |
| --- | --- | --- |
| 端口类型、变量、正号 | `app/simulation/core/ports.py:7-231` | `PortVariableDefinition`、`PortDefinition`、`PortState` |
| 组件事实来源 | `app/simulation/core/base.py:21-63` | `Component.PORTS`、`register_declared_port()` |
| 端口显示信息 | `app/simulation/core/catalog.py:27-71` | `PortDisplaySpec`、`ComponentDisplaySpec` |
| 注册发现与校验 | `app/simulation/registry.py:41-170, 416-490, 825-1027` | `ComponentModelSpec`、`_validate_port()`、`build_component_catalog()` |
| 网络接线与方程 | `app/simulation/systems/network.py:24-314` | `Connection`、`SimulationNetwork.connect()`、`connection_equation_residuals()` |
| 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` |
| JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` |
| XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` |
| XML v3 当前格式 | `schemas/system-simulation-v3.xsd`、`docs/standard/system-xml-v3.md` | `sampleStep`、`modelVersion`、`Parameter`、`Endpoint` |
| 目录 JSON 格式 | `schemas/component-catalog-v1.schema.json:52-160` | `$defs.portVariable`、`$defs.port` |
| 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` |
| 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` |
| 接口核心测试 | `tests/test_component_interfaces.py` | 变量规则、端点中立、单连接限制 |
| XML 协议测试 | `tests/test_system_xml_protocol.py`、`tests/test_system_xml_parser.py` | v3 表示和语义诊断 |
## 12. 一句话复盘
SystemSimulationApp 用三种端口把组件组成网络:气动和机械端口负责“守恒与相容”,信号端口负责“有方向地传一个数值”;目录 JSON 定义型号,工程 JSON 保存画布,System XML v3 保存精简求解清单,编译结果则证明后端最终理解并装配出了什么。
@@ -0,0 +1,863 @@
# 求解器性能与鲁棒性优化任务清单
> 用途:记录求解器优化的现状、证据、实施顺序和验收结果,供后续开发前后对比与持续更新。
> 首次建立:2026-08-17
> 基线代码:`6bb0591d320d0c448ee8d224dd44127bfe3ce00f`(本地 `model-development`)
> 基线模型:`tests/data/test_mql-full-branches-01-04.xml`
> 模型 SHA-256:`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`
> 当前主固化目标:`tests/data/test-mql-8.xml`
> 主目标 XML SHA-256:`0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b`
> 配套项目 JSON:`tests/data/test-mql-8.json`,SHA-256 `b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18`
> AMESim 权威物理基线归档:`AmesimModels/test_mql.ame`,SHA-256 `cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb`
## 1. 使用规则
本文档不是一次性的建议列表,而是优化工作的验收账本。
- 状态统一使用:`未开始`、`进行中`、`部分实现`、`已完成`、`阻塞`、`不采用`。
- 只有同时完成代码、自动测试、基准复测和本文档更新后,任务才可标记为“已完成”。
- 每次性能对比必须记录代码提交、工作树状态、输入哈希、解释器与依赖版本、硬件和运行参数。
- 正确性门槛先于速度收益。若结果越过误差契约,即使运行更快也不能合入默认路径。
- AMESim 归档中的仿真结果是物理数值正确性的唯一基线;每次正式回归都必须按投影逐项计算并保存当前值、AMESim 基线值、绝对误差和相对误差。
- Python exact/state golden 仅用于检测确定性、实现漂移和输出契约变化,不得单独或与本地 physical golden 一起批准物理正确性。
- AMESim 基线为 0 时相对误差在数学上未定义,报告写为 `null` 并用绝对误差判定;AMESim 未保存的内部守恒量必须明确标记为不可外部比较,并继续执行独立绝对残差门。
- 容差、模型方程或输出字段发生变化时,必须单独说明;不得将其伪装成纯性能优化。
- 墙钟时间只在同一台机器、相同负载和相同环境下直接比较;跨环境以工作量计数和正确性指标为主。
- 每项优化都应保留明确的关闭开关或旧路径,直到新路径经过复杂模型和通用回归验证。
## 2. 当前结论与基线
### 2.1 关于 2.05 s 卡死
当前随附 XML 的磁盘配置是 `tStop=0.81 s`,因此原文件本身不会运行到 2.05 s。将停止时间仅在内存中改为 `2.10 s` 后,当前代码已经完整越过 2.05 s 并正常结束:
- `2.040432 s`:墙钟 `114.065 s`
- `2.046141 s`:墙钟 `120.746 s`,期间 CPU 时间持续增长
- `2.051691 s`:墙钟 `121.548 s`
- `2.100000 s`:完成积分并进入后处理
- 总运行完成,无重试、无非线性回退,也没有无进度死锁
因此,该历史输入的证据支持“此前的 2.05 s 卡死在当时版本中没有复现”;该区间仍存在数秒级慢推进。本节形成时尚未验证
`10 s`,不能由该次结果外推保证。后续主目标曾以历史 `maxStep=0.02 s` 完成单次 `10 s`,但当前权威
`maxStep=0.001 s` 的 `10 s` 基线仍未运行,两者不得混用。
### 2.2 环境说明
首次历史复测时仓库 `.venv` 尚不完整,因此当时使用现有 `/opt/srm-trial-review/.venv`:
| 项目 | 本次值 |
| --- | --- |
| Python | 3.12.3 |
| NumPy | 2.4.6 |
| SciPy | 1.17.1 |
| 求解器 | BDF |
| 输出步长 | 0.01 s |
| 执行路径 | stream/cancel-check |
该环境满足仓库依赖范围,但并非已经锁定的正式项目环境。当前物理解哈希与历史调研文档不同,所以逐位结果基线必须在正式锁定环境中再次确认。
### 2.3 当前实测基线
| 指标 | 原始 `0.81 s` | 仅内存延长至 `2.10 s` |
| --- | ---: | ---: |
| 状态 | 完成 | 完成,越过 2.05 s |
| 墙钟时间 | 63.779 s | 126.211 s |
| 积分时间 | 62.116 s | 122.180 s |
| 后处理时间 | 1.118 s | 2.703 s |
| 最大 RSS | 165,464 KiB | 198,348 KiB |
| 输出样本数 | 82 | 213 |
| 状态数 | 74 | 74 |
| `nfev / njev / nlu` | 3763 / 253 / 761 | 6734 / 487 / 1507 |
| 接受步 | 1076 | 1857 |
| 分段启动 | 3 | 5 |
| 状态切换 | 0 | 2 |
| 重试 | 0 | 0 |
| 有限差分附加 RHS 估计 | 7843 | 15097 |
| 压力闭合次数 | 30,502 | 57,601 |
| 非线性/块/稠密回退 | 0 / 0 / 0 | 0 / 0 / 0 |
| 最大热流体外迭代 | 3 | 3 |
| Jacobian 稀疏度 | 1284 nnz / 31 色 | 1284 nnz / 31 色 |
补充观察:
- 积分占总耗时约 97%,当前首要瓶颈不是后处理。
- `2.10 s` 运行中,估计总 RHS 工作量约为 `6734 + 15097 = 21831`;有限差分扰动约占 69%。
- 压力闭合约为每次估计 RHS 2.64 次,但全部走已播种的因果路径,没有触发 `least_squares`。
- 全局因果执行已启用:快速执行 22,216 次,完整残差审计 351 次,审计失败 0 次,旧路径回退 0 次,审计间隔为 64。
- 当前结果哈希仅作为本次环境的诊断记录:`0.81 s` 为 `c6354c97...`,`2.10 s` 为 `efef49f8...`;它们暂不作为跨环境验收标准。
### 2.4 当前模型结构基线
| 项目 | 数量 |
| --- | ---: |
| XML 组件 / 编译组件 | 99 / 98 |
| 连接 | 106 |
| 连续状态 | 74 |
| 代数未知量 / 方程 | 472 / 472 |
| 原始关联矩阵非零元 / 方程块 | 919 / 58 |
| effort 未知量 / flow 未知量 | 272 / 200 |
| effort 等价组 / 可消去重复 effort | 68 / 204 |
| 显式 flow/force 赋值 | 200 |
| stream 块 / stream 未知量 | 9 / 192 |
| 结果变量 | 1,021 |
### 2.5 新主固化目标 `test-mql-8`
自 2026-08-17 起,后续通用求解器优化以 `tests/data/test-mql-8.xml` 为主固化目标;配套 `test-mql-8.json` 用于校验项目结构,但 XML 是权威执行输入。原 `test_mql-full-branches-01-04.xml` 继续保留为历史慢区、2.05 s 与首批半解析 Jacobian 的回归样例。runner 只在内存中覆盖 `tStop/sampleStep/maxStep`,不得改写权威输入。
| 项目 | 主目标值 |
| --- | ---: |
| 运行组件 / 连接 | 156 / 178 |
| 动态组件 / 连续状态 | 58 / 132 |
| 代数未知量 / 方程 | 776 / 776 |
| ODE Jacobian 结构 | 3280 nnz / 52 色 |
| 因果 effort / flow 赋值 | 440 / 336 |
| secondary 代数块 / 未知量 | 12 / 368 |
| 结果变量 | 1,784 |
| 原始 `tStop / sampleStep / maxStep` | 10 / 0.01 / 0.001 s |
| 信号断点 | 0.04、0.8 s |
本轮正式环境使用仓库 `.venv`:Python 3.12.3、NumPy 2.5.2、SciPy 1.18.0。`.python-version` 与 `constraints/python312-direct.txt` 固定跨平台开发参考;`constraints/python312-linux-x86_64.lock` 则固定 Linux x86_64 发布环境的 22 个直接/传递包、wheel SHA-256,并强制 binary-only 与 hash 校验。README、CI 与依赖契约测试使用同一安装口径。机器可读 manifest 与 runner 分别位于 `tests/baselines/simulation/test_mql_8/manifest.json` 和 `app/simulation/benchmark_regression.py`;默认顺序为 `0.01 smoke → 0.2 → 1 → 5 → 10 s`,每档均有合作取消、硬终止、资源记录与后续档延迟门,且 `sampleStep` 与 `maxStep` 可按 lane 独立覆盖。
## 3. 总体验收协议
每个优化 PR 至少执行以下分层验证;高风险改动不得只用单点输出或单个哈希判断正确性。
### 3.1 快速结构检查(CI)
- [x] 模型输入 SHA-256 与固定 fixture 一致。
- [x] 组件、连接、状态、代数方程和 stream 结构数量符合预期。
- [x] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。
- [x] 因果计划覆盖率、回退原因和审计失败数可观测。
### 3.2 数值检查点
至少覆盖以下区间和模式边界:
- [ ] `0.68–0.71 s`:历史慢区。
- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。
- [ ] `2.00–2.10 s`:此前报告卡死区间和状态切换。
- [x] `10 s / maxStep=0.02 s`:2026-08-17 最终通用接线版本完成一次历史长时间模式变化运行。
- [ ] `10 s / maxStep=0.001 s`:当前权威配置尚未运行;连续 3 次、批准 golden 与完整步长矩阵仍属于 OPT-09。
每个检查点比较:连续状态、关键压力/流量/位移/速度、事件时刻与顺序、模式状态、有限性、最大缩放残差及守恒量。
### 3.3 性能记录
每次正式对比至少预热 1 次、测量 3 次并报告中位数,同时保存:
- 总时间、积分时间、后处理时间、CPU 利用率、峰值 RSS。
- `nfev`、`njev`、`nlu`、接受/拒绝步、分段和重试次数。
- SciPy 模式的有限差分 RHS 估计;callable 模式的真实扰动、基准和 Jv 审计 RHS 计数;Jacobian 颜色数与构建时间。
- 代数闭合次数、快速因果次数、完整审计次数和各类回退次数。
- stream/热流体迭代次数、物性缓存命中率、事件候选与定位次数。
- 输出标量数、编码字节数、传输字节数和后处理峰值内存。
### 3.4 P0 最大积分步长路径鲁棒性门
权威工程场景固定为 `test-mql-8.json` 经浏览器导出并由服务执行的同一系统语义,方法为 BDF。短基线使用
`tStop=0.2 s`、`sampleStep=0.01 s`、`maxStep=0.001 s`;长基线只将 `tStop` 延长到
`10 s`。`sampleStep` 是输出网格,`maxStep` 是积分步长上限,报告与诊断不得混用两者。
- **时域延长不变量**:固定模型、容差、方法和 `maxStep` 时,如果较短的 `tStop=T1` 能完成,则
`T2>T1` 的运行不得因数值错误在 `T1` 之前提前结束。只比较严格位于 `T1` 之前的公共检查点;
短任务终点的 accepted endpoint 与长任务内部插值单独标记,不作位级误判。
- **步长细化可解性不变量**:在约定工程区间内,如果较大的 `maxStep` 能完成同一时域,则更小
`maxStep` 不得反而出现不可恢复数值失败。更小上限可以更慢;若仅因工作量增加超过预算,必须归类为
`budget_limited` 并证明仍持续推进,不能记为 solver failure 或借此选择一个“幸运步长”。
- [x] 已对账 JSON、浏览器生成 System XML 的参数直传代码、服务请求和 worker 最终回显:`BDF / 0.2 s / 0.01 s / 0.001 s` 没有被前端或后端改写;JSON/XML 权威哈希及配对契约测试通过。
- [x] 已复现并分类浏览器 `t≈0.0489 s` 计算超时:同参流式 API 能完整到达 `0.2 s`,但修复前浏览器曾把一次仍有 CPU 活动的约 70 s 慢步误判为 `SOLVER_STALLED` 并主动取消。该结果是“服务假超时 + 后端真实慢区”,不是该状态的不可恢复数值失败或网络断流。
- [x] 已串行完成 `tStop={0.2,1} s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的 10 个短时单元:全部到达终点,`caseFailureCount=0`,无 NaN/Inf、不可恢复数值错误、热流体恢复或超时;同 `maxStep` 的 `0.2→1 s` 严格公共前缀五档均通过。
- [x] 已将同一五档矩阵延长到 `2 s`:5 个单元全部到达终点、0 次恢复重试,`1→2 s` 的严格公共前缀五档逐位一致,机械事件顺序一致且时刻跨度不超过 `9.58 µs`。
- [ ] 继续延长到 `5 s → 10 s`。2026-08-19 的 5 s 尝试在用户要求下中止且未生成聚合报告;当前代码的权威 `10 s / 0.001 s` 未运行。
- [x] 已人工分层复核跨 `maxStep` 结果:`0.2/1 s` 的积分 `v/x` 无超差;`2 s` 的压力、守恒、离散模式和事件通过,差异集中于接触后的近零 `v/a` 及 `1.85–1.90 s` 流量换向附近。
- [ ] 将上述分层判据自动接入矩阵 runner。当前报告顶层仍因旧的统一 state comparator 把派生 `a`(以及 2 s 接触后的近零 `v`)计为 comparison failure,不能写成“矩阵整体 passed”。
- [ ] 每个 `0.01 s` 模拟区间记录墙钟、实际 `h_abs`/BDF 阶次、接受/拒绝步、重启、`nfev/njev/nlu`、Jacobian 构建、stream/热流体闭合与恢复轨迹;性能悬崖必须能定位到具体阶段和组件。
- [x] 内部 RHS、solver step、stream/热流体闭合仍有活动时,服务持续发送活动心跳;真实浏览器已证明接受进度平台期不会再因 60 s 规则被误杀。
- [ ] 真正无活动时仍须实现可硬终止的隔离 worker,并报告最后阶段、时刻、步长和计数;当前线程内合作取消不能杀死永不返回的本地调用。
- [ ] 将终止结果明确分类为 `numerical_failure`、`service_timeout_worker_active`、`active_slow_trial`、`true_stall` 或 `budget_limited`;浏览器超时始终属于工程路径未通过,但在证据不足时不得冒充数值失败。
- [ ] 任一会改变数值路径、事件语义、容差或默认求解策略的修复,必须先形成“复现证据 → 首个异常阶段 → 根因假设 → 最小方案 → A/B 判据 → 回退方式”,提交审阅后再实施;每轮只修改一个概念并先复跑原失败单元。
- [x] 用户后续明确批准先定位并解决 70 s 慢区;已只接受保持数值语义的精确热路径优化,并拒绝改变接触轨迹或表现更差的容差/Jacobian 候选。
- [ ] 恢复长时测试时,从完整的 5 s 报告继续,再取得当前优化版本的 `10 s / 0.001 s` 权威基线;历史 `0.02 s` 报告不得替代。
该门的目标不是寻找一个“碰巧能跑”的固定 `maxStep`,也不是要求所有步长得到位级相同轨迹,而是让合理工程区间内的
`maxStep` 只影响可解释的误差与成本,不决定仿真能否完成。
#### 2026-08-18 / 浏览器 `0.0489 s` 超时的修复前定位(步骤 1–3)
- 输入对账:权威 JSON/XML SHA-256 分别为 `b44bf540...` / `0a2d9331...`;诊断请求只把 XML 的 `tStop` 从 `10` 改为 `0.2`,请求载荷 SHA-256 为 `2e9d6577...`。前端 `resolveSimulationConfig` 与 `buildSystemXml` 对四个数值及方法直接序列化,后端最终回显 `tStop=0.2`、`sampleStep=0.01`、`maxStep=0.001`、`method=BDF`。
- API 实测:真实 `/api/system-xml/simulate-stream` 于 `164.954 s` 完成,`status=completed`、`success=true`、`simulatedUntil=0.2`、21 个采样点、2054 个接受步、0 次可恢复失败;`nfev/njev/nlu=6190/267/982`,与同配置离线 worker 轨迹一致。
- 决定性时间线:最后一次普通进度为 `t=0.048668428726 s`(15:29:03.676),下一次为 `t=0.049248338602 s`(15:30:14.045),间隔 `70.369 s`。期间后端每约 5 s 持续发送 heartbeat,求解线程采样约 `99%` 单核 CPU;第 60.416 s 的 heartbeat 到达时,前端按现有规则必然先抛出 `SOLVER_STALLED`。
- 直接根因:前端只以“非 heartbeat 的累计 accepted progress”刷新 60 s 计时;Generic 又把普通进度节流为总时域的 `0.25%`(本例为 `0.0005 s`)。因此内部 RHS/Jacobian/闭合仍在运行、甚至接受微步时,也可能被错误取消。30 s 网络 idle 门没有触发,因为 heartbeat 始终存在。
- 时域放大效应:该 `0.25%` 门槛随 `tStop` 变为 `0.2/1/2/5/10 s → 0.0005/0.0025/0.005/0.0125/0.025 s`。相同物理公共前缀在更长任务中会更少发送普通进度,更容易被 60 s 规则误杀;这会直接破坏时域延长不变量,不能通过单纯提高超时常数根治。
- 当时尚未归因的性能问题:后端确有约 70 s 满核慢区;修复前协议没有 RHS 调用数、trial time、Jacobian/闭合阶段和任务级活动序列,不能直接断言具体数值根因。
- 当时建议 A/B:将 accepted-time 平台期改为“活跃慢步”提示,并增加节流的任务级 activity telemetry;该方案已于后续实现并通过真实浏览器复验。
- 当时建议 C:记录 step/RHS/Jacobian/闭合增量以定位慢区;当前已完成离线 step/RHS/闭合及组件归因,但仍缺 BDF order、全程 `h_abs` 和 SciPy 内部有限差分 Jacobian 的实时精确分类。
#### 2026-08-19 / 慢区归因、精确优化与浏览器复验
- 数值根因:原 `0.048668428726→0.049248338602 s` 区间包含约 1385 个接受步,其中 1223 步小于 `1e-8 s`、186 步小于 `1e-9 s`,步长中位数 `1.409e-9 s`、最小值 `6.125e-11 s`;8 个微步簇与 8 个高刚度 `LSTP00A` 接触依次激活一一对应。因此它是刚性接触层中的真实慢推进,不是单次调用死锁。
- 工作量归因:该区间 `nfev/njev/nlu=3527/90/453`,约 4786 次额外 RHS 来自 SciPy 稀疏有限差分 Jacobian;RHS 墙钟绝大部分位于 `_close_current_state`。全部压力/流量代数解走已播种因果路径,无非线性、块或稠密回退。
- 服务修复:后端增加 `activitySequence/activityKind/currentTrialTime/rhsCallCount/acceptedStepSequence/acceptedTime` 及 solver/Jacobian/闭合计数快照;5 s heartbeat 携带快照。前端仅在 `integrating` 阶段的 accepted 与 activity 同时连续 60 s 不变时判停,activity 继续推进时只提示慢步,缺少新字段时也不误杀;30 s 完全无字节的传输门保持不变。
- 精确优化:因果 sum-to-zero 直接赋值、PNL0001 循环不变量/摩阻不变量与 equation-level 直接 reader 均保留完整残差审计、显式 capability 门和 opt-out;短 A/B 逐位一致。接触感知容差和半解析 Jacobian 候选因改变接触瞬态或收益不足未升为默认,正式配置继续使用 `legacy` 机械容差与 SciPy Jacobian。
- worker/API 效果:当前 production worker `0.2 s / 0.001 s` 为 `147.634 s`,相对旧批准基线 `159.607 s` 缩短 `7.50%`;真实流式 API 于 `147.299 s` 完成。普通进度最长空窗由 `70.369 s` 降到 `57.185 s`,但空窗内 activity 持续推进。
- 真实浏览器:隔离 Chromium→Vite→FastAPI 链路使用同一 JSON,`BDF / 0.2 / 0.01 / 0.001 s` 于 `156.136 s` 完成,输出 21 点;无取消请求、stream error 或 page error,activity sequence 从 `25114` 增至 `66670`,13 个 heartbeat 均携带活动证据。存在一条无关资源 404 控制台消息,不影响仿真验收。
- 当前边界:线程内取消仍不能硬杀永不返回的 native 调用;尚无断流重连;实时 `jacobianEvaluationCount` 不能看穿 SciPy 内部有限差分构建。它们继续留在 OPT-08,不影响本次“活跃慢步不再被浏览器误杀”的结论。
- 证据:`runs/2026-08-18-production-slow-region-exact-v1-0.2.json`(SHA-256 `34268e58...`)和 `runs/2026-08-18-production-browser-live-activity-v1-0.2.json`(SHA-256 `1f72746c...`)。
## 4. 优化任务总览
优先级定义:`P0` 为基线或正确性前置,`P1` 为主要性能收益,`P2` 为第二阶段,`P3` 为战略性或条件性工作。
| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 |
| --- | --- | --- | --- | --- | --- | --- |
| OPT-00 | P0 | 固化复现、环境和回归基线 | 已完成(本地 P0 基础闭环;远端 CI 运营证据待补) | 中 | 很高 | 无 |
| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 基本完成(主要矛盾闭环) | 中高 | 中高 | OPT-00 |
| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 部分实现(参考 IR) | 很高 | 很高 | OPT-01 |
| OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 |
| OPT-04 | P1 | stream 拓扑传播与物性成组复用 | 部分实现 | 中高 | 中高 | OPT-00 |
| OPT-05 | P0/P1 | 最大积分步长路径鲁棒性、状态缩放和步长策略 | 进行中(0.2/1/2 s 可解性主阻断解除;分层契约与 5/10 s 待续) | 中高 | 很高 | OPT-00 |
| OPT-06 | P2 | 事件检测与 dense output 按需化 | 部分实现 | 中 | 中 | OPT-00 |
| OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 |
| OPT-08 | P0/P2 | 进度、取消和服务并发鲁棒性 | 部分实现(`0.0489 s` 假超时闭环;真停滞、断连与并发仍待) | 中 | 很高 | OPT-00、OPT-05 P0 门 |
| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 进行中(0.2/1/2 s 已完成;5 s 中止无报告;权威 10 s 未运行) | 中高 | 很高 | OPT-00、OPT-05 P0 门、OPT-08 服务门 |
| OPT-10 | P3 | 明确高指数 DAE/强非光滑系统边界 | 未开始 | 很高 | 条件性 | OPT-09 |
当前顺序:`OPT-00 本地基础闭环已收口 → OPT-05 将跨步长比较器改为分层契约 → OPT-08 补真停滞/断连/并发边界 → 按用户要求暂停 5/10 s → 恢复时先生成完整 5 s 报告,再运行 OPT-09 的 10 s / 0.001 s 权威基线`。OPT-01/02/03/04 的既有成果保留;任何后续收益都不得替代分类正确性与长时验收。
## 5. 详细任务
### OPT-00 固化复现、环境和回归基线
**目标**:先让“是否更快、是否仍正确、是否又卡住”可以稳定复现和自动判断。
**当前状态**:已完成本地 P0 基础闭环。2026-08-18 的 production runner、发布锁、golden、physical-state-v2.1、历史 `2.10 s` 三次复测和本地自动测试证据继续有效;2026-08-19 又用真实浏览器完成权威 JSON 的 `BDF / tStop=0.2 s / sampleStep=0.01 s / maxStep=0.001 s`,消除了 `t≈0.0489 s` 的假超时。`0.2/1 s × 五档 maxStep` 的 10 个单元全部完成,同 `maxStep` 严格公共前缀通过。跨步长分层契约、真停滞硬终止和 5/10 s 长时验证分别继续归 OPT-05、OPT-08、OPT-09,不再阻塞 OPT-00 的本地基础设施收口;远端 CI 首次运营证据仍待提交后补充。
**工作项**:
- [x] 将新主目标 XML/JSON 放入固定 fixture 路径,并在 manifest/测试中校验双哈希、字节数和配对配置;提交本轮工作时必须一并纳入版本控制。
- [x] 建立 Python 3.12.3 与六个直接依赖的跨平台参考约束,并建立 Linux x86_64 的 22 个直接/传递包、binary-only wheel SHA-256 发布锁;空 venv 离线安装、`pip check` 与依赖契约均通过。
- [x] 将临时探针整理为仓库内可重复运行的 benchmark,不依赖 `/tmp` 文件。
- [x] 添加 `0.81 s` 和仅改 `tStop=2.10 s` 的历史标准运行入口。
- [x] 添加模型结构快照断言;结构有意变化时显式更新原因。
- [x] 建立 `physical-state-v2` 的首批 state/checkpoint/event 投影;其 Python golden 现仅作为 production `0.2 s` 的确定性与实现回归诊断。
- [x] 将关键压力、质量流量、三类质量守恒、总储气质量和离散模式加入 `physical-state-v2.1`,绑定 AMESim 单位/符号变换,并在每次运行时以 AMESim reference values 执行物理门。
- [x] 将无数值的完整输出形状契约与物理状态 golden 分开;完整 API 序列化契约若需逐字段稳定性,后续另行定义。
- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。
- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。
- [x] 以权威 JSON 经浏览器生成 XML 并走流式 API 的真实路径完成 `0.2 s / 0.001 s`,与 production worker 对账输入、生效参数和结果;定位并消除前端对 `t≈0.0489 s` 活跃慢区的假超时。
- [x] 完成第 3.4 节的 `0.2/1 s × 五档 maxStep` P0 单元可解性门及同 `maxStep` 时域延长不变量;跨 `maxStep` 的自动分层数值契约继续归 OPT-05。
**验收条件**:
- [x] 干净环境可按发布锁一条安装命令复现:全新空 venv 使用 22 个锁定 wheel 与 SHA-256 完成安装,`pip check`、锁定环境契约和最终 quick workflow 同口径测试通过。
- [x] 正式 production 环境严格串行 3 次完成 `0.81/2.10 s`;两档检查点、状态、事件、输出契约和除计时外的诊断逐值一致,无非有限值或非预期回退。
- [x] 新主目标 `0.2 s` 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。
- [x] 浏览器、流式服务和 worker 三条路径对权威 `0.2 s / 0.001 s` 均能完成;内部活动持续时不再发生无证据的 60 s 假停滞。
范围边界:真正无活动或单次 native 调用永不返回时的硬终止属于 OPT-08 服务隔离验收,不再作为 OPT-00 基线基础设施的完成条件。
**前后对比**:
| 指标 | 当前 | 完成后 |
| --- | --- | --- |
| 正式锁定环境 | Python 3.12.3 + 22 包 hash lock | 空 venv 安装、`pip check`、依赖契约通过 |
| 复杂模型自动回归 | 新主目标 0.01/0.2 分层门禁 | 1/5/10 递进入口 + 历史 0.81/2.10 production 入口 |
| 物理解哈希 | 142 个状态键 × 3 检查点 | Python state golden 作确定性诊断 + output shape + AMESim 当次相对误差物理门 |
| `2.10 s` 连续成功率 | 3/3 | worker 墙钟 113.497–115.868 s,逐值一致且 0 回退 |
#### 2026-08-18 / AMESim 权威基线对齐与 OPT-00 收口
- 状态:部分实现 → 已完成(本地验收)。AME 归档固定 SHA-256 `cbc3aadd...`;XML/JSON 修正后的 SHA-256 分别为 `0a2d9331...`、`b44bf540...`,仿真配置统一为 `0→10 s / sampleStep=0.01 s / maxStep=0.001 s`。
- 权威契约:直接解析 AME 的 117 个 COMP 与 40 个建模 LINE,推导出 157 个项目节点、178 条连接、20 类组件和 1092 个参数;单位转 SI、表压转绝压、公式等价、DIRECT/接触/线模型拓扑以及 XML↔JSON 逐 ID/端口均有自动测试。
- 权威物理门:production `0.2 s` 每次都把当前 physical-state-v2.1 投影直接与 AMESim reference values 比较并保存相对误差;25 项参与判定,2 个 `t=0.04 s` 跳变流量保留误差但因左右极限语义不参与判定,另有 6 项无 AMESim 数据的内部守恒量单独执行绝对残差门。Python 142 个状态键 × 3 个检查点的 426 值 golden 仅作确定性诊断,不再批准物理正确性。
- 历史最终报告:`runs/2026-08-18-production-opt00-approved-replay-0.2.json`,SHA-256 `2e27cd54...`;worker/orchestration 墙钟 `159.607/160.473 s`。其中 Python 值零重放误差是确定性证据;物理结论以该次结果对 AMESim 的最差相对误差 `0.1393485%` 为准,最大质量守恒残差为 `1.82146e-17 kg/s`。
- 历史稳定性:production `0.81/2.10 s` 严格串行运行三次,所有 case 通过;`2.10 s` worker 墙钟分别为 `114.075/113.497/115.868 s`,机械事件时刻、检查点、1200 个状态值和除性能计时外的诊断逐值一致。
- 环境与自动化:新增 22 包 Linux x86_64 hash lock,并在全新空 venv 完成离线安装和 `pip check`;最终 quick workflow 同口径为 179 项通过(2 项预期跳过),完整后端为 849 项通过(3 项预期跳过)。
- 递进边界:由最终 `0.2 s` worker 时间按 `×5×1.5` 外推,`1 s` 为 `1197.053 s`,略低于 1200 s soft budget,因此记录为下一阶段 `eligible`;本次 OPT-00 不启动 1/5/10 s,后续长时递进仍归 OPT-09。
- 远端说明:workflow 已使用相同 hash lock 与测试命令;本轮未获授权提交/推送,因此没有声称远端 CI 已运行,提交后的首次托管运行作为运营证据补充。
#### 2026-08-17 / `test-mql-8` 固化 runner v2
- 状态:进行中 → 部分实现(P0 基础闭环)。新权威 XML/JSON、双哈希、结构快照、参考环境约束、分层 manifest、机器可读报告、批准的 production `0.2 s` golden 和仓库内 runner 已建立;发布级依赖锁、关键代数量投影以及该里程碑时尚未运行的 1/5/10 s 结果仍待后续。
- runner 行为:默认严格按 `0.01 smoke → 0.2 → 1 → 5 → 10 s` 递进;smoke 不参与耗时外推。soft deadline 先经 stdin 合作取消,hard deadline 再 terminate/kill;失败、超时、物理验收失败或下一档预测超过预算时,剩余档位统一标记 `deferred`。
- 已执行正确性门:完成并到达终点、非空且全有限的输出序列、采样时间严格递增、检查点及状态值、最大缩放残差、预期信号事件及其实际积分分段、机械切换次数/时刻、golden 来源报告与布局哈希、逐状态容差比较和独立 output-shape contract。
- 两条 lane:该 2026-08-17 里程碑的 manifest 中,`solver-only` 在内存把 `sampleStep` 改为 0.02 s,并把 `maxStep` 固定为 0.05 s,用于算法迭代;当时 SHA `170463d6...` 的 `production` 源值为 `sampleStep/maxStep=0.01/0.01 s`。当前 2026-08-18 权威 AME/XML/JSON 已统一为 `sampleStep/maxStep=0.01/0.001 s`;旧报告仅作历史证据。
- 进程鲁棒性:软取消、硬终止、子进程提前关闭 stdin 的 BrokenPipe 和 stdout/stderr 资源清理均有自动测试。
- 备份:`backup/general-solver-v1-before-20260817-16a7eb2` 精确指向进入本轮前的 `16a7eb2d6c2f01b23e3bdc7781a6cf6cc3fbe369`。
- P0 证据:`tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json`、`goldens/production-0.2s-v1.json` 与 `runs/2026-08-17-production-v2-extension-decision.json`。
- 自动验证:CI 同口径快速基础套件共 149 项,OK(2 项长时测试按开关跳过);全量后端 discover 共 792 项,OK(3 项长时/可选测试跳过)。原有 5 个失败均确认是仓库整理后的旧文档/XML/CSV 路径,并已修正为现有 fixture 路径。
递进复测命令:
```bash
PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
--lane production \
--output tests/baselines/simulation/test_mql_8/runs/latest-production.json
```
正式验收默认使用 `production`,从而对 `0.2 s` 当前结果执行 AMESim 权威物理基线比较;Python 数值 golden 同时输出确定性诊断但不作为物理通过依据。算法迭代若需降低输出成本,可显式改为 `--lane solver-only`。仅重跑首个正式基线档可加 `--case 0.2s`。
命令退出码约定:`0` 表示所有选定档完成,`2` 表示依据预算安全暂缓后续档,`1` 表示运行失败或正确性验收失败。显式选择 `1s/5s/10s` 时,runner 仍会自动补齐并先执行所有较短前置档。
### OPT-01 完成因果代数内核与坐标消元
**目标**:在已存在的因果快速路径上,真正移除运行时冗余坐标和对象访问,而不是再次实现一套同类快速路径。
**当前状态**:新主目标的主要矛盾已经在执行层闭环。原有 `760` 个 PortState 兼容代数槽由 `432` 个 effort 槽和 `328` 个 flow/force 槽组成;当前内核将其编译为 `112` 个 effort 等价组和 `328` 条显式赋值,即 `440` 个逻辑坐标,在求解执行层消去 `320` 个 effort 别名。全局与 secondary stream 块均使用预分配 workspace、按 component 批量计算 anchor 并直接 scatter,完整残差仍在初始化、事件和每 64 次求解时审计。
这里的“消去”是逻辑求解坐标消元:stream、状态导数和结果提取仍直接读取 `760` 个 PortState 兼容镜像,因此对象槽尚未物理删除;这属于 OPT-02 后续。旧 `472/204/68/200` 是历史 `test_mql-full-branches-01-04.xml` 的规模,只保留为历史基线,不再描述当前主目标。
**工作项**:
- [x] 将 112 个 effort 等价组压缩为独立逻辑坐标,在执行层消去 320 个重复 effort 别名。
- [x] 将 328 条显式 flow/force 规则预编译为稳定阶段和槽绑定。
- [x] 用预分配 workspace、批量 component anchor 和直接属性 scatter 减少热路径对象遍历、临时集合与重复缩放。
- [ ] 仅清理会被当前计划写入的槽,避免每次全量清零和复制。
- [x] 保留初始化、事件后、显式请求或固定间隔的完整残差审计。
- [x] 自定义组件、声明缺失、审计失败、非有限外部 effort 或奇异结构自动回退旧求解器。
- [x] 输出逻辑/兼容坐标数、消元数、显式规则覆盖率、审计率、失败原因和回退次数。
“仅清理当前计划写入槽”暂不勾选:当前 flow 目标仍先清零再赋值,以保持既有 `target = -residual(target=0)` 语义逐位一致;在 IR 能证明目标系数与历史无关前不移除这一步。
**验收条件**:
- [x] 新主目标因果 flow/force 覆盖率为 `328/328`,0.01/0.2 s 中审计、运行时验证和旧路径回退均为 0。
- [x] kernel on/off 的状态导数、760 个兼容代数槽、积分统计、物理解与输出契约一致。
- [x] 自定义组件、接触模型、非因果结构和故障注入的回退测试通过。
- [x] 在 0.01 s 与 production 0.2 s 证明端到端不退化并取得单次收益;严格性能签收仍需补 3 次中位数。
**风险与回滚**:别名写回、事件后模式改变和不完整依赖声明可能造成静默错误。新路径必须可通过配置关闭,并在审计失败时记录首个违规方程与变量。
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| 兼容代数槽 | 760 | 760(逻辑坐标 440) |
| 重复 effort 别名 | 320 | 逻辑消去 320;兼容镜像保留 |
| 已预热 Python 调用/单 RHS | 9,423 | 5,955(`-36.8%`) |
| 全局代数 solve 中位时间 | 0.708890 ms | 0.521711 ms(`-26.4%`) |
| 整体 RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(`-12.9%`) |
| 0.01 s worker 墙钟 | 13.5983 s | 12.5878 s(`-7.43%`) |
| production 0.2 s worker 墙钟 | 135.8240 s | 130.8233 s(单次 `-3.68%`) |
| 因果审计 / 运行时验证 / 旧路径回退失败 | 0 / 0 / 0 | 0 / 0 / 0 |
#### 2026-08-17 / 通用因果执行器 v2
- 状态:该段记录低分配执行器 v2 的首版里程碑;后续因果坐标内核已将新主目标的 `760` 个兼容槽压缩为 `440` 个逻辑坐标,OPT-01 当前已达到“基本完成(主要矛盾闭环)”。`760` 个 PortState 兼容镜像的物理删除仍属于 OPT-02 后续。
- 全局执行:直接执行预编译的 432 个 effort 写入与 328 个 flow/force 赋值,普通 fast solve 不再构造 seeded-id set、遍历 760 个未知量或重复构造 diagnostics。
- secondary 执行:对 352 未知量的因果块仅保存和写入 176 个 selected flow 槽,普通 fast solve 跳过完整 mutation snapshot、seed set 和 scale/residual 构造。
- 正确性边界:初始化、事件、显式请求及每 64 次求解仍执行完整残差审计;非有限 assignment、stage 异常或外部机械 x/v 非有限会熔断 v2,并在同次求解回到旧 seed/audit 路径。`SIMULATION_CAUSAL_EXECUTOR_V2=0` 保留一键回滚。
- 默认决策:在目标 0.01 s 逐位 A/B、故障注入、聚焦测试与完整 0.2 s 验收后,v2 设为通用默认;只在原有 causal compile 证明通过时启用,不满足证明的模型继续走原路径。
- 微基准:新目标 100 次同状态 RHS 中位时间由 0.305764 s 降至 0.247467 s(单次基准约 `-19.1%`),导数逐位相同;405 次 v2 fast、7 次完整审计,0 次验证失败。
- 0.01 s 端到端:SciPy Jacobian 下总墙钟 15.160 → 13.172 s(`-13.1%`),积分 14.092 → 12.122 s(`-14.0%`);`nfev/njev/nlu=526/48/149`、物理解哈希 `0e64c6f...` 均相同。
- 历史 0.2 s solver-only:旧空格路径、SHA `42e2d627...` 与 0.002 s 网格下曾以 132.305 s 完成;报告 `runs/2026-08-17-solver-only-v1.json` 和旧 `runs/2026-08-17-extension-decision.json` 已在 manifest 中标为 `historicalOnly`,不得作为新权威输入的 golden 或耗时预测来源。
- 当前 production 0.2 s:新 SHA `170463d6...` 与 0.01 s 网格下 worker 墙钟 135.824 s、CPU 139.105 s、峰值 RSS 189,874,176 B;`nfev/njev/nlu=5755/307/1081`,接受步 1696,2 个信号分段,0 状态切换/重试。50,615 次闭合全部 seeded,主 v2 fast/audit 为 21,771/341,审计失败、运行时验证失败和旧路径回退均为 0,最大缩放残差 `1.0947e-16`。
- 当前 P0 报告与 golden:`runs/2026-08-17-production-v2-0.2.json` 通过全部验收门;`goldens/production-0.2s-v1.json` 对 134 个投影结果键的 3 个检查点共比较 402 个值,并独立校验 output contract。本目标仍使用 SciPy Jacobian,不能把该成绩归因于半解析 Jacobian。
- 当时的延期决策:`runs/2026-08-17-production-v2-extension-decision.json` 绑定新报告 SHA;`1 s` 的 1018.680 s 由 `135.8240278 × 5 × 1.5` 保守外推,超过 900 s soft budget,因此在该里程碑先未启动 1/5/10 s。后续实测结论统一记录在 OPT-09,不用该历史外推覆盖实测。
### OPT-02 建立扁平数值 IR 和数组执行内核
**目标**:把组件对象、字典查找和端口读写转换成稳定的数值执行计划,为 NumPy、Numba 或原生后端提供共同基础。
**当前状态**:已启动第一版独立、可执行的 schema v1 参考 IR,但尚未接管默认热路径。它把结构程序与运行绑定分离,包含 `440 canonical / 760 compatibility` 双层槽、稳定结构签名、NumPy workspace、按 component 批量 effort 计算、六阶段 flow 执行、逐阶段观察器和可选事务模式。权威目标可编译为 `112` 个 effort 坐标、`328` 个 flow 坐标和 `320` 个逻辑别名消元,flow stages 为 `[110, 130, 49, 33, 5, 1]`。
该原型目前只覆盖全局因果代数计划;secondary、stream、结果提取、模式重编译、自定义适配器和原生后端均未接入。PortState 仍是兼容镜像。事务模式目前只保证受控返回失败的回滚,writer/MemoryError/BaseException 语义尚未冻结;结构签名也未包含组件实现版本和后端,因此不能作为持久缓存键。
**工作项**:
- [x] 定义首批最小代数 IR:canonical/compatibility 双层槽、稳定绑定、常量和分阶段操作码。
- [ ] 将组件方程、因果规则、stream 传播和结果提取分成明确执行阶段。
- [x] 实现可执行的纯 Python/NumPy 全局因果参考后端。
- [x] 添加 IR 与当前对象执行器的结构签名、逐槽和逐阶段差分测试。
- [ ] 评估 Numba 与 C/C++ 后端;在 IR 稳定前不绑定单一编译技术。
- [ ] 对动态自定义组件保留对象适配层和明确的性能降级提示。
- [ ] 缓存编译结果,并以模型结构、组件版本和数值后端作为缓存键。
**验收条件**:
- [ ] 全部现有组件族通过新旧执行器差分测试。
- [ ] 事件切换后能正确重编译或选择预编译模式计划。
- [ ] 明显降低 Python 调用数、对象分配和 RHS 中位时间,并改善完整仿真墙钟。
- [ ] 不以牺牲异常信息、取消检查或回退能力换取速度。
| 指标 | 当前 | 原型后 | 完成后 |
| --- | ---: | ---: | ---: |
| Python 调用/单 RHS | 9,423 | 5,955(OPT-01 默认内核;参考 IR 尚未接线) | 待填 |
| 临时分配字节/单 RHS | 待测 | 待填 | 待填 |
| RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(OPT-01) | 待填 |
| `2.10 s` 积分时间 | 122.180 s | 待填 | 待填 |
#### 2026-08-17 / 因果数值 IR schema v1
- 新增独立参考实现 `app/simulation/solvers/causal_ir.py`,将结构程序与运行绑定分离,覆盖 `440 canonical / 760 compatibility` 双层槽、`112` 个 effort 坐标、`328` 个 flow 坐标、`320` 个逻辑别名及六阶段 flow 计划。
- `tests/test_causal_numeric_ir.py` 已覆盖结构签名、逐槽、逐阶段、观察器和受控事务回滚差分。
- 该 IR 尚未接管默认 RHS,当前不能把 OPT-01 的调用数或墙钟收益归因于 IR;secondary、stream、结果提取、事件后模式计划和原生后端仍待接入。
### OPT-03 稀疏 Jacobian 数值层与解析/半解析演进
**目标**:先建立可审计、可回滚的 callable sparse Jacobian 数值层,再逐步把组件、因果代数计划、stream 和物性的局部导数传播进来。完整稀疏有限差分、受审计 secant 和真正的解析/半解析 Jacobian 是三个不同阶段,必须分别记录和验收。
**当前状态**:数值层基础与实验候选已经实现;首批“证明门控”的三活塞 6 列半解析切片已经接入,但通用组件、stream SCC 和其余状态列仍未覆盖,因此 OPT-03 总体继续标记为“部分实现”。默认执行路径继续使用 SciPy `jac_sparsity`,半解析路径只允许通过 `SIMULATION_ODE_JACOBIAN_MODE=semi-analytic` 显式启用。
现有实现包括:
- direct 和 stepwise BDF/Radau 均可接收 callable `jac`;信号断点、状态事件和可恢复重启会清空 Jacobian 数值状态并重新构建,显式积分器完全忽略该对象。
- 新增独立的 sparse numerical Jacobian 内核,隔离并检查 SciPy 私有 `num_jac/group_columns` 接口。
- 每个 solver segment 记录完整构建、有限差分扰动、基准 RHS、Jv 审计、secant 复用/失败和装配时间;SciPy 模式的估计值不再伪装成 callable 模式的真实计数。
- 4 条无离散端挡模式歧义的机械运动学行直接装配为 `d(x')/d(v)=1`;带端挡的行继续数值差分。
- callable 内核新增 `exact_columns=(indices, provider)`:已提供精确导数的列从分组有限差分中移除,其余列仍按原始保守结构做 subset FD;原始色数、剩余色数、单次真实 FD、精确列构建及回退次数/原因都进入分段诊断。
- 精确列提供器用类型化 `ExactColumnsUnavailable` 表达当前点不可用;同一次构建会恢复原始 seed 0 的完整数值 Jacobian,避免把未知导数静默当成 0。模型编译证明失败、SciPy 私有接口不兼容或配置关闭时则直接保留原生 SciPy 路径。
- 首批目标是三条同构活塞支路的 6 个机械状态列 `(20, 21, 38, 39, 54, 55)`。编译器只有在组件类型、连接拓扑、因果赋值计划、机械等价组和 stream 影响范围都满足证明条件时才启用;该 XML 中共覆盖 34 条 reachable assignments,FD 颜色由 31 降至 25,另由提供器装配 6 列。
- 已增加 Ideal/PR 介质 `m/U/V` 物性线性化,以及 PNRP、PNCH012、PNL0001、LSTP、MECMAS 的局部切向原语;每个原语都返回 `valid/reason`,以便在非光滑接触、临界流动或不支持的模式上拒绝解析近似。
- Jacobian 内部每次 RHS 都执行取消检查;不安全的共享模型基准缓存已经撤销。随后实现的一次性 generation/dirty token 安全版本在正式 `0.81 s` 中 `253` 次 Jacobian 请求命中 `0` 次:BDF 首次构建前会做初始步长试算,后续构建前也会留下 Newton 试探状态,模型并不位于请求的基准点。该版本没有节省 RHS,最终也已删除。
- `SIMULATION_ODE_JACOBIAN_MODE=scipy` 是默认和回滚路径;小型全稠密结构或 SciPy 私有接口不兼容时也回到该路径。
显式 `SIMULATION_ODE_JACOBIAN_MODE=optimized` 仍构造完整稀疏有限差分 Jacobian,不使用 secant。最终实现严格固定 SciPy seed 0,并从原始 `1284 nnz` 保守结构生成 31 色扰动批次;移除精确行不会重新着色。曾试验的 seed 54 为 30 色,结构虽未删边,却改变了事件敏感模型的运行轨迹,因此多 seed 自动择优已经从代码中删除。
历史 30 色候选有性能收益,但没有通过事件/状态等价验收:
- 最终安全版本的 `0.81 s` 单次相邻 A/B 中,optimized 积分 `55.034 s`、总墙钟 `56.957 s`,SciPy 基线积分 `59.924 s`、总墙钟 `61.953 s`,分别约改善 `8.2% / 8.1%`。
- `0.81 s` 中 callable 实际 Jacobian RHS(扰动加基准)为 `7,103`,SciPy 估计为 `8,096`,约减少 `12.3%`;`nfev/njev/nlu` 为 `3467/228/670`,基线为 `3763/253/761`。
- 两条 `0.81 s` 轨迹具有相同结果键、采样时刻、0 次状态切换和约 `1e-16` 的最大代数残差,但最终 74 维状态的最大差异为 `51.59 × (atol + rtol·|y|)`,最差状态相对差约 `5.2e-5`,超过当前拟定的严格等价门槛。
- `2.10 s` optimized 仍成功越过 2.05 s,积分 `115.928 s`,而 SciPy 基线为 `122.180 s`;但 optimized 出现 `4` 次状态切换、`7` 次 solver 启动和 `215` 个样本,基线为 `2 / 5 / 213`。因此该候选的事件等价验收失败,不能设为默认。
- 短变体隔离显示:seed 0 callable(有或没有 4 条精确行)在 `0.01 s` 的最终 74 维状态与 SciPy 逐项一致;轨迹分叉来自 30 色 seed 54,而不是精确运动学行。这提示事件敏感模型需要更多运行中 Jacobian 漏边/弱依赖审计,不能只依赖初始点结构测试。
最终 seed 0 安全候选的正式 `0.81 s` 探针与 SciPy 基线具有相同的物理解哈希 `c6354c97...`、`3763/253/761` 的 `nfev/njev/nlu`、`1076` 个接受步、3 次 solver 启动、0 次状态切换和 `30,502` 次压力闭合。实际 Jacobian 内部 RHS 为 `7,872 + 253 = 8,125`;安全 token 缓存命中为 0。积分时间 `60.972 s`、探针总墙钟 `62.945 s`,相邻 SciPy 基线为 `59.924/61.953 s`,没有净收益并略有退化。因此安全缓存已删除,seed 0 callable 只保留为后续解析行接入与诊断基础,不进入默认路径;无需为一个已经失败收益门槛的候选继续做 `2.10 s` 性能复测。
`SIMULATION_ODE_JACOBIAN_MODE=hybrid` 另提供实验性的数值 secant 原型:最多连续复用一次,复用前执行确定性方向 Jv 审计,失败或审计无信息量会在同一次调用中完整刷新。目标模型的早期探针中候选审计普遍失败;用 seed 0 的旧完整 Jacobian 做 `0.01 s` 探针时,39 次复用审计全部失败,额外产生 39 次 Jv RHS,实际复用仍为 0。因此它目前既不是解析 Jacobian,也没有可声明的端到端收益。
**已完成的数值层工作**:
- [x] direct/stepwise BDF、Radau callable `jac` 接线;显式方法隔离。
- [x] breakpoint、事件、可恢复重启后的强制重建与分段计数。
- [x] 完整稀疏有限差分内核、严格 seed 0 着色、4 条安全精确行。
- [x] exact-columns subset FD、类型化同次完整回退和原始/剩余色数及回退诊断。
- [x] 真实 RHS/装配计数,以及 SciPy 估计口径分离。
- [x] Jacobian 内部有界取消检查;撤销不安全缓存及命中为 0 的安全 token 缓存。
- [x] 稠密结构、兼容问题和配置关闭时保留 SciPy 路径。
- [x] 最多一次复用、Jv 审计、无信息审计拒绝和失败完整刷新测试。
- [x] 复杂 XML `0.81/2.10 s` 单次性能与事件探针。
- [x] 同一代码版本完成 3 组相邻 `0.81 s` A/B,报告中位数与范围。
- [ ] 为事件敏感模型定义并通过状态、事件时刻/顺序和模式等价契约。
- [ ] 在正式锁定环境完成独立预热后的 3 次 A/B,复核中位数与离散度。
**解析/半解析后续工作**:
- [x] 为首批 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 路径定义带有效性诊断的局部切向契约。
- [x] 对目标三活塞 6 列沿 34 条可证明因果赋值传播导数,并从 FD 分组中排除这些列。
- [ ] 将局部导数/JVP 契约扩展到其余基础与自定义组件。
- [ ] 将因果传播推广到目标切片以外的状态列和代数计划。
- [ ] 对 stream SCC 推导显式或隐式小块导数。
- [ ] 对物性函数提供解析导数、可靠自动微分或受控局部差分接口。
- [ ] 在接触、饱和、开关和临界模式附近使用分段导数与局部回退。
- [ ] 对自定义组件缺失的导数声明生成明确诊断,不得静默置零。
- [ ] 在 `0.68–0.71`、`0.79–0.81`、事件两侧和 `2.00–2.10 s` 检查点执行稠密数值漏边审计与随机方向 JVP。
**验收条件**:
- [x] 历史 external-volume 跨域结构护栏与初始点稠密数值漏边测试继续通过。
- [x] callable 接线、分段重置、取消、显式方法隔离、secant 上限和审计失败回退有自动测试。
- [ ] `0.81/2.10 s` 的连续状态、事件时刻/顺序、模式和残差满足统一契约;当前 30 色候选未通过。
- [ ] 默认候选在锁定环境的 3 次中位墙钟有净收益,小模型无显著退化。
- [x] 首批目标切向原语和 6 列通过逐列中心差分、模式分支与局部回退验证。
- [ ] 通用组件级解析/半解析导数通过随机方向 JVP、逐列抽查和局部回退验证。
**风险与回滚**:历史 external-volume 漏边说明“颜色更少”本身不是正确性证据。不同合法颜色组合也可能暴露保守结构中未声明的弱依赖,并改变非光滑接触附近的事件序列。默认保持 `scipy`;`optimized/hybrid` 仅显式实验。非光滑点的解析或 secant 近似未必可靠,事件分段重置、审计和旧路径必须长期保留。
| 历史实验指标 | SciPy 基线 | 已撤销的 30 色候选 | 验收 |
| --- | ---: | ---: | --- |
| 保守结构 / 实际 FD 颜色 | 1284 nnz / 31 | 1284 nnz / 30(seed 54) | 结构不删边 |
| 精确装配行 | 0 | 4 条运动学行 | 短变体证明不改变轨迹 |
| `0.81 s` Jacobian RHS(含基准) | 估计 8,096 | 实际 7,103 | `-12.3%` |
| `0.81 s` `nfev/njev/nlu` | 3763 / 253 / 761 | 3467 / 228 / 670 | 工作量下降 |
| `0.81 s` 积分 / 总墙钟 | 59.924 / 61.953 s | 55.034 / 56.957 s | 单次约 `-8.2% / -8.1%` |
| `0.81 s` 最大最终状态误差尺度 | 参考 | 51.59 | 未通过 |
| `2.10 s` Jacobian RHS(含基准) | 估计 15,584 | 实际 14,556 | `-6.6%` |
| `2.10 s` `nfev/njev/nlu` | 6734 / 487 / 1507 | 6606 / 468 / 1469 | 工作量小幅下降 |
| `2.10 s` 积分时间 | 122.180 s | 115.928 s | 单次约 `-5.1%` |
| `2.10 s` 状态切换 / solver 启动 / 样本 | 2 / 5 / 213 | 4 / 7 / 215 | 未通过 |
| 最终安全候选指标(`0.81 s`) | SciPy 基线 | seed 0 callable | 验收 |
| --- | ---: | ---: | --- |
| 保守结构 / FD 颜色 | 1284 nnz / 31 | 1284 nnz / 31(seed 0) | 相同扰动批次 |
| `nfev/njev/nlu` | 3763 / 253 / 761 | 3763 / 253 / 761 | 相同 |
| 接受步 / solver 启动 / 状态切换 | 1076 / 3 / 0 | 1076 / 3 / 0 | 相同 |
| 压力闭合 | 30,502 | 30,502 | 相同 |
| 物理解哈希 | `c6354c97...` | `c6354c97...` | 通过 |
| 安全基准 RHS 缓存命中 | 不适用 | 0 / 253 | 无收益,代码已删除 |
| 积分 / 探针总墙钟 | 59.924 / 61.953 s | 60.972 / 62.945 s | 略有退化,未通过收益门槛 |
#### 2026-08-17 / 工作树基于 `6bb0591d`
- 状态:未开始 → 部分实现(数值接入层完成;30 色候选未通过事件等价,seed 0 候选未通过收益门槛;解析/半解析传播未开始)
- 代码备份:`backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。该分支只备份 tracked 代码基线,不包含当时未跟踪的本文档。
- 运行环境:Python 3.12.3、NumPy 2.4.6、SciPy 1.17.1;输入 SHA-256 `2fb95e65...`;`2.10 s` 仅内存覆盖停止时间,磁盘 XML 未修改。
- 正确性结果:Jacobian 内核、core solver、Generic sparsity 和 Generic XML 共 57 项通过;压力因果、stream 块、机械接触、PNRP17、代数稀疏与方程块另 52 项通过,external-volume 漏边护栏继续通过。热流体闭合计划 13 项中 12 项通过,剩余 1 项因用户已将 fixture 移至 `tests/data/fixtures/`、旧测试仍读取 `tests/fixtures/` 而报既有 `FileNotFoundError`,与本次改动无关。30 色候选在 `2.10 s` 的事件数由 2 变为 4;最终 seed 0 候选在 `0.81 s` 恢复相同物理解哈希与求解统计。
- 性能结果:见上表。数字均为同机相邻单次结果,不是 3 次中位数;最终 seed 0 候选没有减少求解工作并略慢。
- 卡死结果:历史 30 色探针在 `2.040187 s @ 106.499 s`、`2.051323 s @ 113.996 s`、`2.065299 s @ 115.472 s` 持续推进并完成到 2.10 s;默认 SciPy 的正式探针同样越过 2.05 s 并完成,无重试、无死锁。
- 安全收口:默认保持 SciPy;移除多 seed 自动择优、不安全共享缓存和零命中的安全 token 缓存;Jacobian 内部保留取消检查;无信息 Jv 审计强制刷新;显式 solver 不观察或重置 Jacobian。
- 决策:保留严格 seed 0 的 callable/诊断/精确行基础和显式实验开关;30 色、基准缓存与 secant 均不进入默认路径。下一阶段优先建立多检查点弱依赖审计和组件级局部导数,不再以颜色数或数值缓存单独作为优化成功标准。
- 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_core_solver.py`、`tests/test_generic_jacobian_sparsity.py`、`tests/test_generic_system_xml_simulation.py`
#### 2026-08-17 / 首批三活塞半解析 6 列切片
- 状态:部分实现 → 部分实现(首批目标切片完成并通过局部导数验证;OPT-03 的通用解析/半解析覆盖尚未完成)。
- 实现范围:新增 exact-columns subset FD 接口、类型化同次完整数值回退和分段诊断;为三条目标活塞支路编译状态列 `(20, 21, 38, 39, 54, 55)`,沿 34 条可达因果赋值传播切向量,使剩余 FD 颜色从 31 降到 25。
- 局部导数:实现 Ideal/PR 介质 `m/U/V` 物性线性化,以及 PNRP、PNCH012、PNL0001、LSTP、MECMAS 的几何、压力、质量/能量、流量/力和接触模式切向原语;原语显式报告 `valid/reason`。
- 证明与回退:组件类型、连接拓扑、因果计划、机械组和静态 stream 影响范围必须全部满足编译证明。causal/stream/custom/兼容性证明不成立时不安装 callable,继续使用原生 SciPy;运行点进入非光滑接触边界、临界流动、陈旧 primal 或其他不支持模式时抛出类型化 `ExactColumnsUnavailable`,同一次构建恢复原始 seed 0 完整数值 Jacobian。任何不可证明项都不会静默填 0。
- 配置边界:默认仍为 `SIMULATION_ODE_JACOBIAN_MODE=scipy`;首批路径仅通过 `semi-analytic` 显式 opt-in,不替换生产默认值。
- 自动测试:focused 套件 86 项、adjacent 套件 164 项,共 250 项通过。热流体 closure 计划另为 12/13 项通过;唯一失败仍是旧测试读取 `tests/fixtures/`、而 fixture 已被用户移至 `tests/data/fixtures/` 导致的既有 `FileNotFoundError`,与本轮 Jacobian 改动无关。
- 局部正确性:在平滑检查点,SciPy 分组有限差分漏掉 `J[19,20] ≈ -3201.486`;半解析列相对独立中心差分的最大相对误差为 `1.897e-8`。inactive/active 接触分支、过期 primal、非因果计划和不支持拓扑均覆盖了成功或回退路径。
- 轨迹正确性:默认容差下,两条 `0.81 s` 轨迹最差点为 `t=0.65 s` 的能量状态 `state[33]`,原始相对差 `8.24e-5`,缩放误差 `82.36`;事件数和顺序一致,但尚未满足拟定的严格逐点轨迹门槛。提高精度后互差收敛:`rtol=1e-7` 时最大绝对/相对差为 `0.081965 / 1.592e-6`,`rtol=1e-8` 时为 `0.0175357 / 3.09062e-7`,分别缩小约 `4.67× / 5.15×`,且两组事件均一致。这支持“求解路径差异随容差收敛”,但不足以把候选升为默认。
- 性能口径:`0.81 s` 已在同机、同一工作树连续完成 3 组相邻 A/B;表中时间为中位数,括号给出 3 次范围。测试使用现有 `/opt/srm-trial-review/.venv`,没有独立预热且依赖版本未由项目锁文件固定,因此仍需在正式锁定环境复核,不能单独作为切换默认值的依据。`2.10 s` 为最终 one-shot primal 捕获版本的单次复跑;此前数学路径相同的预备运行墙钟为 `111.068 s`,本次为 `116.512 s`,长程时间仍需重复测量。
| 最终 `0.81 s` 三次指标 | SciPy 基线 | `semi-analytic` 候选 | 变化/说明 |
| --- | ---: | ---: | --- |
| FD 颜色 / 精确状态列 | 31 / 0 | 25 / 6 | 目标列为 20、21、38、39、54、55 |
| `nfev/njev/nlu` | 3763 / 253 / 761 | 3650 / 228 / 711 | 求解工作下降 |
| 接受步 / solver 启动 / 状态事件 / 样本 | 1076 / 3 / 0 / 82 | 1056 / 3 / 0 / 82 | 事件和输出网格一致 |
| Jacobian RHS | 8,096(估计) | 5,985(实计) | `-26.1%` |
| 精确列构建 / 类型化回退 | 不适用 | 224 / 4 | 4 次恢复完整数值构建 |
| 压力闭合 | 30,502 | 25,672 | `-15.8%` |
| 积分时间中位数(范围) | 59.725 s(59.568–60.188) | 55.631 s(55.432–56.307) | 中位数 `-6.85%` |
| 总墙钟中位数(范围) | 61.203 s(61.070–61.704) | 56.708 s(56.508–57.410) | 中位数 `-7.34%`;逐组改善 6.96%–7.47% |
| 延长至 `2.10 s` 单次指标 | SciPy 基线 | `semi-analytic` 候选 | 变化/说明 |
| --- | ---: | ---: | --- |
| 状态 | 完成,越过 2.05 s | 完成,越过 2.05 s | 最终版本越过 2.05 s 的墙钟为 111.660 s |
| `nfev/njev/nlu` | 6734 / 487 / 1507 | 6246 / 445 / 1328 | 求解工作下降 |
| 接受步 | 1857 | 1753 | `-104` |
| solver 启动 / 状态切换 / 样本 | 5 / 2 / 213 | 5 / 2 / 213 | 事件计数和输出网格一致 |
| Jacobian RHS | 15,584(估计) | 11,771(实计) | `-24.5%` |
| 类型化回退 | 不适用 | 26 | 非平滑/不支持点恢复完整数值构建 |
| 压力闭合 | 57,601 | 48,248 | `-16.2%` |
| 积分时间 | 122.180 s | 112.825 s | 单次 `-7.7%` |
| 总墙钟 | 126.211 s | 116.512 s | 单次 `-7.7%` |
- 卡死复核:最终 `semi-analytic` 候选在墙钟 `111.660 s` 越过模拟时刻 `2.05 s`,随后于 `116.512 s` 完成到 `2.10 s`;与 SciPy 基线一样未出现无进度死锁。
- 未覆盖范围:通用 stream SCC 导数、目标三支路以外的组件/状态列、自定义组件导数契约、正式锁定环境的独立预热复测,以及 `10 s` 长时模式覆盖。
- 决策:保留首批半解析切片和自动回退作为显式实验路径;OPT-03 继续为“部分实现”,默认继续使用 SciPy。完成上述通用覆盖、严格轨迹契约和重复基准前,不切换默认值。
- 代码备份:仍使用进入 Jacobian 优化前建立的 `backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。
- 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/tangent.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`app/simulation/core/medium.py`、`app/simulation/components/amesim/media/mediums.py`、`app/simulation/components/amesim/mechanical/pistons.py`、`app/simulation/components/amesim/storage/chambers.py`、`app/simulation/components/amesim/flow/pipes.py`、`app/simulation/components/amesim/mechanical/translational.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_analytic_tangent_primitives.py`、`tests/test_three_piston_tangent.py`
#### 2026-08-17 / 名字无关的受支持活塞支路编译器
- 将原三条固定实例扩展为按组件类型、端口域、连接、机械状态 owner/slot、因果 reach 与 stream 影响证明自动发现任意数量的受支持支路;通用路径不固定组件实例名、支路数或状态 offset,旧三活塞入口仅作为兼容 wrapper。
- 新主目标自动发现 8 条 MECMAS21→PNRP17→PNCH012→PNL0001/LSTP 支路,覆盖 16 个机械状态列 `104..119` 与 84 条可达赋值;理论剩余 FD 颜色由 52 降至 36。
- 平滑工作点 16 列对完整 RHS 中心差分通过;初始接触边界会类型化回退完整 52 色数值 Jacobian,不会静默使用错误列。
- 0.01 s A/B 显示该目标早期 56 次 Jacobian 中只有 16 次使用精确列、40 次因流量局部斜率/接触边界安全回退;单独半解析总墙钟为 17.522 s,慢于 SciPy 的 15.160 s。当前目标因此继续使用默认 SciPy Jacobian,半解析保持显式 opt-in,下一步应做支路分区回退或扩大光滑模式覆盖,而不是放宽守卫。
### OPT-04 stream 拓扑传播与物性成组复用
**目标**:让无环 stream 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。
**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点上限仍为 25 次:production `0.2 s` 实测最多 3 次;2026-08-17 较大 `maxStep` 的历史延长运行在 `2/5/10 s` 实测最多 18–23 次,修复前在 `t≈1.8595–1.8603 s` 会耗尽 25 次。当前已补充试探点事务回滚和类型化可恢复失败,并由 StreamResolver 为所有覆盖温度参考更新钩子的组件统一刷新连接参考;SCC/DAG 传播与物性成组复用尚未实现。
**工作项**:
- [ ] 构建 stream 图的 SCC,并将缩点图编译为拓扑顺序。
- [ ] 对单节点和无环段使用一次传播,仅在循环 SCC 内迭代。
- [ ] 使用预分配数组和原地误差统计,避免每轮临时字典/列表。
- [ ] 缓存同一求解阶段的连接焓结果,避免返回前重复计算。
- [ ] 将 `p/T/rho/h/s` 等同源物性组织为状态包,按精确输入键成组复用。
- [ ] 增加缓存命中、SCC 迭代、失效原因和物性调用次数指标。
- [ ] 评估脏标记传播,但必须证明事件和反向流切换时不会复用陈旧值。
- [x] 为热流体外层 25 次耗尽提供类型化可恢复失败和单次 RHS 事务回滚,避免失败试探点污染下一次尝试;这是鲁棒性前置,不代表 SCC/DAG 优化已经完成。
- [x] StreamResolver 按组件行为预编译所有覆盖 `update_flow_temperature_references` 的组件,并在每轮 stream 更新后统一刷新温度参考;物理岛边界同时识别 stream outflow 与温度参考钩子覆盖。
**验收条件**:
- [ ] 无环、单环、多环、反向流和事件后拓扑测试全部通过。
- [ ] 复杂模型的最大 stream/热流体迭代不增加,残差不恶化。
- [ ] 量化减少物性调用、临时分配、压力闭合或 RHS 时间。
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| stream 块 / 未知量 | 9 / 192 | 待填 |
| 最大热流体迭代 | production 0.2 s:3;2026-08-17 较大 maxStep 历史 2/5/10 s:18–23;恢复阈值:25 | 待填 |
| `2.10 s` 压力闭合 | 57,601 | 待填 |
| 物性调用 / 缓存命中率 | 待测 | 待填 |
### OPT-05 最大积分步长路径鲁棒性、状态缩放和步长策略
**目标**:首先保证在合理 `maxStep` 工程区间内,步长上限只影响可解释的误差和成本,而不决定仿真能否完成;随后再减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。
**当前状态**:进行中,但 0.2/1/2 s 的可解性主阻断已经解除。原浏览器 `t≈0.0489 s` 问题已分解为“前端 accepted-progress 误杀”和“8 个高刚度接触引发的大量纳秒级 BDF 微步”;activity-aware watchdog 已修复前者,保持数值语义的因果/PNL 热路径优化降低了后者的工作量。`0.2/1/2 s × 五档 maxStep` 共 15 个单元全部完成,0 次恢复重试,且 `0.2→1→2 s` 同 `maxStep` 的严格公共前缀逐位一致;没有出现更小 `maxStep` 独有的不可恢复失败。任务仍未完成,因为 runner 尚未自动分层积分状态、派生量和物理投影,2 s 接触后的近零 `v/a` 与 `1.85–1.90 s` 流量换向仍有跨步长敏感性,5/10 s 也尚未完成。
**工作项**:
- [ ] 按状态物理量、标称值和工程容差建立分量 `atol`/缩放规则。
- [ ] 为未提供标称值的组件定义安全默认值并输出诊断。
- [ ] 分开积分误差、代数残差、stream 固定点和事件定位容差。
- [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。
- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。
- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。
- [x] 用完全相同的 JSON 生成请求,对浏览器流式路径、真实 API 与 production worker 做同参 A/B,逐层核对 requested/effective `tStop/sampleStep/maxStep/method`。
- [x] 围绕 `t=0.04–0.05 s` 记录接受步、Jacobian/RHS/闭合工作量并定位首次性能悬崖到 8 个 `LSTP00A` 高刚度接触的微步簇。
- [ ] 补齐逐 `0.01 s` 的 BDF order、全程 `h_abs`、拒步与 SciPy 内部有限差分 Jacobian 阶段时间线;现有实时 `jacobianEvaluationCount` 只覆盖显式 callable Jacobian。
- [x] 在改变算法前提交定位报告与候选方案供审阅,明确证据、影响面、正确性风险、预期计数变化和回退方式;获准后逐概念 A/B,拒绝了改变轨迹或收益门未过的候选。
- [x] 审计失败试探的事务恢复、缓存、端口和离散模式;故障注入证明失败后可重放,失败诊断保留且不会污染下一条缩步路径。
- [ ] 只在证据支持时对事件、接触、流向或闭合边界使用局部步长上限、有界缩步或模式感知策略;禁止靠全程硬编码某个“幸运” `maxStep` 收口。
- [x] 完成第 3.4 节的 `0.2/1 s × 五档` 短时矩阵和 `2 s × 五档` 延长矩阵;15 个单元全部完成,严格公共前缀验证到 2 s。
- [ ] 恢复时继续生成可签收的 5 s 报告和权威 10 s 报告,并将分层矩阵设为每次步长/缩放改动的 P0 回归。
- [x] 对可恢复的热流体闭合失败使用积分器实际试探步 `h_abs` 对半回退;最多 16 次且不低于 64 ULP,恢复步仅设置 `first_step`,首次接受后恢复分段 `maxStep` 上限并记录 attempted/next step。
- [x] 为 eventless Generic 显式启用 `recoverable_trial_retries`,使没有状态事件、断点或取消回调的通用模型也能选择 stepwise 恢复;该参数默认关闭,避免改变其他调用者的直接 `solve_ivp` 语义。
**验收条件**:
- [ ] 每个配置都有状态、事件、残差和守恒误差界限。
- [ ] 标准配置在复杂模型上减少拒步或分解工作,不引入模式遗漏。
- [ ] 所有收益报告同时给出误差变化,禁止只报告墙钟。
- [ ] 满足第 3.4 节 P0 门:短时二维矩阵满足时域延长与步长细化不变量,事件/模式/守恒满足分类契约,任何性能悬崖都有可复现的阶段与组件归因。
#### 2026-08-19 / 权威五档短时与 2 s 矩阵
所有单元均为 production lane、BDF、`sampleStep=0.01 s`,并只在内存覆盖 `tStop/maxStep`:
| `tStop` | `maxStep=.001` | `.002` | `.005` | `.01` | `.02` |
| ---: | ---: | ---: | ---: | ---: | ---: |
| 0.2 s | 145.942 s | 166.070 s | 156.667 s | 156.431 s | 157.374 s |
| 1 s | 198.351 s | 214.308 s | 210.164 s | 214.616 s | 209.664 s |
| 2 s | 348.040 s | 306.140 s | 333.424 s | 341.867 s | 335.172 s |
- 完成性:15/15 单元到达终点,单元 `matrixAcceptance.passed=true`;无 soft/hard timeout、NaN/Inf、热流体失败或恢复重试。最大缩放残差为 `9.56e-17–1.09e-16`。
- 时域不变量:五个 `maxStep` 的 `0.2→1 s` 与 `1→2 s` 严格公共前缀逐位一致;短任务终点不参与严格前缀比较。2 s 的两次机械事件顺序一致,时刻最大跨度 `9.57425e-6 s`,小于 `2e-5 s` 门限。
- 分层结果:0.2 s 的 9/10、1 s 的 10/10、2 s 的 10/10 跨步长 pair 在旧顶层比较器中为红,但没有单元失败。0.2/1 s 红项全部是派生 `a`;2 s 为 1111 个 `a` 与 720 个事件后近零 `v`,`x` 及其余状态无超差。physical-state-v2.1 的压力、守恒和离散模式通过;流量差异集中在 `t=0.04 s` 左右极限和 2 s 的 `1.85–1.90 s` 换向区。
- 性能结论:耗时随 `maxStep` 非单调,0.2/1 s 单次最快为 `.001`,2 s 单次最快为 `.002`;不能据单次结果选择“幸运步长”或修改正式默认值。
- 检查点边界:早期 0.2 s 报告中的请求 `.048/.0489 s` 实际映射到输出网格 `.05 s`,不得作为精确慢区检查点;runner 现已拒绝 off-grid 检查点,慢区使用 activity/step trace 取证。
- 证据:`runs/2026-08-18-production-0.2s-max-step-robust-v1.json`(SHA-256 `ec5480af...`)、`runs/2026-08-18-production-1s-max-step-robust-v1.json`(`1f1c639b...`)和 `runs/2026-08-18-production-2s-max-step-robust-v1.json`(`93367d0f...`)。
### OPT-06 事件检测与 dense output 按需化
**目标**:避免在绝大多数没有事件候选、也不跨输出采样点的接受步上创建 dense output。
**当前状态**:已有事件候选筛选和部分非事件优化,但只要存在状态转换处理器,接受步仍可能构造 dense output。`2.10 s` 有 1857 个接受步而只有 2 次状态切换,存在减少插值构造的空间。
**工作项**:
- [ ] 在构造 dense output 前执行低成本端点符号/模式候选检查。
- [ ] 仅在跨输出采样点或存在事件候选时创建插值器。
- [ ] 将输出插值与事件定位的生命周期和精度需求分离。
- [ ] 统计候选数、误报数、定位次数、dense output 构造数和耗时。
**验收条件**:
- [ ] 同时事件、擦边事件、抖动防护和多模式顺序测试通过。
- [ ] 事件时刻误差不超契约,事件顺序和最终模式不变。
- [ ] 完整模型 dense output 构造数与耗时明显下降。
### OPT-07 输出、后处理和传输内存优化
**目标**:在长仿真中控制结果生成、JSON 编码、前端复制和峰值内存。
**当前状态**:历史复杂 XML 有 1,021 个结果变量;当前主目标有 1,784 个结果变量,加时间轴共 1,785 条序列。`10 s / 0.01 s` 的 1001 个采样点预计产生 1,786,785 个标量。现路径会对每个样本重新闭合、追加全部结果,并把完整结果作为一个 NDJSON 消息发送。它不是本次接触慢区的主因,但会成为长时间运行的显著成本。
**工作项**:
- [ ] 支持结果变量白名单、分组和按需派生量。
- [ ] 将积分内部采样、结果存储采样和显示采样分离。
- [ ] 对显示路径提供服务端降采样,同时保留可选完整数据模式。
- [ ] 分块编码和传输结果,或返回 `resultId` 后分页/流式获取。
- [ ] 评估前端 TypedArray/列式数据,减少嵌套对象和重复复制。
- [ ] 避免后处理中对每个样本重复执行不必要的完整闭合。
- [ ] 记录原始标量数、编码/传输字节数、后处理时间和峰值 RSS。
**验收条件**:
- [ ] 完整输出模式保持现有 API 契约,或通过显式版本升级迁移。
- [ ] 精简模式的变量选择和降采样行为可预测、可测试。
- [ ] `10 s` 基准中后处理时间、传输字节和峰值 RSS 有量化改善。
### OPT-08 进度、取消和服务并发鲁棒性
**目标**:区分“内部慢步”和“真正无进度”,并让长任务可取消、可限流、不会拖垮服务进程。
**当前状态**:部分实现,浏览器 P0 假超时已闭环。后端现在分别上报 accepted progress 与 RHS/solver step/热流体闭合等内部活动,5 s heartbeat 携带 activity 快照;前端在 `integrating` 阶段有活动遥测时,仅在 accepted 和 activity 同时连续 60 s 不变后请求停止,活动继续增长时保持运行;缺少活动遥测的旧后端使用 15 分钟保守兜底,30 s 完全无字节的断流门不变。真实浏览器 `0.2 s / 0.001 s` 已完整到达终点,原 `0.0489 s` 慢区内 activity 持续增长且未触发取消。剩余边界是:线程内 cooperative cancel 不能硬杀永不返回的 native/Python 调用,客户端断流不能重连到原任务,尚无并发 worker/队列/资源租约的完整门控,SciPy 内部有限差分 Jacobian 也不能由当前实时字段精确分类。
**工作项**:
- [x] 分别上报模拟时间、接受步、内部 RHS/solver step/闭合活动和墙钟心跳。
- [x] 将“运行中但步很慢”与“求解器无活动”使用不同状态和超时策略;缺少新 telemetry 的旧后端也不会被前端自动误杀。
- [ ] 在代数闭合、stream 迭代、Jacobian 构建和后处理内加入有界取消检查。
- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。
- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。
- [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。
- [x] 将活动心跳扩展到 RHS、solver step、显式 Jacobian、stream/热流体闭合和恢复循环,并携带 activity sequence/kind、current trial time、RHS/accepted/solver/Jacobian/closure 计数。
- [ ] 补充实时 `h_abs`、BDF order、SciPy 内部有限差分 Jacobian 阶段和任务级 CPU delta;当前 `jacobianEvaluationCount` 不能代表 SciPy 内部 `njev`。
- [ ] 验证客户端流断开、浏览器本地 watchdog 和显式取消的语义不同;客户端误判或断连不得在无用户授权时静默丢失仍健康运行的 worker 结果。
- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。
- [x] 矩阵报告分别记录外层 `soft_timeout` 和 worker 的合作 `cancelled`,避免把预算取消误记为求解器数值失败。
- [x] 单格 max-step 矩阵将空的跨步长比较集合视为“不适用”而非失败;最终 `2 s / 0.02 s` 单格复验整体通过且 `comparisonFailureCount=0`。
**验收条件**:
- [x] 正常活跃慢步不会被误判为死锁;真实浏览器在原慢区持续收到活动 heartbeat 并正常完成。
- [ ] 真实无活动或单次调用永不返回时,能在约定时间内硬终止并给出诊断。
- [ ] 取消请求在每个主要阶段都能在有界时间内生效。
- [ ] 并发压力下服务仍能响应健康检查和新请求拒绝/排队逻辑。
- [x] 权威 `0.2 s / 0.001 s` 浏览器路径完成且不发生假超时;有活动遥测时仅 accepted 与 activity 同时连续 60 s 不变才判停,内部活动持续时保持运行;缺少活动遥测时采用 15 分钟兼容兜底。
### OPT-09 建立 10 s 长时验证与模式覆盖
**目标**:用权威 JSON 的 `BDF / tStop=10 s / sampleStep=0.01 s / maxStep=0.001 s` 完整实测,替代“短仿真或较大 `maxStep` 可以外推到最终工程场景”的假设。
**当前状态**:进行中,当前代码的权威 `10 s / 0.001 s` 长基线尚未运行。权威五档已经全部完成到 `2 s`,同 `maxStep` 的 `0.2→1→2 s` 严格公共前缀逐位一致;这支持“延长 tStop 不改变已覆盖轨迹”。2026-08-19 的 5 s 三档尝试约于 `15:14:14 UTC` 启动:`.001` 子进程约 `15:24:09` 结束并进入 `.005`,约 `9 分 55 秒`;`.005` 运行约 60 秒后按用户要求中止,`.02` 未启动。由于 runner 只在整组完成后落盘,本次没有 5 s 聚合报告,不能把 `.001` 写成正式通过。既有 `10 s / maxStep=0.02 s` 只作为历史算法可行性与恢复证据,不能签收当前 JSON 的工程基线。
以下 2026-08-17 的结果均为历史恢复与接线证据,不代表当前 `.001 s` 权威长时验收。修复前,`tStop=2 s` 与 `tStop=5 s` 在同一 `maxStep=0.05 s` 下具有相同的首次失败时刻和求解统计,均在 `t=1.859512845 s` 耗尽热流体外层 25 次;四档 `maxStep` 的失败时刻集中在 `1.8595–1.8603 s`。这说明远端 `tStop` 不是直接失败原因,它只决定运行是否到达该局部数值困难区。
PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复完成后,production `2 s` 的 `maxStep=0.01/0.02/0.05/0.10 s` 四个单元均到达 `2.0 s`,`caseFailureCount=0`。矩阵命令整体退出码仍为 1,原因是跨 `maxStep` 的严格状态一致性门未通过,而不是任何单元运行失败:差异集中在事件后的 8 个 MECMAS21 速度和 8 个加速度;在差异最大的一组跨 `maxStep` 终点比较中,绝对差约 `1.01e-6–1.12e-6`。`0.05/0.10 s` 两档则逐位一致。因此当时结论是“2 s 运行失败已解决”,但“跨步长数值等价”尚未签收,不能据此批准长时 golden。
`5 s / maxStep=0.02 s` 已完成,worker 墙钟 `696.418 s`,0 次可恢复重试,最大热流体迭代 19,`nfev/njev/nlu=18736/1347/4988`。`maxStep=0.05 s` 在 1200 s soft budget 后由 runner 合作取消,停止于 `t=4.2523535 s`,此前仅发生 1 次已成功恢复的试探步;它是有界预算结果,不是 solver failure,也不能与已完成的 `0.02 s` 单元做终点一致性签收。形成该阶段记录时,`10 s / maxStep=0.02 s` 尚在运行;完成结果及其后追加的通用接线复验见下方收口记录。
| 历史 `tStop` | 历史 `maxStep` | lane / 结果 | worker 墙钟或预算 | 可恢复重试 | 说明 |
| ---: | ---: | --- | ---: | ---: | --- |
| 1 s | 0.05 s | solver-only / 完成 | 182.111 s | —(旧版未记录) | 首次延长门通过 |
| 2 s | 0.01 s | production / 完成 | 324.727 s | 8 | 最大热流体迭代 19 |
| 2 s | 0.02 s | production / 完成 | 292.035 s | 0 | 首次 recovery 矩阵当时最快;最大热流体迭代 19 |
| 2 s | 0.05 s | production / 完成 | 450.425 s | 1 | 最大热流体迭代 18 |
| 2 s | 0.10 s | production / 完成 | 448.033 s | 1 | 与 0.05 s 路径逐位一致,上限未实际约束 |
| 2 s | 0.02 s | production / 最终通用接线复验完成 | 301.782 s | 0 | 2 次事件;单格矩阵整体通过 |
| 5 s | 0.02 s | production / 完成 | 696.418 s | 0 | 最大热流体迭代 19;`18736/1347/4988` |
| 5 s | 0.05 s | production / soft budget 合作取消 | 1200 s | 1 | 停止于 4.2523535 s;不是 solver failure |
| 10 s | 0.02 s | production / 历史:最终通用接线前单元完成 | 803.622 s | 0 | 接线前历史证据,不作为最终性能口径 |
| 10 s | 0.02 s | production / 最终通用接线后完成 | 1602.733 s | 1 | orchestration 1604.152 s;`45455/3075/15282`;接受步 9569;启动 6;事件 2 |
#### 2026-08-17 / PNL00R 正确性、热流体事务与实际步长恢复
- PNL00R 的端口温度参考改为同侧连接对端的温度参考焓:连接到 node 时使用对端组件的 `temperature_reference_h`,普通组件则使用常规 `connected_h`(即连接端口的 `h_outflow`);零容积元件自身的 `h_outflow` 仍保持对侧传播语义。42 项 PNL00R/stream 相关测试通过。
- 单次 RHS 事务会回滚物理端口、flow、物性缓存、因果绑定及相关诊断,防止失败试探点污染下一次尝试。只有热流体外层 25 次耗尽被分类为可恢复错误;`StreamSolveError` 和 secondary `AlgebraicSolveError` 仍保持致命错误语义。
- 事务开销的 7×100 RHS 微基准为关闭 `0.813488 s`、开启 `0.829156 s`,增加 `1.926%`,导数逐位一致。
- 聚焦组合回归共 163 项通过、1 项跳过。修复后 production `0.2 s` worker 墙钟 `128.296 s`,402 个 golden 值通过,最大绝对差 `0.0171461`、最大容差比 `0.151304`,output contract 不变。
- 证据:`runs/2026-08-17-production-thermofluid-recovery-v1-0.2.json`、`runs/2026-08-17-production-2s-max-step-matrix-v1.json`、`runs/2026-08-17-production-2s-max-step-matrix-recovery-v2.json`、`runs/2026-08-17-production-5s-max-step-matrix-recovery-v1.json`。
#### 2026-08-17 / 最终通用接线后的 `10 s` repeat 与收口
- 最终通用接线后的 `runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json` 完成到 `10.0 s`:worker 墙钟 `1602.733 s`、orchestration 墙钟 `1604.152 s`,`nfev/njev/nlu=45455/3075/15282`,接受步 9569,solver 启动 6 次,2 次状态事件。运行在 `t=6.9640458 s` 发生 1 次热流体可恢复失败并以 1 次重试继续完成,最大热流体迭代 23,最大缩放残差 `1.082e-16`;1717 条序列、1,722,151 个标量全部有限。
- `runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json` 的 worker `803.622 s` 结果明确属于上述两项最终通用接线之前的历史运行,只保留为阶段性正确性和故障定位证据,不作为最终版本的性能数据。
- 该接线前历史 10 s 报告的运行单元和 case acceptance 均通过,但旧版单格矩阵因 `sameHorizonAcrossMaxSteps=[]` 被空比较器误判,导致报告顶层 `passed=false` 和旧退出码 1;这不是仿真或数值验收失败。空比较器缺陷已经修复,最终接线后的 10 s repeat 与 `2 s / maxStep=0.02 s` 单格报告均整体 `passed=true`;后者另明确记录 `caseFailureCount=0`、`comparisonFailureCount=0`。
- 旧 10 s 报告生成时曾根据目标的状态事件与拓扑边界推断两项最终接线不会改变已覆盖边界;该推断作为历史说明保留,现在已由最终接线后的完整 10 s repeat 直接取代。
- 最终接线前后 `0.01 s` 输出逐值一致。两次 production `0.2 s` final candidate 运行也彼此逐值相同并均完成到终点,但两次对旧批准 golden 都只有 `398/402` 个值通过:同样的 4 个 `t=0.2 s` 派生 MECMAS21 加速度超出旧容差,最大容差比均为 `1.373`。因此不覆盖或重新批准旧 golden;应先独立确认派生加速度语义或调整投影契约。
- 最终 `2 s / maxStep=0.02 s` 复验 worker 墙钟 `301.782 s`,0 次热流体失败/可恢复重试,2 次状态事件,单格矩阵整体通过。真实 SciPy RK45/BDF 的 direct 与 opt-in stepwise A/B 在无失败时采样、状态及 `nfev/njev/nlu` 一致。完整 `unittest discover` 共 828 项,OK(3 项跳过)。
- 证据:`runs/2026-08-17-production-general-recovery-v2-smoke.json`、`runs/2026-08-17-production-general-recovery-v2-0.2.json`、`runs/2026-08-17-production-general-recovery-v2-repeat-0.2.json`、`runs/2026-08-17-production-2s-max-step-0p02-general-recovery-v3.json`、`runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json`、`runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json`。
**工作项**:
- [x] 在 OPT-00 的 `0.2/1 s` 短时门通过后,使用同一 JSON 和五档 `maxStep` 延长到 `2 s`;5 个单元全部完成且 0 次恢复重试。
- [ ] 按用户要求暂停后,恢复时从头生成完整 5 s 报告,再运行权威 `10 s / 0.001 s` 当前优化版本基线。
- [ ] 首次长基线不得因总墙钟较长而提前当作性能失败;只有 worker、CPU 和内部活动心跳均停止并满足真停滞条件时才有界终止。若发现致命正确性问题,只做使基线可完成的最小修复,然后从 `t=0` 重新运行。
- [ ] 在正式锁定环境运行当前优化版本基线 `10 s`,设置心跳、资源上限和可恢复日志;用户已明确授权在首次 10 s 前先解决 70 s 慢区。
- [ ] 保存事件、模式、步长、拒步、Jacobian、闭合和内存随模拟时间的时间线。
- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。
- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。
- [ ] 根据首次基线制定合理的 CI 频率和资源门槛。
- [x] 2026-08-17 最终通用接线版本完成一次历史 `10 s / maxStep=0.02 s` 运行并保存完整统计;它不计入当前权威基线。
- [ ] 首次 `10 s / maxStep=0.001 s` 完整报告生成后,才根据各阶段墙钟与内部计数决定性能优化目标;旧 `0.02 s` 报告不得用于跳过该顺序。
**验收条件**:
- [ ] 权威 `10 s / 0.001 s` 首次基线到达 `t=10`,输出 `0..10 s` 共 1001 个采样时刻且全部有限;事件、模式、关键状态、压力/流量和守恒量满足契约。
- [ ] 连续 3 次完成 `10 s`,没有无解释回退、NaN/Inf 或资源失控。
- [ ] 全程模式、事件、关键状态和守恒量满足契约。
- [ ] 可从日志快速判断任何慢区属于积分、Jacobian、闭合、事件还是输出。
- [ ] 同一 `maxStep=0.001 s` 下,`0.2/1/2/5/10 s` 的严格公共前缀按分类契约一致;短任务终点单独标记 terminal,不与长任务内部插值作位级误判。
- [ ] 最终 `10 s` 至少完成 `maxStep=0.001/0.005/0.02 s` 三个代表档,并逐步补齐 `0.002/0.01 s`;较小步长不得出现较大步长没有的可复现数值失败或更早真停滞。
### OPT-10 明确高指数 DAE 和强非光滑系统边界
**目标**:明确当前通用求解能力的工程边界,并决定是否值得引入真正的 DAE/互补问题求解器。
**当前状态**:当前架构更适合结构明确、可唯一闭合、状态较连续的规则 index-1 类系统。超硬非光滑接触、临界抖动、近奇异代数系统、更高指数 DAE 和依赖声明不完整的自定义组件仍是薄弱点。
**工作项**:
- [ ] 建立小型基准族:刚性接触、反复开闭、近奇异闭合、尺度跨越、自定义漏依赖和 index-2/3 示例。
- [ ] 对每类系统定义“支持”“降级支持”“明确拒绝”,并给出诊断。
- [ ] 评估质量矩阵 DAE、指数约简、互补/半光滑方法与现有架构的成本。
- [ ] 只有真实模型需求和基准证明必要时,才启动通用 DAE 后端项目。
**验收条件**:
- [ ] 文档与运行时错误能明确说明能力边界,不出现静默错误。
- [ ] 若启动新后端,有独立设计、基准和迁移计划,不与普通 RHS 性能优化混合。
## 6. 统一回归矩阵
| 场景 | 结构 | 数值状态 | 事件/模式 | 回退 | 性能 | 长时内存 |
| --- | --- | --- | --- | --- | --- | --- |
| 小型线性组件 | 必测 | 必测 | 不适用 | 必测 | 冒烟 | 不适用 |
| 非线性压力/流量 | 必测 | 必测 | 可选 | 必测 | 必测 | 可选 |
| stream 无环/成环/反向流 | 必测 | 必测 | 必测 | 必测 | 必测 | 可选 |
| 接触与模式切换 | 必测 | 必测 | 必测 | 必测 | 必测 | 可选 |
| 自定义组件与漏依赖 | 必测 | 必测 | 可选 | 必测 | 可选 | 不适用 |
| 本文复杂 XML `0.81 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `0.2 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `1/2/5/10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 权威 JSON 浏览器/流式 API `0.2 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(含内部活动心跳) | 可选 |
| 权威 worker `0.2/1/2 s × maxStep={0.001,0.002,0.005,0.01,0.02} s` | 必测 | 必测(分类容差) | 必测 | 必测 | 必测(串行矩阵) | 可选 |
| 权威 JSON `10 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(完整时间线) | 必测 |
2026-08-17 的 `maxStep=0.01/0.02/0.05/0.10 s` 延长结果继续作为恢复机制与历史路径证据,但不替代
当前权威 `maxStep=0.001 s` 的 browser/API/worker 0.2 s 门已经完成,但历史结果仍不能替代尚未运行的 10 s 最终基线。
当前相关回归套件包括:
- `tests/test_sparse_secant_jacobian.py`
- `tests/test_generic_jacobian_sparsity.py`
- `tests/test_pressure_flow_causal_execution.py`
- `tests/test_stream_pressure_block_solver.py`
- `tests/test_core_solver.py`
- `tests/test_causal_numeric_ir.py`
- `tests/test_thermofluid_recovery.py`
- `tests/test_amesim_pnl00r_component.py`
- `tests/test_stream_resolver_execution_plan.py`
- `tests/test_thermofluid_closure_plan.py`
- `tests/test_max_step_matrix.py`
这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。2026-08-19 当前工作树完整
`unittest discover` 共 896 项,OK(3 项跳过);前端 activity watchdog 聚焦测试 8/8、真实 live 浏览器 E2E 1/1 通过。
## 7. 单项更新模板
完成一个原型或 PR 后,在对应任务下追加以下记录:
```markdown
#### YYYY-MM-DD / <commit-or-branch>
- 状态:未开始 → 进行中 / 部分实现 → 已完成
- 实现范围:
- 未覆盖范围:
- 运行环境:
- 输入与配置:
- 正确性结果:
- 性能结果(中位数与离散度):
- 回退/审计结果:
- 风险或已知退化:
- 决策:合入默认路径 / 继续实验 / 回滚 / 不采用
- 证据文件或 CI 链接:
```
## 8. 总体更新记录
| 日期 | 代码/分支 | 任务 | 变化 | 正确性 | 性能 | 决策 |
| --- | --- | --- | --- | --- | --- | --- |
| 2026-08-17 | `6bb0591d` | 基线 | 原始 `0.81 s` 完成;内存延长 `2.10 s` 完成并越过 2.05 s | 无卡死;当前环境哈希与历史不同,待正式环境复核 | 63.779 s / 126.211 s(单次) | 建立任务清单,先完成 OPT-00 |
| 2026-08-17 | 工作树基于 `6bb0591d`;备份 `backup/jacobian-before-20260817-6bb0591` | OPT-03 | callable sparse Jacobian、真实计数、分段重置、取消、严格 seed 0 与实验 secant | 121 项相关测试通过;另 1 项既有 fixture 路径错误;30 色候选事件不等价,seed 0 候选恢复相同哈希 | 30 色历史候选有收益但不正确;seed 0 候选略慢且缓存 0 命中 | 默认 SciPy;移除多 seed/缓存;保留接入基础;解析/半解析继续后续 |
| 2026-08-17 | 工作树基于 `6bb0591d`;同一备份分支 | OPT-03 首批半解析切片 | exact-columns subset FD、类型化回退/诊断、三活塞 6 列与 34 条因果赋值;31→25 个 FD 颜色;新增 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 切向原语 | focused 86 + adjacent 164 = 250 项通过;closure 12/13,唯一失败为既有 fixture 路径;局部列对中心 FD 最大相对误差 `1.897e-8`;默认容差轨迹仍超严格逐点门槛,但随 rtol 收紧约 4.67×/5.15× 收敛且事件一致 | `0.81 s` 三次墙钟中位数 61.203→56.708 s,Jac RHS 8096(估计)→5985(实计);最终 `2.10 s` 单次 126.211→116.512 s,正常越过 2.05 s,事件/启动/样本均与基线一致 | 首批目标切片完成,OPT-03 总体仍部分实现;默认 SciPy,`semi-analytic` 显式 opt-in;待通用 stream/其余列、正式锁定环境独立预热和 10 s 验证 |
| 2026-08-17 | 同一 OPT-03 工作树;3 组相邻 A/B | OPT-03 重复性能复核 | 原始 `0.81 s`,每组先 SciPy 后 `semi-analytic`,运行期间无并发仿真负载 | 三组求解统计、哈希、事件和输出网格各自完全稳定;Jacobian RHS 8096(估计)→5985(实计) | 总墙钟中位数 61.203→56.708 s(`-7.34%`),积分中位数 59.725→55.631 s(`-6.85%`) | 保持显式 opt-in;仍需正式锁定环境独立预热、严格轨迹契约和 10 s 验证 |
| 2026-08-17 | 工作树基于 `16a7eb2d`;备份 `backup/general-solver-v1-before-20260817-16a7eb2` | OPT-00/01/03/09 通用求解器 v1(历史输入) | 初版 `test-mql-8` runner/正确性门;默认低分配因果执行器 v2;名字无关的 8 支路/16 列半解析编译器 | 旧 SHA `42e2d627...` 下 0.01 s v1/v2 物理解逐位相同;0.2 s 全有限且 0 审计/回退失败 | v2 RHS 微基准 `-19.1%`;0.01 s 总墙钟 `-13.1%`;旧 0.2 s 132.305 s | v2 升为默认并保留 opt-out;旧报告标为 `historicalOnly`,不得生成新 golden |
| 2026-08-17 | 同一工作树;新权威 SHA `170463d6...` | OPT-00 P0 基础闭环 | 固化无空格 XML/JSON、参考依赖约束、runner v2、state golden、output contract、三层 CI 和有界延期决策 | production 0.2 s 全有限;402 个 golden 值逐项重放误差 0;信号分段/机械事件/残差/审计/回退门均通过;全量共 792 项,OK(3 项跳过) | worker 135.824 s;1 s 保守预测 1018.680 s,未启动 1/5/10 s | P0 基础设施完成,完整 OPT-00/09 仍部分实现;先优化算法,再恢复长时递进 |
| 2026-08-17 | 同一工作树 | OPT-01/02 因果坐标与参考 IR | `760` 个兼容槽压缩为 `440` 个逻辑坐标;独立 schema v1 参考 IR 覆盖 `112+328` 坐标和 320 个逻辑别名 | kernel on/off、兼容槽、状态导数、结构签名和逐阶段差分通过;审计/验证/回退失败均为 0 | Python 调用 `-36.8%`,RHS 微基准 `-12.9%`,production 0.2 s 单次 `-3.68%` | OPT-01 基本完成;IR 暂不接管默认热路径 |
| 2026-08-17 | 同一工作树 | OPT-00/04/05/08/09 热流体恢复与延长矩阵 | 修正 PNL00R 温度 stream 参考;加入 RHS 事务、类型化闭合失败、基于 `h_abs` 的对半重试和完整诊断 | production 0.2 s golden 通过;2 s 四档 `maxStep` 均完成且 `caseFailureCount=0`,但跨步长严格门因近零机械 `a/v` 差异未过;5 s 的 0.02 s 档完成,0.05 s 档为预算取消而非 solver failure | 2 s worker 墙钟为 324.727/292.035/450.425/448.033 s;5 s 的 0.02 s 档为 696.418 s、0 retry、`18736/1347/4988`,0.05 s 档在 1200 s 预算停止于 4.2523535 s | 原 1.86 s 致命失败已恢复;暂以 0.02 s 作为延长测试首选但不修改正式默认值或批准 golden;10 s 的 0.02 s 档进行中 |
| 2026-08-17 | 同一工作树;最终通用接线与 10 s repeat | OPT-04/05/08/09 `10 s` 最终收口 | eventless Generic opt-in stepwise recovery;StreamResolver 刷新全部温度参考 override;修复单格矩阵空比较器 | 0.01 s 接线前后逐值一致;两次 0.2 s final candidate 彼此逐值相同且均为旧 golden 398/402,同样 4 个终点派生 MECMAS21 `a` 超差、最大容差比 1.373,未覆盖 golden;最终 2 s 单格通过;真实 SciPy direct/stepwise A/B 等价;完整 unittest 828 项 OK(3 项跳过) | 最终接线后 10 s worker/orchestration 1602.733/1604.152 s,`45455/3075/15282`,接受步 9569、启动 6、事件 2;`t=6.9640458 s` 的 1 次热流体失败经 1 次重试恢复,最大迭代 23、残差 `1.082e-16`,1717 序列/1,722,151 标量全有限;最终 2 s worker 301.782 s | 最终通用接线后的 10 s 已完成;803.622 s 旧报告只作接线前历史证据、不作最终性能;旧 exit 1 仅为空比较器缺陷;旧 golden 保留,连续 3 次 10 s 仍待后续 |
| 2026-08-18 | 工作树基于 `684d287`;AME SHA `cbc3aadd...` | OPT-00 完成 | AME→XML/JSON 权威契约、22 包发布锁、双 golden、最终 replay 与历史 2.10 s 三次复测 | AME 25 项外部评估通过;状态 426/426、物理 33/33 本地重放零误差;quick 179、全量 849 项通过 | 0.2 s worker 159.607 s;2.10 s 三次 113.497–115.868 s | OPT-00 本地验收完成;1 s 预算内 eligible,长时递进转 OPT-09;远端 CI 待提交触发 |
| 2026-08-18 | 同一权威 AME/XML/JSON 工作树 | OPT-00/05/08/09 步长鲁棒性重新打开 | 浏览器在 `BDF / 0.2 s / sampleStep=0.01 s / maxStep=0.001 s` 下于 `t≈0.0489 s` 计算超时,当前工程路径判定失败;新增 browser/API/worker 对账、`0.2/1 s × 五档` 短时矩阵、内部活动心跳和权威 10 s 门 | 失败事实已确认,具体根因尚未区分为数值真停滞、内部慢步、后处理/传输或 60 s 服务假超时;离线 OPT-00 证据保留但不足以签收浏览器工程路径 | 暂不使用旧 `0.02 s` 长跑推断 `0.001 s`;先定位并提交方案审阅,短时门通过后再完整取得未经本次性能优化的 `10 s / 0.001 s` 基线 | OPT-00 工程端到端门重新打开;OPT-05 提升为 P0/P1,OPT-08 为 P0 服务门,OPT-09 只认 `0.001 s` 权威长基线 |
| 2026-08-18 | 同一工作树;API 诊断任务 `diag-opt00-api-20260818` | OPT-00/05/08 步骤 1–3 定位 | 同参 API `164.954 s` 完成;普通进度 `0.048668→0.049248 s` 间隔 `70.369 s`,期间 5 s heartbeat 与约 99% 单核 CPU 持续 | 参数未改写、0 数值/恢复失败;浏览器在第 `60.416 s` heartbeat 必然先触发 `SOLVER_STALLED`,确认“前端误杀 + 后端真实慢区” | 暂不修改数值算法;建议先把 accepted 平台期改为慢步警告,并增加 activity telemetry,再用 step/RHS/Jacobian/闭合增量定位慢区 | 修改意见已提交待审;在获批前停止后续修复和延长测试 |
| 2026-08-19 | 同一权威工作树;真实浏览器与精确慢区优化 | OPT-00/05/08 本地 P0 收口 | activity telemetry 与 activity-aware watchdog;定位 8 个高刚度 LSTP 接触微步簇;因果 direct-sum/direct-reader 与 PNL 循环不变量;改变轨迹或收益不足的容差/Jacobian 候选未启用 | worker/API/browser 均完成 `0.2 s / 0.001 s`;浏览器 21 点、0 cancel/stream/page error,activity `25114→66670`;AMESim physical-state-v2.1 通过;后端 896 项 OK(3 skip),前端 watchdog 8/8、live E2E 1/1 | worker `159.607→147.634 s`(`-7.50%`);API 147.299 s;浏览器 156.136 s;普通进度最大空窗 `70.369→57.185 s` | OPT-00 本地基础闭环完成;OPT-08 的活跃慢步误杀关闭,真停滞硬杀/断连/并发仍待 |
| 2026-08-19 | 同一工作树;0.2/1/2 s 五档串行矩阵 | OPT-05/09 步长与时域鲁棒性 | `maxStep={.001,.002,.005,.01,.02}` 的 15 个单元全部完成;严格公共前缀验证 `0.2→1→2 s`;runner 拒绝 off-grid 检查点 | 15/15 单元通过、0 timeout/NaN/热流体失败/恢复重试;顶层 comparison 红项分层为 0.2/1 s 派生 `a`,2 s 接触后近零 `v/a` 与局部流量换向;压力/守恒/模式/事件通过 | 0.2 s 为 145.942–166.070 s;1 s 为 198.351–214.616 s;2 s 为 306.140–348.040 s,耗时对 maxStep 非单调 | 可解性与时域延长主阻断解除;自动分层契约仍待。5 s 首格进程约 595 s 后转入第二格,第二格约 60 s 时按用户要求中止且无聚合报告;10 s 未启动 |
## 9. 相关文档
- [后端求解逻辑与效率优化调研](./后端求解逻辑与效率优化调研.md)
- [仿真性能评估-2026-08-15](./仿真性能评估-2026-08-15.md)
- [文档目录说明](../README.md)
@@ -0,0 +1,228 @@
# 后端接口版本与定义规范 v1
本文统一说明 SystemSimulationApp 后端的接口边界、版本编号和事实来源。规范版本为
`1.0.0`。这个编号只表示本文档自身的修订版本,不等同于 HTTP API、System XML、
组件目录或单个模型的版本。
## 1. 当前版本基线
后端没有一个可以替代所有子版本的“总版本号”。调用方必须按所使用的数据合同读取
对应版本:
| 版本轴 | 当前值 | 作用范围 | 机器可读事实来源 |
| --- | --- | --- | --- |
| HTTP API | 未版本化 | `/api/...` 路由、请求和响应 | `app/main.py`、FastAPI OpenAPI |
| 后端应用发布版本 | 未定义 | 整个后端部署产物 | 当前没有包元数据或运行时常量 |
| 组件目录 Schema | `1` | `GET /api/components/catalog` | `schemas/component-catalog-v1.schema.json` |
| System XML Schema | `3` | XML 校验、编译和仿真输入 | `schemas/system-simulation-v3.xsd` |
| 组件库版本 | 各库独立 | 一个组件库的发布边界 | 各库 `library.py` 的 `version` |
| 模型合同版本 | 各模型独立 | 单个 `MODEL_TYPE` 的物理和数据合同 | 模型类的 `MODEL_VERSION` |
| ReactFlow 工程 JSON | `1` | 编辑器存档 | `projectSchemaVersion`、`ReactFlowProjectPayload` 和前端读取代码 |
| 结果文件格式 | `1` | 前端导入、导出的结果快照 | `SimulationResultsView.tsx` |
因此,`schemaVersion=3` 只能说明文件是 System XML v3,不能说明“后端 API 是
v3”;`MODEL_VERSION=0.2.0` 也只描述对应模型合同。
FastAPI 自动生成的 OpenAPI 当前可能显示默认 `info.version=0.1.0`;后端没有显式
声明这个值,因此它不是正式 API 或应用发布版本。
## 2. 事实来源和优先级
接口定义冲突时,按以下优先级处理:
1. 可执行代码和机器可读 Schema;
2. 自动化合同测试;
3. 当前版本规范文档;
4. 示例、调研记录和历史说明。
各类合同的唯一事实来源如下:
| 合同 | 唯一事实来源 |
| --- | --- |
| 公开模型类型和版本 | 模型类的 `MODEL_TYPE`、`MODEL_VERSION` |
| 端口物理合同 | 模型类的 `PORTS` 和 `app/simulation/core/ports.py` |
| 参数、结果及单位 | 模型类的 `PARAMETERS`、`RESULT_VARIABLES` |
| 组件库及分类 | 各库 `library.py` |
| 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema |
| System XML | v3 XSD、`app/system_xml.py` |
| 网络最终连接检查 | `SimulationNetwork.connect()` |
| HTTP 路由和请求模型 | `app/main.py` |
前端兜底目录、画布布局和示例 XML 不能反向定义后端物理合同。
## 3. 组件接口合同
每个公开组件必须显式声明:
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
并提供统一的 `create()` 入口。各字段含义如下:
- `MODEL_TYPE` 是工程、XML、目录和结果元数据共同使用的稳定机器标识;
- `MODEL_VERSION` 使用 `主版本.次版本.修订版本`;
- `PORTS` 定义端口名称、种类、物理域、名义角色、变量和连接规则;
- `PARAMETERS` 定义 SI 单位、默认值、范围和离散选项;
- `RESULT_VARIABLES` 定义结构化结果元数据;
- `DISPLAY` 只定义前端展示,不得成为物理方程的隐式输入。
物理流变量统一以进入组件为正。物理连接的两个端点无方向;信号方向由注册端口的
`output/input` 合同决定。
## 4. 组件目录接口
`GET /api/components/catalog` 返回组件库、分类和模型合同。响应顶层必须为:
```json
{
"schemaVersion": 1,
"libraries": []
}
```
目录结构由 `schemas/component-catalog-v1.schema.json` 约束。库必须携带
`version`,模型必须携带 `modelVersion`。目录 Schema 的整数版本只描述目录 JSON
结构;库和模型版本仍分别使用三段式版本号。
当前三段版本只接受 `数字.数字.数字`,不接受 prerelease 或 build metadata,不能直接
等同于完整 SemVer 实现。目录 Schema 对对象使用 `additionalProperties=false`,因此
新增字段也必须先评估严格消费者,不能只因字段可选就默认兼容。
目录响应是前端生成组件面板、参数编辑器和端口快照的来源。修改响应结构时必须同时
更新 JSON Schema、前端解析和合同测试。
## 5. System XML v3
System XML v3 是当前唯一支持的 XML 求解输入。根元素固定使用:
```xml
<System schemaVersion="3" unitSystem="SI">
```
每个组件必须包含 `id`、`type`、`modelVersion` 和完整 SI 参数;连接只包含两个
`Endpoint(component, port)`。端口合同由 `type + port` 从注册表恢复,XML 不保存
端口快照和画布布局。
`modelVersion` 必须与当前注册模型完全一致。版本不一致时返回
`COMPONENT_MODEL_VERSION_MISMATCH`,不会静默使用当前模型解释旧输入。完整结构见
`docs/standard/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。
## 6. HTTP API
FastAPI 当前直接注册未版本化的 `/api/...` 路由,没有 `/api/v1` 命名空间,也没有
运行时 `apiVersion`。主要业务接口如下:
| 方法与路径 | 请求合同 | 响应合同 |
| --- | --- | --- |
| `GET /api/components/catalog` | 无 | 组件目录 JSON v1 |
| `GET /api/reactflow/projects` | 无 | 已保存工程摘要列表 |
| `GET /api/reactflow/projects/{id}` | 工程 ID | 严格校验后的工程 JSON v1 |
| `POST /api/reactflow/projects/{id}` | `ReactFlowProjectPayload` | 保存摘要 |
| `POST /api/reactflow/system-xml` | `ReactFlowProjectPayload` | `application/xml`,System XML v3 |
| `POST /api/reactflow/compile-model` | `ReactFlowProjectPayload` | 编译网络 JSON |
| `POST /api/system-xml/validate` | 原始 XML v3 | 三层校验报告 |
| `POST /api/system-xml/parse` | 原始 XML v3 | 规范化执行模型 |
| `POST /api/system-xml/compile-model` | 原始 XML v3 | 校验报告、设置和网络 |
| `POST /api/system-xml/simulate` | 原始 XML v3 | 同步仿真结果 |
| `POST /api/system-xml/simulate-stream` | 原始 XML v3 | `application/x-ndjson` 事件流 |
| `GET /api/system-xml/simulations/{id}` | 路径 ID | 任务快照 |
| `POST /api/system-xml/simulations/{id}/cancel` | 取消原因 JSON | 取消受理状态 |
| `POST /api/simulation-results/csv` | 结构化结果 JSON | UTF-8 CSV 附件 |
两个固定算例接口 `simulate-testmodel` 和 `simulate-test-mql` 仍属于测试/基线能力,不能
视为任意拓扑仿真合同。
FastAPI 会生成 `/openapi.json`、`/docs` 和 `/redoc`。Pydantic JSON 请求体能够进入
OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、CSV 和 NDJSON
也使用原始响应类型。因此 OpenAPI 目前不是完整的响应合同;代码、Schema 和合同测试
仍是必要依据。
## 7. 数据命名、单位和错误
- XML 属性和目录 JSON 主要使用 `camelCase`;
- ReactFlow 仿真设置保留现有 `t_start/t_stop/step/max_step` 名称;
- 不允许调用方自行猜测或转换字段命名;
- XML 和求解参数统一使用 SI 基准值;
- 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。
System XML 校验问题统一包含:
```json
{
"severity": "error",
"layer": "semantic",
"code": "ENDPOINT_PORT_UNKNOWN",
"message": "...",
"path": "...",
"line": 1
}
```
其中 `severity/layer/code/message` 是核心字段,`path/line` 可选。同步 HTTP 错误目前既
可能是 FastAPI 字符串 `detail`,也可能是带 `message/issues/diagnostics` 的结构化
`detail`;流式接口则使用 `event=error` 的 NDJSON 事件。统一错误响应模型尚未实现,
调用方必须同时处理这三种现有形状。
## 8. 版本变更规则
### 8.1 模型和组件库
- 修订版本:修复实现,不改变输入、端口和结果合同;
- 次版本:向后兼容地增加有默认值的参数、结果或能力;
- 主版本:删除或改名端口/参数,或者改变既有物理语义。
修改 `MODEL_TYPE` 视为新模型,不得用原标识承载不兼容合同。
以上版本含义用于分类变更影响;当前 System XML v3 对 `modelVersion` 执行完整字符串
精确匹配。因此即使只是修订或次版本变化,旧 XML 也会被拒绝,不能把“向后兼容”
理解成解析器会自动接受旧版本。
### 8.2 目录和 XML Schema
- 兼容澄清不改变 Schema 版本;
- 新增可选字段前必须验证旧消费者行为;
- 删除字段、改名或改变既有语义必须提高 Schema 版本;
- Schema、解析器、导出器、文档和合同测试必须在同一修改中更新。
### 8.3 HTTP API
当前 HTTP API 未版本化,因此不得在原路径上直接发布破坏性变更。需要破坏现有请求
或响应合同前,应先单独设计 API 版本命名空间、兼容周期和下线规则;该机制不在本文
本次整理范围内。
## 9. 旧版本和迁移边界
当前后端只接受 System XML v3:
- 不按 `schemaVersion` 自动选择 v1/v2 解析器;
- 不提供 v1/v2 到 v3 的自动迁移器;
- 不对不匹配的 `modelVersion` 做自动升级;
- 旧版本值只用于验证“不受支持输入应被拒绝”的边界测试。
ReactFlow 工程 JSON 只接受 `projectSchemaVersion: 1` 的当前结构,每个节点必须保存
目录给出的 `modelVersion`。执行、编译和 XML 导出前会再次核对节点版本;缺失或不匹配
时明确拒绝,不能先补当前默认参数再冒充当前模型。字符串端口、缺失连接 Handle 或
已经删除的兼容标记也不会被猜测、补齐或迁移;以后确有升级需求时再为新的工程版本
单独设计迁移器。
MECMAS21 的 `useFriction`、`strib` 等 AMESim 选项统一使用目录声明的原生编码:
`1` 表示“否/禁用”,`2` 表示“是/启用”。工程 JSON v1、组件目录、System XML v3
和模型构造器不再接受或自动换算旧的 `0/1` 编码,也不再使用
`amesimParameterEncodingVersion` 触发猜测式转换。
## 10. 接口修改完成条件
任何接口合同变更至少应同时完成:
1. 更新唯一事实来源;
2. 根据兼容性决定是否提高对应版本;
3. 更新机器可读 Schema;
4. 更新当前规范和示例;
5. 增加请求、响应、拒绝边界和前后端联调测试;
6. 明确说明未实现的兼容或迁移能力。
@@ -2,7 +2,7 @@
状态:已在 `experimental` 临时组件库实施
适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库
当前试验库:`experimental`(界面名称:临时测试组件库)
当前试验库:`experimental`(仅用于注册契约验证,不在前端组件库中显示)
## 0. 文档定位
@@ -150,8 +150,11 @@ MODEL_VERSION = "1.0.0"
- 次版本:向后兼容地新增参数、结果或能力。
- 主版本:端口、参数语义或方程发生不兼容变化。
当前 System XML v2 尚未保存模型版本。正式发布组件库前,应在 XML 中增加
`library` 和 `modelVersion`,并提供旧工程迁移规则。
当前 System XML v3 要求每个 `Component` 显式保存 `modelVersion`,并与注册模型
版本完全一致;不一致时拒绝加载,不做静默升级。v3 不另存 `library`,而由全局唯一的
`Component/@type` 定位注册模型。旧模型的自动迁移仍未实现,需要另行提供显式规则。
因此当前“修订/次版本向后兼容”只表示合同设计意图,不表示旧 XML 会被解析器自动
接受;任意模型版本变化都会使旧 XML 的精确版本检查失败。
## 5. 组件库清单
@@ -206,7 +209,7 @@ LIBRARY = ComponentLibrarySpec(
- 启动错误能明确定位到具体库和模型。
当前 `experimental/library.py` 已按此格式声明库、分类和五个公开模型;
`experimental/__init__.py` 只保留旧常量的兼容别名。
`experimental/__init__.py` 只公开规范化的 `LIBRARY` 清单。
## 6. 模型类契约
@@ -244,7 +247,7 @@ class ExampleComponent(Component):
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
完整方程示例参见
[`app/simulation/components/example.md`](../app/simulation/components/example.md)。
[`app/simulation/components/example.md`](../../app/simulation/components/example.md)。
## 7. 界面显示声明
@@ -550,8 +553,20 @@ GET /api/components/catalog
6. 使用 `symbol` 选择图标渲染器。
7. 不在前端重新定义参数默认值、边界或端口语义。
目录协议提供以下可选的 AMESim 介质扩展字段:
目录协议提供以下可选的参数编辑器与 AMESim 介质扩展字段:
- 参数的 `editor: "choice"` 表示数值是稳定的离散编码,必须同时提供非空
`options: [{"value": 1, "label": "..."}]`。前端显示标签,但工程、XML 与
求解器仍保存和接收 `value` 数值,不得把标签写入模型数据。
- 参数可通过 `visibleWhen: [{"parameter": "mode", "values": [1, 2]}]`
声明显示条件。多个条件之间按 AND 处理,同一条件的多个 `values` 按 OR
处理;控制参数必须拥有固定 `options`。隐藏参数的既有值必须保留,不能因
界面联动而重置或从工程、XML 中删除。
- 模型可通过可选的 `parameterGroups` 声明纯展示用参数分组。每组包含稳定的
`id`、显示 `label`、组间 `order`、有序参数名数组 `parameters` 和布尔值
`defaultExpanded`;默认应为 `false`。同一参数最多属于一个组,组内参数名
必须引用该模型已注册的参数。分组不改变参数默认值、条件显示、工程保存或
System XML 语义;无分组的模型不输出该字段。
- 参数的 `editor: "amesimGasReference"` 表示该数值不是普通连续量,而是
项目介质定义的 `gi` 引用。前端应保留索引 `0`,并从当前画布的介质定义
组件生成其余下拉项;索引下拉项只显示数值,不拼接介质名称或中文说明。
@@ -560,8 +575,8 @@ GET /api/components/catalog
一类目录数据,前端据此生成下拉栏;后续增加算法时由介质模型注册新的选项,
前端不硬编码算法名称。
- 模型的 `role: "amesimGasMediumDefinition"` 表示该模型是项目级介质定义。
此类模型允许 `ports: []`,在 System XML v2 中仍按普通零端口
`Component` 保存。
此类模型允许 `ports: []`,在 System XML v3 中仍按普通零端口
`Component` 保存;XML 不写任何 `Port` 快照,只保存模型版本和完整参数。
这些字段在目录对象中均为可选。宽松读取目录的消费者可以把未知编辑器参数
退化为普通数值输入;按本仓库 JSON Schema 严格校验的消费者必须与后端成套
@@ -574,8 +589,9 @@ GET /api/components/catalog
Peng-Robinson,并映射到 `fluidType=12/eosType=6`。编译层负责保存这种映射,
前端只使用目录选项。
当前前端保留内置兜底目录,用于后端未启动时继续打开工程。兜底只是一种开发期
容错机制,不能成为新增模型的正式注册方式;正式环境应明确提示目录加载失败。
前端不再维护内置兜底目录。后端目录不可用时,组件区保持为空,并在“组件库”
标题旁显示红色“加载失败”状态;悬停或聚焦该状态可查看失败范围和详细原因。
`experimental` 试验库即使由成功的目录响应返回,也不会出现在组件区。
### 14.1 前端实际读取步骤
@@ -587,12 +603,10 @@ React Flow 启动时:
4. 检查模型 `type` 是否全局重复。
5. 将参数数组转换为参数面板定义。
6. 按库、分类和模型的 `order` 排序。
7. 成功时显示“后端目录”。
8. 请求或格式校验失败时显示“内置兜底”并使用开发期兜底目录。
前端兜底目录不保证包含新模型。新增公开模型后,只要 FastAPI 正常提供目录,前端
就能读取;若要求后端离线时也显示新模型,才需要有意识地同步兜底定义。兜底定义
仍不能成为端口、参数或默认值的权威来源。
7. 过滤仅用于注册验证的 `experimental` 试验库。
8. 成功时用绿色状态显示“已加载 X 个组件库”,不追加其他成功说明。
9. 请求或格式校验失败时显示红色“加载失败”,不显示任何兜底组件;悬停状态可
查看具体库名(目录响应可识别时)或受影响范围、接口地址与错误原因。
### 14.2 修改后如何生效
@@ -613,19 +627,20 @@ System XML 中:
```xml
<Component
id="cylinder_1"
name="cylinder_1"
type="cylinder"
componentType="cylinder">
modelVersion="1.0.0">
<Parameter name="volume" value="0.01"/>
</Component>
```
映射规则:
- `id`:工程内唯一的组件实例 ID。
- `name`:用户可修改的组件实例名称。
- `type`:必须匹配唯一的 `MODEL_TYPE`。
- `componentType`:当前为兼容字段,应与 `type` 相同。
- `<Port name>`:必须存在于模型的 `PORTS`。
- `<Parameter name>`:必须存在于模型的 `PARAMETERS`。
- `modelVersion`:必须与该模型当前 `MODEL_VERSION` 完全一致。
- `<Parameter name>`:必须完整且只能来自模型的 `PARAMETERS`,数值使用 SI。
- XML v3 不保存 `name/componentType/Port` 或画布布局;连接中的
`Endpoint/@port` 必须存在于模型的 `PORTS`。
介质定义组件不通过物理端口连接。编译器先收集目录角色为
`amesimGasMediumDefinition` 的零端口组件,再解析带
@@ -708,7 +723,9 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 模型工厂 | 已实现 | 模型类统一 `create()` |
| 模型发现 | 已实现 | 按库清单受控发现 |
| 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 |
| XML/工程中的模型版本与迁移 | 未实现 | 正式库发布前补齐 |
| System XML 中的模型版本 | 已实现 | v3 显式保存并严格匹配 `modelVersion` |
| 工程 JSON 的整体版本 | 已实现 | 固定为 `projectSchemaVersion: 1`,节点显式锁定 `modelVersion` |
| 自动版本迁移 | 未实现 | 当前明确拒绝不匹配版本,本阶段不实现迁移 |
| 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` |
## 19. 推荐实施顺序
@@ -719,7 +736,8 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。
5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。
6. 已完成:已增加组件目录 JSON Schema。
7. 待完成:在 System XML 和工程文件中保存模型版本,并设计迁移机制。
7. 已完成:System XML v3 保存并严格校验模型版本;工程 JSON 使用
`projectSchemaVersion: 1`,每个节点保存创建时的 `modelVersion`,当前不实现旧工程迁移。
8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。
该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、
@@ -733,7 +751,7 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 |
| 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 |
| 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 |
| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、XML/网络测试、版本迁移 | 库分类 |
| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、主版本、XML/网络拒绝边界测试 | 库分类 |
| 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 |
| 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 |
| 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 |
@@ -745,7 +763,7 @@ AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开
| 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` |
| FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 |
| 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 |
| 前端显示“内置兜底” | 8000 端口、`/api/components/catalog`、后端是否重启 |
| 前端显示红色“加载失败” | 悬停状态查看详情,再检查 8000 端口、`/api/components/catalog`、后端是否重启 |
| 分类错误 | `DISPLAY.category_id` 与库 `categories` |
| 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 |
| 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 |
@@ -34,11 +34,11 @@
| 改动 | 版本建议 | 兼容性要求 |
| --- | --- | --- |
| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 |
| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 |
| 修复数值实现但不改变契约 | 修订版本 | 若提高 `modelVersion`,既有 XML 会因精确版本不匹配而被拒绝;需明确是否真的变更合同 |
| 新增有默认值的参数或结果 | 次版本 | 新 XML 必须写全当前参数;本阶段不提供旧文件自动迁移 |
| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 |
| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 |
| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 |
| 删除、改名端口或参数 | 主版本 | 当前格式直接拒绝旧端口或参数;如以后需要兼容,再单独设计迁移器 |
| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 |
### 2.3 新增内部模型
@@ -47,7 +47,7 @@
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
当前示例是
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../../app/simulation/examples/testmodel/dynamic_pipe.py)。
### 2.4 新增物理域
@@ -70,11 +70,11 @@
1. 本文档。
2. 目标库的 `library.py`。
3. 同分类中物理行为最接近的现有模型。
4. [`core/base.py`](../app/simulation/core/base.py)。
5. [`core/ports.py`](../app/simulation/core/ports.py)。
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。
4. [`core/base.py`](../../app/simulation/core/base.py)。
5. [`core/ports.py`](../../app/simulation/core/ports.py)。
6. [`core/metadata.py`](../../app/simulation/core/metadata.py)。
7. [`core/catalog.py`](../../app/simulation/core/catalog.py)。
8. [`registry.py`](../../app/simulation/registry.py) 中的启动校验。
9. 与目标模型最接近的测试。
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
@@ -111,6 +111,7 @@ app/simulation/components/experimental/junctions/tee.py
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
@@ -153,6 +154,18 @@ def create(
- `pressure_flow_equation_residuals()`。
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
实现 `update_stream_outflows()` 或 `update_flow_temperature_references()` 的公开模型,
还应在该公开类自身显式声明 `PRESSURE_FLOW_DEPENDS_ON_STREAM`:构成压力/流量方程
会读取这些 hook 写入的焓或温度引用时设为 `True`,否则设为 `False`。省略声明、
声明非法值或由自定义子类仅继承父类声明时,求解器会保守使用全网热流闭合;不要
为了获得分块加速而错误声明 `False`。
`pressure_flow_equation_residuals()` 返回的每条 `EquationResidual.variables` 必须完整
列出该残差实际读取的全部代数端口量(`p/m_flow/x/v/f`),不能只写“主要变量”。
求解器会用这份声明编译稀疏 Jacobian 和独立方程块;漏写依赖可能让有限差分方向
不完整。仓库内置组件会接受结构与数值依赖回归,外部自定义组件当前仍保守使用
全网 dense 回退,直到具备同等的依赖验证边界。
### 6.2 `ThermodynamicVolumeComponent`
适用于包含质量和能量状态的气体容腔,例如:
@@ -406,6 +419,27 @@ def create(
- 必要正则化必须有物理解释,并通过边界测试保护。
- 不得用画布坐标、连接线方向或组件名称决定方程。
### 12.1 可因果执行的残差语义
后端只会对经过结构门控的内置模型启用完全因果执行。除完整声明
`variables` 外,这些模型还必须遵守以下可执行语义:
- `role="effort", relation="state"` 的残差写成
`端口 effort - 状态给定值`,被约束的端口量系数必须为 `+1`。
- `role="effort", relation="equal"` 的残差写成两个同类 effort 的差。
- `role="flow", relation="sumToZero"` 按“流入组件为正”的约定求和,待消元
flow 的系数必须为 `+1`。
- `role="flow", relation="constitutive"` 写成
`待消元 flow - 本构计算值`,待消元 flow 的系数必须为 `+1`。
- `pressure_flow_equation_values()` 必须是无副作用的只读计算,返回顺序和长度
必须与 `pressure_flow_equation_residuals()` 的编译结果永久一致;不得在求残差时
修改端口、状态或活动集缓存。
求解器仍会在首次闭合、离散事件之后和固定周期执行完整残差审计。结构不满足、
运行时覆盖不完整或审计不通过时,会立即熔断到原有残差/非线性求解路径。现场诊断
时可在启动进程前设置 `SIMULATION_CAUSAL_FAST_PATH=0`,一键关闭该优化而不改变模型
文件。
动态模型还必须:
- 状态向量长度稳定。
@@ -435,6 +469,7 @@ from app.simulation.core.ports import PortDefinition
class ExampleRestriction(AlgebraicComponent):
MODEL_TYPE = "example_restriction"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
@@ -529,11 +564,11 @@ class ExampleRestriction(AlgebraicComponent):
真实现有模型可参考:
- 储能元件:
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py)
[`cylinder.py`](../../app/simulation/components/experimental/storage/cylinder.py)
- 阻性元件:
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py)
[`orifice.py`](../../app/simulation/components/experimental/flow/orifice.py)
- 多端口连接元件:
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py)
[`tee.py`](../../app/simulation/components/experimental/junctions/tee.py)
## 14. 注册模型
@@ -666,7 +701,7 @@ AI 创建或修改模型时必须遵守:
| --- | --- | --- |
| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 |
| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI |
| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 |
| 前端显示红色“加载失败” | `/api/components/catalog` 不可用或目录合同无效 | 悬停状态查看详情,再检查 8000 端口和接口响应 |
| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 |
| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 |
| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` |
+285
View File
@@ -0,0 +1,285 @@
# System XML v3 协议
System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。
机器可读结构见 [`schemas/system-simulation-v3.xsd`](../../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。
## 1. 设计边界
v3 遵循一条简单规则:
> XML 保存“求解什么”,工程 JSON 保存“怎样编辑和显示”。
因此 XML 保留:
- 仿真起止时间、结果采样间隔、内部最大步长和积分方法;
- 组件实例 ID、后端模型类型、模型版本和完整 SI 参数;
- 每条连接的两个端点。
XML 不保存:
- 组件显示名称、画布坐标、旋转、镜像;
- 图标、端口显示侧和显示顺序;
- 端口的 `kind/domain/nominalRole/positiveFlowDirection/variables` 快照;
- 参数表达式、显示单位、科学记数法偏好;
- ReactFlow 的选择状态、撤销历史或仿真结果。
这些信息中,编辑器状态留在工程 JSON;端口物理合同由 `Component.type + Endpoint.port` 从后端组件注册表恢复。
## 2. 完整结构示例
下面的例子包含一条信号连接和一条机械连接,展示 v3 的全部结构元素:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="signal-force-demo" schemaVersion="3" unitSystem="SI">
<Simulation
tStart="0"
tStop="1"
sampleStep="0.01"
maxStep="0.001"
method="BDF"/>
<Components>
<Component id="step_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="0"/>
<Parameter name="time" value="0.5"/>
</Component>
<Component id="force_1" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="zero_1" type="amesim_f000" modelVersion="0.1.0"/>
</Components>
<Connections>
<Connection id="signal-1">
<Endpoint component="step_1" port="out"/>
<Endpoint component="force_1" port="res"/>
</Connection>
<Connection id="mechanical-1">
<Endpoint component="force_1" port="port_2"/>
<Endpoint component="zero_1" port="port_1"/>
</Connection>
</Connections>
</System>
```
XML 的外形是一棵树,模型本身仍是一张连接图。组件平铺在 `Components` 中,`Connections` 再用 `(component, port)` 地址建立拓扑。
固定骨架为:
```text
System
├─ Simulation
├─ Components
│ └─ Component *
│ └─ Parameter *
└─ Connections
└─ Connection *
├─ Endpoint
└─ Endpoint
```
顶层顺序固定为 `Simulation → Components → Connections`。每条 `Connection` 恰好包含两个 `Endpoint`。
## 3. `System` 根元素
| 属性 | 是否必填 | 规则 |
| --- | --- | --- |
| `schemaVersion` | 是 | 固定为 `3` |
| `unitSystem` | 是 | 固定为 `SI` |
| `name` | 否 | 非空工程名称;省略后解析模型使用 `untitled` |
`schemaVersion="3"` 已经表示唯一的介质引用和 AMESim 离散参数编码语义。根元素不接受额外的版本提示字段,也不会据此触发兼容猜测。
## 4. `Simulation`
| 属性 | 含义 | 主要校验 |
| --- | --- | --- |
| `tStart` | 仿真开始时刻 | 必须是有限数值 |
| `tStop` | 仿真结束时刻 | 必须有限且大于 `tStart` |
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0;不设置固定的采样点数上限 |
| `maxStep` | 自适应积分器单个内部步的上限 | 必须大于 0 |
| `method` | 积分方法 | `RK45/RK23/DOP853/Radau/BDF/LSODA` |
`sampleStep` 和 `maxStep` 不是一回事:
- `sampleStep` 决定结果曲线多久保存一个点;
- `maxStep` 限制求解器内部一次最多前进多久;
- 自适应求解器可以因为误差、事件或试探状态失败而走得比 `maxStep` 更短。
采样点数量会在创建时间数组前计算。若区间长度不可表示为有限数、点数超过当前
运行时可表示的集合大小,或在当前浮点精度下无法得到包含 `tStart/tStop` 的严格
递增时间序列,输入会在仿真前被拒绝。采样点不再受固定业务上限约束,但结果内存、
序列化体积和浏览器负载仍会随“采样点数 × 输出变量数”线性增长。
工程 JSON 为兼容现有前端仍把采样字段命名为 `simulation.step`;导出 v3 时必须映射为 `Simulation/@sampleStep`。
## 5. `Component` 与 `Parameter`
### 5.1 `Component`
| 属性 | 是否必填 | 含义 |
| --- | --- | --- |
| `id` | 是 | 当前系统内唯一的实例 ID;连接通过它引用组件 |
| `type` | 是 | 后端组件注册键,例如 `amesim_forc` |
| `modelVersion` | 是 | 该 `type` 的模型合同版本 |
语义校验要求 `modelVersion` 与当前注册表完全一致。版本不匹配时返回 `COMPONENT_MODEL_VERSION_MISMATCH`,不会静默套用新模型默认值或自动改写旧参数。
v3 不保存 `name/componentType/x/y/rotation/mirrored`。其中:
- 显示名称和画布位置只属于工程 JSON;
- `componentType` 在当前系统中与后端 `type` 重复;
- 旋转和镜像只属于画面布局,不再改变求解方程。
### 5.2 `Parameter`
```xml
<Parameter name="direction" value="-1"/>
```
每个参数只保存稳定参数名和已经换算到 SI 基准单位的数值。v3 要求组件显式列出当前模型注册表中的全部参数:
- 参数名重复会报错;
- 缺少注册参数会报 `PARAMETER_REQUIRED_MISSING`;
- 出现未知参数会报 `PARAMETER_UNSUPPORTED`;
- 超范围或不在枚举集合内会报 `PARAMETER_VALUE_INVALID`。
前端和后端导出器会先用注册默认值补齐工程 JSON 中省略的参数,再写入 XML。XML 解析器本身不替缺失参数猜默认值。
### 5.3 AMESim 介质引用
介质仍用普通参数表达,不增加额外 XML 层级:
- `gi=0`:内置理想空气;
- 对普通气动组件,`gi=1..99` 引用同一 XML 中显式介质定义组件的索引;
- 对介质定义组件自身,`gi=1..99` 表示它所定义的索引;
- 介质定义的 `gi` 必须唯一;
- 一个连通气动网络只能使用同一 `gi`;
- `property_model` 也是普通必填参数,由对应介质模型注册表解释。
## 6. 端口与连接
### 6.1 为什么 v3 没有 `Port` 元素
端口不是组件实例的自由数据,而是组件模型合同的一部分。后端根据:
```text
Component.type + Endpoint.port
```
从注册表恢复:
- `kind`:物理或信号;
- `domain`:气动、机械或信号;
- `nominalRole`:输入、输出或物理名义角色;
- `positiveFlowDirection`:物理流变量统一以进入组件为正;
- 端口变量、单位和 `equal/sumToZero/streamMix/directed` 连接规则。
XML 不能通过写一个新端口名来扩展组件,也不能通过修改字符串把气动口变成机械口。
### 6.2 `Connection`
`Connection/@id` 可省略。省略时解析器按文档顺序生成 `connection_1`、`connection_2` 等内部 ID;显式 ID 和生成 ID 都必须唯一。
连接本身不再保存 `kind` 或 `domain`。语义层解析两个端点的注册端口后检查:
- 组件和端口存在;
- 两端同为物理端口或同为信号端口;
- 两端 `domain` 和完整变量合同一致;
- 信号连接恰好连接一个 `output` 和一个 `input`;
- 一个信号输出可以驱动多个输入,但每个信号输入只能有一个驱动;
- 同一物理端口只使用一次;分支必须使用显式 Tee/节点组件;
- 不允许自连接或重复端点对。
### 6.3 两个 `Endpoint` 的顺序
物理连接的两个端点无序,交换顺序不改变方程或实际流向。
信号连接也不写 `role="source"` 或 `role="target"`。发送方和接收方由注册端口的 `output/input` 合同确定,而不是由 XML 中的先后顺序决定。导出器可以为了便于阅读把输出端写在前面,但求解器不能依赖这一顺序。
## 7. `AmesimForc.direction`
`amesim_forc` 从模型版本 `0.2.0` 开始使用显式物理参数:
```xml
<Parameter name="direction" value="1"/>
```
它只允许:
- `+1`:默认方向,方程为 `port_2.f + inputForce = 0`;
- `-1`:反向,方程为 `port_2.f - inputForce = 0`。
组件的图标旋转和镜像不会改变该参数,也不会改变求解结果。用户要反转施力方向时必须修改 `direction`,而不是旋转图标。
### 7.1 MECMAS21 离散选项编码
`amesim_mecmas21` 只使用 AMESim 原生的 `1/2` 编码。以两个布尔选项为例:
| 参数 | `1` | `2` |
| --- | --- | --- |
| `useFriction` | 不启用摩擦 | 启用摩擦 |
| `strib` | 不使用 Stribeck 效应 | 使用 Stribeck 效应 |
工程 JSON v1 和 System XML v3 都直接保存上述值。后端不会把 `0/1` 自动换算成
`1/2`,也不会根据缺失的兼容标记猜测工程含义;不在目录选项集合内的值会被拒绝。
## 8. 工程 JSON 与 System XML 的分工
| 信息 | 工程 JSON | System XML v3 |
| --- | --- | --- |
| 组件实例 ID、模型类型 | 保存 | 保存 |
| 模型版本 | 每个节点显式保存并与目录核对 | 每个组件显式保存 |
| 数值参数 | 保存编辑值及显示信息 | 保存完整 SI 数值 |
| 组件显示名、坐标、旋转、镜像 | 保存 | 不保存 |
| 端口快照、`side/order` | 保存供编辑器使用 | 不保存 |
| ReactFlow `source/target/handle` | 保存 | 转成两个 `Endpoint` |
| 端口物理合同 | 目录快照用于前端检查 | 不重复保存,由注册表恢复 |
| 参数表达式、显示单位 | 保存 | 不保存 |
| 仿真结果 | 不作为模型输入 | 不保存 |
因此 `/api/system-xml/parse` 返回的是规范化执行模型,不是可无损恢复原画布的 ReactFlow 工程文件。需要继续编辑时,应保存和打开工程 JSON;需要校验、交换或求解时,使用 System XML v3。
## 9. 校验与 API
校验固定分三层:
| 层级 | 负责内容 |
| --- | --- |
| XML | 5 MiB 大小限制、语法、安全解析、禁止 DTD/实体和网络访问 |
| XSD | 元素顺序、必填属性、数量、基础数值类型、`schemaVersion=3`、`unitSystem=SI` |
| semantic | 模型及版本、完整参数、端点引用、注册端口兼容性、介质引用和拓扑占用 |
当前相关接口都直接接收 `Content-Type: application/xml` 的原始 v3 XML:
- `POST /api/system-xml/validate`:返回三层校验报告;
- `POST /api/system-xml/parse`:返回规范化执行模型;
- `POST /api/system-xml/compile-model`:返回编译后的网络和仿真设置;
- `POST /api/system-xml/simulate`:同步运行并返回完整结果;
- `POST /api/system-xml/simulate-stream`:通过 NDJSON 返回心跳、进度和最终结果。
`POST /api/reactflow/system-xml` 可将工程 JSON 导出为 v3;当前前端也能在浏览器中直接生成同一结构。
通过 XML/XSD/语义校验只说明输入合同正确。完整仿真前仍会检查动态储能锚点、未连接物理端口、方程结构和不允许的理想储能直连等可求解条件。物理连通岛只由物理组件和物理连接构成;控制信号扇出不会把两个独立气路或机械网络合并成一个物理岛。
## 10. 旧版本处理边界
当前 API 不读取或自动转换 System XML v1/v2,也不会为不匹配的
`Component/@modelVersion` 选择旧模型实现。旧输入会在 XSD 或语义层被明确拒绝。
本阶段不定义转换步骤、迁移注册表或兼容承诺。仓库不再保存旧版 XSD、规范或示例;
需要进入当前系统的模型必须由来源端重新导出为 v3,不能只修改版本号。
## 11. 事实来源
- XSD:`schemas/system-simulation-v3.xsd`
- XML 数据类、解析和三层校验:`app/system_xml.py`
- 后端 JSON→XML 导出及 XML 仿真路由:`app/main.py`
- 前端 XML 生成:`frontend/src/App.tsx` 中的 `buildSystemXml()`
- 组件和端口事实来源:`app/simulation/registry.py`、`app/simulation/core/ports.py`
- 网络最终兼容检查:`app/simulation/systems/network.py`
-72
View File
@@ -1,72 +0,0 @@
# System XML v1 协议
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。
System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。
## 基本约定
- 根元素固定为 `System`,`schemaVersion` 固定为 `1`。
- `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。
- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。
- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。
- v1 文档不携带结果数据;仿真结果通过运行接口返回。
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="1" unitSystem="SI">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180">
<Port name="port_b"/>
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180">
<Port name="port_a"/>
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection id="edge-1" source="cylinder_1" sourcePort="port_b" target="tank_1" targetPort="port_a"/>
</Connections>
</System>
```
## Simulation
| 属性 | 类型 | 含义 | 约束 |
|---|---|---|---|
| `tStart` | double | 仿真起始时间,单位 s | 有限数 |
| `tStop` | double | 仿真结束时间,单位 s | 必须大于 `tStart` |
| `step` | double | 结果采样间隔,单位 s | 必须大于 0 |
| `maxStep` | double | 求解器最大积分步长,单位 s | 必须大于 0 |
| `method` | string | 求解器名称,例如 `BDF` | 非空 |
## Components
每个 `Component` 必须包含唯一 `id`、显示名称、模型类型、前端类型和画布坐标。组件下可以包含多个 `Port` 和 `Parameter`。
参数的 `value` 必须是可转换为有限浮点数的 SI 值。具体组件支持的端口、参数和取值范围由后续组件注册表协议约束。
## Connections
每个 `Connection` 必须包含唯一 `id` 和两个完整端点:
- `source` + `sourcePort`
- `target` + `targetPort`
端点中的组件 ID 必须存在,端口名称必须属于对应组件。v1 前端仍使用 source/target 组织连线,但仿真层必须将它们视为连接的两个端点,不得直接解释为固定物理流向。
## 版本兼容
- 解析器遇到缺少 `schemaVersion` 的旧 XML 时,应明确报告“旧协议”,不能静默当作 v1。
- v1 新增可选字段时必须保持旧解析器可忽略;删除字段或改变字段语义时必须升级主版本。
- XSD 只验证文档结构和基础数据类型。跨字段约束、组件端口规则和拓扑可求解性由仿真校验层负责。
-283
View File
@@ -1,283 +0,0 @@
# System XML v2 协议
System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
## 基本约定
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
- 新导出的介质引用语义使用可选根属性
`mediumReferenceVersion="1"`。缺少该属性的文档按旧版介质索引规则读取。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
- 物理端口统一规定 `m_flow > 0` 表示流入组件,`m_flow < 0` 表示流出组件。
- `nominalRole` 只表示设计意图和展示语义,不限制实际流向。
- v2 文档不携带仿真结果;端口实际流向、流量幅值和累计质量由运行结果接口返回。
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="2" unitSystem="SI" mediumReferenceVersion="1">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
<Port name="port_b" kind="physical" domain="pneumatic" nominalRole="outlet" positiveFlowDirection="intoComponent" side="right"/>
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180" rotation="0" mirrored="false">
<Port name="port_a" kind="physical" domain="pneumatic" nominalRole="inlet" positiveFlowDirection="intoComponent" side="left"/>
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection id="edge-1" kind="physical" domain="pneumatic">
<Endpoint component="cylinder_1" port="port_b"/>
<Endpoint component="tank_1" port="port_a"/>
</Connection>
</Connections>
</System>
```
## Port
组件的 `rotation` 只能为 `0/90/180/270`,`mirrored` 表示水平镜像。它们只用于恢复画布布局和端口显示位置,不参与物理方程或流向判断。
| 属性 | 含义 |
|---|---|
| `name` | 组件模型中的稳定端口名 |
| `kind` | `physical` 或 `signal` |
| `domain` | 端口物理域,当前流体组件使用 `pneumatic` |
| `nominalRole` | 物理端口使用 `inlet/outlet/bidirectional`;信号端口使用 `input/output` |
| `positiveFlowDirection` | 物理端口固定为 `intoComponent`;信号端口省略 |
| `side` | 前端图标上的 `left/right` 布局位置,不参与物理求解 |
物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
## Connection
物理连接包含两个无序 `Endpoint`。第一个端点不代表上游,第二个端点也不代表下游;交换二者顺序不得改变仿真结果。
信号连接也使用两个 `Endpoint`,但必须分别携带 `role="source"` 和 `role="target"`。信号端口只允许 `output` 与 `input` 相连。
连接生成前必须验证:
- 组件和端口存在。
- 两端 `kind` 相同。
- 两端 `domain` 相同。
- 信号连接一端为 `output`,另一端为 `input`。
## v1 迁移
- v1 的字符串端口在加载时按组件注册表迁移成 v2 端口对象。
- v1 的 `source/sourcePort/target/targetPort` 在导出 v2 时转换为两个 `Endpoint`。
- 物理连接不继承 v1 的 source/target 方向。
- v1 文件仍由原 XSD 描述;新生成文件只输出 v2。
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
### AMESim 介质定义与索引兼容
AMESim 介质定义继续使用普通的零端口 `Component`,不增加新的 XML
层级。其介质索引和物性选择仍以数值 `Parameter` 保存,例如:
```xml
<Component id="air_1" name="Air 1"
type="amesim_ideal_air_medium"
componentType="amesim_ideal_air_medium"
x="40" y="40">
<Parameter name="gi" value="1"/>
<Parameter name="property_model" value="0"/>
</Component>
```
氦气 Peng-Robinson 定义使用相同结构:
```xml
<Component id="helium_1" name="Helium 1"
type="amesim_helium_medium"
componentType="amesim_helium_medium"
x="40" y="140">
<Parameter name="gi" value="2"/>
<Parameter name="property_model" value="0"/>
</Component>
```
这里的 `property_model=0` 是氦气组件内部的 Peng-Robinson 选项编号;编译时
同时保留源 AMESim `fluidType=12/eosType=6` 元数据,不能把两个编号体系混用。
气动组件的 `gi=0` 表示内置的空气理想气体;正整数引用画布中的显式介质
定义。介质定义的 `property_model` 是该介质内部的物性计算模型编号,当前
空气定义中的 `0` 表示理想气体。界面中的 `gi` 下拉栏只显示索引数值,
`property_model` 下拉栏的名称和可选值则来自组件目录。
解析旧文档时,只有同时满足以下条件才执行旧 `gi` 兼容转换:
- 根元素没有 `mediumReferenceVersion`;
- 所有组件类型均可由当前注册表识别;
- 画布中不存在目录角色为 `amesimGasMediumDefinition` 的组件。
在这种可证明没有显式介质定义的旧文档中,缺失的 `gi` 会补为 `0`,旧
`gi=1` 会映射为 `0`,并返回语义层警告。带有
`mediumReferenceVersion="1"` 的新文档不会重解释正整数索引。
在物性计算模型下拉接口加入之前保存的介质定义可能只有 `gi`。无论是否存在
`mediumReferenceVersion`,解析器都会为缺失的 `property_model` 注入组件目录
声明的默认值,并返回 `AMESIM_GAS_PROPERTY_MODEL_DEFAULTED` 警告;重新保存后
该参数会显式写入 XML。
完成兼容转换后,解析器统一按介质引用版本 1 执行以下语义校验:
- 介质定义组件的 `gi` 必须是 `1..99` 的整数,且工程内不得重复。
- 气动组件的介质引用必须是 `0..99` 的整数。
- `gi=0` 始终引用内置空气理想气体;所有正索引必须存在对应介质定义。
- 同一个气动连通分量内只能使用一个 `gi`,不同的独立气动网络可以选择不同
介质。
相应错误码为 `AMESIM_GAS_MEDIUM_INDEX_INVALID`、
`AMESIM_GAS_MEDIUM_INDEX_DUPLICATE`、
`AMESIM_GAS_REFERENCE_INDEX_INVALID`、`AMESIM_GAS_REFERENCE_UNDEFINED` 和
`AMESIM_GAS_REFERENCE_CONFLICT`。XML 解析得到的规范化工程 JSON 总是输出
数值字段 `"mediumReferenceVersion": 1`;因此旧文档一经解析并重新保存,便
不再依赖旧版启发式迁移。
## 仿真模型编译接口
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。
2. 将前端端口声明与组件注册端口逐项比对。
3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。
4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。
成功响应中的物理连接只包含两个 `endpoints`,不包含 `source/target`:
```json
{
"success": true,
"name": "transfer-system",
"components": [
{
"id": "cylinder_1",
"type": "cylinder",
"ports": [
{
"name": "port_b",
"kind": "physical",
"domain": "pneumatic",
"nominalRole": "outlet",
"positiveFlowDirection": "intoComponent",
"variables": [
{"name": "p", "role": "effort", "connectionRule": "equal"},
{"name": "m_flow", "role": "flow", "connectionRule": "sumToZero"},
{"name": "h_outflow", "role": "stream", "connectionRule": "streamMix"}
]
}
]
}
],
"connections": [
{
"id": "edge-1",
"kind": "physical",
"domain": "pneumatic",
"endpoints": [
{"component": "cylinder_1", "port": "port_b"},
{"component": "tank_1", "port": "port_a"}
]
}
],
"unconnectedPorts": []
}
```
一个物理端口当前只允许一条连接;需要分支时必须显式放置 `Tee` 等结点组件。这样拓扑不会通过“一个端口连多条线”隐式产生结点方程。
此接口完成模型实例化、端口契约校验、拓扑编译和压力-流量方程结构组装。编译结果中的 `pressureFlowSystem` 包含未知量、方程、数量及 `isSquare` 状态;方阵只表示结构数量平衡,不代表方程一定可解。
当前组件已提供可执行残差:气瓶和贮箱提供状态-压力约束,孔板提供流量守恒和压差-流量本构关系,三通提供等压零结点和流量守恒。XML 注册表中的管段使用准稳态 Darcy 阻性模型,同时提供流量守恒和双向压降关系。连接层根据端口契约生成 `p` 相等及 `m_flow` 代数和为零的残差。
`/api/reactflow/simulate-testmodel` 继续保留固定 TestModel 和动态管段,用于已有基线对比。XML 驱动仿真使用独立的通用半显式求解链路,不调用固定 TestModel 闭合器。
## 第二阶段:XML 解析与校验
第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层:
| 层级 | `layer` | 负责内容 |
|---|---|---|
| XML | `xml` | 文档大小、XML 语法、禁止 DTD 和实体声明 |
| XSD | `schema` | v2 版本、元素顺序、必填属性、枚举、基础数值类型 |
| 模型语义 | `semantic` | 组件注册、端口契约、参数集合和范围、端点引用、物理域、连接占用及仿真设置 |
校验诊断统一包含:
```json
{
"severity": "error",
"layer": "semantic",
"code": "ENDPOINT_PORT_UNKNOWN",
"message": "Connection edge-1 references unknown port tank_1.port_x.",
"path": "/System/Connections/Connection[1]/Endpoint[2]",
"line": 18
}
```
未连接端口使用 `PORT_UNCONNECTED` 警告,不会阻止解析和网络编译;结构错误、接口不一致、参数错误和非法拓扑会使 `valid=false`。
### API
四个接口均直接接收 `Content-Type: application/xml` 的原始 XML 请求体:
- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。
- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。
- `POST /api/system-xml/compile-model`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 HTTP `422`。
- `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。
示例:
```powershell
Invoke-RestMethod `
-Method Post `
-Uri http://127.0.0.1:8000/api/system-xml/validate `
-ContentType application/xml `
-InFile .\test\system.xml
```
组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
## 第三阶段:XML 驱动仿真 MVP
第三阶段当前已经打通:
1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。
2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。
3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。
4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。
5. 动态组件自动拼装质量及内能导数,使用 XML 的 `tStart/tStop/step/maxStep/method` 开展积分。
6. 结果包含动态组件的 `m/U/p/T/rho/u/h`,以及全部物理端口的 `p/m_flow/h_outflow` 时间序列。
当前限制:
- 只支持注册表中的气动物理组件,不支持信号端口仿真。
- 所有物理端口在运行前必须完成连接;分支必须显式使用三通。
- 每个独立物理网络必须包含至少一个气瓶或贮箱作为压力和焓的储能锚点。
- 两个储能组件不能通过理想连接或纯三通直接耦合,必须在中间放置孔板或管段。
- XML 管段当前是准稳态阻性元件,`p0/T0` 用于名义密度和初始代数猜测,不包含管内储气动态。
- 当前 stream 混合是适合 MVP 的正则化近似,还不是 Modelica `inStream/actualStream` 的严格复刻。
- 当前是半显式 ODE/代数求解链路,不支持一般高指数 DAE 和事件系统。
运行示例:
```powershell
Invoke-RestMethod `
-Method Post `
-Uri http://127.0.0.1:8000/api/system-xml/simulate `
-ContentType application/xml `
-InFile .\test\system.xml
```
+376
View File
@@ -0,0 +1,376 @@
# 更新日志 2026-08-15
## 记录范围
本文记录 `model-development` 分支截至 2026-08-15 的当前未提交工作区改动。范围包括前端建模交互、组件图标与参数驱动布局、后端模型合同、求解与采样安全、工程 JSON、System XML v3、文档、Schema 和自动化测试。
## 重点摘要
- 建模区完成框选纠正、待放置式粘贴、纵向滚轮平移、双向滚动条、接触边去交互化和端口显示重构。
- 仿真控制台改为停靠在图形建模区底部,可折叠、调高并在折叠状态显示摘要。
- 组件库固定使用图标模式,移除前端兜底测试库,增加明确的加载成功/失败状态。
- LMECHN1 改为 1~20 个动态右侧端口,默认 2 个,并同步更新图标、编号、连接、迁移和求解合同。
- FORC 的力方向改为显式参数,不再由图标旋转或镜像改变物理符号。
- MECMAS21 默认改为启用摩擦并使用理想限位,图标根据摩擦和限位参数显示四种外观。
- System XML 升级为 v3;ReactFlow 工程 JSON 固定为 `projectSchemaVersion: 1`,组件实例增加严格的 `modelVersion` 合同。
- 增加采样点数量、浮点时间精度、物理岛、端口基数和模型版本等安全检查。
## 前端建模与交互
### 选择与框选
- 框选过程改为按组件实际图标包络同步纠正 React Flow 内部选择状态,而不是依赖透明节点外框。
- 框选区域缩回图标包络之外时立即取消误选,消除“没有框到却仍被选中”和一帧选中闪烁。
- 框选结束、取消、自动平移和视口变化后会复核并清理选择矩形状态。
- 批量选择结束后只保留各组件自身的虚线选中框,不保留整体选择矩形。
- 从组件库拖入的新组件会成为唯一选中项,后续复制、旋转、镜像等操作不会继续作用于旧选择。
### 复制与待放置粘贴
- `Ctrl+V` 不再立即把副本写入工程,而是进入跟随鼠标的待放置预览状态。
- 待放置状态支持:
- 左键确认位置;
- 中键单击旋转;
- `Esc` 取消;
- 普通滚轮平移、`Ctrl+滚轮` 缩放、中键拖动画布和拖动滚动条。
- 待放置期间会锁定撤销、重做、再次粘贴、属性修改、组件库拖入、工程加载和视图切换等会改变模型的操作。
- 多组件副本会连同内部连线一起预览和落地。
- 节点和边 ID 在生成副本时会避让当前工程中的既有 ID;修复复制已连接的同类型组件时副本复用旧 ID、覆盖预览并最终移动原组件的问题。
- 提交待放置内容前再次执行重复 ID 防御检查。
### 端口、连线与吸附
- 自动接触吸附采用双下限:低倍缩放至少保留 `10px` 屏幕判定范围,高倍缩放至少保留 `10` 个画布单位;解决放大后半径被换算得过小、并与 `18` 单位网格叠加后端口难以对接的问题,同时避免缩小时退化成约 `1px` 的命中范围。
- 一次拖动可匹配同一落点内的全部兼容端口,并按距离和端口合同进行确定性配对。
- 已连接端口统一隐藏、禁止指针事件且不可再次发起连接;前端信号端口不再提供扇出入口。
- 物理端口可见层改为真实 SVG 圆形;信号端口使用 SVG 圆角矩形,避免非整数缩放时 CSS 圆角端口显示成不同椭圆。
- 可见端口图形以 `6×6` 为基准并随画布同比缩放;物理圆形横纵缩放保持一致。
- 可见图形与 Handle 命中层解耦,外层继续保留约 `15×15px` 的操作热区,缩小画面后仍便于连接。
- `isContactEdge` 成为工程边的显式字段;完全重合的接触边不绘制路径、不提供按钮、焦点、点击或框选入口。
- 删除接触边端点的透明圆形保护层,避免保护层覆盖 PNCH012 等小型多端口元件的主体选择区域。
- 普通可见连线仍保留中段选择和删除能力;点击节点、画布或普通边会清理残留的端口连接状态。
- 加载工程时以当前目录端口合同为准,删除、重复占用、类型不兼容或当前参数下未启用的端口连接会被逐条丢弃并报告原因。
### 画布导航
- 普通鼠标滚轮改为纵向平移建模画布。
- `Ctrl+滚轮` 保留缩放;中键拖动画布平移行为保持不变。
- 建模区新增底部水平滚动条和右侧垂直滚动条,滚动条、React Flow 视口和键盘滚动操作双向同步。
- 滚动条使用窄轨道,在保证可点击的同时尽量减少对建模区域的占用。
- 移除建模区左下角 React Flow 放大、缩小、适配和锁定按钮。
- 隐藏建模区和结果系统图右下角的 React Flow 水印。
### 仿真控制台
- 新增 `DockedSimulationConsole`,控制台只占用中央图形建模区底部,不再横跨模型库和参数栏。
- 支持鼠标拖拽和键盘调整高度,双击分隔条恢复默认高度;高度写入本地存储。
- 折叠后只保留约 `40px` 标题栏和最后一条日志/进度摘要。
- 折叠状态收到新日志或仿真进度时只更新摘要,不再自动展开完整控制台。
- 保留仿真进度、停止、清空、XML 日志和普通日志功能。
- 控制台不再记录拖入组件、手动连接、自动吸附、拖开接触连接、全选、网格显示和适配画布等低价值建模噪声;继续保留仿真、检查、XML、复制粘贴、撤销重做、旋转镜像、删除、保存加载及重要迁移警告。
- 控制台文本选择使用浏览器原生复制,不再触发画布组件复制快捷键。
- 旧浮动控制台代码暂由功能开关保留,便于对基础版本进行回退审阅。
### 组件库
- 组件库固定使用图标卡片模式,删除图标/列表切换按钮、列表元信息、本地显示模式设置和相关残留代码。
- 图标卡片保持固定尺寸,保留名称截断、悬停完整名称和放大预览。
- 字体层级调整为:组件库标题 `16px`、库分组 `14px`、类别分组 `12px`。
- 成功状态只显示“已加载 X 个组件库”和绿色状态标记。
- 加载失败时不再显示前端临时测试兜底库,组件区域保持为空并显示红色状态。
- 失败状态支持悬停或键盘聚焦查看详细原因;能识别目录中具体失败的组件库时直接显示其名称。
- `experimental` 临时测试库无论目录加载成功还是失败都不会出现在组件库中。
- 从组件库拖拽时使用工作区 `viewBox`、实际节点尺寸和当前缩放生成待拖入图像,避免落地前后图标形状或大小跳变。
### 参数、工程与结果页
- 只有一个固定选项的 `choice` 参数不再显示,避免出现没有实际选择价值的参数行。
- LMECHN1 右侧端口数执行 `1..20` 整数校验;超过上限时显示“右侧端口数量不能超过20”。
- LMECHN1 缩减端口数前会检查即将隐藏的端口是否仍连接;参考端口编号变化时迁移已有参考连接。
- 参数表、模型检查和 XML 导出共用模型合同与参数验证。
- XML 数值输出使用可往返的双精度文本,减少格式化造成的物理输入精度损失。
- 缺失或不匹配 `modelVersion` 的工程仍可加载检查,但组件显示合同警告,且不能通过模型检查、生成 XML 或运行仿真。
- 仿真结果快照同步要求工程 Schema、节点模型版本、端口结构和 `isContactEdge` 合同;结果系统图复用新的端口图形。
- 结果页容器尺寸变化时重新约束上下窗格布局,避免控制台或工作区尺寸变化造成裁切。
## 组件图标与模型合同
### 图标布局基础设施
- 组件图标布局可根据参数动态返回 `viewBox`、工作区画布、节点尺寸和端口锚点。
- 图标渲染器新增 `palette/canvas` 场景,使组件库固定缩略图与工作区动态图标能够分别定义。
- 删除旧 `legacyPortAnchors` 映射,端口布局、连接、吸附和 XML 导出统一使用组件目录及当前注册图标合同。
### LMECHN1 动态线性机械节点
- 模型版本由 `0.1.0` 升级到 `0.2.0`。
- 默认右侧端口数由 8 改为 2,允许范围扩展为 1~20。
- 目录注册 `port_1`~`port_21`;当 `v1=N` 时,显示 `N` 个右侧端口,并把 `port_{N+1}` 作为左侧参考端口。
- 工作区使用 `custom` 动态画布:端口数量增加时纵向扩展,改变数量时尽量保持元件中心不动。
- 默认 2 个右侧端口时节点约为 `148×96px`;20 个右侧端口时高度约为 `534px`。
- 主框线宽为 `2.5px`;引线和小连接框线宽为 `1.5px`;连接框约为 `6×5`,引线与连接框保持同轴。
- 组件库固定显示 5 个右侧引脚和 1 个左侧参考引脚,占组件库画布 `90%`,不受工作区参数影响。
- 端口编号随数量更新;20 个右侧端口时保持单列,旋转后保持单行且不重叠。
- “节点求和模式”后端仍保留唯一标准模式合同,前端隐藏该无效配置行。
- 旧 `0.1.0` 工程中的固定参考端口 `port_9` 可按 `v1` 迁移为动态参考端口,并将 `sum` 规范化为标准值。
- 求解层只要求活动端口 `port_1..port_{N+1}` 连接;活动端口位移、速度相等,力代数和为零。
- 未启用的预留端口增加 `x/v/f=0` 约束,避免其参与当前节点计算或造成方程欠定。
### PNRP17 图标
- 按参考图重绘上部壳体、上下左侧填充壁面、下部基座、活塞套、活塞面、水平活塞杆和接口引线。
- 左侧壳体补充参考图中的实心壁面。
- 上下两组左右箭头复用同一几何,右箭头由镜像生成,确保大小和形状完全一致。
- 箭头尖端分别触碰左侧壁面和活塞壁面。
- 两侧箭杆各等量延长 `1.8` 个逻辑单位,中间仍保留约 `2.4` 个逻辑单位的空隙,与参考图比例一致。
- 主轮廓线宽调整为 `2.1px`,箭头等细节调整为 `1.9px`。
- 使用约 `1.21` 的纵向比例修正整体外观;保持 `standard` 尺寸档位和约 `132×112px` 节点尺寸。
- 端口排列调整为:左侧 `port_3/port_2`、右侧 `port_4/port_5`、底部 `port_1`;锚点随最终几何同步更新。
- 忽略参考截图中的绿色接口提示像素,并保证引线不会被端口图标完全遮挡。
### PN3NODE2 与 P4NODE2 图标
- 两个节点均调整为 `small` 工作区画布,图形最长边占方形 viewBox 的 `40%`。
- 按参考图改为直线汇流结构:上下、右侧及端口外露引线使用 `1.5px` 细线,中心汇流支路使用 `4px` 粗线。
- 粗汇流支路显式绑定 `port_2`,固定显示在左侧;PN3NODE2 的其余端口位于上、下方,P4NODE2 的其余端口位于上、右、下方。
- 中心使用与粗线比例一致的实心洋红圆点,颜色统一为 `#8b134f`。
- 删除 SVG 内重复绘制的端口小圆,端口图形由统一的 React Flow Handle 负责;外露细引线在 Handle 覆盖后仍保持可见。
### PNL00R 与 PNL0001~PNL0003 图标
- 四个管路元件统一使用 `standard` 工作区画布,节点尺寸统一为约 `132×112px`。
- 图形以画布中心等比缩放,最长边占 64×64 方形 viewBox 的 `60%`。
- 左右接口锚点随图形同步收拢,引线在端口图标覆盖后仍保留可见长度。
- 组件库继续按 `standard` 档位的独立缩略图规则显示,不受工作区 60% 占比影响。
### MECMAS21 图标与默认状态
- `useFriction` 默认值由 1 改为 2,即默认启用摩擦。
- `stoptype` 默认值由 4 改为 1,即默认使用理想限位。
- 新拖入元件和组件库缩略图默认显示“有摩擦、有末端约束”外观。
- 根据摩擦与限位参数显示四种图标:有摩擦有约束、有摩擦无限位、无摩擦有约束、无摩擦无限位。
- 参数切换只改变摩擦、导轨和限位区域,核心滑块、节点尺寸和选择包络保持一致。
- 滑块主轮廓继续使用 `2.5px` 最终线宽。
### LSTP00A 图标
- 按参考图重绘左右接口框、三条竖向导轨、上部弹性曲线和下部机械连接结构。
- 使用 `standard` 画布和约 65% 的工作区占比;组件库按 standard 档位显示 90%。
- 图标颜色为 `#00af00`,工作区线宽统一为 `3px`。
- 左右引线延伸到接口 Handle 内侧后仍保持可见,节点包络基本为正方形。
### FORC 力源
- 模型版本由 `0.1.0` 升级为 `0.2.0`。
- 新增显式 `direction` 参数:`1` 为正向,`-1` 为反向。
- 删除图标旋转或镜像改变求解力符号的布局耦合;旋转和镜像只影响显示。
- 旧版 FORC 工程在前端定向迁移时补充正向参数和当前模型版本。
## 后端求解与安全性
### 组件与网络合同
- 为组件增加 `required_connection_ports`:默认要求全部物理端口连接,动态组件可只声明当前活动端口。
- 禁止同一组件的任意两个端口互相连接。
- 每个信号输入只允许一个驱动源。
- 后端和 System XML 仍支持一个信号输出连接多个输入;当前前端 UI 采用更严格的一端口一连接策略,不提供信号扇出。
- 物理岛检查只遍历物理组件与物理连接,信号连线不再错误合并独立物理岛。
- 可识别通过同一信号源控制、但自身缺少动态储能锚点的物理孤岛。
### 仿真采样安全
- 新增稳定的采样时间错误类型与错误码合同。
- 采样网格在分配数组前检查起止时间、跨度、步长和派生数量是否有限且可表示。
- 采样步长必须大于零;最多允许 `10001` 个采样点。
- 极小步长、整数转换溢出和浮点精度不足以推进绝对时间的情况会被提前拒绝。
- 采样序列保证同时包含起止时间、至少两个点且严格递增。
- System XML 语义校验会提前调用同一采样检查,不安全输入不会进入积分器。
## 工程 JSON v1
- ReactFlow 工程顶层增加必填 `projectSchemaVersion: 1`。
- 每个节点保存 `modelVersion`;执行前必须与组件目录当前模型版本完全一致。
- `componentType`、`modelType` 与注册模型类型必须一致。
- 端口必须保存为结构化对象,不再从旧字符串端口猜测合同。
- 工程边必须保存布尔值 `data.isContactEdge`。
- 参数显示单位、科学计数法原始文本和未来展示元数据继续参与工程持久化。
- 后端严格校验工程 JSON v1;损坏数据和不支持的版本返回 422。
- 删除并拒绝旧兼容标记:
- `mediumReferenceVersion`;
- `amesimParameterEncodingVersion`;
- `presentationLayoutVersion`。
## System XML v3
- System XML v3 成为当前唯一支持的求解协议,并新增 `schemas/system-simulation-v3.xsd` 与 `docs/system-xml-v3.md`。
- 删除 v1/v2 XSD 和协议文档,不再自动识别或迁移旧 XML。
- 根节点固定 `schemaVersion="3"` 和 `unitSystem="SI"`。
- `Simulation/@step` 更名为 `sampleStep`。
- 组件只保存 `id`、`type`、必填 `modelVersion` 和完整 SI 参数。
- 连接只保存可选 `id` 及两个 `(component, port)` 端点。
- 坐标、旋转、镜像、显示名、端口快照、连接 `kind/domain` 和端点 `role` 等编辑器数据不再进入 XML。
- 端口种类、物理域、信号方向和变量合同统一从组件注册表恢复。
- v3 语义校验覆盖模型版本、参数完整性与范围、重复 ID/端点、组件自连、端口存在性、端口域、信号方向、信号输入多驱动和物理端口重复连接。
- `/api/system-xml/parse` 返回编辑器无关的执行 `model`,不再返回 ReactFlow 工程。
- XML 可直接编译为公共 `SolverModelInput`;ReactFlow JSON 与 XML 共用网络编译入口。
- JSON→XML 导出会补齐注册默认参数、写出当前完整参数、剔除编辑器字段,并在返回前再次执行 XSD 与语义校验。
## 兼容性与破坏性变更
- System XML v1/v2 文件必须由来源端重新导出 v3,不能只修改版本号。
- 旧 XML 的 `step` 必须改为 `sampleStep`,并为每个组件补齐当前 `modelVersion` 和完整注册参数。
- `/api/system-xml/parse` 的响应从 ReactFlow `project` 改为执行 `model`,调用方必须适配。
- 严格模型版本检查会拒绝未迁移的 FORC `0.1.0` 和 LMECHN1 `0.1.0` 执行文件。
- MECMAS21 不再把旧 `0/1` 编码静默转换成 AMESim 原生 `1/2`;旧工程需要明确迁移。
- 介质引用不再猜测旧格式:缺失介质定义时不会把 `gi=1` 自动映射为内置空气,XML 缺失 `gi` 或 `property_model` 时不会自动补值。
- 缺少 `projectSchemaVersion`、使用字符串端口或携带已删除兼容标记的旧工程不符合工程 JSON v1。
- 删除 Pydantic v1 序列化兼容分支,后端当前明确依赖 Pydantic v2。
- `experimental` 组件包删除旧 `LIBRARY_ID/LABEL/VERSION/...` 兼容别名,只保留规范化 `LIBRARY` 清单。
## 文档与 Schema
### 新增
- `docs/backend-interface-version-spec-v1.md`:统一说明 HTTP、组件目录、工程 JSON、组件库、模型和 System XML 的版本边界与事实优先级。
- `docs/system-xml-v3.md`:System XML v3 当前协议。
- `schemas/system-simulation-v3.xsd`:System XML v3 Schema。
- `docs/后端求解逻辑与效率优化调研.md`:当前半显式 ODE、代数闭合、采样、流式任务与性能优化方向。
- `docs/接口类型与表示方式总结.md`:组件目录、工程 JSON、System XML 和编译结果之间的字段边界。
### 更新
- 根 `README.md`、`app/simulation/README.md` 和 `docs/README.md` 同步当前接口、协议入口和求解能力说明;文档索引新增本更新日志入口。
- `docs/component-library-spec-v1.md` 更新组件库发现、启动校验、临时库显示和前端读取规则。
- `docs/component-model-authoring-spec-v1.md` 更新模型版本、动态端口和合同测试要求。
- `docs/amesim-component-migration-matrix.md` 更新 AMESim 机械组件迁移状态。
### 删除
- `docs/system-xml-v1.md`
- `docs/system-xml-v2.md`
- `schemas/system-simulation-v1.xsd`
- `schemas/system-simulation-v2.xsd`
- `tests/test_reactflow_project_medium_reference.py`,其覆盖被工程 JSON v1、介质合同和 System XML v3 测试替代。
## 测试覆盖
### 新增或扩展的后端测试
- 工程 JSON v1 的版本、节点模型版本、端口结构、旧兼容标记、持久化元数据和损坏数据拒绝。
- System XML v3 的最小模型、编辑器字段拒绝、模型版本、参数、组件自连、端口合同、信号方向和连接基数。
- 仿真采样点上限、严格递增、两端点、极小步长、非有限时间和浮点不可推进时间。
- 物理岛检查在信号扇出场景下的正确性。
- FORC 正反方向、非法值及旋转不改变物理方向。
- MECMAS21 新默认值和旧编码拒绝。
- LMECHN1 默认 2 个、最大 20 个右侧端口、动态参考端口、未启用端口零约束和方程组方阵性。
- LMECHN1 的 2 端口与 8 端口完整仿真,均验证 `10 N / 2 kg = 5 m/s²`。
- 氦气、介质、气室、管路、阀、孔板、节点、PNRP17、UD00 等现有 XML 测试统一迁移到工程 JSON v1 与 System XML v3。
### 新增或扩展的前端 E2E
- 实际图标包络框选、框选回撤和虚线选择框。
- 待放置式粘贴、鼠标跟随、中键旋转、视口操作、ID 避让和控制台原生复制。
- 底部停靠控制台的范围、高度、折叠摘要和持久化。
- 组件库成功/失败状态、临时库过滤、固定图标模式、字号、折叠、悬停预览和工作区拖拽预览。
- 动态端口数量、编号、旋转布局、参考端口吸附、旧工程迁移及上限错误。
- 接触边无交互入口、已连接端口隐藏、端口圆形和随视口缩放。
- 高倍缩放吸附双下限、PNRP17 等长箭杆以及 PN3NODE2/P4NODE2 的 40% 直线汇流图标。
- PNL00R、PNL0001、PNL0002、PNL0003 的 standard 画布、60% 工作区占比与接口锚点。
- 工程版本、模型版本、MECMAS21 编码拒绝、System XML v3 导出与双精度参数文本。
- 结果页适配、端口图形、水印隐藏和控制台作用范围。
### 当前已复核的定向检查
- LMECHN1 方程、默认值、整数/上限与 2/8 端口仿真:6 个测试通过。
- 建模区端口连接、端口随画布缩放及结果页端口:3 个 E2E 通过。
- 本轮 PNRP17、PN3NODE2/P4NODE2、高倍缩放并开启网格的吸附、既有 10px 边界和控制台降噪:5 个定向 E2E 通过。
- PNL00R、PNL0001、PNL0002、PNL0003 的 standard 画布与 60% 工作区占比:1 个定向 E2E 通过。
- 合并远端求解优化后,完整后端测试共 `589` 项通过。
- 前端全量 E2E 首轮 `91/95` 通过;其余 4 项陈旧断言修正后定向重跑全部通过,当前 95 项覆盖均已验证。
- TypeScript 类型检查通过。
- Vite 生产构建通过;仍存在已有的单个大于 500 kB chunk 警告。
- `git diff --check` 在写入本日志前通过,仅报告既有 LF/CRLF 转换提示。
## 当前变更文件范围
### 前端源码
- `frontend/src/App.tsx`
- `frontend/src/ComponentSymbol.tsx`
- `frontend/src/ContactAwareEdge.tsx`
- `frontend/src/DockedSimulationConsole.tsx`(新增)
- `frontend/src/SimulationResultsView.tsx`
- `frontend/src/componentSymbols/mechanical.tsx`
- `frontend/src/componentSymbols/pneumatic.tsx`
- `frontend/src/componentSymbols/types.ts`
- `frontend/src/styles.css`
### 后端源码
- `app/main.py`
- `app/system_xml.py`
- `app/simulation/components/amesim/mechanical/translational.py`
- `app/simulation/components/experimental/__init__.py`
- `app/simulation/core/base.py`
- `app/simulation/systems/generic.py`
- `app/simulation/systems/network.py`
### 文档与 Schema
- `README.md`
- `app/simulation/README.md`
- `docs/README.md`
- `docs/amesim-component-migration-matrix.md`
- `docs/backend-interface-version-spec-v1.md`(新增)
- `docs/component-library-spec-v1.md`
- `docs/component-model-authoring-spec-v1.md`
- `docs/system-xml-v1.md`(删除)
- `docs/system-xml-v2.md`(删除)
- `docs/system-xml-v3.md`(新增)
- `docs/后端求解逻辑与效率优化调研.md`(新增)
- `docs/接口类型与表示方式总结.md`(新增)
- `docs/更新日志-2026-08-15.md`(新增)
- `schemas/system-simulation-v1.xsd`(删除)
- `schemas/system-simulation-v2.xsd`(删除)
- `schemas/system-simulation-v3.xsd`(新增)
### 前端测试
- `frontend/tests/e2e/amesim-medium.spec.ts`
- `frontend/tests/e2e/component-symbols.spec.ts`
- `frontend/tests/e2e/fit-view.spec.ts`
- `frontend/tests/e2e/fixtures.ts`
- `frontend/tests/e2e/modeling-actions.spec.ts`
- `frontend/tests/e2e/palette-display.spec.ts`
- `frontend/tests/e2e/parameter-table.spec.ts`
### 后端测试
- `tests/test_amesim_gas_registry.py`
- `tests/test_amesim_helium_medium.py`
- `tests/test_amesim_helium_step_long_run.py`
- `tests/test_amesim_mechanical_public_components.py`
- `tests/test_amesim_mechanical_xml.py`
- `tests/test_amesim_pnch012_xml.py`
- `tests/test_amesim_pnch023_xml.py`
- `tests/test_amesim_pneumatic_node_xml.py`
- `tests/test_amesim_pnl0001_xml.py`
- `tests/test_amesim_pnl0002_pnl0003_xml.py`
- `tests/test_amesim_pnl00r_xml.py`
- `tests/test_amesim_pnor001_xml.py`
- `tests/test_amesim_pnpl01_xml.py`
- `tests/test_amesim_pnrp17_xml.py`
- `tests/test_amesim_pnvo001_fixed_xml.py`
- `tests/test_amesim_pnvo001_signal_xml.py`
- `tests/test_amesim_signal_components.py`
- `tests/test_amesim_ud00_xml.py`
- `tests/test_component_catalog.py`
- `tests/test_generic_system_xml_simulation.py`
- `tests/test_medium_reference_contract.py`
- `tests/test_reactflow_project_medium_reference.py`(删除)
- `tests/test_reactflow_project_schema.py`(新增)
- `tests/test_simulation_safety.py`(新增)
- `tests/test_system_xml_parser.py`
- `tests/test_system_xml_protocol.py`
- `tests/test_system_xml_v3.py`(新增)
- `tests/test_test_mql_example_runner.py`
@@ -0,0 +1,85 @@
# 更新日志 2026-08-17
## 11:15
- 复测复杂系统 XML:磁盘文件的终止时间为 `0.81 s`,原样运行正常完成;仅在内存中延长至 `2.10 s` 后也正常越过此前报告的 `2.05 s` 慢区并结束,没有发生重试、回退或无进度卡死。
- 性能分析确认约 97% 时间消耗在积分阶段,有限差分 Jacobian 估计约占 RHS 工作量的 69%;后续优化重点确定为半解析 Jacobian、扁平数值中间表示和 stream/物性传播,而不是继续优先优化未触发的全局非线性求解。
- Jacobian、因果快路径、stream 方程块和逐步积分相关的 44 项回归测试通过;`10 s` 长时运行尚未验证,不能由 `2.10 s` 结果外推保证。
## 11:18
- 重构 `docs/README.md`,明确 `update-log/`、`standard/`、`other/` 三类目录职责,并建立按日期命名、按实际完成时间分段的更新日志规范与模板。
## 11:28
- 完成求解器架构复核:当前 472 个代数未知量已形成覆盖 200 个 flow/force 未知量的因果赋值计划,复杂模型运行中因果快路径审计没有失败或回退;确认下一阶段应在现有计划之上建设可求导、可数组化的数值内核,而不是重复实现因果求解器。
## 11:30
- 精简文档索引,删除容易过期的专题清单、代码入口和单日日志链接,只保留目录职责与更新日志规范;明确历史工作缺少准确完成时间时不得猜测补写。
## 11:35
- 新增 `docs/other/求解器性能优化任务清单.md`,记录 `0.81 s`、`2.10 s` 基线,建立 OPT-00~OPT-10 优化任务、验收标准、回滚要求、性能对比口径以及 `10 s` 长时验证计划。
## 11:38
- 修复文档重新分类产生的 18 条 Markdown 断链、4 处旧路径文字和 1 处易失效的行号引用;保留历史日志原文不变,全仓本地 Markdown 相对链接复扫为 0 条断链,`git diff --check` 通过。
## 15:04
- 新增 callable 稀疏 Jacobian 数值层,并为 BDF/Radau 接入真实 RHS 计数、求解分段重建、取消检查、诊断统计和类型化完整数值回退;显式积分器继续忽略 Jacobian。
- 完成三条活塞支路 6 个机械状态列的首批半解析传播,覆盖 34 条因果赋值,并增加 Ideal/Peng–Robinson 介质及 PNRP17、PNCH012、PNL0001、LSTP00A、MECMAS21 等组件的局部切向原语;有限差分颜色数由 31 降至 25。
- 新路径仅在 `SIMULATION_ODE_JACOBIAN_MODE=semi-analytic` 时显式启用,默认仍使用 SciPy;无法证明拓扑、进入非光滑边界或局部导数不可用时恢复完整数值 Jacobian,不会把未知导数静默填为零。
- 相关测试共 250 项通过;局部半解析列相对独立中心差分的最大相对误差为 `1.897e-8`。另有一项热流体闭合测试因测试资源已移动而仍读取旧路径失败,与本次 Jacobian 修改无关。
- `0.81 s` 三组同机对比中,实验路径总墙钟中位数由 `61.203 s` 降至 `56.708 s`,Jacobian RHS 减少约 26.1%;`2.10 s` 单次由 `126.211 s` 降至 `116.512 s`,事件、启动和样本数量保持一致并正常越过 `2.05 s`。
- 默认容差下两条路径尚未满足拟定的严格逐点轨迹门槛,且通用 stream 导数、自定义组件、正式锁定环境复测和 `10 s` 长时覆盖尚未完成,因此 OPT-03 仍为部分实现,没有切换生产默认路径。
## 15:14
- 将后端、前端和一键启动入口统一到 `bat/` 目录,保留三组 Windows `.bat` 脚本,并新增一一对应的 Linux `.sh` 脚本;删除仓库根目录和 `frontend/` 下已被替代的旧启动入口。
- Windows 脚本改为从自身位置解析仓库路径,分别使用 `.venv-win` 和仓库内兼容的便携 Node.js;Linux 脚本使用 `.venv`,并优先选择 `.tools/node-*-linux-x64`,缺少环境、依赖或兼容 Node.js 时会给出明确提示。
- Linux 一键启动脚本可在同一终端管理 FastAPI 与 Vite;收到 `Ctrl+C`、终止信号或任一服务退出时会清理两个进程组,超时后仅强制结束尚未退出的进程,避免遗留后台服务。
- 新增脚本换行规则并设置 Linux 脚本可执行权限;本地 Linux Node.js 运行时加入忽略规则,避免 `.venv`、`node_modules` 和便携工具污染版本库。
- 根 `README.md` 补充 Windows/Linux 环境准备、Node.js 版本要求、六个启动命令、服务地址及停止方式,相关技术文档中的旧启动路径同步为 `bat/` 下的新入口。
- 完成本机开发环境准备:创建 Python `.venv` 并按 `requirements.txt` 安装依赖,`pip check` 无冲突;安装项目本地 Node.js `v24.18.0`、npm `11.16.0`,并通过 `npm ci` 安装前端依赖。
- Linux 脚本语法、启动和进程清理验证通过;FastAPI `8000` 与 Vite `5173` 均返回 HTTP 200,测试结束后端口无残留监听。Windows 脚本已完成静态检查,尚未在 Windows 实机运行。
- npm 审计报告 2 个高危依赖问题;本次未执行可能改变锁定依赖版本的自动修复,留待单独评估处理。
## 15:27
- 根据同日其他项目会话的最终记录补全上述文档管理、性能诊断和求解器优化工作,并与当前源码、测试及优化任务账本交叉核对。
- 在当前项目 `.venv` 中重新运行 Jacobian、切向原语、三活塞、core solver、稀疏结构和 XML 仿真的定向测试,共 86 项全部通过;`git diff --check` 通过。
- 热流体闭合套件当前仍为 12/13,通过项不受影响;唯一失败是测试继续读取已经移动的旧 fixture 路径。测试资源移动属于用户操作,本日志未将其计入其他会话的完成成果。
## 19:40
- 将用户提供的 `test-mql-8` XML 接入主回归目标并锁定输入哈希;新增支持软取消、硬超时、预算判断、检查点、信号分段和机械事件验收的递进回归运行器,延长测试只在子进程内存中覆盖 `tStop`。
- 默认启用可回滚的因果执行器 v2,普通 RHS 不再重复构造集合或扫描全部 760 个代数未知量,secondary 块只维护 176 个必要 flow 槽;初始化、事件和每 64 次求解仍执行完整残差审计。
- 半解析 Jacobian 改为按组件类型和端口拓扑自动发现支路:该模型识别 8 条支路、16 个精确列,理论有限差分颜色数由 52 降至 36;短测因 40/56 次边界回退而慢于 SciPy,因此继续保留为显式实验模式,未设为默认。
- 同状态 RHS 微基准约提升 19.1%,`0.01 s` 端到端由 `15.160 s` 降至 `13.172 s`且物理解哈希一致;`0.2 s` solver-only 运行正常完成,审计、运行时验证和旧路径回退均为 0。
- 聚焦测试 143 项通过、1 项长测跳过;全量 779 项中 773 项通过、1 项跳过,其余 5 项为既有缺失 fixture。按当时 `0.2 s` 耗时预算,`1/5/10 s` 暂缓执行,checkpoint 和依赖环境尚未批准为发布基线。
## 20:01
- 完成大型工程导入后的端口几何重测,建模页与结果页分别记忆视口,仅首次打开、导入或加载时自动适配;多组件移动和复制支持整块自由端口吸附,并在拖动时立即断开块外接触连接。
- 新增点击端口接线、空白处添加折点、`Esc` 取消、路由写入工程 JSON、内部线段拖动及未连接交叉线的电路图式线桥。
- 结果曲线支持框选、单轴、撤销和自动缩放,并保持切页后的缩放状态;使用大型 `test-mql-8` 工程验证导入、适配和连线端点。
- 前端 E2E `104/104`、TypeScript 检查、生产构建和 `git diff --check` 全部通过;仅保留非阻断的 bundle 大小提示。
## 22:35
- 将用户提供的 `test-mql-8.xml` 与 `test-mql-8.json` 纳入权威回归输入校验,自动检查双哈希、模型结构和仿真配置;回归运行器支持独立覆盖 `tStop`、`sampleStep`、`maxStep`,并将 `production` 设为默认验收通道。
- 建立 `0.01 s` smoke 和 `0.2/1/5/10 s` 递进门禁、软硬超时及超预算暂缓;production `0.2 s` 正常完成,并批准包含 3 个检查点、134 个投影键、共 402 个状态值的 golden,另行校验输出形状合同。
- 增加 Python 3.12.3 与直接依赖参考约束,以及短测、历史模型夜间回归和主目标周期长测三层 CI;全量后端共运行 `792` 项测试,结果为 OK,其中 `3` 项按条件跳过,测试后无遗留仿真进程。
- P0 已形成支持后续优化的基础闭环,但仍缺关键压力、流量和守恒量投影、发布级完整依赖锁、干净环境重建、远端 CI 首次验证及正式环境三次 `2.10 s` 复测;按当时预算仍未启动 `1/5/10 s`。
## 22:40
- 确认 P0 门禁已足以进入 OPT-01,并允许启动 OPT-02 的小型参考数值 IR;后续扩围统一执行“结构测试 → `0.01 s` smoke → production `0.2 s` golden A/B → 性能对比”,但当前状态尚不代表求解器已完成发布验收。
## 22:48
- 修复同一组件不同端口引出线路交叉时漏画线桥的问题,并让结果页系统图复用线桥;连接线支持拖动水平段、竖直段和拐点,直线可自动转换为可调正交折线。
- 结果曲线增加以鼠标位置为中心的滚轮缩放、坐标轴单轴缩放、中键拖动平移、框选放大、越过数据范围及负数区域、恢复原始尺寸;缩放与游标互斥,空白视口禁用游标,并移除点击后的黑色焦点框。
- 使用 `test-mql-8.json` 完成专项验证;前端 E2E `109/109`、TypeScript 检查、项目 Node 24 下的生产构建和 `git diff --check` 全部通过。
@@ -0,0 +1,37 @@
# 更新日志 2026-08-18
## 02:28
- 完成 P0 基础回归闭环,并推进 OPT-01:将 760 个兼容代数槽压缩为 440 个逻辑坐标,逻辑消去 320 个 effort 别名;全局 RHS 微基准约提升 12.9%,Python 调用数约下降 36.8%。
- 建立独立可执行的因果数值 IR schema v1,分离结构程序与运行时绑定并支持逐阶段对照和事务回滚;该 IR 仍是参考实现,尚未接管默认 RHS 热路径。
- 修复 PNL00R 上游连接温度引用语义;为热流体闭合增加事务快照、类型化失败诊断、试探态回滚和基于实际试探步长的减步重试,并修复成功恢复后最大步长被永久限制的问题;无状态事件的 Generic 系统也可使用可恢复积分路径。
- `1 s`、`2 s`、`5 s` 和 `10 s` 递进长测均取得完成结果,`maxStep=0.02` 是当前长测首选;另一个 `5 s/maxStep=0.05` 单元在模拟时刻约 `4.252 s` 因 1200 秒预算合作取消,属于预算控制而非求解失败。
- 最终 `10 s/maxStep=0.02` 单次运行在约 `1602.7 s` 完成,1717 条序列、1,722,151 个标量全部有限并经历 2 次机械状态转换;模拟时刻约 `6.964 s` 的一次热流体试探态失败经事务回滚、减步和 1 次重试后继续完成,因果审计、运行时验证和旧路径回退均为 0。
- 相同 `maxStep=0.02` 的 `2 s` 与 `10 s` 运行在公共严格前缀检查点逐值一致,确认此前约 `1.86 s` 的失败来自不可恢复的试探态闭合处理,而不是远端 `tStop` 直接改变物理方程。
- 全量后端共运行 `828` 项测试,结果为 OK,其中 `3` 项按条件跳过;差异检查通过且测试后无遗留仿真进程。
- 当前仍有明确限制:跨最大步长严格比较尚未全部通过;最终 `0.2 s` 候选相对旧 golden 为 `398/402`,4 个超差项均是终点派生加速度,旧 golden 未被覆盖;最终 `10 s` 仅完成一次,仍需三次中位数、资源稳定性和新 golden 决策。
## 07:58
- 以 `AmesimModels/test_mql.ame` 为权威重新核对 `test-mql-8.xml/json`:20 类、157 个项目组件、178 条连接和 1092 个参数一致;唯一确定差异是仿真配置,已按 AME 与其结果网格修正为 `0→10 s`、`sampleStep=0.01 s`、`maxStep=0.001 s`。
- 新增 AME 防漂移契约,直接验证归档 SHA/字节、`.sim/.results` 时间轴、单位与表压转换、公式等价、DIRECT/接触/建模线拓扑,以及 XML↔JSON 的逐 ID、参数和端口一致性。
- 建立并批准 production `0.2 s` 的通用状态 golden 与 physical-state-v2.1 golden:前者覆盖 142 个状态键、3 个检查点和 426 个值;后者覆盖压力、质量流量、质量守恒、总储气质量和离散模式,并保留 AMESim 外部校准 provenance。
- AMESim 外部评估 25 项通过;2 个 `t=0.04 s` 跳变流量因左右极限语义显式不参与外部比较。最差相对误差为 `0.1393485%`,未超过 0.2% 审阅容差;本地物理 golden 33/33 重放误差为 0。
- 最终 production replay 的 worker/orchestration 墙钟为 `159.607/160.473 s`,状态与物理门禁均通过;按既定安全公式外推 1 s 为 `1197.053 s`,略低于 1200 s soft budget,记录为后续阶段可运行但本次 OPT-00 未启动。
- 历史 production `0.81/2.10 s` 严格串行复测三次并批准两档 golden;三次检查点、状态、事件、输出契约及除计时外诊断逐值一致,无非有限值或非预期回退。
- 新增 Linux x86_64 的 22 包 binary-only SHA-256 发布锁;全新空 venv 离线安装、`pip check` 和依赖契约通过,CI 的 quick/historical/main-long 三层任务统一使用该锁。
- 最终 quick workflow 同口径 179 项通过(2 项预期跳过),完整后端 849 项通过(3 项预期跳过),`git diff --check` 与残留进程检查进入最终收尾。
- OPT-00 已完成当时工作树的本地验收。仍存在的问题:批准报告来自 `684d287` 的脏工作树;后续求解器提交合入后尚未重新运行真实 production 基线和全量回归,远端 workflow 也待提交后首次托管验证。
## 23:10
- 统一物理回归基线口径:AMESim 仿真结果成为唯一物理数值基线,production runner 每次运行都重新输出当前值、AMESim 基线值、绝对误差和相对误差;Python exact/state golden 降为确定性与实现漂移诊断,不再参与物理正确性批准,但输出契约变化仍会阻断验收。
- 对 AMESim 零基线不再使用人为最小分母:相对误差明确记为 `null` 并由绝对误差门判定;无 AMESim 数据的内部守恒量完整记录为基线不可用,并继续执行独立绝对残差门;通用 AMESim 时序比较 CSV 同步采用该零基线语义。
- manifest loader 现在校验 AMESim 权威归档的角色、仓库内路径、字节数和 SHA-256,并与 physical-state artifact 的 AMESim provenance 交叉绑定,防止基线文件或引用静默漂移。
- 用同步远端元件修正后的真实 production `0.2 s` 报告复核新门禁:33 条指标均记录,27 条具有 AMESim 基线,其中 25 条参与判定、2 条跳变流量仅记录不判定、6 条内部量走本地不变量门;两类门均通过,最坏相对误差为 `0.139334%`,占 0.2% 包络的比例为 `0.696670`。
- AMESim/runner/主模型等关联回归 67 项通过(3 项按条件跳过),通用 AMESim 比较及上层调用 30 项通过;Python 编译、manifest JSON 校验和 `git diff --check` 均通过。
## 23:21
- 完成当前工作区快照存档并推送至远端 `model-development`,提交为 `a8c7338`;本次纳管 32 个文件,相关 68 项回归测试通过(3 项按条件跳过),差异检查无异常。
- 其他对话仍在进行的工作未纳入本次日志提交。当前仍待基于最新求解器代码重新运行真实 production 基线,并确认远端 workflow 首次托管结果。
@@ -0,0 +1,17 @@
# 更新日志 2026-08-19
## 18:36
- 使用原始 157 节点、178 连线八路 JSON 完成真实前端 5 s / 1 ms / BDF 端到端复验,HTTP 200 并生成 5,003 个采样点;新旧 1,784 条序列中仅 8 条 PNL0003 `Re` 按预期改变,其余 1,776 条及时间轴逐值不变。
- PNL0003 `Re` 改用 AMESim NASA 氦黏度定义后,八路对 AMESim 的全程归一化 RMS 降至 `0.005132%–0.005929%`;动力学、流量方程、求解器和公开模型版本 `0.4.0` 均未改变。
## 18:41
- 完成 PNL0003 派生 `Re` 修正提交 `27f9f4a`;LSTP00A 定向核验收口为事件输出采样语义,不修改组件模型。完整后端 870 项测试通过,其中 3 项按条件跳过。
## 19:34
- 将前端“只有心跳、没有新积分进度”的警钟从 60 秒延长为 15 分钟,保留 30 秒无后端数据的断流检测;超时数值、判定和提示统一到可测试模块。
- 移除 System XML 固定 10,001 点业务上限,继续在分配前拒绝非有限、运行时无法表示或不能严格递增的采样网格;50,001 点网格和 20,001 点真实小系统仿真及 JSON 回环均通过,结果资源占用仍随采样点数和输出变量数增长。
- 原始八路 JSON 在未注入任何前端超时覆盖的条件下再次完成 5 s / 1 ms 真实网站仿真,HTTP 200、5,003 点、最大方程残差 `1.1070e-16`;与上一结果的时间轴和全部 1,784 条序列逐值相同。
- 完整后端 872 项测试通过,其中 3 项按条件跳过;前端构建通过,111 项 E2E 首轮 110 项通过,唯一既有复制组件坐标用例随后连续复跑 2 次均通过。相关实现提交为 `eb6ea70`。
@@ -0,0 +1,16 @@
# 更新日志 2026-08-20
## 00:23
- 同步远端 `model-development` 的 3 个提交并完成语义合并:保留 PNL0003 的 AMESim Reynolds 诊断修正、无固定采样点业务上限及运行时安全校验,同时保留本地 PNL 热路径优化、求解器内部活动遥测、浏览器活跃慢步识别和对应回归,未覆盖任一侧成果。
- 统一浏览器停滞判定:有活动遥测时,仅在接受步和内部活动同时连续 60 秒不变后判停;内部活动持续时保持运行;缺少活动遥测的旧后端采用 15 分钟兼容兜底,30 秒完全无字节的断流门保持不变。
- 整理并纳管权威八路模型 `0.2/1/2 s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的运行证据:15/15 个单元到达终点,无超时、NaN/Inf、热流体失败或恢复重试,同一 `maxStep` 的 `0.2→1→2 s` 严格公共前缀一致;顶层旧统一比较器仍因接触后近零派生量和局部流量换向报告差异,因此不记为矩阵整体通过。
- 纳管慢区归因、真实 worker/API/浏览器复验及未启用候选的历史报告;最终默认方案保持 `legacy` 机械容差和 SciPy Jacobian。本地证据中 production worker `0.2 s / 0.001 s` 相对旧批准基线缩短约 `7.50%`,AMESim 物理门保持通过,改变轨迹或收益不足的接触感知容差与半解析 Jacobian 候选未启用。
- 合并后的完整后端 900 项测试通过,其中 3 项按条件跳过;前端两套 TypeScript 检查、Vite 生产构建及 12 项活动看门狗/超时回归通过,差异格式检查无异常。
- 仍存在的问题:跨 `maxStep` 的自动分层比较器尚未完成;当前权威 `5 s` 聚合报告和 `10 s / 0.001 s` 基线尚未生成;真正无活动调用的硬终止、客户端断连恢复、并发资源门控及 SciPy 内部有限差分 Jacobian 的实时分类仍待实现。
## 13:55
- 对齐固定开度与信号控制 PNVO001 的 AMESim 默认端口显示:`port_2` 位于右侧、`port_3` 位于左侧,同时保留目录中的端口编号顺序;旧工程仅在检测到历史端口方位快照时幂等迁移一次镜像状态,不修改连接端口名、压力流量方程或结果键。
- 修复机械状态事件对结果曲线的展示干扰:结果页仅隐藏事件时刻相对两侧连续样本异常放大的孤立力尖峰,并连续绘制其两侧正常样本;规则同时覆盖机械端口力和 LSTP00A、LMECHN1 等机械组件的派生力,不处理外部力信号、持续接触力及非力状态。原始 `series`、`.simresult` 与 CSV 导出保持不变。
- PNVO 目录/组件定向后端测试 20 项、端口符号/旧工程迁移/事件力纯函数与真实浏览器回归 4 项均通过;前端 TypeScript 检查与 Vite 生产构建通过,差异格式检查无异常。
+5 -1
View File
@@ -1,5 +1,8 @@
import { defineConfig } from "@playwright/test";
const channel = process.env.PLAYWRIGHT_CHANNEL?.trim();
const executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH?.trim();
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
@@ -10,7 +13,8 @@ export default defineConfig({
},
use: {
baseURL: "http://127.0.0.1:4173",
channel: "msedge",
...(channel ? { channel } : {}),
...(executablePath ? { launchOptions: { executablePath } } : {}),
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: "only-on-failure",
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig } from "@playwright/test";
import { fileURLToPath } from "node:url";
const channel = process.env.PLAYWRIGHT_CHANNEL?.trim();
const executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH?.trim();
const frontendRoot = fileURLToPath(new URL(".", import.meta.url));
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
const simulationEnvironment = {
...process.env,
SIMULATION_CAUSAL_EXECUTOR_V2: "1",
SIMULATION_CAUSAL_COORDINATE_KERNEL: "1",
SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS: "1",
SIMULATION_CAUSAL_DIRECT_EQUATION_READERS: "1",
SIMULATION_CAUSAL_FAST_PATH: "1",
SIMULATION_MECHANICAL_ATOL_MODE: "legacy",
SIMULATION_ODE_JACOBIAN_MODE: "scipy",
SIMULATIONAPP_PROPERTY_CACHE: "on",
};
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
timeout: 600_000,
expect: {
timeout: 15_000,
},
outputDir: "test-results/live-mql8",
use: {
baseURL: "http://127.0.0.1:14173",
...(channel ? { channel } : {}),
...(executablePath ? { launchOptions: { executablePath } } : {}),
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: "only-on-failure",
trace: "on",
},
webServer: [
{
command:
".venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 18181",
cwd: repositoryRoot,
env: simulationEnvironment,
url: "http://127.0.0.1:18181/api/components/catalog",
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 },
},
{
command:
"node --input-type=module -e \"import { createServer } from 'vite'; import react from '@vitejs/plugin-react'; const server = await createServer({ configFile: false, root: process.cwd(), plugins: [react()], server: { host: '127.0.0.1', port: 14173, strictPort: true, proxy: { '/api': 'http://127.0.0.1:18181' } } }); await server.listen();\"",
cwd: frontendRoot,
url: "http://127.0.0.1:14173",
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 },
},
],
});
+5135 -1095
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
import { Background, BackgroundVariant } from "@xyflow/react";
export const CANVAS_GRID_SIZE = 18;
export type CanvasGridVisibility = {
lines: boolean;
dots: boolean;
};
type CanvasGridBackgroundProps = {
visibility: CanvasGridVisibility;
};
export function CanvasGridBackground({
visibility,
}: CanvasGridBackgroundProps) {
return (
<>
{visibility.lines ? (
<Background
color="#d6e0ea"
gap={CANVAS_GRID_SIZE * 5}
id="major-grid-lines"
lineWidth={1}
variant={BackgroundVariant.Lines}
/>
) : null}
{visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_GRID_SIZE}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null}
</>
);
}
+472 -103
View File
@@ -1,8 +1,20 @@
import { AmesimPnpl01Symbol } from "./componentSymbols/boundary";
import { memo, useLayoutEffect, useRef, useState } from "react";
import {
AmesimPnpl01Symbol,
PNPL01_PORT_ANCHOR,
} from "./componentSymbols/boundary";
import {
AmesimF000Symbol,
F000_PORT_ANCHOR,
AmesimForcSymbol,
FORC_PORT_ANCHORS,
AmesimLmechn1Symbol,
lmechn1RightPortCount,
lmechn1SymbolMetrics,
LSTP00A_PORT_ANCHORS,
MECMAS21_PORT_ANCHORS,
PNRP17_PORT_ANCHORS,
AmesimPnrp17Symbol,
AmesimLstp00aSymbol,
AmesimMecmas21Symbol,
@@ -21,13 +33,34 @@ import {
AmesimPnor001Symbol,
AmesimPnvo001FixedSymbol,
AmesimPnvo001SignalSymbol,
P4NODE2_PORT_ANCHORS,
PIPE_NETWORK_PORT_ANCHORS,
PN3NODE2_PORT_ANCHORS,
PNVO001_PORT_ANCHORS,
} from "./componentSymbols/pneumatic";
import { SymbolSvg } from "./componentSymbols/primitives";
import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals";
import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage";
import {
SymbolSvg,
SymbolViewBoxProvider,
} from "./componentSymbols/primitives";
import {
AmesimStep0Symbol,
AmesimUd00Symbol,
SIGNAL_OUTPUT_PORT_ANCHOR,
} from "./componentSymbols/signals";
import {
AmesimPnch012Symbol,
AmesimPnch023Symbol,
PNCH012_PORT_ANCHORS,
PNCH023_PORT_ANCHORS,
} from "./componentSymbols/storage";
import type {
ComponentSymbolLayout,
ComponentSymbolParameters,
ComponentSymbolPortAnchors,
ComponentSymbolPresentation,
ComponentSymbolSize,
ComponentSymbolSizeTier,
ComponentSymbolViewBox,
SymbolDefinition,
} from "./componentSymbols/types";
@@ -38,8 +71,140 @@ type ComponentSymbolProps = {
symbol: string;
surface: ComponentSymbolSurface;
className?: string;
parameters?: ComponentSymbolParameters;
};
const DEFAULT_SYMBOL_VIEW_BOX: ComponentSymbolViewBox = {
x: 0,
// 既有图形使用 y=0..48;上下各保留 8 个逻辑单位,统一为 64×64 方形画布。
y: -8,
width: 64,
height: 64,
};
const SYMBOL_SIZE_BY_TIER: Record<
Exclude<ComponentSymbolSizeTier, "custom">,
ComponentSymbolSize
> = {
compact: { width: 80, height: 80 },
small: { width: 96, height: 96 },
standard: { width: 112, height: 112 },
large: { width: 128, height: 128 },
};
/** 组件库按工作区尺寸档位表达元件的相对大小,工作区画布不使用这组比例。 */
const PALETTE_OCCUPANCY_BY_TIER: Record<ComponentSymbolSizeTier, number> = {
compact: 0.45,
small: 0.75,
standard: 0.9,
large: 0.9,
custom: 0.9,
};
function fittedPaletteViewBox(
bounds: Pick<DOMRect, "x" | "y" | "width" | "height">,
occupancy: number,
): ComponentSymbolViewBox | null {
const longestSide = Math.max(bounds.width, bounds.height);
if (!Number.isFinite(longestSide) || longestSide <= 0) {
return null;
}
const side = longestSide / occupancy;
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const round = (value: number) => Math.round(value * 10_000) / 10_000;
return {
x: round(centerX - side / 2),
y: round(centerY - side / 2),
width: round(side),
height: round(side),
};
}
function sameViewBox(
first: ComponentSymbolViewBox,
second: ComponentSymbolViewBox,
) {
return (
first.x === second.x &&
first.y === second.y &&
first.width === second.width &&
first.height === second.height
);
}
type CustomSymbolLayout = {
viewBox?: ComponentSymbolViewBox;
symbolSize: ComponentSymbolSize;
nodeSize?: ComponentSymbolSize;
};
function makeSymbolLayout(
tier: Exclude<ComponentSymbolSizeTier, "custom">,
portAnchors?: ComponentSymbolPortAnchors,
): ComponentSymbolLayout;
function makeSymbolLayout(
tier: "custom",
portAnchors: ComponentSymbolPortAnchors | undefined,
custom: CustomSymbolLayout,
): ComponentSymbolLayout;
function makeSymbolLayout(
tier: ComponentSymbolSizeTier,
portAnchors?: ComponentSymbolPortAnchors,
custom?: CustomSymbolLayout,
): ComponentSymbolLayout {
const symbolSize =
tier === "custom"
? custom?.symbolSize ?? SYMBOL_SIZE_BY_TIER.large
: SYMBOL_SIZE_BY_TIER[tier];
return {
tier,
viewBox: custom?.viewBox ?? DEFAULT_SYMBOL_VIEW_BOX,
symbolSize,
// 两侧各保留 10px 给接口 Handle;无接口的自定义图标可显式收紧节点框。
nodeSize: custom?.nodeSize ?? {
width: symbolSize.width + 20,
height: symbolSize.height,
},
portAnchors,
};
}
/** 无接口元件不需要预留 Handle 空间,节点占地与对应档位的方形图标画布一致。 */
function makePortlessSymbolLayout(
tier: Exclude<ComponentSymbolSizeTier, "custom">,
): ComponentSymbolLayout {
const layout = makeSymbolLayout(tier);
return {
...layout,
nodeSize: layout.symbolSize,
};
}
function lmechn1CanvasLayout(
parameters?: ComponentSymbolParameters,
): ComponentSymbolLayout {
const metrics = lmechn1SymbolMetrics(parameters);
const symbolHeight = metrics.viewBoxHeight * 2;
return makeSymbolLayout(
"custom",
{
left: [{ x: 2, y: metrics.viewBoxHeight / 2 }],
right: metrics.rightPortRows.map((y) => ({ x: 62, y })),
},
{
viewBox: {
x: 0,
y: 0,
width: 64,
height: metrics.viewBoxHeight,
},
symbolSize: { width: 128, height: symbolHeight },
nodeSize: { width: 148, height: symbolHeight },
},
);
}
function CylinderSymbol() {
return (
<SymbolSvg>
@@ -162,175 +327,298 @@ function JunctionSymbol() {
}
const symbolRegistry = {
cylinder: { presentation: "framed", render: CylinderSymbol },
tank: { presentation: "framed", render: TankSymbol },
pipe: { presentation: "framed", render: PipeSymbol },
orifice: { presentation: "framed", render: OrificeSymbol },
tee: { presentation: "framed", render: TeeSymbol },
generic: { presentation: "framed", render: GenericSymbol },
signal: { presentation: "framed", render: SignalSymbol },
junction: { presentation: "framed", render: JunctionSymbol },
amesim_pnpl01: { presentation: "framed", render: AmesimPnpl01Symbol },
amesim_pnch023: { presentation: "framed", render: AmesimPnch023Symbol },
amesim_pnch012: { presentation: "framed", render: AmesimPnch012Symbol },
amesim_pnor001: { presentation: "framed", render: AmesimPnor001Symbol },
amesim_pnvo001_fixed: {
presentation: "framed",
render: AmesimPnvo001FixedSymbol,
},
amesim_pnvo001: {
presentation: "framed",
render: AmesimPnvo001SignalSymbol,
},
amesim_pnl00r: { presentation: "framed", render: AmesimPnl00rSymbol },
amesim_pnl0001: { presentation: "framed", render: AmesimPnl0001Symbol },
amesim_pnl0002: { presentation: "framed", render: AmesimPnl0002Symbol },
amesim_pnl0003: { presentation: "framed", render: AmesimPnl0003Symbol },
amesim_pn3node2: { presentation: "framed", render: AmesimPn3Node2Symbol },
amesim_p4node2: { presentation: "framed", render: AmesimP4Node2Symbol },
amesim_step0: { presentation: "framed", render: AmesimStep0Symbol },
amesim_ud00: { presentation: "framed", render: AmesimUd00Symbol },
amesim_f000: { presentation: "framed", render: AmesimF000Symbol },
amesim_forc: { presentation: "framed", render: AmesimForcSymbol },
amesim_mecmas21: { presentation: "framed", render: AmesimMecmas21Symbol },
amesim_lstp00a: { presentation: "framed", render: AmesimLstp00aSymbol },
amesim_lmechn1: { presentation: "framed", render: AmesimLmechn1Symbol },
amesim_pnrp17: { presentation: "framed", render: AmesimPnrp17Symbol },
amesim_ideal_air_medium: {
presentation: "bare",
render: AmesimIdealAirMediumSymbol,
},
amesim_helium_medium: {
presentation: "bare",
render: AmesimHeliumMediumSymbol,
},
} satisfies Record<string, SymbolDefinition>;
type RegisteredComponentSymbol = keyof typeof symbolRegistry;
const symbolPortAnchors: Partial<
Record<RegisteredComponentSymbol, ComponentSymbolPortAnchors>
> = {
cylinder: {
presentation: "framed",
render: CylinderSymbol,
layout: makeSymbolLayout("small", {
left: [{ x: 3, y: 24 }],
right: [{ x: 61, y: 24 }],
}),
},
tank: {
presentation: "framed",
render: TankSymbol,
layout: makeSymbolLayout("small", { left: [{ x: 2, y: 24 }] }),
},
tank: { left: [{ x: 2, y: 24 }] },
pipe: {
presentation: "framed",
render: PipeSymbol,
layout: makeSymbolLayout("compact", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
}),
},
orifice: {
presentation: "framed",
render: OrificeSymbol,
layout: makeSymbolLayout("compact", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
}),
},
tee: {
presentation: "framed",
render: TeeSymbol,
layout: makeSymbolLayout("compact", {
left: [{ x: 2, y: 24 }],
right: [
{ x: 62, y: 12 },
{ x: 62, y: 36 },
],
}),
},
generic: {
presentation: "framed",
render: GenericSymbol,
layout: makeSymbolLayout("small", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
}),
},
signal: {
presentation: "framed",
render: SignalSymbol,
layout: makeSymbolLayout("small", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
}),
},
junction: {
presentation: "framed",
render: JunctionSymbol,
layout: makeSymbolLayout("compact", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
}),
},
amesim_pnpl01: {
presentation: "framed",
render: AmesimPnpl01Symbol,
layout: {
...makeSymbolLayout("compact", { left: [PNPL01_PORT_ANCHOR] }),
},
},
amesim_pnpl01: { left: [{ x: 2, y: 24 }] },
amesim_pnch023: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnch023Symbol,
layout: {
...makeSymbolLayout("small", {
left: [PNCH023_PORT_ANCHORS.left],
right: [PNCH023_PORT_ANCHORS.right],
}),
},
},
amesim_pnch012: {
presentation: "framed",
render: AmesimPnch012Symbol,
layout: {
...makeSymbolLayout("small", {
// 保留第一对左右接口,将第二对对应到参考图的上下接口。
left: [
{ x: 2, y: 16 },
{ x: 2, y: 32 },
PNCH012_PORT_ANCHORS.left,
PNCH012_PORT_ANCHORS.top,
],
right: [
{ x: 62, y: 16 },
{ x: 62, y: 32 },
PNCH012_PORT_ANCHORS.right,
PNCH012_PORT_ANCHORS.bottom,
],
}),
},
},
amesim_pnor001: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnor001Symbol,
layout: {
...makeSymbolLayout("small", {
left: [PNVO001_PORT_ANCHORS.physicalLeft],
right: [PNVO001_PORT_ANCHORS.physicalRight],
}),
},
},
amesim_pnvo001_fixed: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnvo001FixedSymbol,
layout: {
...makeSymbolLayout("small", {
left: [PNVO001_PORT_ANCHORS.physicalLeft],
right: [PNVO001_PORT_ANCHORS.physicalRight],
}),
},
},
amesim_pnvo001: {
presentation: "framed",
render: AmesimPnvo001SignalSymbol,
layout: {
...makeSymbolLayout("small", {
left: [
{ x: 26, y: 46 },
{ x: 2, y: 24 },
PNVO001_PORT_ANCHORS.signal,
PNVO001_PORT_ANCHORS.physicalLeft,
],
right: [{ x: 62, y: 24 }],
right: [PNVO001_PORT_ANCHORS.physicalRight],
}),
},
},
amesim_pnl00r: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnl00rSymbol,
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pnl0001: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnl0001Symbol,
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pnl0002: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnl0002Symbol,
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pnl0003: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimPnl0003Symbol,
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pn3node2: {
left: [{ x: 3, y: 24 }],
presentation: "framed",
render: AmesimPn3Node2Symbol,
layout: makeSymbolLayout("small", {
// 目录中 port_1 属于 left 组,但参考图把它显示在顶部;
// port_2 是粗线汇流支路,固定显示在左侧。
left: [PN3NODE2_PORT_ANCHORS.port1],
right: [
{ x: 61, y: 12 },
{ x: 61, y: 36 },
PN3NODE2_PORT_ANCHORS.port2,
PN3NODE2_PORT_ANCHORS.port3,
],
}),
},
amesim_p4node2: {
left: [{ x: 3, y: 24 }],
presentation: "framed",
render: AmesimP4Node2Symbol,
layout: makeSymbolLayout("small", {
left: [P4NODE2_PORT_ANCHORS.port1],
right: [
{ x: 61, y: 8 },
{ x: 61, y: 24 },
{ x: 61, y: 40 },
P4NODE2_PORT_ANCHORS.port2,
P4NODE2_PORT_ANCHORS.port3,
P4NODE2_PORT_ANCHORS.port4,
],
}),
},
amesim_step0: {
presentation: "framed",
render: AmesimStep0Symbol,
layout: {
...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }),
},
},
amesim_ud00: {
presentation: "framed",
render: AmesimUd00Symbol,
layout: {
...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }),
},
},
amesim_f000: {
presentation: "framed",
render: AmesimF000Symbol,
layout: {
...makeSymbolLayout("compact", { right: [F000_PORT_ANCHOR] }),
},
},
amesim_step0: { right: [{ x: 60, y: 24 }] },
amesim_ud00: { right: [{ x: 60, y: 24 }] },
amesim_f000: { right: [{ x: 62, y: 24 }] },
amesim_forc: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimForcSymbol,
layout: {
...makeSymbolLayout("standard", {
left: [FORC_PORT_ANCHORS.left],
right: [FORC_PORT_ANCHORS.right],
}),
},
},
amesim_mecmas21: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
presentation: "framed",
render: AmesimMecmas21Symbol,
layout: {
...makeSymbolLayout("standard", {
left: [MECMAS21_PORT_ANCHORS.left],
right: [MECMAS21_PORT_ANCHORS.right],
}),
},
variantKey: (parameters) =>
`${parameters?.useFriction ?? 2}:${parameters?.stoptype ?? 1}`,
},
amesim_lstp00a: {
presentation: "framed",
render: AmesimLstp00aSymbol,
layout: {
...makeSymbolLayout("standard", {
left: [LSTP00A_PORT_ANCHORS.left],
right: [LSTP00A_PORT_ANCHORS.right],
}),
},
},
amesim_lmechn1: {
presentation: "framed",
render: AmesimLmechn1Symbol,
layout: makeSymbolLayout(
"custom",
{
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
},
amesim_lmechn1: {
left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })),
right: [{ x: 59, y: 24 }],
{
symbolSize: { width: 128, height: 128 },
nodeSize: { width: 148, height: 128 },
},
),
layoutForParameters: lmechn1CanvasLayout,
variantKey: (parameters) => String(lmechn1RightPortCount(parameters)),
},
amesim_pnrp17: {
presentation: "framed",
render: AmesimPnrp17Symbol,
// PNRP17 的当前 112×112 画布作为 standard 档位基准。
layout: makeSymbolLayout("standard", {
left: [
{ x: 25.5, y: 48 },
{ x: 2, y: 4.4 },
{ x: 2, y: 24.1 },
PNRP17_PORT_ANCHORS.bottom,
PNRP17_PORT_ANCHORS.upperLeft,
PNRP17_PORT_ANCHORS.middleLeft,
],
right: [{ x: 62, y: 4.4 }, { x: 62, y: 24.1 }],
right: [
PNRP17_PORT_ANCHORS.upperRight,
PNRP17_PORT_ANCHORS.middleRight,
],
}),
},
amesim_ideal_air_medium: {
presentation: "bare",
render: AmesimIdealAirMediumSymbol,
layout: makePortlessSymbolLayout("standard"),
},
amesim_helium_medium: {
presentation: "bare",
render: AmesimHeliumMediumSymbol,
layout: makePortlessSymbolLayout("standard"),
},
} satisfies Record<string, SymbolDefinition>;
type RegisteredComponentSymbol = keyof typeof symbolRegistry;
const fallbackComponentSymbolLayout: ComponentSymbolLayout = {
...symbolRegistry.generic.layout,
// 未注册图标只显示通用边框和名称,接口应落在边框边缘而不是 SVG 锚点。
portAnchors: undefined,
};
function isRegisteredComponentSymbol(symbol: string): symbol is RegisteredComponentSymbol {
@@ -349,23 +637,56 @@ export function componentSymbolPresentation(
: "framed";
}
export function componentSymbolLayout(
symbol: string,
parameters?: ComponentSymbolParameters,
): ComponentSymbolLayout {
if (!isRegisteredComponentSymbol(symbol)) {
return fallbackComponentSymbolLayout;
}
const definition: SymbolDefinition = symbolRegistry[symbol];
return definition.layoutForParameters?.(parameters) ?? definition.layout;
}
export function componentSymbolPortAnchors(
symbol: string,
): ComponentSymbolPortAnchors | undefined {
return isRegisteredComponentSymbol(symbol)
? symbolPortAnchors[symbol]
: undefined;
return componentSymbolLayout(symbol).portAnchors;
}
export function ComponentSymbol({
export function componentSymbolVariantKey(
symbol: string,
parameters?: ComponentSymbolParameters,
): string {
if (!isRegisteredComponentSymbol(symbol)) {
return "";
}
const definition: SymbolDefinition = symbolRegistry[symbol];
return definition.variantKey?.(parameters) ?? "";
}
function ComponentSymbolImpl({
symbol,
surface,
className,
parameters,
}: ComponentSymbolProps) {
const resolvedSymbol: RegisteredComponentSymbol = isRegisteredComponentSymbol(symbol)
? symbol
: "generic";
const definition = symbolRegistry[resolvedSymbol];
const definition: SymbolDefinition = symbolRegistry[resolvedSymbol];
const SymbolRenderer = definition.render;
const layout =
surface === "canvas"
? definition.layoutForParameters?.(parameters) ?? definition.layout
: definition.layout;
const rootRef = useRef<HTMLSpanElement>(null);
const [paletteViewBox, setPaletteViewBox] = useState<ComponentSymbolViewBox>(
layout.viewBox,
);
const paletteOccupancy = PALETTE_OCCUPANCY_BY_TIER[layout.tier];
const variantKey = definition.variantKey?.(parameters) ?? "";
const renderedViewBox = surface === "palette" ? paletteViewBox : layout.viewBox;
const rootClassName = [
"component-symbol",
`component-symbol-${surface}`,
@@ -375,14 +696,62 @@ export function ComponentSymbol({
.filter(Boolean)
.join(" ");
useLayoutEffect(() => {
if (surface !== "palette") {
return;
}
const svg = rootRef.current?.querySelector<SVGSVGElement>("svg");
if (!svg) {
return;
}
let bounds: DOMRect;
try {
bounds = svg.getBBox();
} catch {
return;
}
const fittedViewBox = fittedPaletteViewBox(bounds, paletteOccupancy);
if (!fittedViewBox) {
return;
}
setPaletteViewBox((currentViewBox) =>
sameViewBox(currentViewBox, fittedViewBox)
? currentViewBox
: fittedViewBox,
);
}, [
layout.tier,
layout.viewBox.height,
layout.viewBox.width,
layout.viewBox.x,
layout.viewBox.y,
paletteOccupancy,
resolvedSymbol,
surface,
variantKey,
]);
return (
<span
aria-hidden="true"
className={rootClassName}
data-component-symbol={resolvedSymbol}
data-node-height={layout.nodeSize.height}
data-node-width={layout.nodeSize.width}
data-palette-occupancy={surface === "palette" ? paletteOccupancy : undefined}
data-symbol-height={layout.symbolSize.height}
data-symbol-presentation={definition.presentation}
data-symbol-size-tier={layout.tier}
data-symbol-width={layout.symbolSize.width}
data-symbol-view-box={`${renderedViewBox.x} ${renderedViewBox.y} ${renderedViewBox.width} ${renderedViewBox.height}`}
ref={rootRef}
>
{definition.render()}
<SymbolViewBoxProvider viewBox={renderedViewBox}>
<SymbolRenderer parameters={parameters} surface={surface} />
</SymbolViewBoxProvider>
</span>
);
}
export const ComponentSymbol = memo(ComponentSymbolImpl);
ComponentSymbol.displayName = "ComponentSymbol";
+216 -33
View File
@@ -1,20 +1,59 @@
import {
useEffect,
useRef,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
BaseEdge,
getSmoothStepPath,
useReactFlow,
type EdgeProps,
type EdgeTypes,
} from "@xyflow/react";
import {
createOrthogonalSegmentDetour,
edgeSegmentAxis,
moveOrthogonalCorner,
moveOrthogonalSegment,
orthogonalEdgePoints,
orthogonalPolylinePath,
type EdgeRouteData,
type EdgeRoutePoint,
} from "./edgeRouting";
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
// React Flow 在 Loose 模式下会把重叠 Handle 的边端点分别放在圆点两侧,
// 因此两个端口中心完全重合时,edge props 仍会相差约一个 Handle 直径。
export const CONTACT_EDGE_SCREEN_TOLERANCE = 12;
export function ContactAwareEdge({
type RouteDrag = {
basePoints: EdgeRoutePoint[];
cleanup: () => void;
kind: "corner" | "detour" | "segment";
pointerId: number;
routeIndex: number;
startPointer: EdgeRoutePoint;
};
function longestSegmentIndex(points: EdgeRoutePoint[]) {
let bestIndex = 0;
let bestLength = -1;
for (let index = 0; index < points.length - 1; index += 1) {
const length = Math.hypot(
points[index + 1].x - points[index].x,
points[index + 1].y - points[index].y,
);
if (length > bestLength) {
bestIndex = index;
bestLength = length;
}
}
return bestIndex;
}
function RoutedEdge({
data,
id,
interactionWidth,
markerEnd,
markerStart,
selected,
sourcePosition,
sourceX,
sourceY,
@@ -23,43 +62,119 @@ export function ContactAwareEdge({
targetX,
targetY,
}: EdgeProps) {
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
if (distance <= CONTACT_EDGE_SCREEN_TOLERANCE) {
const centerX = (sourceX + targetX) / 2;
const centerY = (sourceY + targetY) / 2;
return (
<>
<circle
aria-label="接触连接"
className="contact-edge-hit"
cx={centerX}
cy={centerY}
data-contact-edge-id={id}
r={12}
role="button"
tabIndex={0}
/>
<circle
aria-hidden="true"
className="contact-edge-marker"
cx={centerX}
cy={centerY}
r={3.5}
/>
</>
const reactFlow = useReactFlow();
const dragRef = useRef<RouteDrag | null>(null);
const edgeData = (data ?? {}) as EdgeRouteData;
useEffect(
() => () => {
dragRef.current?.cleanup();
dragRef.current = null;
},
[],
);
}
const [edgePath] = getSmoothStepPath({
const points = orthogonalEdgePoints({
routePoints: edgeData.routePoints,
sourcePosition,
sourceX,
sourceY,
targetPosition,
targetX,
targetY,
borderRadius: 0,
});
const edgePath = orthogonalPolylinePath(points, edgeData.crossingJumps);
const beginRouteDrag = (
event: ReactPointerEvent<SVGElement>,
basePoints: EdgeRoutePoint[],
kind: RouteDrag["kind"],
routeIndex: number,
) => {
event.preventDefault();
event.stopPropagation();
dragRef.current?.cleanup();
const drag: RouteDrag = {
basePoints: basePoints.map((point) => ({ ...point })),
cleanup: () => undefined,
kind,
pointerId: event.pointerId,
routeIndex,
startPointer: reactFlow.screenToFlowPosition(
{ x: event.clientX, y: event.clientY },
{ snapToGrid: false },
),
};
const moveRoute = (pointerEvent: PointerEvent) => {
if (
dragRef.current !== drag ||
pointerEvent.pointerId !== drag.pointerId
) {
return;
}
pointerEvent.preventDefault();
pointerEvent.stopPropagation();
const pointer = reactFlow.screenToFlowPosition(
{ x: pointerEvent.clientX, y: pointerEvent.clientY },
{ snapToGrid: false },
);
const delta = {
x: pointer.x - drag.startPointer.x,
y: pointer.y - drag.startPointer.y,
};
const nextPoints =
drag.kind === "corner"
? moveOrthogonalCorner(drag.basePoints, drag.routeIndex, delta)
: drag.kind === "detour"
? createOrthogonalSegmentDetour(
drag.basePoints,
drag.routeIndex,
delta,
)
: moveOrthogonalSegment(
drag.basePoints,
drag.routeIndex,
delta,
);
edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1));
};
const finishRoute = (pointerEvent: PointerEvent) => {
if (
dragRef.current !== drag ||
pointerEvent.pointerId !== drag.pointerId
) {
return;
}
pointerEvent.preventDefault();
pointerEvent.stopPropagation();
drag.cleanup();
dragRef.current = null;
};
drag.cleanup = () => {
window.removeEventListener("pointermove", moveRoute);
window.removeEventListener("pointerup", finishRoute);
window.removeEventListener("pointercancel", finishRoute);
};
dragRef.current = drag;
window.addEventListener("pointermove", moveRoute, { passive: false });
window.addEventListener("pointerup", finishRoute);
window.addEventListener("pointercancel", finishRoute);
edgeData.onRouteEditStart?.(id);
};
const editable = selected && edgeData.editable === true;
const internalSegments = points
.slice(0, -1)
.map((start, index) => ({ end: points[index + 1], index, start }))
.filter(({ index }) => index > 0 && index < points.length - 2);
const internalCorners = points
.map((point, index) => ({ index, point }))
.filter(({ index }) => index > 0 && index < points.length - 1);
const fallbackIndex = longestSegmentIndex(points);
const fallbackStart = points[fallbackIndex];
const fallbackEnd = points[fallbackIndex + 1];
return (
<>
<BaseEdge
id={id}
interactionWidth={interactionWidth}
@@ -68,9 +183,77 @@ export function ContactAwareEdge({
path={edgePath}
style={style}
/>
{editable
? internalSegments.map(({ end, index, start }) => (
<g
className="manual-edge-segment-control"
key={`${id}-segment-${index}`}
>
<line
aria-label={`调整连接线段 ${index}`}
className={`manual-edge-segment-handle ${edgeSegmentAxis(start, end)}`}
data-edge-id={id}
data-segment-index={index}
onPointerDown={(event) =>
beginRouteDrag(event, points, "segment", index)
}
x1={start.x}
x2={end.x}
y1={start.y}
y2={end.y}
/>
<line
aria-hidden="true"
className="manual-edge-segment-guide"
x1={start.x}
x2={end.x}
y1={start.y}
y2={end.y}
/>
</g>
))
: null}
{editable
? internalCorners.map(({ index, point }) => (
<circle
aria-label={`双向调整连接线拐点 ${index}`}
className="manual-edge-corner-handle"
cx={point.x}
cy={point.y}
data-corner-index={index}
data-edge-id={id}
key={`${id}-corner-${index}`}
onPointerDown={(event) =>
beginRouteDrag(event, points, "corner", index)
}
r={4.5}
/>
))
: null}
{editable && internalCorners.length === 0 && fallbackEnd ? (
<circle
aria-label="双向调整连接线路由"
className="manual-edge-route-handle"
cx={(fallbackStart.x + fallbackEnd.x) / 2}
cy={(fallbackStart.y + fallbackEnd.y) / 2}
data-edge-id={id}
onPointerDown={(event) =>
beginRouteDrag(event, points, "detour", fallbackIndex)
}
r={5}
/>
) : null}
</>
);
}
export function ContactAwareEdge(props: EdgeProps) {
if (props.data?.isContactEdge === true) {
return null;
}
return <RoutedEdge {...props} />;
}
export const contactAwareEdgeTypes: EdgeTypes = {
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
};
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import {
ChevronDown,
ChevronUp,
Square,
SquareTerminal,
Trash2,
} from "lucide-react";
type DockedConsoleMode = "minimized" | "normal" | "maximized";
type DockedConsoleLevel = "info" | "success" | "warning" | "error";
type DockedConsoleEntry = {
id: number;
timestamp: string;
level: DockedConsoleLevel;
message: string;
format: "message" | "xml";
};
type DockedConsoleProgress = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
type DockedSimulationConsoleProps = {
entries: DockedConsoleEntry[];
mode: DockedConsoleMode;
progress: DockedConsoleProgress;
onClear: () => void;
onModeChange: (mode: DockedConsoleMode) => void;
onStop: () => void;
stopPending: boolean;
};
type ResizeSession = {
pointerId: number;
startHeight: number;
startY: number;
};
const CONSOLE_DOCK_HEIGHT_KEY =
"system-simulation-flow:modeling-console-dock-height";
const DEFAULT_CONSOLE_DOCK_HEIGHT = 180;
const MIN_CONSOLE_DOCK_HEIGHT = 120;
const MAX_CONSOLE_DOCK_HEIGHT = 420;
const MIN_MODELING_CANVAS_HEIGHT = 240;
const COLLAPSED_CONSOLE_DOCK_HEIGHT = 40;
const consoleLevelLabels: Record<DockedConsoleLevel, string> = {
info: "信息",
success: "完成",
warning: "警告",
error: "错误",
};
function simulationProgressClockText(progress: DockedConsoleProgress) {
const simulatedTime = Number.isFinite(progress.simulatedTime)
? progress.simulatedTime
: progress.startTime;
const totalTime = Number.isFinite(progress.totalTime)
? progress.totalTime
: progress.startTime;
return `${simulatedTime.toFixed(2)} s / ${totalTime.toFixed(2)} s`;
}
function maximumConsoleDockHeight(hostHeight = window.innerHeight) {
return Math.max(
MIN_CONSOLE_DOCK_HEIGHT,
Math.min(
MAX_CONSOLE_DOCK_HEIGHT,
Math.round(hostHeight * 0.48),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, hostHeight - MIN_MODELING_CANVAS_HEIGHT),
),
);
}
function clampConsoleDockHeight(height: number, hostHeight = window.innerHeight) {
return Math.min(
maximumConsoleDockHeight(hostHeight),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, Math.round(height)),
);
}
function loadConsoleDockHeight() {
try {
const stored = Number(localStorage.getItem(CONSOLE_DOCK_HEIGHT_KEY));
return Number.isFinite(stored) && stored > 0
? clampConsoleDockHeight(stored)
: DEFAULT_CONSOLE_DOCK_HEIGHT;
} catch {
return DEFAULT_CONSOLE_DOCK_HEIGHT;
}
}
function storeConsoleDockHeight(height: number) {
try {
localStorage.setItem(CONSOLE_DOCK_HEIGHT_KEY, String(height));
} catch {
// 本地存储不可用时仍可在当前页面调整高度。
}
}
function collapsedSummary(
entries: DockedConsoleEntry[],
progress: DockedConsoleProgress,
) {
if (progress.state === "running") {
return `${progress.message} · ${simulationProgressClockText(progress)} · ${progress.percent}%`;
}
const latestEntry = entries.at(-1);
if (!latestEntry) {
return progress.message;
}
return latestEntry.format === "xml"
? "系统 XML 已生成"
: latestEntry.message.replace(/\s+/g, " ").trim();
}
export function DockedSimulationConsole({
entries,
mode,
progress,
onClear,
onModeChange,
onStop,
stopPending,
}: DockedSimulationConsoleProps) {
const dockRef = useRef<HTMLElement>(null);
const logRef = useRef<HTMLDivElement>(null);
const resizeSessionRef = useRef<ResizeSession | null>(null);
const [height, setHeight] = useState(loadConsoleDockHeight);
const [hostHeight, setHostHeight] = useState(() => window.innerHeight);
const [resizing, setResizing] = useState(false);
const collapsed = mode === "minimized";
const canStop = progress.state === "running" && !stopPending;
const summary = useMemo(
() => collapsedSummary(entries, progress),
[entries, progress],
);
useEffect(() => {
if (!collapsed && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [collapsed, entries]);
useEffect(() => {
if (!collapsed) {
storeConsoleDockHeight(height);
}
}, [collapsed, height]);
useEffect(() => {
const host = dockRef.current?.parentElement;
const updateHostHeight = () => {
const nextHostHeight = host?.clientHeight ?? window.innerHeight;
setHostHeight(nextHostHeight);
setHeight((current) => clampConsoleDockHeight(current, nextHostHeight));
};
updateHostHeight();
const observer =
host && typeof ResizeObserver !== "undefined"
? new ResizeObserver(updateHostHeight)
: null;
if (host) {
observer?.observe(host);
}
window.addEventListener("resize", updateHostHeight);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updateHostHeight);
};
}, []);
const beginResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (collapsed || event.button !== 0) {
return;
}
event.preventDefault();
resizeSessionRef.current = {
pointerId: event.pointerId,
startHeight: height,
startY: event.clientY,
};
event.currentTarget.setPointerCapture(event.pointerId);
setResizing(true);
};
const resize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
setHeight(
clampConsoleDockHeight(
session.startHeight + session.startY - event.clientY,
hostHeight,
),
);
};
const finishResize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resizeSessionRef.current = null;
setResizing(false);
};
const consoleStyle = {
"--simulation-console-dock-height": `${
collapsed ? COLLAPSED_CONSOLE_DOCK_HEIGHT : height
}px`,
} as CSSProperties;
return (
<aside
aria-label="仿真控制台"
className={`simulation-console-dock ${collapsed ? "minimized" : "normal"} ${
resizing ? "resizing" : ""
}`}
data-editor-shortcuts-ignore="true"
ref={dockRef}
style={consoleStyle}
>
{!collapsed ? (
<div
aria-label="调整仿真控制台高度"
aria-orientation="horizontal"
aria-valuemax={maximumConsoleDockHeight(hostHeight)}
aria-valuemin={MIN_CONSOLE_DOCK_HEIGHT}
aria-valuenow={height}
className="simulation-console-dock-resizer"
onDoubleClick={() => setHeight(DEFAULT_CONSOLE_DOCK_HEIGHT)}
onKeyDown={(event) => {
const nextHeight =
event.key === "ArrowUp"
? height + 20
: event.key === "ArrowDown"
? height - 20
: event.key === "Home"
? MIN_CONSOLE_DOCK_HEIGHT
: event.key === "End"
? maximumConsoleDockHeight(hostHeight)
: null;
if (nextHeight === null) {
return;
}
event.preventDefault();
setHeight(clampConsoleDockHeight(nextHeight, hostHeight));
}}
onLostPointerCapture={finishResize}
onPointerCancel={finishResize}
onPointerDown={beginResize}
onPointerMove={resize}
onPointerUp={finishResize}
role="separator"
tabIndex={0}
title="拖动调整控制台高度,双击恢复默认高度"
/>
) : null}
<header className="simulation-console-dock-header">
<div className="simulation-console-dock-title">
<SquareTerminal aria-hidden="true" size={17} />
<strong>仿真控制台</strong>
<span className="simulation-console-dock-summary" title={summary}>
{summary}
</span>
</div>
<div className="simulation-console-dock-actions">
<button
aria-label={stopPending ? "正在停止仿真" : "停止仿真"}
className={canStop ? "active" : ""}
disabled={!canStop}
onClick={onStop}
title={canStop ? "停止当前仿真" : "当前没有可停止的仿真"}
type="button"
>
<Square aria-hidden="true" fill="currentColor" size={13} strokeWidth={1.8} />
</button>
{!collapsed ? (
<button
aria-label="清空控制台"
disabled={entries.length === 0}
onClick={onClear}
title="清空控制台"
type="button"
>
<Trash2 aria-hidden="true" size={16} />
</button>
) : null}
<button
aria-label={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
onClick={() => onModeChange(collapsed ? "normal" : "minimized")}
title={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
type="button"
>
{collapsed ? (
<ChevronUp aria-hidden="true" size={17} />
) : (
<ChevronDown aria-hidden="true" size={17} />
)}
</button>
</div>
</header>
{!collapsed ? (
<>
<div className={`simulation-console-dock-progress ${progress.state}`}>
<div className="simulation-console-dock-progress-label">
<span>{progress.message}</span>
<span>{simulationProgressClockText(progress)}</span>
<strong>{progress.percent}%</strong>
</div>
<div
aria-label={`仿真进度 ${progress.percent}%,${simulationProgressClockText(progress)}`}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progress.percent}
className="simulation-console-dock-progress-track"
role="progressbar"
>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
<div
aria-label="控制台日志"
className="simulation-console-dock-log"
ref={logRef}
role="region"
>
{entries.length === 0 ? (
<div className="simulation-console-dock-empty">暂无仿真消息</div>
) : (
entries.map((entry) => (
<div
className={`simulation-console-dock-entry ${entry.level} ${entry.format}`}
data-entry-id={entry.id}
key={entry.id}
>
<time>{entry.timestamp}</time>
<span className="simulation-console-dock-entry-level">
{consoleLevelLabels[entry.level]}
</span>
{entry.format === "xml" ? (
<pre
aria-label="生成的系统 XML"
className="simulation-console-dock-entry-xml"
>
{entry.message}
</pre>
) : (
<span className="simulation-console-dock-entry-message">
{entry.message}
</span>
)}
</div>
))
)}
</div>
</>
) : null}
</aside>
);
}
+235 -56
View File
@@ -2,12 +2,13 @@ import {
Fragment,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
@@ -35,9 +36,19 @@ export type ParameterTableRow = {
calculatedValue?: string;
};
export type ParameterTableGroup = {
id: string;
label: string;
rowIds: readonly string[];
defaultExpanded?: boolean;
errorCount?: number;
hiddenErrorCount?: number;
};
type ParameterTableProps = {
ariaLabel: string;
rows: readonly ParameterTableRow[];
groups?: readonly ParameterTableGroup[];
columnLayout: ParameterColumnLayout;
availableWidth: number;
onColumnLayoutChange: (layout: ParameterColumnLayout) => void;
@@ -169,18 +180,51 @@ function helpPreviewPosition(clientX: number, clientY: number) {
export function ParameterTable({
ariaLabel,
rows,
groups = [],
columnLayout,
availableWidth,
onColumnLayoutChange,
}: ParameterTableProps) {
const effectiveLayout = effectiveColumnLayout(columnLayout, availableWidth);
const groupDescriptionIdPrefix = useId().replace(/:/g, "");
const tableWrapRef = useRef<HTMLDivElement>(null);
const [measuredAvailableWidth, setMeasuredAvailableWidth] = useState<
number | null
>(null);
const resolvedAvailableWidth = measuredAvailableWidth ?? availableWidth;
const effectiveLayout = effectiveColumnLayout(
columnLayout,
resolvedAvailableWidth,
);
const columnDragRef = useRef<ColumnDragState | null>(null);
const helpPreviewTimerRef = useRef<number | null>(null);
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
const [activeColumnResize, setActiveColumnResize] =
useState<ColumnName | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
{},
);
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(null);
useLayoutEffect(() => {
const wrap = tableWrapRef.current;
if (!wrap) {
return;
}
const measureAvailableWidth = () => {
const nextWidth = wrap.clientWidth;
if (nextWidth <= 0) {
return;
}
setMeasuredAvailableWidth((currentWidth) =>
currentWidth === nextWidth ? currentWidth : nextWidth,
);
};
measureAvailableWidth();
const observer = new ResizeObserver(measureAvailableWidth);
observer.observe(wrap);
return () => observer.disconnect();
}, []);
const dismissHelpPreview = useCallback(() => {
if (helpPreviewTimerRef.current !== null) {
window.clearTimeout(helpPreviewTimerRef.current);
@@ -190,6 +234,17 @@ export function ParameterTable({
setHelpPreview(null);
}, []);
const hasActiveParameterSelect = useCallback(() => {
if (typeof document === "undefined") {
return false;
}
const activeElement = document.activeElement;
return (
activeElement instanceof HTMLSelectElement &&
activeElement.closest(".parameter-table") !== null
);
}, []);
const scheduleHelpPreview = useCallback(
(
target: HTMLElement,
@@ -197,7 +252,7 @@ export function ParameterTable({
pointer?: { clientX: number; clientY: number },
) => {
dismissHelpPreview();
if (!rowHasHelpPreview(row)) {
if (hasActiveParameterSelect() || !rowHasHelpPreview(row)) {
return;
}
const bounds = target.getBoundingClientRect();
@@ -221,11 +276,19 @@ export function ParameterTable({
});
}, HELP_PREVIEW_DELAY_MS);
},
[dismissHelpPreview],
[dismissHelpPreview, hasActiveParameterSelect],
);
const dismissHelpOnSelectPointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.target instanceof HTMLSelectElement) {
dismissHelpPreview();
}
};
const trackHelpPointer = (
event: ReactMouseEvent<HTMLTableRowElement>,
event: ReactMouseEvent<HTMLTableCellElement>,
row: ParameterTableRow,
) => {
helpPreviewAnchorRef.current = {
@@ -236,12 +299,20 @@ export function ParameterTable({
};
useEffect(() => {
const dismissOnViewportChange = () => dismissHelpPreview();
window.addEventListener("scroll", dismissOnViewportChange, true);
window.addEventListener("resize", dismissOnViewportChange);
const dismissOnScroll = () => {
// Programmatic hover may scroll a lower row into view after mouseenter.
// Keep that row's pending preview alive; mouseleave will still cancel it
// if scrolling moves the pointer outside the name cell.
if (helpPreviewTimerRef.current === null) {
helpPreviewAnchorRef.current = null;
setHelpPreview(null);
}
};
window.addEventListener("scroll", dismissOnScroll, true);
window.addEventListener("resize", dismissHelpPreview);
return () => {
window.removeEventListener("scroll", dismissOnViewportChange, true);
window.removeEventListener("resize", dismissOnViewportChange);
window.removeEventListener("scroll", dismissOnScroll, true);
window.removeEventListener("resize", dismissHelpPreview);
if (helpPreviewTimerRef.current !== null) {
window.clearTimeout(helpPreviewTimerRef.current);
helpPreviewTimerRef.current = null;
@@ -249,6 +320,15 @@ export function ParameterTable({
};
}, [dismissHelpPreview]);
useEffect(() => {
if (
helpPreview &&
!rows.some((row) => row.id === helpPreview.rowId)
) {
dismissHelpPreview();
}
}, [dismissHelpPreview, helpPreview, rows]);
const changeColumnLayout = (
column: ColumnName,
boundaryDelta: number,
@@ -258,7 +338,9 @@ export function ParameterTable({
column === "name"
? { ...startLayout, nameWidth: startLayout.nameWidth + boundaryDelta }
: { ...startLayout, unitWidth: startLayout.unitWidth - boundaryDelta };
onColumnLayoutChange(effectiveColumnLayout(proposed, availableWidth));
onColumnLayoutChange(
effectiveColumnLayout(proposed, resolvedAvailableWidth),
);
};
const startColumnResize = (
@@ -311,11 +393,17 @@ export function ParameterTable({
const nameColumnMaximum = Math.max(
MIN_NAME_WIDTH,
Math.min(MAX_NAME_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.unitWidth),
Math.min(
MAX_NAME_WIDTH,
resolvedAvailableWidth - MIN_VALUE_WIDTH - effectiveLayout.unitWidth,
),
);
const unitColumnMaximum = Math.max(
MIN_UNIT_WIDTH,
Math.min(MAX_UNIT_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.nameWidth),
Math.min(
MAX_UNIT_WIDTH,
resolvedAvailableWidth - MIN_VALUE_WIDTH - effectiveLayout.nameWidth,
),
);
const tableStyle = {
"--parameter-name-width": `${effectiveLayout.nameWidth}px`,
@@ -326,6 +414,25 @@ export function ParameterTable({
: null;
const previewDescription = previewRow?.description ||
(typeof previewRow?.help === "string" ? previewRow.help : "");
const rowsById = new Map(rows.map((row) => [row.id, row]));
const groupedRowIds = new Set<string>();
const resolvedGroups = groups.flatMap((group) => {
const groupRows = group.rowIds.flatMap((rowId) => {
if (groupedRowIds.has(rowId)) {
return [];
}
const row = rowsById.get(rowId);
if (!row) {
return [];
}
groupedRowIds.add(rowId);
return [row];
});
return groupRows.length > 0 || (group.errorCount ?? 0) > 0
? [{ ...group, rows: groupRows }]
: [];
});
const ungroupedRows = rows.filter((row) => !groupedRowIds.has(row.id));
const renderColumnResizer = (column: ColumnName) => {
const currentWidth =
@@ -359,16 +466,57 @@ export function ParameterTable({
);
};
const rowBlurred = (event: ReactFocusEvent<HTMLTableRowElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
dismissHelpPreview();
const renderRows = (parameterRows: readonly ParameterTableRow[]) =>
parameterRows.map((row) => {
const hasUnit = row.unit !== undefined && row.unit !== null;
return (
<Fragment key={row.id}>
<tr
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
>
<th
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
};
className="parameter-table-cell parameter-table-name-cell"
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
scope="row"
>
{row.name}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
</td>
</tr>
) : null}
</Fragment>
);
});
return (
<div
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
data-resizing-column={activeColumnResize ?? undefined}
onPointerDownCapture={dismissHelpOnSelectPointerDown}
ref={tableWrapRef}
>
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
<colgroup>
@@ -391,57 +539,88 @@ export function ParameterTable({
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const hasPreview = rowHasHelpPreview(row);
const hasUnit = row.unit !== undefined && row.unit !== null;
<tbody>{renderRows(ungroupedRows)}</tbody>
{resolvedGroups.map((group, groupIndex) => {
const expanded =
expandedGroups[group.id] ?? Boolean(group.defaultExpanded);
const errorCount =
group.errorCount ?? group.rows.filter((row) => Boolean(row.error)).length;
const hiddenErrorCount = Math.min(
errorCount,
Math.max(0, group.hiddenErrorCount ?? 0),
);
const errorDescriptionId = `${groupDescriptionIdPrefix}-group-${groupIndex}-error-description`;
const errorDescription = hiddenErrorCount > 0
? `${group.label}分组有 ${errorCount} 个参数错误,其中 ${hiddenErrorCount} 个参数当前被配置选项隐藏。`
: `${group.label}分组有 ${errorCount} 个参数错误。`;
return (
<Fragment key={row.id}>
<tr
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
onBlurCapture={rowBlurred}
onFocusCapture={(event) =>
scheduleHelpPreview(event.currentTarget, row)
}
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
tabIndex={row.readOnly && hasPreview ? 0 : undefined}
<tbody
className={`parameter-table-group${errorCount > 0 ? " invalid" : ""}`}
key={group.id}
>
<tr className="parameter-table-group-row">
<th
className="parameter-table-cell parameter-table-name-cell"
scope="row"
className="parameter-table-cell parameter-table-group-cell"
colSpan={3}
scope="rowgroup"
>
{row.name}
<button
aria-describedby={errorCount > 0 ? errorDescriptionId : undefined}
aria-expanded={expanded}
className="parameter-table-group-toggle"
onClick={() => {
dismissHelpPreview();
setExpandedGroups((current) => ({
...current,
[group.id]: !expanded,
}));
}}
type="button"
>
<span
aria-hidden="true"
className="parameter-table-group-chevron"
>
{expanded ? "▾" : "▸"}
</span>
<span
aria-hidden="true"
className="parameter-table-group-folder"
/>
<span className="parameter-table-group-label">
{group.label}
</span>
{errorCount > 0 ? (
<span aria-hidden="true" className="parameter-table-group-error">
{errorCount} 个错误
{hiddenErrorCount > 0
? `(${hiddenErrorCount} 个隐藏)`
: ""}
</span>
) : null}
</button>
{errorCount > 0 ? (
<span className="visually-hidden" id={errorDescriptionId}>
{errorDescription}
</span>
) : null}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
{expanded ? (
<>
{renderRows(group.rows)}
{hiddenErrorCount > 0 ? (
<tr className="parameter-table-group-hidden-error-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
其中 {hiddenErrorCount} 个错误参数被当前配置选项隐藏,请调整上方配置后修改。
</td>
</tr>
) : null}
</Fragment>
</>
) : null}
</tbody>
);
})}
</tbody>
</table>
{helpPreview && previewRow && typeof document !== "undefined"
? createPortal(
File diff suppressed because it is too large. Load diff
+33 -6
View File
@@ -1,15 +1,42 @@
import { SYMBOL_ACCENT, SYMBOL_STROKE, SymbolSvg } from "./primitives";
import { SymbolSvg } from "./primitives";
const PNPL01_STROKE = "#8b134f";
const PNPL01_CONTENT_SCALE = 0.25;
const PNPL01_CONTENT_CENTER = { x: 32, y: 24 } as const;
const PNPL01_CONTENT_TRANSFORM = `translate(${PNPL01_CONTENT_CENTER.x} ${PNPL01_CONTENT_CENTER.y}) scale(${PNPL01_CONTENT_SCALE}) translate(${-PNPL01_CONTENT_CENTER.x} ${-PNPL01_CONTENT_CENTER.y})`;
const PNPL01_CROSS_CENTER = {
x:
PNPL01_CONTENT_CENTER.x +
(47 - PNPL01_CONTENT_CENTER.x) * PNPL01_CONTENT_SCALE,
y: PNPL01_CONTENT_CENTER.y,
} as const;
export const PNPL01_PORT_ANCHOR = {
x: 16,
y: PNPL01_CONTENT_CENTER.y,
} as const;
export function AmesimPnpl01Symbol() {
return (
<SymbolSvg domain="pneumatic">
<path d="M2 24h34" stroke={SYMBOL_STROKE} strokeWidth="2" />
<g data-pnpl01-part="symbol">
<path
d="m35 17 14 14m0-14L35 31"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeWidth="3"
d={`M${PNPL01_PORT_ANCHOR.x} ${PNPL01_PORT_ANCHOR.y}H${PNPL01_CROSS_CENTER.x}`}
data-pnpl01-part="extension"
stroke={PNPL01_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<g data-pnpl01-part="body" transform={PNPL01_CONTENT_TRANSFORM}>
<path
d="M30 7 64 41M64 7 30 41"
data-pnpl01-part="cross"
stroke={PNPL01_STROKE}
strokeLinecap="butt"
strokeWidth="4"
/>
</g>
</g>
</SymbolSvg>
);
}
+547 -141
View File
@@ -1,15 +1,106 @@
import { useId } from "react";
import {
SYMBOL_ACCENT,
SYMBOL_FILL,
SYMBOL_MUTED,
SYMBOL_STROKE,
SymbolSvg,
} from "./primitives";
import type { ComponentSymbolRendererProps } from "./types";
const MECHANICAL_LINE_WIDTH = 1.8;
const MECMAS21_STROKE = "#00af00";
const MECMAS21_HATCH = "#66cf66";
const MECMAS21_FILL = "#ffffff";
const MECMAS21_CONTENT_SCALE = 0.72;
const MECMAS21_CONTENT_CENTER = { x: 32, y: 24 } as const;
const MECMAS21_CONTENT_TRANSFORM = `translate(${MECMAS21_CONTENT_CENTER.x} ${MECMAS21_CONTENT_CENTER.y}) scale(${MECMAS21_CONTENT_SCALE}) translate(${-MECMAS21_CONTENT_CENTER.x} ${-MECMAS21_CONTENT_CENTER.y})`;
const MECMAS21_SELECTION_ENVELOPE = {
x: 9.608,
y: 2.328,
width: 44.784,
height: 43.344,
} as const;
const F000_CONTENT_SCALE = 0.25;
const F000_CONTENT_CENTER = { x: 32, y: 24 } as const;
const F000_CONTENT_TRANSFORM = `translate(${F000_CONTENT_CENTER.x} ${F000_CONTENT_CENTER.y}) scale(${F000_CONTENT_SCALE}) translate(${-F000_CONTENT_CENTER.x} ${-F000_CONTENT_CENTER.y})`;
const F000_FRAME_RIGHT_X =
F000_CONTENT_CENTER.x +
(52 - F000_CONTENT_CENTER.x) * F000_CONTENT_SCALE;
export const F000_PORT_ANCHOR = {
x: 48,
y: 24,
} as const;
const FORC_STROKE = "#00af00";
const FORC_CONTENT_SCALE = 0.64;
const FORC_CONTENT_CENTER = { x: 32, y: 24 } as const;
const FORC_CONTENT_TRANSFORM = `translate(${FORC_CONTENT_CENTER.x} ${FORC_CONTENT_CENTER.y}) scale(${FORC_CONTENT_SCALE}) translate(${-FORC_CONTENT_CENTER.x} ${-FORC_CONTENT_CENTER.y})`;
export const FORC_PORT_ANCHORS = {
left: {
x:
FORC_CONTENT_CENTER.x +
(2 - FORC_CONTENT_CENTER.x) * FORC_CONTENT_SCALE,
y: FORC_CONTENT_CENTER.y,
},
right: {
x:
FORC_CONTENT_CENTER.x +
(62 - FORC_CONTENT_CENTER.x) * FORC_CONTENT_SCALE,
y: FORC_CONTENT_CENTER.y,
},
} as const;
export const MECMAS21_PORT_ANCHORS = {
left: {
x:
MECMAS21_CONTENT_CENTER.x +
(2 - MECMAS21_CONTENT_CENTER.x) * MECMAS21_CONTENT_SCALE,
y: 24,
},
right: {
x:
MECMAS21_CONTENT_CENTER.x +
(62 - MECMAS21_CONTENT_CENTER.x) * MECMAS21_CONTENT_SCALE,
y: 24,
},
} as const;
const LSTP00A_STROKE = "#00af00";
const LSTP00A_STROKE_WIDTH = 3;
const LSTP00A_CONTENT_SCALE = (64 * 0.65) / 60;
const LSTP00A_CONTENT_CENTER = { x: 32, y: 24 } as const;
const LSTP00A_CONTENT_TRANSFORM = `translate(${LSTP00A_CONTENT_CENTER.x} ${LSTP00A_CONTENT_CENTER.y}) scale(${LSTP00A_CONTENT_SCALE}) translate(${-LSTP00A_CONTENT_CENTER.x} ${-LSTP00A_CONTENT_CENTER.y})`;
export const LSTP00A_PORT_ANCHORS = {
left: {
x:
LSTP00A_CONTENT_CENTER.x +
(2 - LSTP00A_CONTENT_CENTER.x) * LSTP00A_CONTENT_SCALE,
y: LSTP00A_CONTENT_CENTER.y,
},
right: {
x:
LSTP00A_CONTENT_CENTER.x +
(62 - LSTP00A_CONTENT_CENTER.x) * LSTP00A_CONTENT_SCALE,
y: LSTP00A_CONTENT_CENTER.y,
},
} as const;
const PNRP17_STROKE = "#8b134f";
const PNRP17_FILL = "#cf9db6";
const PNRP17_LIGHT_FILL = "#f5ebf0";
// PNRP17 使用最终像素线宽;几何缩放不再改变参考图中的粗细层级。
const PNRP17_PRIMARY_STROKE_WIDTH = 2.1;
const PNRP17_DETAIL_STROKE_WIDTH = 1.9;
const PNRP17_VERTICAL_CENTER = 24.5;
const PNRP17_VERTICAL_SCALE = 1.21;
const PNRP17_CONTENT_TRANSFORM = `translate(0 ${PNRP17_VERTICAL_CENTER}) scale(1 ${PNRP17_VERTICAL_SCALE}) translate(0 ${-PNRP17_VERTICAL_CENTER})`;
const pnrp17ScaledY = (y: number) =>
PNRP17_VERTICAL_CENTER +
(y - PNRP17_VERTICAL_CENTER) * PNRP17_VERTICAL_SCALE;
export const PNRP17_PORT_ANCHORS = {
bottom: { x: 25.5, y: pnrp17ScaledY(48) },
upperLeft: { x: 2, y: pnrp17ScaledY(4.4) },
middleLeft: { x: 2, y: pnrp17ScaledY(24.1) },
upperRight: { x: 62, y: pnrp17ScaledY(4.4) },
middleRight: { x: 62, y: pnrp17ScaledY(24.1) },
} as const;
const mechanicalStroke = {
fill: "none",
@@ -22,23 +113,24 @@ const mechanicalStroke = {
function MechanicalSocket({ side }: { side: "left" | "right" }) {
const facingLeft = side === "left";
return (
<g {...mechanicalStroke}>
<path d={facingLeft ? "M2 24h7" : "M55 24h7"} />
<path d={facingLeft ? "M15 19H9v10h6" : "M49 19h6v10h-6"} />
</g>
);
}
function GuideHatching({ y, upward }: { y: number; upward: boolean }) {
const direction = upward ? -1 : 1;
return (
<g stroke={SYMBOL_MUTED} strokeWidth="1">
{[14, 22, 30, 38, 46].map((x) => (
<g
fill="none"
stroke={MECMAS21_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
>
<path
d={`M${x - 3} ${y + direction * 3}L${x + 3} ${y - direction * 3}`}
key={x}
d={facingLeft ? "M2 24h4.2" : "M57.8 24H62"}
strokeWidth="0.9"
/>
<path
d={
facingLeft
? "M14 18.5H6.2v11H14"
: "M50 18.5h7.8v11H50"
}
strokeWidth="2.5"
/>
))}
</g>
);
}
@@ -46,18 +138,28 @@ function GuideHatching({ y, upward }: { y: number; upward: boolean }) {
export function AmesimF000Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-f000" domain="mechanical">
<path d="M41 24h21" {...mechanicalStroke} />
<rect
fill={SYMBOL_FILL}
height="28"
rx="1"
<g data-f000-part="symbol">
<path
d={`M${F000_FRAME_RIGHT_X} ${F000_PORT_ANCHOR.y}H${F000_PORT_ANCHOR.x}`}
data-f000-part="extension"
stroke={SYMBOL_STROKE}
strokeWidth="2.2"
width="18"
x="23"
y="12"
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path d="M27 26h10" stroke={SYMBOL_ACCENT} strokeWidth="2" />
<g data-f000-part="body" transform={F000_CONTENT_TRANSFORM}>
<rect
data-f000-part="frame"
fill="#ffffff"
height="34"
stroke={SYMBOL_STROKE}
strokeLinejoin="miter"
strokeWidth="2.5"
width="52"
x="0"
y="7"
/>
</g>
</g>
</SymbolSvg>
);
}
@@ -65,78 +167,205 @@ export function AmesimF000Symbol() {
export function AmesimForcSymbol() {
return (
<SymbolSvg className="component-symbol-amesim-forc" domain="mechanical">
<g
<g data-forc-part="symbol" transform={FORC_CONTENT_TRANSFORM}>
<path
d="M2 24H18"
data-forc-part="left-extension"
stroke={FORC_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d="M11.5 19.8 18 24 11.5 28.2"
data-forc-part="input-arrow"
fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M2 24h15" />
<path d="m11 19 6 5-6 5" />
</g>
stroke={FORC_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="2"
/>
<rect
data-forc-part="mechanical-socket"
fill="#ffffff"
height="10"
stroke={FORC_STROKE}
strokeLinejoin="miter"
strokeWidth="2"
width="14"
x="39"
y="19"
/>
<path
d="M53 24H62"
data-forc-part="right-extension"
stroke={FORC_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<circle
cx="32"
cx="30"
cy="24"
fill={SYMBOL_FILL}
data-forc-part="force-circle"
fill="#ffffff"
r="12"
stroke={SYMBOL_STROKE}
stroke={FORC_STROKE}
strokeWidth="2"
/>
<text
data-forc-part="label"
dominantBaseline="central"
fill={SYMBOL_STROKE}
fill={FORC_STROKE}
fontFamily="Arial, sans-serif"
fontSize="13"
fontSize="15"
fontWeight="600"
textAnchor="middle"
x="32"
x="30"
y="24"
>
F
</text>
<path d="M44 24h6M50 18v12M50 18h8M50 30h8M58 18v12M58 24h4" {...mechanicalStroke} />
</g>
</SymbolSvg>
);
}
export function AmesimMecmas21Symbol() {
export function AmesimMecmas21Symbol({
parameters,
}: ComponentSymbolRendererProps) {
const frictionEnabled = Number(parameters?.useFriction ?? 2) === 2;
const stopType = Number(parameters?.stoptype ?? 1);
const limitsEnabled = stopType === 1 || stopType === 2 || stopType === 3;
const showGuides = frictionEnabled || limitsEnabled;
const variant = frictionEnabled
? limitsEnabled
? "friction-limited"
: "friction-unlimited"
: limitsEnabled
? "plain-limited"
: "plain-unlimited";
const hatchingId = `mecmas21-hatching-${useId().replace(
/[^a-zA-Z0-9_-]/g,
"",
)}`;
return (
<SymbolSvg className="component-symbol-amesim-mecmas21" domain="mechanical">
<GuideHatching upward y={5} />
<GuideHatching upward={false} y={43} />
<path d="M10 8h44M10 40h44" stroke={SYMBOL_MUTED} strokeWidth="1.3" />
<SymbolSvg
className="component-symbol-amesim-mecmas21"
domain="mechanical"
>
<defs>
<pattern
height="5.05"
id={hatchingId}
patternUnits="userSpaceOnUse"
width="5.05"
>
<rect fill={MECMAS21_FILL} height="5.05" width="5.05" />
<path
d="M-1 1 1-1M0 5.05 5.05 0M4.05 6.05 6.05 4.05"
stroke={MECMAS21_HATCH}
strokeWidth="1"
/>
</pattern>
</defs>
<g
data-mecmas-friction={frictionEnabled ? "enabled" : "disabled"}
data-mecmas-limits={limitsEnabled ? "finite" : "unlimited"}
data-mecmas-variant={variant}
transform={MECMAS21_CONTENT_TRANSFORM}
>
{showGuides ? (
<g data-mecmas-part="guides">
<rect
data-mecmas-part="upper-guide"
fill={`url(#${hatchingId})`}
height="8.5"
stroke={MECMAS21_STROKE}
strokeWidth="1.2"
width="61"
x="1.5"
y="-5.5"
/>
{limitsEnabled ? (
<g data-mecmas-part="finite-lower-guide">
<path
d="M1.5 34.5H9V45H55V34.5H62.5V53.5H1.5Z"
fill={`url(#${hatchingId})`}
stroke={MECMAS21_STROKE}
strokeLinejoin="miter"
strokeWidth="1.2"
/>
</g>
) : (
<rect
data-mecmas-part="unlimited-lower-guide"
fill={`url(#${hatchingId})`}
height="8.5"
stroke={MECMAS21_STROKE}
strokeWidth="1.2"
width="61"
x="1.5"
y="45"
/>
)}
</g>
) : null}
{frictionEnabled ? (
<path
d="M16.5 5.25h4.9m3.8 0h4.9m3.9 0h4.9m3.8 0h4.9M16.5 42.75h4.9m3.8 0h4.9m3.9 0h4.9m3.8 0h4.9"
data-mecmas-part="friction-markers"
stroke={MECMAS21_STROKE}
strokeLinecap="butt"
strokeWidth="0.92"
/>
) : null}
<g data-mecmas-part="core">
<MechanicalSocket side="left" />
<MechanicalSocket side="right" />
<rect
fill={SYMBOL_FILL}
height="26"
rx="1"
stroke={SYMBOL_STROKE}
strokeWidth="2"
width="34"
x="15"
y="11"
data-mecmas-part="mass"
fill={MECMAS21_FILL}
height="34"
stroke={MECMAS21_STROKE}
strokeWidth="2.5"
width="36"
x="14"
y="7"
/>
<text
dominantBaseline="central"
fill={SYMBOL_STROKE}
fill={MECMAS21_STROKE}
fontFamily="Arial, sans-serif"
fontSize="12"
fontWeight="600"
fontSize="10"
fontWeight="500"
textAnchor="middle"
x="31"
y="28"
x="32"
y="31"
>
M
</text>
<path
d="M22 18h15m-5-4 5 4-5 4"
d="M18.8 16.8H25M21.9 13.8v6"
fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
stroke={MECMAS21_STROKE}
strokeLinecap="square"
strokeWidth="1.05"
/>
<path
d="M46 16.8 39.5 12v3.6h-13V18h13v3.6Z"
fill={MECMAS21_STROKE}
/>
</g>
</g>
{/* 固定选择包络:参数切换为仅中央滑块时,虚线框仍保持完整状态大小。 */}
<rect
data-mecmas-part="selection-envelope"
fill="transparent"
height={MECMAS21_SELECTION_ENVELOPE.height}
pointerEvents="none"
width={MECMAS21_SELECTION_ENVELOPE.width}
x={MECMAS21_SELECTION_ENVELOPE.x}
y={MECMAS21_SELECTION_ENVELOPE.y}
/>
</SymbolSvg>
);
@@ -145,159 +374,336 @@ export function AmesimMecmas21Symbol() {
export function AmesimLstp00aSymbol() {
return (
<SymbolSvg className="component-symbol-amesim-lstp00a" domain="mechanical">
<path d="M2 24h8M54 24h8" {...mechanicalStroke} />
<g transform="rotate(90 32 24)">
<path d="M32 2v7M32 39v7" {...mechanicalStroke} />
<path d="M27 9V5h10v4M27 39v4h10v-4" {...mechanicalStroke} />
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="10"
/>
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="31"
/>
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="39"
<g
data-lstp00a-part="symbol"
fill="#ffffff"
stroke={LSTP00A_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={LSTP00A_STROKE_WIDTH}
transform={LSTP00A_CONTENT_TRANSFORM}
>
<path
d="M2 24H7.5"
data-lstp00a-part="left-extension"
fill="none"
/>
<path
d="M14 13v4c0 2 12 2 12 4s-12 2-12 4 12 2 12 6"
d="M56.5 24H62"
data-lstp00a-part="right-extension"
fill="none"
/>
<path
data-lstp00a-part="left-socket"
d="M13.75 19.75H6.25V28.25H13.75"
fill="none"
/>
<path
data-lstp00a-part="right-socket"
d="M49.75 19.75H57.25V28.25H49.75"
fill="none"
/>
<path
d="M28.75 17.2C31.2 16 32.6 0 35 0c1.4 0 2.2 17.2 3.7 17.2 2.8 0 3.8-16 7.3-16"
data-lstp00a-part="spring"
fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path d="M36 18v-5M36 18h14v6H36v7M40 22h6" {...mechanicalStroke} />
<path
d="M28.75 39H36M41 39H46M36 31V47M33.75 27H41V50.5H33.75"
data-lstp00a-part="lower-link"
fill="none"
/>
<rect
data-lstp00a-part="left-rail"
height="53"
width="3.75"
x="13.75"
y="-2.5"
/>
<rect
data-lstp00a-part="middle-rail"
height="53"
width="3.75"
x="25"
y="-2.5"
/>
<rect
data-lstp00a-part="right-rail"
height="53"
width="3.75"
x="46"
y="-2.5"
/>
</g>
</SymbolSvg>
);
}
export function AmesimLmechn1Symbol() {
const leftPortRows = [7, 12, 17, 22, 27, 32, 37, 42];
export const LMECHN1_DEFAULT_RIGHT_PORT_COUNT = 2;
export const LMECHN1_MAX_RIGHT_PORT_COUNT = 20;
const LMECHN1_PALETTE_RIGHT_PORT_COUNT = 5;
const LMECHN1_PORT_ROW_SPACING = 13;
const LMECHN1_BODY_LEFT = 18;
const LMECHN1_BODY_RIGHT = 46;
const LMECHN1_SOCKET_WIDTH = 6;
const LMECHN1_SOCKET_HALF_HEIGHT = 2.5;
const LMECHN1_LEFT_SOCKET_OUTER_X = LMECHN1_BODY_LEFT - LMECHN1_SOCKET_WIDTH;
const LMECHN1_RIGHT_SOCKET_OUTER_X = LMECHN1_BODY_RIGHT + LMECHN1_SOCKET_WIDTH;
export function lmechn1RightPortCount(
parameters?: ComponentSymbolRendererProps["parameters"],
) {
const candidate = Number(parameters?.v1);
if (!Number.isInteger(candidate)) {
return LMECHN1_DEFAULT_RIGHT_PORT_COUNT;
}
return Math.min(
LMECHN1_MAX_RIGHT_PORT_COUNT,
Math.max(1, candidate),
);
}
export function lmechn1SymbolMetrics(
parameters?: ComponentSymbolRendererProps["parameters"],
) {
const rightPortCount = lmechn1RightPortCount(parameters);
const requestedBodyHeight = Math.max(
28,
12 + (rightPortCount - 1) * LMECHN1_PORT_ROW_SPACING,
);
const viewBoxHeight = Math.max(48, requestedBodyHeight + 8);
const bodyHeight = Math.min(requestedBodyHeight, viewBoxHeight - 8);
const bodyTop = (viewBoxHeight - bodyHeight) / 2;
const rightPortSpan = (rightPortCount - 1) * LMECHN1_PORT_ROW_SPACING;
const firstPortY = (viewBoxHeight - rightPortSpan) / 2;
const rightPortRows = Array.from(
{ length: rightPortCount },
(_, index) => firstPortY + index * LMECHN1_PORT_ROW_SPACING,
);
return {
bodyHeight,
bodyTop,
rightPortCount,
rightPortRows,
viewBoxHeight,
} as const;
}
export function AmesimLmechn1Symbol({
parameters,
surface,
}: ComponentSymbolRendererProps) {
// 组件库始终展示一个固定、易辨识的标准图;工作区才按实际端口数展开。
const metrics = lmechn1SymbolMetrics(
surface === "palette"
? { ...parameters, v1: LMECHN1_PALETTE_RIGHT_PORT_COUNT }
: parameters,
);
const centerY = metrics.viewBoxHeight / 2;
return (
<SymbolSvg className="component-symbol-amesim-lmechn1" domain="mechanical">
<rect
data-lmechn1-part="body"
fill={SYMBOL_FILL}
height="44"
height={metrics.bodyHeight}
rx="1"
stroke={SYMBOL_STROKE}
strokeWidth="1.8"
width="12"
x="27"
y="2"
strokeWidth="2.5"
width={LMECHN1_BODY_RIGHT - LMECHN1_BODY_LEFT}
x={LMECHN1_BODY_LEFT}
y={metrics.bodyTop}
/>
{leftPortRows.map((y) => (
{metrics.rightPortRows.map((y, index) => (
<g data-lmechn1-port={`port_${index + 1}`} key={index}>
<path
d={`M5 ${y}h22`}
key={y}
d={`M${LMECHN1_RIGHT_SOCKET_OUTER_X} ${y}H62`}
data-lmechn1-part="right-port-line"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeWidth="1.4"
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d={`M${LMECHN1_BODY_RIGHT} ${y - LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_RIGHT_SOCKET_OUTER_X}V${y + LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_BODY_RIGHT}`}
data-lmechn1-part="right-port-socket"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
/>
</g>
))}
<path d="M39 24h20" stroke={SYMBOL_STROKE} strokeWidth="1.8" />
<circle cx="33" cy="24" fill={SYMBOL_ACCENT} r="2.2" />
<g data-lmechn1-part="reference-port">
<path
d={`M2 ${centerY}H${LMECHN1_LEFT_SOCKET_OUTER_X}`}
data-lmechn1-part="reference-port-line"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d={`M${LMECHN1_BODY_LEFT} ${centerY - LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_LEFT_SOCKET_OUTER_X}V${centerY + LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_BODY_LEFT}`}
data-lmechn1-part="reference-port-socket"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
/>
</g>
<text
data-lmechn1-part="label"
fill={SYMBOL_STROKE}
fontFamily="Arial, sans-serif"
fontSize="6.2"
fontWeight="600"
stroke="none"
textAnchor="middle"
x="32"
y={centerY - 6.2}
>
<tspan x="32">linear</tspan>
<tspan dy="6.5" x="32">node</tspan>
<tspan dy="6.5" x="32">(displ.)</tspan>
</text>
</SymbolSvg>
);
}
function Pnrp17ArrowPair({ centerY }: { centerY: number }) {
return (
<g
data-pnrp17-part="chamber-arrows"
fill="none"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={PNRP17_DETAIL_STROKE_WIDTH}
transform={`translate(0 ${centerY})`}
>
<g data-pnrp17-arrow="left" transform="translate(18.2 0)">
<path d="M9.1 0H3.9" />
<path d="M3.9 -2.5 0 0 3.9 2.5Z" fill="#ffffff" />
</g>
<g
data-pnrp17-arrow="right"
transform="translate(38.8 0) scale(-1 1)"
>
<path d="M9.1 0H3.9" />
<path d="M3.9 -2.5 0 0 3.9 2.5Z" fill="#ffffff" />
</g>
</g>
);
}
export function AmesimPnrp17Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-pnrp17" domain="mechanical">
<g data-pnrp17-part="symbol" transform={PNRP17_CONTENT_TRANSFORM}>
<path
d="M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2v13.8h-10V7h5.2V5.9H9.3V3h4.1Z"
data-pnrp17-part="upper-shell"
d="M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2V21.1H3.3V7.3H13.4V5.9H9.3V3h4.1Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth="1.2"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
data-pnrp17-part="upper-left-wall"
fill={PNRP17_STROKE}
height="13.8"
width="2.9"
x="15.3"
y="7.3"
/>
<path
d="M8.2 26.7h12.7v13.8h2v6.7H8.2Z"
data-pnrp17-part="lower-left-shell"
d="M3.3 27H18.2V40.7H20V46.6H3.3Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth="1.2"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
data-pnrp17-part="lower-left-wall"
fill={PNRP17_STROKE}
height="13.7"
width="2.9"
x="15.3"
y="27"
/>
<path
data-pnrp17-part="lower-base"
d="M28 40.5h28.5v6.7H28Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth="1.2"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
data-pnrp17-part="piston-sleeve"
fill={PNRP17_LIGHT_FILL}
height="34"
stroke={PNRP17_STROKE}
strokeWidth="1.2"
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7"
x="40.8"
y="7.2"
/>
<rect fill={PNRP17_STROKE} height="34" width="1.8" x="38.8" y="7.2" />
<rect
data-pnrp17-part="piston-face"
fill={PNRP17_STROKE}
height="34"
width="1.8"
x="38.8"
y="7.2"
/>
<rect
data-pnrp17-part="horizontal-rod-left"
fill={PNRP17_LIGHT_FILL}
height="2.8"
stroke={PNRP17_STROKE}
strokeWidth="1.2"
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="31.5"
x="9.3"
y="22.7"
/>
<rect
data-pnrp17-part="horizontal-rod-right"
fill={PNRP17_LIGHT_FILL}
height="2.8"
stroke={PNRP17_STROKE}
strokeWidth="1.2"
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7"
x="47.8"
y="22.7"
/>
<path
d="M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62M25.5 43.4V48"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeWidth="1.2"
/>
{[15.3, 33].map((centerY) => (
<g
data-pnrp17-part="horizontal-extensions"
d="M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62"
fill="none"
key={centerY}
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.1"
>
<path d={`M28.8 ${centerY}h-3.4`} />
<path
d={`M25.4 ${centerY - 2.5} 21.5 ${centerY}l3.9 2.5Z`}
fill="#ffffff"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<path d={`M30.9 ${centerY}h3.4`} />
<path
d={`M34.3 ${centerY - 2.5} 38.3 ${centerY} 34.3 ${centerY + 2.5}Z`}
fill="#ffffff"
data-pnrp17-part="bottom-port"
d="M25.5 43.4V48"
fill="none"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<Pnrp17ArrowPair centerY={15.15} />
<Pnrp17ArrowPair centerY={32.9} />
</g>
))}
</SymbolSvg>
);
}
+14 -7
View File
@@ -1,24 +1,31 @@
import { SYMBOL_ACCENT, SYMBOL_FILL, SYMBOL_STROKE, SymbolSvg } from "./primitives";
import { SYMBOL_FILL, SymbolSvg } from "./primitives";
const MEDIUM_SYMBOL_COLOR = "#8b134f";
const MEDIUM_SYMBOL_RADIUS = 19.2;
const MEDIUM_SYMBOL_LABEL_FONT_SIZE = 13;
function MediumSymbol({ label }: { label: "Air" | "He" }) {
return (
<SymbolSvg className="component-symbol-medium-svg" domain="medium">
<SymbolSvg
className="component-symbol-medium-svg"
domain="medium"
>
<circle
cx="32"
cy="24"
fill={SYMBOL_FILL}
r="21"
stroke={SYMBOL_STROKE}
r={MEDIUM_SYMBOL_RADIUS}
stroke={MEDIUM_SYMBOL_COLOR}
strokeWidth="2"
/>
<text
className="component-symbol-medium-label"
data-symbol-label={label}
dominantBaseline="central"
fill={SYMBOL_ACCENT}
fill={MEDIUM_SYMBOL_COLOR}
fontFamily="Arial, sans-serif"
fontSize={label === "Air" ? 12 : 16}
fontWeight="500"
fontSize={MEDIUM_SYMBOL_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
x="32"
y="24"
+151 -100
View File
@@ -8,88 +8,90 @@ import {
const PNEUMATIC_STROKE_WIDTH = 1.7;
const PNVO001_STROKE = "#8b134f";
const ORIFICE_CONTENT_SCALE = 0.6;
const ORIFICE_CONTENT_CENTER = { x: 32, y: 24 } as const;
const ORIFICE_CONTENT_TRANSFORM = `translate(${ORIFICE_CONTENT_CENTER.x} ${ORIFICE_CONTENT_CENTER.y}) scale(${ORIFICE_CONTENT_SCALE}) translate(${-ORIFICE_CONTENT_CENTER.x} ${-ORIFICE_CONTENT_CENTER.y})`;
function OrificeBody({
centerY = 25,
leftPortY = centerY,
}: {
centerY?: number;
leftPortY?: number;
}) {
const top = centerY - 10;
const bottom = centerY + 10;
const leftConnector =
leftPortY === centerY
? `M2 ${centerY}h18`
: `M2 ${leftPortY}h14L20 ${centerY}`;
function scaledOrificePoint(x: number, y: number) {
return {
x:
ORIFICE_CONTENT_CENTER.x +
(x - ORIFICE_CONTENT_CENTER.x) * ORIFICE_CONTENT_SCALE,
y:
ORIFICE_CONTENT_CENTER.y +
(y - ORIFICE_CONTENT_CENTER.y) * ORIFICE_CONTENT_SCALE,
};
}
export const PNVO001_PORT_ANCHORS = {
physicalLeft: scaledOrificePoint(0, 24),
physicalRight: scaledOrificePoint(64, 24),
signal: scaledOrificePoint(24.25, 56),
} as const;
type OrificeControl = "none" | "fixed" | "signal";
function Pnvo001Body({ control }: { control: OrificeControl }) {
return (
<g
data-orifice-control={control}
data-orifice-family="pnvo001"
transform={ORIFICE_CONTENT_TRANSFORM}
>
<path
d="M0 24H64"
data-orifice-part="flow-path"
stroke={PNVO001_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d="M14.4 11Q31.7 27.5 48.7 11M14.4 38Q31.7 18.2 48.7 38"
data-orifice-part="curves"
stroke={PNVO001_STROKE}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
/>
{control === "none" ? null : (
<>
<path
d={`${leftConnector}M44 ${centerY}h18M20 ${centerY}h24`}
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeWidth={PNEUMATIC_STROKE_WIDTH}
d="M16.5 54.5 42 3.5"
data-orifice-part="opening-arrow"
stroke={PNVO001_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d={`M20 ${top}c5 6 9 7 12 7s7-1 12-7M20 ${bottom}c5-6 9-7 12-7s7 1 12 7`}
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeWidth={PNEUMATIC_STROKE_WIDTH}
d="M48 -8 45.8 7.9 37 2.4Z"
data-orifice-part="opening-arrow-head"
fill={PNVO001_STROKE}
/>
</>
)}
{control === "signal" ? (
<path
d="M24.25 56V39L32 54.5"
data-orifice-part="signal-control"
stroke={PNVO001_STROKE}
strokeLinecap="butt"
strokeLinejoin="round"
strokeWidth="1.5"
/>
) : null}
</g>
);
}
/** Constant-coefficient pneumatic orifice (AMESim PNOR001). */
/** 定系数气动孔口(AMESim PNOR001)。 */
export function AmesimPnor001Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-pnor001-svg" domain="pneumatic">
<OrificeBody centerY={24} />
<Pnvo001Body control="none" />
</SymbolSvg>
);
}
function Pnvo001Body({
signalControlled = false,
}: {
signalControlled?: boolean;
}) {
return (
<>
<path
d="M0 24.5h64"
stroke={PNVO001_STROKE}
strokeLinecap="butt"
strokeWidth="2.1"
/>
<path
d="M15.5 14.4Q33.4 27 51.2 14.4M15.5 34.8Q33.2 22.4 51.2 34.8"
stroke={PNVO001_STROKE}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.1"
/>
<path
d="M19 46.7 44.5 10"
stroke={PNVO001_STROKE}
strokeLinecap="butt"
strokeWidth="2.6"
/>
{signalControlled ? (
<path
d="M26 48V36.6l7.2 10.1"
stroke={PNVO001_STROKE}
strokeLinecap="butt"
strokeLinejoin="round"
strokeWidth="2.3"
/>
) : null}
<path d="M50.6 0 38.6 8.2 48 12.3Z" fill={PNVO001_STROKE} />
</>
);
}
/** PNVO001 convenience variant whose opening is fixed by a parameter. */
export function AmesimPnvo001FixedSymbol() {
return (
@@ -97,7 +99,7 @@ export function AmesimPnvo001FixedSymbol() {
className="component-symbol-amesim-pnvo001-fixed-svg"
domain="pneumatic"
>
<Pnvo001Body />
<Pnvo001Body control="fixed" />
</SymbolSvg>
);
}
@@ -109,13 +111,33 @@ export function AmesimPnvo001SignalSymbol() {
className="component-symbol-amesim-pnvo001-signal-svg"
domain="pneumatic"
>
<Pnvo001Body signalControlled />
<Pnvo001Body control="signal" />
</SymbolSvg>
);
}
type PipeSection = "C" | "R";
const PIPE_NETWORK_CONTENT_CENTER = { x: 32, y: 24 } as const;
const PIPE_NETWORK_CONTENT_SCALE = 0.64;
const PIPE_NETWORK_CONTENT_TRANSFORM = `translate(${PIPE_NETWORK_CONTENT_CENTER.x} ${PIPE_NETWORK_CONTENT_CENTER.y}) scale(${PIPE_NETWORK_CONTENT_SCALE}) translate(${-PIPE_NETWORK_CONTENT_CENTER.x} ${-PIPE_NETWORK_CONTENT_CENTER.y})`;
function scaledPipeNetworkPoint(x: number, y: number) {
return {
x:
PIPE_NETWORK_CONTENT_CENTER.x +
(x - PIPE_NETWORK_CONTENT_CENTER.x) * PIPE_NETWORK_CONTENT_SCALE,
y:
PIPE_NETWORK_CONTENT_CENTER.y +
(y - PIPE_NETWORK_CONTENT_CENTER.y) * PIPE_NETWORK_CONTENT_SCALE,
};
}
export const PIPE_NETWORK_PORT_ANCHORS = {
left: scaledPipeNetworkPoint(2, 24),
right: scaledPipeNetworkPoint(62, 24),
} as const;
function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
const left = 9;
const right = 55;
@@ -124,7 +146,10 @@ function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
const sectionWidth = (right - left) / sections.length;
return (
<>
<g
data-pipe-network-content="true"
transform={PIPE_NETWORK_CONTENT_TRANSFORM}
>
<path
d="M2 24h7M55 24h7"
stroke={SYMBOL_STROKE}
@@ -167,7 +192,7 @@ function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
{section}
</text>
))}
</>
</g>
);
}
@@ -180,11 +205,11 @@ export function AmesimPnl00rSymbol() {
);
}
/** C-R dynamic pneumatic line (AMESim PNL0001). */
/** C-R dynamic pneumatic line (AMESim PNL0001): port 1 is R, port 2 is C. */
export function AmesimPnl0001Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-pnl0001-svg" domain="pneumatic">
<PipeNetworkBody sections={["C", "R"]} />
<PipeNetworkBody sections={["R", "C"]} />
</SymbolSvg>
);
}
@@ -207,34 +232,50 @@ export function AmesimPnl0003Symbol() {
);
}
function JunctionTerminal({ x, y }: { x: number; y: number }) {
return (
<circle
cx={x}
cy={y}
fill={SYMBOL_FILL}
r="2"
stroke={SYMBOL_STROKE}
strokeWidth="1.3"
/>
);
}
const JUNCTION_STROKE = "#8b134f";
const JUNCTION_FINE_STROKE_WIDTH = 1.5;
const JUNCTION_COLLECTOR_STROKE_WIDTH = 4;
const JUNCTION_CENTER_RADIUS = 2.75;
export const PN3NODE2_PORT_ANCHORS = {
port1: { x: 36.6, y: 11.2 },
port2: { x: 24.6, y: 24 },
port3: { x: 36.6, y: 36.8 },
} as const;
export const P4NODE2_PORT_ANCHORS = {
port1: { x: 32, y: 11.2 },
port2: { x: 19.2, y: 24 },
port3: { x: 44.8, y: 24 },
port4: { x: 32, y: 36.8 },
} as const;
/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */
export function AmesimPn3Node2Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-pn3node2-svg" domain="pneumatic">
<path
d="M4 24h25M29 24C41 24 42 12 60 12M29 24c12 0 13 12 31 12"
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={PNEUMATIC_STROKE_WIDTH}
d="M36.6 11.2V36.8M24.6 24H27.5"
data-junction-part="fine-branches"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_FINE_STROKE_WIDTH}
/>
<path
d="M27.5 24H36.6"
data-junction-part="collector-branch"
data-port-name="port_2"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_COLLECTOR_STROKE_WIDTH}
/>
<circle
cx="36.6"
cy="24"
data-junction-part="center"
fill={JUNCTION_STROKE}
r={JUNCTION_CENTER_RADIUS}
/>
<circle cx="29" cy="24" fill={SYMBOL_ACCENT} r="3" />
<JunctionTerminal x={3} y={24} />
<JunctionTerminal x={61} y={12} />
<JunctionTerminal x={61} y={36} />
</SymbolSvg>
);
}
@@ -244,17 +285,27 @@ export function AmesimP4Node2Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-p4node2-svg" domain="pneumatic">
<path
d="M4 24h24M28 24C40 24 42 8 60 8M28 24h32M28 24c12 0 14 16 32 16"
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={PNEUMATIC_STROKE_WIDTH}
d="M32 11.2V36.8M32 24H44.8M19.2 24H22.1"
data-junction-part="fine-branches"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_FINE_STROKE_WIDTH}
/>
<path
d="M22.1 24H32"
data-junction-part="collector-branch"
data-port-name="port_2"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_COLLECTOR_STROKE_WIDTH}
/>
<circle
cx="32"
cy="24"
data-junction-part="center"
fill={JUNCTION_STROKE}
r={JUNCTION_CENTER_RADIUS}
/>
<circle cx="28" cy="24" fill={SYMBOL_ACCENT} r="3" />
<JunctionTerminal x={3} y={24} />
<JunctionTerminal x={61} y={8} />
<JunctionTerminal x={61} y={24} />
<JunctionTerminal x={61} y={40} />
</SymbolSvg>
);
}
+28 -3
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { createContext, useContext, type ReactNode } from "react";
import type { ComponentSymbolViewBox } from "./types";
export type SymbolDomain =
| "neutral"
@@ -11,25 +13,48 @@ type SymbolSvgProps = {
children: ReactNode;
className?: string;
domain?: SymbolDomain;
viewBox?: string;
strokeScaling?: "independent" | "geometry";
};
type SymbolViewBoxProviderProps = {
children: ReactNode;
viewBox: ComponentSymbolViewBox;
};
const SymbolViewBoxContext = createContext("0 -8 64 64");
export const SYMBOL_STROKE = "var(--component-symbol-stroke, currentColor)";
export const SYMBOL_FILL = "var(--component-symbol-fill, #ffffff)";
export const SYMBOL_ACCENT = "var(--component-symbol-accent, currentColor)";
export const SYMBOL_MUTED = "var(--component-symbol-muted, #64748b)";
/** 由图标注册表统一提供 SVG 画布,避免渲染器与接口布局分别维护 viewBox。 */
export function SymbolViewBoxProvider({
children,
viewBox,
}: SymbolViewBoxProviderProps) {
return (
<SymbolViewBoxContext.Provider
value={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
>
{children}
</SymbolViewBoxContext.Provider>
);
}
export function SymbolSvg({
children,
className,
domain = "neutral",
viewBox = "0 0 64 48",
strokeScaling = "independent",
}: SymbolSvgProps) {
const viewBox = useContext(SymbolViewBoxContext);
return (
<svg
aria-hidden="true"
className={["component-symbol-svg", className].filter(Boolean).join(" ")}
data-symbol-domain={domain}
data-symbol-stroke-scaling={strokeScaling}
fill="none"
focusable="false"
viewBox={viewBox}
+29
View File
@@ -6,6 +6,31 @@ import {
} from "./primitives";
const SIGNAL_LINE_WIDTH = 1.8;
const SIGNAL_CANVAS_SIZE = 64;
const SIGNAL_WORKSPACE_OCCUPANCY = 0.6;
const SIGNAL_SOURCE_BOUNDS = {
left: 9,
right: 60,
top: 6,
bottom: 42,
} as const;
const SIGNAL_SOURCE_CENTER = {
x: (SIGNAL_SOURCE_BOUNDS.left + SIGNAL_SOURCE_BOUNDS.right) / 2,
y: (SIGNAL_SOURCE_BOUNDS.top + SIGNAL_SOURCE_BOUNDS.bottom) / 2,
} as const;
const SIGNAL_CANVAS_CENTER = { x: 32, y: 24 } as const;
const SIGNAL_CONTENT_SCALE =
(SIGNAL_CANVAS_SIZE * SIGNAL_WORKSPACE_OCCUPANCY) /
(SIGNAL_SOURCE_BOUNDS.right - SIGNAL_SOURCE_BOUNDS.left);
const SIGNAL_CONTENT_TRANSFORM = `translate(${SIGNAL_CANVAS_CENTER.x} ${SIGNAL_CANVAS_CENTER.y}) scale(${SIGNAL_CONTENT_SCALE}) translate(${-SIGNAL_SOURCE_CENTER.x} ${-SIGNAL_SOURCE_CENTER.y})`;
export const SIGNAL_OUTPUT_PORT_ANCHOR = {
x:
SIGNAL_CANVAS_CENTER.x +
(SIGNAL_SOURCE_BOUNDS.right - SIGNAL_SOURCE_CENTER.x) *
SIGNAL_CONTENT_SCALE,
y: SIGNAL_CANVAS_CENTER.y,
} as const;
function SignalOutput() {
return (
@@ -25,6 +50,7 @@ function SignalOutput() {
export function AmesimStep0Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-step0" domain="signal">
<g data-signal-source="step0" transform={SIGNAL_CONTENT_TRANSFORM}>
<circle
cx="27"
cy="24"
@@ -42,6 +68,7 @@ export function AmesimStep0Symbol() {
strokeWidth="2.2"
/>
<SignalOutput />
</g>
</SymbolSvg>
);
}
@@ -49,6 +76,7 @@ export function AmesimStep0Symbol() {
export function AmesimUd00Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-ud00" domain="signal">
<g data-signal-source="ud00" transform={SIGNAL_CONTENT_TRANSFORM}>
<circle
cx="27"
cy="24"
@@ -66,6 +94,7 @@ export function AmesimUd00Symbol() {
strokeWidth="2.2"
/>
<SignalOutput />
</g>
</SymbolSvg>
);
}
+96 -34
View File
@@ -1,56 +1,76 @@
import {
SYMBOL_ACCENT,
SYMBOL_FILL,
SYMBOL_STROKE,
SymbolSvg,
} from "./primitives";
function ChamberCore({ variable }: { variable: boolean }) {
const PNCH012_STROKE = "#8b134f";
const PNCH_CHAMBER_CONTENT_SCALE = 0.45;
const PNCH_CHAMBER_CONTENT_CENTER = { x: 32, y: 24 } as const;
const PNCH_CHAMBER_CONTENT_TRANSFORM = `translate(${PNCH_CHAMBER_CONTENT_CENTER.x} ${PNCH_CHAMBER_CONTENT_CENTER.y}) scale(${PNCH_CHAMBER_CONTENT_SCALE}) translate(${-PNCH_CHAMBER_CONTENT_CENTER.x} ${-PNCH_CHAMBER_CONTENT_CENTER.y})`;
const PNCH_CHAMBER_LABEL_RENDERED_SIZE = 15;
const PNCH_CHAMBER_SMALL_CANVAS_SCALE = 96 / 64;
// SVG 字号使用 viewBox 逻辑单位;抵消 small 画布缩放后,工作区最终显示为 15px。
const PNCH_CHAMBER_LABEL_FONT_SIZE =
PNCH_CHAMBER_LABEL_RENDERED_SIZE / PNCH_CHAMBER_SMALL_CANVAS_SCALE;
function scalePnchChamberAnchor(x: number, y: number) {
return {
x:
PNCH_CHAMBER_CONTENT_CENTER.x +
(x - PNCH_CHAMBER_CONTENT_CENTER.x) * PNCH_CHAMBER_CONTENT_SCALE,
y:
PNCH_CHAMBER_CONTENT_CENTER.y +
(y - PNCH_CHAMBER_CONTENT_CENTER.y) * PNCH_CHAMBER_CONTENT_SCALE,
} as const;
}
export const PNCH012_PORT_ANCHORS = {
bottom: scalePnchChamberAnchor(32, 56),
left: scalePnchChamberAnchor(0, 24),
right: scalePnchChamberAnchor(64, 24),
top: scalePnchChamberAnchor(32, -8),
} as const;
export const PNCH023_PORT_ANCHORS = {
left: PNCH012_PORT_ANCHORS.left,
right: PNCH012_PORT_ANCHORS.right,
} as const;
export function AmesimPnch023Symbol() {
return (
<>
<SymbolSvg domain="pneumatic">
<g data-pnch023-part="symbol" transform={PNCH_CHAMBER_CONTENT_TRANSFORM}>
<path
d="M0 24H11M53 24H64"
data-pnch023-part="ports"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeWidth="2"
/>
<circle
cx="32"
cy="24"
data-pnch023-part="chamber"
fill={SYMBOL_FILL}
r="13"
r="21"
stroke={SYMBOL_STROKE}
strokeWidth="2"
/>
</g>
<text
data-pnch023-part="label"
dominantBaseline="central"
fill={SYMBOL_ACCENT}
fill={PNCH012_STROKE}
fontFamily="Arial, sans-serif"
fontSize="13"
fontWeight="700"
fontSize={PNCH_CHAMBER_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
x="32"
y="24"
>
Cp
</text>
{variable ? (
<g
fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
>
<path d="m20 13-5-5m0 0h4m-4 0v4" />
<path d="m44 13 5-5m0 0h-4m4 0v4" />
<path d="m20 35-5 5m0 0h4m-4 0v-4" />
<path d="m44 35 5 5m0 0h-4m4 0v-4" />
</g>
) : null}
</>
);
}
export function AmesimPnch023Symbol() {
return (
<SymbolSvg domain="pneumatic">
<path d="M2 24h17M45 24h17" stroke={SYMBOL_STROKE} strokeWidth="2" />
<ChamberCore variable={false} />
</SymbolSvg>
);
}
@@ -58,12 +78,54 @@ export function AmesimPnch023Symbol() {
export function AmesimPnch012Symbol() {
return (
<SymbolSvg domain="pneumatic">
<g data-pnch012-part="symbol" transform={PNCH_CHAMBER_CONTENT_TRANSFORM}>
<path
d="M2 16h17M2 32h17M45 16h17M45 32h17"
stroke={SYMBOL_STROKE}
strokeWidth="2"
d="M0 24H11M53 24H64M32-8V3M32 45V56"
data-pnch012-part="ports"
stroke={PNCH012_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<ChamberCore variable />
<circle
cx="32"
cy="24"
data-pnch012-part="chamber"
fill={SYMBOL_FILL}
r="21"
stroke={PNCH012_STROKE}
strokeWidth="1.5"
/>
<g
data-pnch012-part="volume-arrows"
fill="none"
stroke={PNCH012_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
>
{[0, 90, 180, 270].map((rotation) => (
<path
d="M4-3 10 3M3 9H16V-4L3 9Z"
data-pnch012-part="volume-arrow"
key={rotation}
transform={`rotate(${rotation} 32 24)`}
/>
))}
</g>
</g>
<text
data-pnch012-part="label"
dominantBaseline="central"
fill={PNCH012_STROKE}
fontFamily="Arial, sans-serif"
fontSize={PNCH_CHAMBER_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
x="32"
y="24"
>
Cp
</text>
</SymbolSvg>
);
}
+49 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { ComponentType } from "react";
export type ComponentSymbolPresentation = "framed" | "bare";
@@ -11,7 +11,54 @@ export type ComponentSymbolPortAnchors = Partial<
Record<"left" | "right", readonly ComponentSymbolPortAnchor[]>
>;
export type ComponentSymbolSizeTier =
| "compact"
| "small"
| "standard"
| "large"
| "custom";
export type ComponentSymbolViewBox = {
x: number;
y: number;
width: number;
height: number;
};
export type ComponentSymbolSize = {
width: number;
height: number;
};
/**
* 元件在建模区中的唯一布局来源。
*
* viewBox 描述 SVG 逻辑坐标,symbolSize 描述图标显示槽,nodeSize 描述
* React Flow 实际占地。接口锚点与三者放在一起,避免调整图标后出现接口漂移。
*/
export type ComponentSymbolLayout = {
tier: ComponentSymbolSizeTier;
viewBox: ComponentSymbolViewBox;
symbolSize: ComponentSymbolSize;
nodeSize: ComponentSymbolSize;
portAnchors?: ComponentSymbolPortAnchors;
};
export type ComponentSymbolParameters = Readonly<
Record<string, number | string>
>;
export type ComponentSymbolRendererProps = {
parameters?: ComponentSymbolParameters;
surface?: "palette" | "canvas";
};
export type SymbolDefinition = {
presentation: ComponentSymbolPresentation;
render: () => ReactNode;
render: ComponentType<ComponentSymbolRendererProps>;
layout: ComponentSymbolLayout;
layoutForParameters?: (
parameters?: ComponentSymbolParameters,
) => ComponentSymbolLayout;
variantKey?: (parameters?: ComponentSymbolParameters) => string;
};
+536
View File
@@ -0,0 +1,536 @@
import { Position } from "@xyflow/react";
export type EdgeRoutePoint = {
x: number;
y: number;
};
export type EdgeCrossingJump = EdgeRoutePoint & {
orientation: "horizontal" | "vertical";
};
export type EdgeRouteData = {
crossingJumps?: EdgeCrossingJump[];
editable?: boolean;
isContactEdge?: boolean;
onRouteEditStart?: (edgeId: string) => void;
onRoutePointsChange?: (edgeId: string, routePoints: EdgeRoutePoint[]) => void;
routePoints?: EdgeRoutePoint[];
};
export type EdgePointAxis = "horizontal" | "vertical";
const ROUTE_EPSILON = 0.01;
const CROSSING_JUMP_RADIUS = 6;
export function edgeAxisForPosition(position: Position): EdgePointAxis {
return position === Position.Left || position === Position.Right
? "horizontal"
: "vertical";
}
export function edgePointIsFinite(point: EdgeRoutePoint) {
return Number.isFinite(point.x) && Number.isFinite(point.y);
}
export function edgePointsMatch(
first: EdgeRoutePoint,
second: EdgeRoutePoint,
epsilon = ROUTE_EPSILON,
) {
return (
Math.abs(first.x - second.x) <= epsilon &&
Math.abs(first.y - second.y) <= epsilon
);
}
function appendDistinctPoint(
points: EdgeRoutePoint[],
point: EdgeRoutePoint,
) {
if (!edgePointIsFinite(point)) {
return;
}
const previous = points.at(-1);
if (!previous || !edgePointsMatch(previous, point)) {
points.push({ x: point.x, y: point.y });
}
}
export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
const distinct: EdgeRoutePoint[] = [];
points.forEach((point) => appendDistinctPoint(distinct, point));
if (distinct.length <= 2) {
return distinct;
}
const simplified: EdgeRoutePoint[] = [distinct[0]];
for (let index = 1; index < distinct.length - 1; index += 1) {
const previous = simplified.at(-1) as EdgeRoutePoint;
const current = distinct[index];
const next = distinct[index + 1];
const collinearX =
Math.abs(previous.x - current.x) <= ROUTE_EPSILON &&
Math.abs(current.x - next.x) <= ROUTE_EPSILON;
const collinearY =
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
if (!collinearX && !collinearY) {
simplified.push(current);
}
}
simplified.push(distinct.at(-1) as EdgeRoutePoint);
return simplified;
}
function appendOrthogonalLeg(
points: EdgeRoutePoint[],
target: EdgeRoutePoint,
firstAxis: EdgePointAxis,
) {
const source = points.at(-1);
if (!source || edgePointsMatch(source, target)) {
appendDistinctPoint(points, target);
return;
}
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
if (changesX && changesY) {
appendDistinctPoint(
points,
firstAxis === "horizontal"
? { x: target.x, y: source.y }
: { x: source.x, y: target.y },
);
}
appendDistinctPoint(points, target);
}
function appendEndpointAwareLeg(
points: EdgeRoutePoint[],
target: EdgeRoutePoint,
sourceAxis: EdgePointAxis,
targetAxis: EdgePointAxis,
) {
const source = points.at(-1);
if (!source || edgePointsMatch(source, target)) {
appendDistinctPoint(points, target);
return;
}
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
if (!changesX || !changesY) {
appendDistinctPoint(points, target);
return;
}
if (sourceAxis !== targetAxis) {
appendDistinctPoint(
points,
sourceAxis === "horizontal"
? { x: target.x, y: source.y }
: { x: source.x, y: target.y },
);
} else if (sourceAxis === "horizontal") {
const middleX = (source.x + target.x) / 2;
appendDistinctPoint(points, { x: middleX, y: source.y });
appendDistinctPoint(points, { x: middleX, y: target.y });
} else {
const middleY = (source.y + target.y) / 2;
appendDistinctPoint(points, { x: source.x, y: middleY });
appendDistinctPoint(points, { x: target.x, y: middleY });
}
appendDistinctPoint(points, target);
}
/** Builds a persisted orthogonal route through each exact user waypoint. */
export function buildOrthogonalRoutePoints(
source: EdgeRoutePoint,
target: EdgeRoutePoint,
sourcePosition: Position,
targetPosition: Position,
waypoints: EdgeRoutePoint[] = [],
) {
const sourceAxis = edgeAxisForPosition(sourcePosition);
const targetAxis = edgeAxisForPosition(targetPosition);
const points: EdgeRoutePoint[] = [{ ...source }];
let nextAxis = sourceAxis;
waypoints.filter(edgePointIsFinite).forEach((waypoint) => {
appendOrthogonalLeg(points, waypoint, nextAxis);
nextAxis = nextAxis === "horizontal" ? "vertical" : "horizontal";
});
appendEndpointAwareLeg(points, target, nextAxis, targetAxis);
return simplifyOrthogonalPoints(points).slice(1, -1);
}
/** Returns the rendered polyline, including both live port endpoints. */
export function orthogonalEdgePoints({
routePoints,
sourcePosition,
sourceX,
sourceY,
targetPosition,
targetX,
targetY,
}: {
routePoints?: EdgeRoutePoint[];
sourcePosition: Position;
sourceX: number;
sourceY: number;
targetPosition: Position;
targetX: number;
targetY: number;
}) {
const source = { x: sourceX, y: sourceY };
const target = { x: targetX, y: targetY };
const storedPoints = routePoints?.filter(edgePointIsFinite) ?? [];
if (storedPoints.length === 0) {
return [
source,
...buildOrthogonalRoutePoints(
source,
target,
sourcePosition,
targetPosition,
),
target,
];
}
const points: EdgeRoutePoint[] = [source];
const first = storedPoints[0];
if (
Math.abs(source.x - first.x) > ROUTE_EPSILON &&
Math.abs(source.y - first.y) > ROUTE_EPSILON
) {
appendDistinctPoint(
points,
edgeAxisForPosition(sourcePosition) === "horizontal"
? { x: first.x, y: source.y }
: { x: source.x, y: first.y },
);
}
storedPoints.forEach((point) => appendDistinctPoint(points, point));
const last = points.at(-1) as EdgeRoutePoint;
if (
Math.abs(last.x - target.x) > ROUTE_EPSILON &&
Math.abs(last.y - target.y) > ROUTE_EPSILON
) {
appendDistinctPoint(
points,
edgeAxisForPosition(targetPosition) === "horizontal"
? { x: last.x, y: target.y }
: { x: target.x, y: last.y },
);
}
appendDistinctPoint(points, target);
return simplifyOrthogonalPoints(points);
}
function pointOnSegmentInterior(
point: EdgeRoutePoint,
start: EdgeRoutePoint,
end: EdgeRoutePoint,
) {
const segmentLength = Math.hypot(end.x - start.x, end.y - start.y);
const fromStart = Math.hypot(point.x - start.x, point.y - start.y);
const fromEnd = Math.hypot(point.x - end.x, point.y - end.y);
return (
segmentLength > CROSSING_JUMP_RADIUS * 2 + 2 &&
fromStart > CROSSING_JUMP_RADIUS + 1 &&
fromEnd > CROSSING_JUMP_RADIUS + 1 &&
Math.abs(fromStart + fromEnd - segmentLength) <= 0.2
);
}
export function edgeSegmentAxis(
start: EdgeRoutePoint,
end: EdgeRoutePoint,
): EdgePointAxis {
return Math.abs(start.x - end.x) >= Math.abs(start.y - end.y)
? "horizontal"
: "vertical";
}
export function orthogonalPolylinePath(
points: EdgeRoutePoint[],
crossingJumps: EdgeCrossingJump[] = [],
) {
if (points.length === 0) {
return "";
}
let path = `M ${points[0].x} ${points[0].y}`;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const orientation = edgeSegmentAxis(start, end);
const horizontal = orientation === "horizontal";
const direction = horizontal
? Math.sign(end.x - start.x)
: Math.sign(end.y - start.y);
if (direction === 0) {
continue;
}
const jumps = crossingJumps
.filter(
(jump) =>
jump.orientation === orientation &&
pointOnSegmentInterior(jump, start, end),
)
.sort((first, second) =>
horizontal
? (first.x - second.x) * direction
: (first.y - second.y) * direction,
);
jumps.forEach((jump) => {
const before = horizontal
? { x: jump.x - direction * CROSSING_JUMP_RADIUS, y: start.y }
: { x: start.x, y: jump.y - direction * CROSSING_JUMP_RADIUS };
const after = horizontal
? { x: jump.x + direction * CROSSING_JUMP_RADIUS, y: start.y }
: { x: start.x, y: jump.y + direction * CROSSING_JUMP_RADIUS };
path += ` L ${before.x} ${before.y}`;
path += horizontal
? ` Q ${jump.x} ${jump.y - CROSSING_JUMP_RADIUS * 1.8} ${after.x} ${after.y}`
: ` Q ${jump.x + CROSSING_JUMP_RADIUS * 1.8} ${jump.y} ${after.x} ${after.y}`;
});
path += ` L ${end.x} ${end.y}`;
}
return path;
}
export type RoutedEdgeCrossingInput = {
id: string;
nodeIds: readonly [string, string];
points: EdgeRoutePoint[];
};
function valueInsideSegment(
value: number,
first: number,
second: number,
margin = CROSSING_JUMP_RADIUS + 2,
) {
const minimum = Math.min(first, second) + margin;
const maximum = Math.max(first, second) - margin;
return value > minimum && value < maximum;
}
/**
* Derives display-only circuit-style jump arcs. The horizontal route is chosen
* consistently, so recalculation never makes the bridge alternate between
* lines as the user edits nearby geometry.
*/
export function detectEdgeCrossingJumps(
edges: RoutedEdgeCrossingInput[],
) {
const jumpsByEdgeId = new Map<string, EdgeCrossingJump[]>();
for (let firstIndex = 0; firstIndex < edges.length; firstIndex += 1) {
const first = edges[firstIndex];
for (let secondIndex = firstIndex + 1; secondIndex < edges.length; secondIndex += 1) {
const second = edges[secondIndex];
for (let firstSegment = 0; firstSegment < first.points.length - 1; firstSegment += 1) {
const firstStart = first.points[firstSegment];
const firstEnd = first.points[firstSegment + 1];
const firstAxis = edgeSegmentAxis(firstStart, firstEnd);
for (let secondSegment = 0; secondSegment < second.points.length - 1; secondSegment += 1) {
const secondStart = second.points[secondSegment];
const secondEnd = second.points[secondSegment + 1];
const secondAxis = edgeSegmentAxis(secondStart, secondEnd);
if (firstAxis === secondAxis) {
continue;
}
const horizontal =
firstAxis === "horizontal"
? { edge: first, start: firstStart, end: firstEnd }
: { edge: second, start: secondStart, end: secondEnd };
const vertical =
firstAxis === "vertical"
? { start: firstStart, end: firstEnd }
: { start: secondStart, end: secondEnd };
const crossing = { x: vertical.start.x, y: horizontal.start.y };
if (
!valueInsideSegment(
crossing.x,
horizontal.start.x,
horizontal.end.x,
) ||
!valueInsideSegment(
crossing.y,
vertical.start.y,
vertical.end.y,
)
) {
continue;
}
const current = jumpsByEdgeId.get(horizontal.edge.id) ?? [];
if (
!current.some(
(jump) =>
Math.abs(jump.x - crossing.x) <= ROUTE_EPSILON &&
Math.abs(jump.y - crossing.y) <= ROUTE_EPSILON,
)
) {
current.push({ ...crossing, orientation: "horizontal" });
jumpsByEdgeId.set(horizontal.edge.id, current);
}
}
}
}
}
return jumpsByEdgeId;
}
/**
* Moves an internal orthogonal segment along its perpendicular axis. Both live
* endpoints remain untouched; the neighbouring perpendicular legs absorb the
* displacement.
*/
export function moveOrthogonalSegment(
points: EdgeRoutePoint[],
segmentIndex: number,
delta: EdgeRoutePoint,
) {
const nextPoints = points.map((point) => ({ ...point }));
if (segmentIndex <= 0 || segmentIndex >= nextPoints.length - 2) {
return nextPoints;
}
const start = nextPoints[segmentIndex];
const end = nextPoints[segmentIndex + 1];
if (edgeSegmentAxis(start, end) === "horizontal") {
start.y += delta.y;
end.y += delta.y;
} else {
start.x += delta.x;
end.x += delta.x;
}
return simplifyOrthogonalPoints(nextPoints);
}
/**
* Moves an internal bend in both axes while preserving an orthogonal route.
* Moving the bend also slides its two neighbouring bends along their existing
* perpendicular legs, which keeps the two live port endpoints fixed.
*/
export function moveOrthogonalCorner(
points: EdgeRoutePoint[],
cornerIndex: number,
delta: EdgeRoutePoint,
) {
const nextPoints = points.map((point) => ({ ...point }));
if (cornerIndex <= 0 || cornerIndex >= nextPoints.length - 1) {
return nextPoints;
}
const previous = nextPoints[cornerIndex - 1];
const corner = nextPoints[cornerIndex];
const incomingAxis = edgeSegmentAxis(previous, corner);
const outgoingAxis = edgeSegmentAxis(
corner,
nextPoints[cornerIndex + 1],
);
corner.x += delta.x;
corner.y += delta.y;
if (cornerIndex > 1) {
if (incomingAxis === "horizontal") {
previous.y = corner.y;
} else {
previous.x = corner.x;
}
} else if (incomingAxis === "horizontal") {
const middleX =
Math.abs(previous.x - corner.x) > ROUTE_EPSILON
? (previous.x + corner.x) / 2
: previous.x + 20;
nextPoints.splice(
cornerIndex,
0,
{ x: middleX, y: previous.y },
{ x: middleX, y: corner.y },
);
cornerIndex += 2;
} else {
const middleY =
Math.abs(previous.y - corner.y) > ROUTE_EPSILON
? (previous.y + corner.y) / 2
: previous.y + 20;
nextPoints.splice(
cornerIndex,
0,
{ x: previous.x, y: middleY },
{ x: corner.x, y: middleY },
);
cornerIndex += 2;
}
const movedCorner = nextPoints[cornerIndex];
const following = nextPoints[cornerIndex + 1];
if (cornerIndex < nextPoints.length - 2) {
if (outgoingAxis === "horizontal") {
following.y = movedCorner.y;
} else {
following.x = movedCorner.x;
}
} else if (outgoingAxis === "horizontal") {
const middleX =
Math.abs(movedCorner.x - following.x) > ROUTE_EPSILON
? (movedCorner.x + following.x) / 2
: following.x - 20;
nextPoints.splice(
cornerIndex + 1,
0,
{ x: middleX, y: movedCorner.y },
{ x: middleX, y: following.y },
);
} else {
const middleY =
Math.abs(movedCorner.y - following.y) > ROUTE_EPSILON
? (movedCorner.y + following.y) / 2
: following.y - 20;
nextPoints.splice(
cornerIndex + 1,
0,
{ x: movedCorner.x, y: middleY },
{ x: following.x, y: middleY },
);
}
return simplifyOrthogonalPoints(nextPoints);
}
/** Creates a movable dogleg when a selected route is still a straight line. */
export function createOrthogonalSegmentDetour(
points: EdgeRoutePoint[],
segmentIndex: number,
delta: EdgeRoutePoint,
) {
const start = points[segmentIndex];
const end = points[segmentIndex + 1];
if (!start || !end) {
return points.map((point) => ({ ...point }));
}
const firstThird = {
x: start.x + (end.x - start.x) / 3,
y: start.y + (end.y - start.y) / 3,
};
const secondThird = {
x: start.x + ((end.x - start.x) * 2) / 3,
y: start.y + ((end.y - start.y) * 2) / 3,
};
const detour =
edgeSegmentAxis(start, end) === "horizontal"
? [
{ x: firstThird.x + delta.x, y: start.y },
{ x: firstThird.x + delta.x, y: start.y + delta.y },
{ x: secondThird.x + delta.x, y: end.y + delta.y },
{ x: secondThird.x + delta.x, y: end.y },
]
: [
{ x: start.x, y: firstThird.y + delta.y },
{ x: start.x + delta.x, y: firstThird.y + delta.y },
{ x: end.x + delta.x, y: secondThird.y + delta.y },
{ x: end.x, y: secondThird.y + delta.y },
];
return simplifyOrthogonalPoints([
...points.slice(0, segmentIndex + 1),
...detour,
...points.slice(segmentIndex + 1),
]);
}
+8
View File
@@ -13,6 +13,14 @@ const NUMERIC_LITERAL_PATTERN =
const UNSIGNED_NUMBER_PREFIX =
/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
export function isScientificNotationValue(value: number | string): value is string {
if (typeof value !== "string") {
return false;
}
const trimmed = value.trim();
return NUMERIC_LITERAL_PATTERN.test(trimmed) && /[eE]/.test(trimmed);
}
type TokenKind =
| "number"
| "identifier"
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more