Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c9f59d78 | ||
|
|
42ffdfff7d | ||
|
|
a87d462e94 | ||
|
|
48da6be21c | ||
|
|
ce62079335 | ||
|
|
408b4ecb22 | ||
|
|
e18399c022 | ||
|
|
c19cf77aee | ||
|
|
eb6ea70e19 | ||
|
|
27f9f4add8 | ||
|
|
60b743dd81 | ||
|
|
a8c733883c | ||
|
|
53f8601fec | ||
|
|
f725f038b6 | ||
|
|
684d28752a | ||
|
|
b435daecf2 | ||
|
|
143e8dd309 | ||
|
|
0fa166c8e5 | ||
|
|
16a7eb2d6c | ||
|
|
6bb0591d32 | ||
|
|
cca9d1e883 | ||
|
|
22b35b2945 | ||
|
|
f09dfcf542 |
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Regression manifests hash these files as raw bytes. Keep their checkout
|
||||||
|
# representation identical on Windows and Linux so hashes remain portable.
|
||||||
|
tests/data/test-mql-8.xml text eol=lf
|
||||||
|
tests/data/test-mql-8.json text eol=lf
|
||||||
|
tests/data/test_mql-full-branches-01-04.xml text eol=lf
|
||||||
|
tests/baselines/simulation/**/*.json text eol=lf
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
name: Solver regression
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "app/simulation/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "constraints/**"
|
||||||
|
- ".python-version"
|
||||||
|
- ".gitattributes"
|
||||||
|
- "README.md"
|
||||||
|
- ".github/workflows/solver-regression.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "app/simulation/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "constraints/**"
|
||||||
|
- ".python-version"
|
||||||
|
- ".gitattributes"
|
||||||
|
- "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:
|
||||||
|
fixture-byte-contract:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os:
|
||||||
|
- ubuntu-24.04
|
||||||
|
- windows-2022
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
- name: Verify portable regression fixture bytes
|
||||||
|
run: python -m unittest tests.test_regression_fixture_line_endings
|
||||||
|
|
||||||
|
quick:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: |
|
||||||
|
requirements.txt
|
||||||
|
constraints/python312-direct.txt
|
||||||
|
constraints/python312-linux-x86_64.lock
|
||||||
|
- name: Install hashed Linux release lock
|
||||||
|
run: |
|
||||||
|
python -m pip install \
|
||||||
|
--force-reinstall \
|
||||||
|
-r constraints/python312-linux-x86_64.lock
|
||||||
|
python -m pip check
|
||||||
|
- name: Run solver foundation tests
|
||||||
|
env:
|
||||||
|
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
|
||||||
|
run: |
|
||||||
|
python -W error::ResourceWarning -m unittest \
|
||||||
|
tests.test_dependency_constraints \
|
||||||
|
tests.test_benchmark_regression \
|
||||||
|
tests.test_physical_state_v21 \
|
||||||
|
tests.test_test_mql_ame_contract \
|
||||||
|
tests.test_test_mql_8_regression \
|
||||||
|
tests.test_mql_full_branches_regression \
|
||||||
|
tests.test_pressure_flow_causal_execution \
|
||||||
|
tests.test_stream_pressure_block_solver \
|
||||||
|
tests.test_core_solver \
|
||||||
|
tests.test_supported_piston_tangent \
|
||||||
|
tests.test_three_piston_tangent \
|
||||||
|
tests.test_sparse_secant_jacobian \
|
||||||
|
tests.test_generic_jacobian_sparsity \
|
||||||
|
tests.test_generic_system_xml_simulation
|
||||||
|
|
||||||
|
historical-nightly:
|
||||||
|
if: >-
|
||||||
|
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: |
|
||||||
|
requirements.txt
|
||||||
|
constraints/python312-direct.txt
|
||||||
|
constraints/python312-linux-x86_64.lock
|
||||||
|
- name: Install hashed Linux release lock
|
||||||
|
run: |
|
||||||
|
python -m pip install \
|
||||||
|
--force-reinstall \
|
||||||
|
-r constraints/python312-linux-x86_64.lock
|
||||||
|
python -m pip check
|
||||||
|
- name: Run 0.81 and 2.10 second historical regression
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
python -m app.simulation.benchmark_regression \
|
||||||
|
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
|
||||||
|
--lane production \
|
||||||
|
--output artifacts/test-mql-full-branches.json
|
||||||
|
- if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: historical-solver-regression
|
||||||
|
path: artifacts/*.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
main-periodic:
|
||||||
|
if: >-
|
||||||
|
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 180
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: |
|
||||||
|
requirements.txt
|
||||||
|
constraints/python312-direct.txt
|
||||||
|
constraints/python312-linux-x86_64.lock
|
||||||
|
- name: Install hashed Linux release lock
|
||||||
|
run: |
|
||||||
|
python -m pip install \
|
||||||
|
--force-reinstall \
|
||||||
|
-r constraints/python312-linux-x86_64.lock
|
||||||
|
python -m pip check
|
||||||
|
- name: Run bounded progressive main-model regression
|
||||||
|
env:
|
||||||
|
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
|
||||||
|
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
python -m app.simulation.benchmark_regression \
|
||||||
|
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
|
||||||
|
--lane "$REQUESTED_LANE" \
|
||||||
|
--case "$REQUESTED_CASE" \
|
||||||
|
--output artifacts/test-mql-8-progressive.json
|
||||||
|
- if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: main-model-progressive-regression
|
||||||
|
path: artifacts/*.json
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -12,6 +12,10 @@ htmlcov/
|
|||||||
# Local virtual environments
|
# Local virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
.venv-win/
|
.venv-win/
|
||||||
|
|
||||||
|
# Local Linux toolchain (downloaded for the startup scripts)
|
||||||
|
.tools/node-*-linux-x64/
|
||||||
|
|
||||||
app/data/
|
app/data/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12.3
|
||||||
@@ -2,6 +2,88 @@
|
|||||||
|
|
||||||
ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
||||||
|
|
||||||
|
## 开发环境准备
|
||||||
|
|
||||||
|
后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
|
||||||
|
`3.12.3`。`requirements.txt` 保留支持范围,
|
||||||
|
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
|
||||||
|
`constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64
|
||||||
|
wheel、全部传递依赖及其 SHA-256。
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 -m venv .venv-win
|
||||||
|
.\.venv-win\Scripts\python.exe -m pip install `
|
||||||
|
-r requirements.txt `
|
||||||
|
-c constraints/python312-direct.txt
|
||||||
|
.\.venv-win\Scripts\python.exe -m pip check
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
./.venv/bin/python -m pip install \
|
||||||
|
-r constraints/python312-linux-x86_64.lock
|
||||||
|
./.venv/bin/python -m pip check
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux 发布锁仅适用于兼容 manylinux_2_28 的 Linux x86_64 和 CPython 3.12。它启用
|
||||||
|
`--only-binary=:all:` 与 `--require-hashes`,因此不会静默改用源码包或未审计 wheel;
|
||||||
|
CI 和正式性能复测必须直接以 `-r` 安装该文件。Windows 或其他平台的开发环境继续
|
||||||
|
使用 `requirements.txt` 加 `constraints/python312-direct.txt`。若要测试
|
||||||
|
`requirements.txt` 声明的兼容范围,可显式省略约束,但这类结果不应与锁定环境的
|
||||||
|
性能数据直接比较。
|
||||||
|
|
||||||
|
升级参考版本时,应在干净的 Python 3.12 Linux x86_64 虚拟环境中解析范围文件,
|
||||||
|
仅下载兼容 wheel,逐个记录 wheel 的 SHA-256,再从空环境安装发布锁并运行
|
||||||
|
`pip check`、依赖契约测试与后端测试。不能只复制 `pip freeze`,因为它既不证明
|
||||||
|
依赖来源,也不校验安装产物。
|
||||||
|
|
||||||
|
前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。
|
||||||
|
|
||||||
|
Windows(PowerShell,使用仓库内的便携 Node.js):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$nodeDir = Get-ChildItem .tools -Directory -Filter "node-*-win-x64" |
|
||||||
|
Where-Object { (Test-Path "$($_.FullName)\node.exe") -and (Test-Path "$($_.FullName)\npm.cmd") } |
|
||||||
|
Select-Object -First 1
|
||||||
|
& "$($nodeDir.FullName)\npm.cmd" --prefix frontend ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 启动脚本会自动使用 `.tools/node-*-win-x64` 下兼容的便携 Node.js;Linux 启动脚本优先使用 `.tools/node-*-linux-x64` 下兼容的运行时(如果存在),否则使用 `PATH` 中的 `node` 和 `npm`。`start-all.sh` 需要 Bash 4.3 或更高版本。
|
||||||
|
|
||||||
|
## 启动项目
|
||||||
|
|
||||||
|
脚本统一存放在 `bat/` 目录。三个入口分别用于同时启动、只启动后端、只启动前端。
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
bat\start-all.bat
|
||||||
|
bat\start-backend.bat
|
||||||
|
bat\start-reactflow.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bat/start-all.sh
|
||||||
|
./bat/start-backend.sh
|
||||||
|
./bat/start-reactflow.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。
|
||||||
|
|
||||||
## 后端接口
|
## 后端接口
|
||||||
|
|
||||||
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
||||||
@@ -19,19 +101,14 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
|||||||
|
|
||||||
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
|
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||||
|
|
||||||
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
|
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [开发文档索引](docs/README.md)
|
- [开发文档索引](docs/README.md)
|
||||||
- [后端接口版本与定义规范 v1](docs/backend-interface-version-spec-v1.md)
|
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
|
||||||
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
|
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
|
||||||
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
|
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
|
||||||
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||||
- [System XML v3 协议(当前规范)](docs/system-xml-v3.md)
|
- [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
|
||||||
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
|
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
|
||||||
|
|
||||||
+87
-10
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import csv
|
import csv
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -22,8 +22,14 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
|||||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
|
from app.parameter_expression import (
|
||||||
|
ParameterExpressionError,
|
||||||
|
evaluate_parameter_expression,
|
||||||
|
expression_value_to_base_unit,
|
||||||
|
)
|
||||||
from app.simulation.performance import performance_span, profile_phase, profile_run
|
from app.simulation.performance import performance_span, profile_phase, profile_run
|
||||||
from app.simulation.property_cache import property_cache_run
|
from app.simulation.property_cache import property_cache_run
|
||||||
|
from app.simulation.solvers.solver import SolverActivityTracker
|
||||||
from app.system_xml import (
|
from app.system_xml import (
|
||||||
SystemXmlDocument,
|
SystemXmlDocument,
|
||||||
SystemXmlValidationReport,
|
SystemXmlValidationReport,
|
||||||
@@ -86,6 +92,9 @@ SimulationTaskStatus = Literal[
|
|||||||
class SimulationTaskRecord:
|
class SimulationTaskRecord:
|
||||||
simulation_id: str
|
simulation_id: str
|
||||||
cancel_event: threading.Event
|
cancel_event: threading.Event
|
||||||
|
activity_tracker: SolverActivityTracker = field(
|
||||||
|
default_factory=SolverActivityTracker
|
||||||
|
)
|
||||||
status: SimulationTaskStatus = "queued"
|
status: SimulationTaskStatus = "queued"
|
||||||
cancel_reason: SimulationCancelReason | None = None
|
cancel_reason: SimulationCancelReason | None = None
|
||||||
result: dict[str, object] | None = None
|
result: dict[str, object] | None = None
|
||||||
@@ -574,6 +583,7 @@ def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]:
|
|||||||
"cancelReason": task.cancel_reason,
|
"cancelReason": task.cancel_reason,
|
||||||
"result": task.result,
|
"result": task.result,
|
||||||
"error": task.error,
|
"error": task.error,
|
||||||
|
**task.activity_tracker.snapshot().as_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -681,6 +691,7 @@ def run_system_xml_simulation(
|
|||||||
xml_bytes: bytes,
|
xml_bytes: bytes,
|
||||||
progress_callback: SimulationProgressEmitter | None = None,
|
progress_callback: SimulationProgressEmitter | None = None,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
with property_cache_run() as property_cache:
|
with property_cache_run() as property_cache:
|
||||||
with profile_run() as trace:
|
with profile_run() as trace:
|
||||||
@@ -688,6 +699,7 @@ def run_system_xml_simulation(
|
|||||||
xml_bytes,
|
xml_bytes,
|
||||||
progress_callback,
|
progress_callback,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
|
activity_tracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
performance = trace.snapshot()
|
performance = trace.snapshot()
|
||||||
@@ -712,6 +724,7 @@ def _run_system_xml_simulation_profiled(
|
|||||||
xml_bytes: bytes,
|
xml_bytes: bytes,
|
||||||
progress_callback: SimulationProgressEmitter | None = None,
|
progress_callback: SimulationProgressEmitter | None = None,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
||||||
from app.simulation.solvers.solver import SolveIVPConfig
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
@@ -774,6 +787,7 @@ def _run_system_xml_simulation_profiled(
|
|||||||
sample_step=document.simulation.sample_step,
|
sample_step=document.simulation.sample_step,
|
||||||
progress_callback=report_system_progress,
|
progress_callback=report_system_progress,
|
||||||
cancel_check=cancel_check,
|
cancel_check=cancel_check,
|
||||||
|
activity_tracker=activity_tracker,
|
||||||
)
|
)
|
||||||
except SimulationPreparationError as exc:
|
except SimulationPreparationError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -862,6 +876,9 @@ def simulation_event_stream(
|
|||||||
latest_message = "正在等待仿真任务启动"
|
latest_message = "正在等待仿真任务启动"
|
||||||
latest_simulated_time: float | None = None
|
latest_simulated_time: float | None = None
|
||||||
latest_total_time: float | None = None
|
latest_total_time: float | None = None
|
||||||
|
activity_tracker = (
|
||||||
|
task.activity_tracker if task is not None else SolverActivityTracker()
|
||||||
|
)
|
||||||
|
|
||||||
def emit_progress(
|
def emit_progress(
|
||||||
progress: int,
|
progress: int,
|
||||||
@@ -889,6 +906,7 @@ def simulation_event_stream(
|
|||||||
event["simulatedTime"] = latest_simulated_time
|
event["simulatedTime"] = latest_simulated_time
|
||||||
if latest_total_time is not None:
|
if latest_total_time is not None:
|
||||||
event["totalTime"] = latest_total_time
|
event["totalTime"] = latest_total_time
|
||||||
|
event.update(activity_tracker.snapshot().as_dict())
|
||||||
events.put(event)
|
events.put(event)
|
||||||
|
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
@@ -899,18 +917,32 @@ def simulation_event_stream(
|
|||||||
xml_bytes,
|
xml_bytes,
|
||||||
emit_progress,
|
emit_progress,
|
||||||
task.cancel_event.is_set if task is not None else None,
|
task.cancel_event.is_set if task is not None else None,
|
||||||
|
activity_tracker,
|
||||||
)
|
)
|
||||||
if task is not None:
|
if task is not None:
|
||||||
result = _mark_simulation_task_result(task, result)
|
result = _mark_simulation_task_result(task, result)
|
||||||
result_status = str(result.get("status", "completed"))
|
result_status = str(result.get("status", "completed"))
|
||||||
|
final_simulated_time = result.get("simulatedUntil")
|
||||||
|
final_activity_kind = (
|
||||||
|
"complete" if result_status == "completed" else result_status
|
||||||
|
)
|
||||||
|
if activity_tracker.snapshot().activity_kind != final_activity_kind:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
final_activity_kind,
|
||||||
|
(
|
||||||
|
float(final_simulated_time)
|
||||||
|
if isinstance(final_simulated_time, (int, float))
|
||||||
|
and isfinite(final_simulated_time)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
result_messages = {
|
result_messages = {
|
||||||
"completed": "仿真完成",
|
"completed": "仿真完成",
|
||||||
"stopped": "仿真已由用户终止,已保留部分结果",
|
"stopped": "仿真已由用户终止,已保留部分结果",
|
||||||
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
|
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
|
||||||
"failed": "仿真异常终止,已保留可用的部分结果",
|
"failed": "仿真异常终止,已保留可用的部分结果",
|
||||||
}
|
}
|
||||||
events.put(
|
result_event = {
|
||||||
{
|
|
||||||
"event": "result",
|
"event": "result",
|
||||||
"progress": 100 if result_status == "completed" else latest_progress,
|
"progress": 100 if result_status == "completed" else latest_progress,
|
||||||
"phase": result_status,
|
"phase": result_status,
|
||||||
@@ -919,8 +951,10 @@ def simulation_event_stream(
|
|||||||
"totalTime": result.get("requestedStopTime"),
|
"totalTime": result.get("requestedStopTime"),
|
||||||
"result": result,
|
"result": result,
|
||||||
}
|
}
|
||||||
)
|
result_event.update(activity_tracker.snapshot().as_dict())
|
||||||
|
events.put(result_event)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
|
activity_tracker.record_phase("failed")
|
||||||
detail = exc.detail
|
detail = exc.detail
|
||||||
message = (
|
message = (
|
||||||
str(detail.get("message", "仿真失败"))
|
str(detail.get("message", "仿真失败"))
|
||||||
@@ -935,10 +969,12 @@ def simulation_event_stream(
|
|||||||
"message": message,
|
"message": message,
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
}
|
}
|
||||||
|
error_event.update(activity_tracker.snapshot().as_dict())
|
||||||
if task is not None:
|
if task is not None:
|
||||||
_mark_simulation_task_error(task, error_event)
|
_mark_simulation_task_error(task, error_event)
|
||||||
events.put(error_event)
|
events.put(error_event)
|
||||||
except Exception as exc: # pragma: no cover - last-resort stream guard
|
except Exception as exc: # pragma: no cover - last-resort stream guard
|
||||||
|
activity_tracker.record_phase("failed")
|
||||||
error_event = {
|
error_event = {
|
||||||
"event": "error",
|
"event": "error",
|
||||||
"progress": latest_progress,
|
"progress": latest_progress,
|
||||||
@@ -947,6 +983,7 @@ def simulation_event_stream(
|
|||||||
"message": "仿真服务发生未预期错误。",
|
"message": "仿真服务发生未预期错误。",
|
||||||
"detail": str(exc),
|
"detail": str(exc),
|
||||||
}
|
}
|
||||||
|
error_event.update(activity_tracker.snapshot().as_dict())
|
||||||
if task is not None:
|
if task is not None:
|
||||||
_mark_simulation_task_error(task, error_event)
|
_mark_simulation_task_error(task, error_event)
|
||||||
events.put(error_event)
|
events.put(error_event)
|
||||||
@@ -964,8 +1001,7 @@ def simulation_event_stream(
|
|||||||
try:
|
try:
|
||||||
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
|
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
yield json.dumps(
|
heartbeat_event = {
|
||||||
{
|
|
||||||
"event": "progress",
|
"event": "progress",
|
||||||
"progress": latest_progress,
|
"progress": latest_progress,
|
||||||
"phase": latest_phase,
|
"phase": latest_phase,
|
||||||
@@ -973,7 +1009,10 @@ def simulation_event_stream(
|
|||||||
"heartbeat": True,
|
"heartbeat": True,
|
||||||
"simulatedTime": latest_simulated_time,
|
"simulatedTime": latest_simulated_time,
|
||||||
"totalTime": latest_total_time,
|
"totalTime": latest_total_time,
|
||||||
},
|
}
|
||||||
|
heartbeat_event.update(activity_tracker.snapshot().as_dict())
|
||||||
|
yield json.dumps(
|
||||||
|
heartbeat_event,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
) + "\n"
|
) + "\n"
|
||||||
@@ -1066,7 +1105,14 @@ def validate_reactflow_component_contract(
|
|||||||
|
|
||||||
parameter_values: dict[str, float] = {}
|
parameter_values: dict[str, float] = {}
|
||||||
for parameter in component_spec.parameters:
|
for parameter in component_spec.parameters:
|
||||||
value = parameter_float(node, parameter.name, parameter.default)
|
value = parameter_float(
|
||||||
|
node,
|
||||||
|
parameter.name,
|
||||||
|
parameter.default,
|
||||||
|
quantity=parameter.quantity,
|
||||||
|
base_unit=parameter.unit,
|
||||||
|
expressions_allowed=parameter.editor is None,
|
||||||
|
)
|
||||||
validation_message = parameter.validation_message(value)
|
validation_message = parameter.validation_message(value)
|
||||||
if validation_message is not None:
|
if validation_message is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -1633,14 +1679,45 @@ def parameter_float(
|
|||||||
node: ReactFlowNodePayload | None,
|
node: ReactFlowNodePayload | None,
|
||||||
name: str,
|
name: str,
|
||||||
default: float,
|
default: float,
|
||||||
|
*,
|
||||||
|
quantity: str = "dimensionless",
|
||||||
|
base_unit: str = "",
|
||||||
|
expressions_allowed: bool = True,
|
||||||
) -> float:
|
) -> float:
|
||||||
if node is None:
|
if node is None:
|
||||||
return default
|
return default
|
||||||
value = node.data.parameters.get(name, default)
|
value = node.data.parameters.get(name, default)
|
||||||
try:
|
try:
|
||||||
return float(value)
|
numeric_value = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"Parameter '{name}' on component '{node.id}' must be numeric."
|
||||||
|
)
|
||||||
|
if not expressions_allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"PARAMETER_EXPRESSION_FORBIDDEN: Parameter '{name}' on component "
|
||||||
|
f"'{node.id}' is a discrete selection and cannot use an expression."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
evaluated = evaluate_parameter_expression(value)
|
||||||
|
selected_unit = node.data.parameterUnits.get(name, base_unit)
|
||||||
|
return expression_value_to_base_unit(
|
||||||
|
evaluated,
|
||||||
|
quantity=quantity,
|
||||||
|
selected_unit=selected_unit,
|
||||||
|
)
|
||||||
|
except ParameterExpressionError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"PARAMETER_EXPRESSION_INVALID: Parameter '{name}' on component "
|
||||||
|
f"'{node.id}' contains an invalid expression: {exc}."
|
||||||
|
) from exc
|
||||||
|
if not isfinite(numeric_value):
|
||||||
|
raise ValueError(
|
||||||
|
f"Parameter '{name}' on component '{node.id}' must be a finite "
|
||||||
|
"numeric value."
|
||||||
|
)
|
||||||
|
return numeric_value
|
||||||
|
|
||||||
|
|
||||||
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
|
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
MAX_INPUT_LENGTH = 512
|
||||||
|
MAX_TOKEN_COUNT = 256
|
||||||
|
MAX_OPERATION_COUNT = 256
|
||||||
|
MAX_NESTING_DEPTH = 32
|
||||||
|
MAX_FUNCTION_ARGUMENTS = 16
|
||||||
|
|
||||||
|
_UNSIGNED_NUMBER_PREFIX = re.compile(
|
||||||
|
r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterExpressionError(ValueError):
|
||||||
|
"""Raised when an editor parameter expression cannot be resolved safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Token:
|
||||||
|
kind: str
|
||||||
|
text: str
|
||||||
|
position: int
|
||||||
|
value: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_parameter_expression(expression: str) -> float:
|
||||||
|
"""Evaluate the same bounded arithmetic subset accepted by the frontend.
|
||||||
|
|
||||||
|
The parser never executes Python code and cannot access names other than
|
||||||
|
the constants ``pi`` and ``e`` or the explicitly supported functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source = expression.strip()
|
||||||
|
if source.startswith("="):
|
||||||
|
source = source[1:].strip()
|
||||||
|
if not source:
|
||||||
|
raise ParameterExpressionError("expression must not be empty")
|
||||||
|
if len(source) > MAX_INPUT_LENGTH:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"expression must not exceed {MAX_INPUT_LENGTH} characters"
|
||||||
|
)
|
||||||
|
return _ParameterExpressionParser(_tokenize(source)).parse()
|
||||||
|
|
||||||
|
|
||||||
|
def expression_value_to_base_unit(
|
||||||
|
value: float,
|
||||||
|
*,
|
||||||
|
quantity: str,
|
||||||
|
selected_unit: str,
|
||||||
|
) -> float:
|
||||||
|
"""Convert an expression result from its editor unit to the SI contract.
|
||||||
|
|
||||||
|
Plain numeric JSON values are already stored in SI and must not pass
|
||||||
|
through this function. Only expression results use the selected display
|
||||||
|
unit, matching the existing frontend behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
conversions = _UNIT_CONVERSIONS.get(quantity)
|
||||||
|
if not conversions:
|
||||||
|
return _ensure_finite(value, "expression result")
|
||||||
|
conversion = conversions.get(selected_unit)
|
||||||
|
if conversion is None:
|
||||||
|
# The frontend falls back to the first (base) unit for an unknown or
|
||||||
|
# absent selection. Keep the execution boundary behavior identical.
|
||||||
|
conversion = next(iter(conversions.values()))
|
||||||
|
scale, offset = conversion
|
||||||
|
return _ensure_finite(value * scale + offset, "converted expression result")
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(source: str) -> tuple[_Token, ...]:
|
||||||
|
tokens: list[_Token] = []
|
||||||
|
position = 0
|
||||||
|
|
||||||
|
def append(token: _Token) -> None:
|
||||||
|
tokens.append(token)
|
||||||
|
if len(tokens) > MAX_TOKEN_COUNT:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"expression must not exceed {MAX_TOKEN_COUNT} tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
while position < len(source):
|
||||||
|
character = source[position]
|
||||||
|
if character.isspace():
|
||||||
|
position += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if character.isdigit() or (
|
||||||
|
character == "."
|
||||||
|
and position + 1 < len(source)
|
||||||
|
and source[position + 1].isdigit()
|
||||||
|
):
|
||||||
|
match = _UNSIGNED_NUMBER_PREFIX.match(source, position)
|
||||||
|
if match is None:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"invalid number near character {position + 1}"
|
||||||
|
)
|
||||||
|
text = match.group(0)
|
||||||
|
value = _ensure_finite(float(text), f"number {text!r}")
|
||||||
|
append(_Token("number", text, position, value))
|
||||||
|
position = match.end()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if character.isascii() and (character.isalpha() or character == "_"):
|
||||||
|
end = position + 1
|
||||||
|
while end < len(source):
|
||||||
|
candidate = source[end]
|
||||||
|
if not candidate.isascii() or not (
|
||||||
|
candidate.isalnum() or candidate == "_"
|
||||||
|
):
|
||||||
|
break
|
||||||
|
end += 1
|
||||||
|
append(_Token("identifier", source[position:end], position))
|
||||||
|
position = end
|
||||||
|
continue
|
||||||
|
|
||||||
|
if character == "*" and source[position : position + 2] == "**":
|
||||||
|
append(_Token("operator", "**", position))
|
||||||
|
position += 2
|
||||||
|
continue
|
||||||
|
if character in "+-*/^":
|
||||||
|
append(_Token("operator", character, position))
|
||||||
|
position += 1
|
||||||
|
continue
|
||||||
|
if character == "(":
|
||||||
|
append(_Token("left_parenthesis", character, position))
|
||||||
|
position += 1
|
||||||
|
continue
|
||||||
|
if character == ")":
|
||||||
|
append(_Token("right_parenthesis", character, position))
|
||||||
|
position += 1
|
||||||
|
continue
|
||||||
|
if character == ",":
|
||||||
|
append(_Token("comma", character, position))
|
||||||
|
position += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"unsupported symbol {character!r} at character {position + 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens.append(_Token("end", "", len(source)))
|
||||||
|
return tuple(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
class _ParameterExpressionParser:
|
||||||
|
def __init__(self, tokens: tuple[_Token, ...]) -> None:
|
||||||
|
self._tokens = tokens
|
||||||
|
self._index = 0
|
||||||
|
self._operation_count = 0
|
||||||
|
|
||||||
|
def parse(self) -> float:
|
||||||
|
value = self._parse_additive(0)
|
||||||
|
trailing = self._current()
|
||||||
|
if trailing.kind != "end":
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"unexpected content {trailing.text!r} near character "
|
||||||
|
f"{trailing.position + 1}"
|
||||||
|
)
|
||||||
|
return _ensure_finite(value, "expression result")
|
||||||
|
|
||||||
|
def _parse_additive(self, depth: int) -> float:
|
||||||
|
value = self._parse_multiplicative(depth)
|
||||||
|
while self._is_operator("+") or self._is_operator("-"):
|
||||||
|
operator = self._advance().text
|
||||||
|
right = self._parse_multiplicative(depth)
|
||||||
|
self._count_operation()
|
||||||
|
value = _safe_operation(
|
||||||
|
lambda: value + right if operator == "+" else value - right,
|
||||||
|
f"operation {operator!r}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _parse_multiplicative(self, depth: int) -> float:
|
||||||
|
value = self._parse_unary(depth)
|
||||||
|
while self._is_operator("*") or self._is_operator("/"):
|
||||||
|
operator = self._advance().text
|
||||||
|
right = self._parse_unary(depth)
|
||||||
|
self._count_operation()
|
||||||
|
if operator == "/" and right == 0:
|
||||||
|
raise ParameterExpressionError("division by zero is not allowed")
|
||||||
|
value = _safe_operation(
|
||||||
|
lambda: value * right if operator == "*" else value / right,
|
||||||
|
f"operation {operator!r}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _parse_unary(self, depth: int) -> float:
|
||||||
|
self._assert_depth(depth)
|
||||||
|
if self._is_operator("+") or self._is_operator("-"):
|
||||||
|
operator = self._advance().text
|
||||||
|
self._count_operation()
|
||||||
|
operand = self._parse_unary(depth + 1)
|
||||||
|
return _ensure_finite(
|
||||||
|
operand if operator == "+" else -operand,
|
||||||
|
f"unary operation {operator!r}",
|
||||||
|
)
|
||||||
|
return self._parse_power(depth)
|
||||||
|
|
||||||
|
def _parse_power(self, depth: int) -> float:
|
||||||
|
self._assert_depth(depth)
|
||||||
|
base = self._parse_primary(depth)
|
||||||
|
if not self._is_operator("^") and not self._is_operator("**"):
|
||||||
|
return base
|
||||||
|
operator = self._advance().text
|
||||||
|
exponent = self._parse_unary(depth + 1)
|
||||||
|
self._count_operation()
|
||||||
|
return _safe_operation(
|
||||||
|
lambda: math.pow(base, exponent),
|
||||||
|
f"operation {operator!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_primary(self, depth: int) -> float:
|
||||||
|
self._assert_depth(depth)
|
||||||
|
token = self._current()
|
||||||
|
if token.kind == "number":
|
||||||
|
self._advance()
|
||||||
|
return _ensure_finite(
|
||||||
|
token.value if token.value is not None else math.nan,
|
||||||
|
f"number {token.text!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if token.kind == "identifier":
|
||||||
|
self._advance()
|
||||||
|
normalized_name = token.text.casefold()
|
||||||
|
if self._current().kind == "left_parenthesis":
|
||||||
|
return self._parse_function_call(
|
||||||
|
normalized_name,
|
||||||
|
token.text,
|
||||||
|
depth + 1,
|
||||||
|
)
|
||||||
|
if normalized_name == "pi":
|
||||||
|
return math.pi
|
||||||
|
if normalized_name == "e":
|
||||||
|
return math.e
|
||||||
|
raise ParameterExpressionError(f"unknown identifier {token.text!r}")
|
||||||
|
|
||||||
|
if token.kind == "left_parenthesis":
|
||||||
|
self._advance()
|
||||||
|
value = self._parse_additive(depth + 1)
|
||||||
|
self._expect("right_parenthesis", "missing closing parenthesis")
|
||||||
|
return value
|
||||||
|
|
||||||
|
if token.kind == "end":
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
"expression ends before a number, constant, or function"
|
||||||
|
)
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"expected a number, constant, or function near character "
|
||||||
|
f"{token.position + 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_function_call(
|
||||||
|
self,
|
||||||
|
normalized_name: str,
|
||||||
|
source_name: str,
|
||||||
|
depth: int,
|
||||||
|
) -> float:
|
||||||
|
self._assert_depth(depth)
|
||||||
|
self._expect(
|
||||||
|
"left_parenthesis",
|
||||||
|
f"function {source_name} is missing an opening parenthesis",
|
||||||
|
)
|
||||||
|
arguments: list[float] = []
|
||||||
|
if self._current().kind != "right_parenthesis":
|
||||||
|
while True:
|
||||||
|
if len(arguments) >= MAX_FUNCTION_ARGUMENTS:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"function {source_name} accepts at most "
|
||||||
|
f"{MAX_FUNCTION_ARGUMENTS} arguments"
|
||||||
|
)
|
||||||
|
arguments.append(self._parse_additive(depth))
|
||||||
|
if self._current().kind != "comma":
|
||||||
|
break
|
||||||
|
self._advance()
|
||||||
|
if self._current().kind == "right_parenthesis":
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"function {source_name} has no argument after its comma"
|
||||||
|
)
|
||||||
|
self._expect(
|
||||||
|
"right_parenthesis",
|
||||||
|
f"function {source_name} is missing a closing parenthesis",
|
||||||
|
)
|
||||||
|
self._count_operation()
|
||||||
|
return _evaluate_function(normalized_name, source_name, arguments)
|
||||||
|
|
||||||
|
def _current(self) -> _Token:
|
||||||
|
return self._tokens[min(self._index, len(self._tokens) - 1)]
|
||||||
|
|
||||||
|
def _advance(self) -> _Token:
|
||||||
|
token = self._current()
|
||||||
|
if token.kind != "end":
|
||||||
|
self._index += 1
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _expect(self, kind: str, message: str) -> _Token:
|
||||||
|
if self._current().kind != kind:
|
||||||
|
raise ParameterExpressionError(message)
|
||||||
|
return self._advance()
|
||||||
|
|
||||||
|
def _is_operator(self, operator: str) -> bool:
|
||||||
|
token = self._current()
|
||||||
|
return token.kind == "operator" and token.text == operator
|
||||||
|
|
||||||
|
def _assert_depth(self, depth: int) -> None:
|
||||||
|
if depth > MAX_NESTING_DEPTH:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"expression nesting must not exceed {MAX_NESTING_DEPTH} levels"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _count_operation(self) -> None:
|
||||||
|
self._operation_count += 1
|
||||||
|
if self._operation_count > MAX_OPERATION_COUNT:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"expression must not exceed {MAX_OPERATION_COUNT} operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_function(
|
||||||
|
normalized_name: str,
|
||||||
|
source_name: str,
|
||||||
|
arguments: list[float],
|
||||||
|
) -> float:
|
||||||
|
def require_count(expected: int) -> None:
|
||||||
|
if len(arguments) != expected:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"function {source_name} requires {expected} arguments, "
|
||||||
|
f"received {len(arguments)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_name == "sqrt":
|
||||||
|
require_count(1)
|
||||||
|
if arguments[0] < 0:
|
||||||
|
raise ParameterExpressionError("sqrt argument must not be negative")
|
||||||
|
operation = lambda: math.sqrt(arguments[0])
|
||||||
|
elif normalized_name == "abs":
|
||||||
|
require_count(1)
|
||||||
|
operation = lambda: abs(arguments[0])
|
||||||
|
elif normalized_name in {"sin", "cos", "tan", "asin", "acos", "atan"}:
|
||||||
|
require_count(1)
|
||||||
|
if normalized_name in {"asin", "acos"} and not -1 <= arguments[0] <= 1:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"{source_name} argument must be between -1 and 1"
|
||||||
|
)
|
||||||
|
function = getattr(math, normalized_name)
|
||||||
|
operation = lambda: function(arguments[0])
|
||||||
|
elif normalized_name == "exp":
|
||||||
|
require_count(1)
|
||||||
|
operation = lambda: math.exp(arguments[0])
|
||||||
|
elif normalized_name in {"ln", "log"}:
|
||||||
|
require_count(1)
|
||||||
|
if arguments[0] <= 0:
|
||||||
|
raise ParameterExpressionError(f"{source_name} argument must be positive")
|
||||||
|
operation = lambda: math.log(arguments[0])
|
||||||
|
elif normalized_name == "log10":
|
||||||
|
require_count(1)
|
||||||
|
if arguments[0] <= 0:
|
||||||
|
raise ParameterExpressionError("log10 argument must be positive")
|
||||||
|
operation = lambda: math.log10(arguments[0])
|
||||||
|
elif normalized_name in {"min", "max"}:
|
||||||
|
if not arguments:
|
||||||
|
raise ParameterExpressionError(
|
||||||
|
f"function {source_name} requires at least one argument"
|
||||||
|
)
|
||||||
|
function = min if normalized_name == "min" else max
|
||||||
|
operation = lambda: float(function(arguments))
|
||||||
|
elif normalized_name == "pow":
|
||||||
|
require_count(2)
|
||||||
|
operation = lambda: math.pow(arguments[0], arguments[1])
|
||||||
|
else:
|
||||||
|
raise ParameterExpressionError(f"unsupported function {source_name!r}")
|
||||||
|
return _safe_operation(operation, f"function {source_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_operation(operation: Callable[[], float], context: str) -> float:
|
||||||
|
try:
|
||||||
|
value = operation()
|
||||||
|
except (ArithmeticError, ValueError) as exc:
|
||||||
|
raise ParameterExpressionError(f"{context} has no finite real result") from exc
|
||||||
|
return _ensure_finite(float(value), context)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_finite(value: float, context: str) -> float:
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise ParameterExpressionError(f"{context} is not finite")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Ordered exactly like the editor's unit selector. The first entry is the
|
||||||
|
# fallback SI unit when a persisted selection is absent or unknown.
|
||||||
|
_UNIT_CONVERSIONS: dict[str, dict[str, tuple[float, float]]] = {
|
||||||
|
"area": {"m2": (1.0, 0.0), "cm2": (1.0e-4, 0.0), "mm2": (1.0e-6, 0.0)},
|
||||||
|
"heat_transfer_coefficient": {"W/(m2*K)": (1.0, 0.0)},
|
||||||
|
"pressure": {
|
||||||
|
"Pa": (1.0, 0.0),
|
||||||
|
"kPa": (1.0e3, 0.0),
|
||||||
|
"MPa": (1.0e6, 0.0),
|
||||||
|
"bar": (1.0e5, 0.0),
|
||||||
|
},
|
||||||
|
"volume": {"m3": (1.0, 0.0), "L": (1.0e-3, 0.0), "mL": (1.0e-6, 0.0)},
|
||||||
|
"temperature": {"K": (1.0, 0.0), "degC": (1.0, 273.15)},
|
||||||
|
"length": {"m": (1.0, 0.0), "cm": (1.0e-2, 0.0), "mm": (1.0e-3, 0.0)},
|
||||||
|
}
|
||||||
@@ -33,8 +33,8 @@ FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件
|
|||||||
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
|
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
|
||||||
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||||
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
|
||||||
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
|
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
|
||||||
|
|
||||||
当前关键文件:
|
当前关键文件:
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行
|
|||||||
`diagnostics.performance.propertyCache` 中返回。
|
`diagnostics.performance.propertyCache` 中返回。
|
||||||
|
|
||||||
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
|
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
|
||||||
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。
|
[`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。
|
||||||
|
|
||||||
## 当前阶段进度
|
## 当前阶段进度
|
||||||
|
|
||||||
@@ -311,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)
|
||||||
|
|
||||||
当前四个主变量的最大误差为:
|
当前四个主变量的最大误差为:
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,7 @@ _FLOWSET_USES_CV = (ParameterCondition("flowset", (2.0,)),)
|
|||||||
_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),)
|
_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),)
|
||||||
_PN_PRESSURE_RATIO_ACCURACY = 0.9999
|
_PN_PRESSURE_RATIO_ACCURACY = 0.9999
|
||||||
_PN_LAMINAR_SMOOTHING_GAIN = 12.0
|
_PN_LAMINAR_SMOOTHING_GAIN = 12.0
|
||||||
|
_PNVO001_CLOSED_OPENING_ABS_TOL = 1.0e-12
|
||||||
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
|
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
|
||||||
id="flow_coefficient",
|
id="flow_coefficient",
|
||||||
label="流量系数",
|
label="流量系数",
|
||||||
@@ -59,6 +60,9 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "amesim_pnor001"
|
MODEL_TYPE = "amesim_pnor001"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -464,6 +468,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "amesim_pnvo001_fixed"
|
MODEL_TYPE = "amesim_pnvo001_fixed"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||||
@@ -567,8 +574,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="amesim_pnvo001_fixed",
|
symbol="amesim_pnvo001_fixed",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_2", "left", order=10),
|
# AMESim default geometry places port 2 right and port 3 left.
|
||||||
PortDisplaySpec("port_3", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=10),
|
||||||
|
PortDisplaySpec("port_3", "left", order=20),
|
||||||
),
|
),
|
||||||
order=30,
|
order=30,
|
||||||
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
||||||
@@ -716,6 +724,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
upstream_temperature=self._upstream_temperature("port_3"),
|
upstream_temperature=self._upstream_temperature("port_3"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32768)
|
||||||
def _one_way_flow_characteristics(
|
def _one_way_flow_characteristics(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -834,6 +843,16 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
downstream_pressure=downstream_pressure,
|
downstream_pressure=downstream_pressure,
|
||||||
upstream_temperature=upstream_temperature,
|
upstream_temperature=upstream_temperature,
|
||||||
)
|
)
|
||||||
|
# AMESim reports no vena-contracta velocity while the valve is closed.
|
||||||
|
# Signal propagation around a step can leave a round-off-sized opening,
|
||||||
|
# so apply the same numerical-zero convention to this diagnostic only.
|
||||||
|
if isclose(
|
||||||
|
self.opening,
|
||||||
|
0.0,
|
||||||
|
rel_tol=0.0,
|
||||||
|
abs_tol=_PNVO001_CLOSED_OPENING_ABS_TOL,
|
||||||
|
):
|
||||||
|
gas_velocity = 0.0
|
||||||
return {
|
return {
|
||||||
"xv": self.opening,
|
"xv": self.opening,
|
||||||
"cm": mass_flow_parameter,
|
"cm": mass_flow_parameter,
|
||||||
@@ -888,6 +907,10 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
MODEL_TYPE = "amesim_pnvo001"
|
MODEL_TYPE = "amesim_pnvo001"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
# Repeat the exact-sum promise on this concrete subclass deliberately.
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.signal("res", nominal_role="input"),
|
PortDefinition.signal("res", nominal_role="input"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -968,8 +991,9 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
symbol="amesim_pnvo001",
|
symbol="amesim_pnvo001",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("res", "left", order=5),
|
PortDisplaySpec("res", "left", order=5),
|
||||||
PortDisplaySpec("port_2", "left", order=10),
|
# AMESim default geometry places port 2 right and port 3 left.
|
||||||
PortDisplaySpec("port_3", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=10),
|
||||||
|
PortDisplaySpec("port_3", "left", order=20),
|
||||||
),
|
),
|
||||||
order=35,
|
order=35,
|
||||||
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from math import isclose, log, log10, pi, sqrt, tanh
|
from math import isclose, isfinite, log, log10, pi, sqrt, tanh
|
||||||
|
|
||||||
from app.simulation.components.amesim.gases import (
|
from app.simulation.components.amesim.gases import (
|
||||||
AMESIM_GAS_INDEX_PARAMETER,
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
@@ -22,11 +23,124 @@ from app.simulation.core.metadata import (
|
|||||||
ResultVariableDefinition,
|
ResultVariableDefinition,
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
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.ports import PortDefinition
|
||||||
from app.simulation.core.state import VolumeState
|
from app.simulation.core.state import VolumeState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Pnl0001MassFlowLinearization:
|
||||||
|
value: float
|
||||||
|
partial_p_1: float
|
||||||
|
partial_p_2: float
|
||||||
|
partial_temperature: float
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
direction: str = "forward"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Pnl0001DerivativeLinearization:
|
||||||
|
derivative: tuple[float, float]
|
||||||
|
tangents: tuple[tuple[float, ...], tuple[float, ...]]
|
||||||
|
properties: ThermodynamicPropertiesLinearization
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_REPORTED_FRICTION_FACTOR = 64_000_000.0
|
||||||
|
|
||||||
|
# ``pn2pipefr`` blends the laminar and turbulent friction branches over a
|
||||||
|
# much wider Reynolds range than the former hard 2300..4000 interpolation.
|
||||||
|
# These constants are a uniform-Re least-squares fit to the unchanged
|
||||||
|
# Simcenter Amesim 2404 test_mql result for PNL00R/PNL0001/PNL0003 at both
|
||||||
|
# rr=0.045/14 and rr=0.045/20. The shifted Hill exponent is greater than 2,
|
||||||
|
# so its zero-valued join to the laminar branch is C2 continuous.
|
||||||
|
_PN2PIPEFR_TRANSITION_START_REYNOLDS = 89.96829989
|
||||||
|
_PN2PIPEFR_TRANSITION_SCALE_REYNOLDS = 2741.96700831
|
||||||
|
_PN2PIPEFR_TRANSITION_SHARPNESS = 8.37293695
|
||||||
|
|
||||||
|
# PNL00R keeps the exact closed-form laminar solve where the fitted
|
||||||
|
# transition contribution is below four parts per million. Extending this
|
||||||
|
# shortcut to Re=2300 bypassed the real AMESim transition and introduced a
|
||||||
|
# solver-visible branch point.
|
||||||
|
_PN2PIPEFR_ANALYTIC_LAMINAR_MAX_REYNOLDS = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _pn2pipefr_friction_factor_with_precomputed_fully_rough(
|
||||||
|
reynolds_number: float,
|
||||||
|
*,
|
||||||
|
relative_roughness: float,
|
||||||
|
fully_rough: float | None,
|
||||||
|
) -> float:
|
||||||
|
"""Evaluate the built-in friction law with its rr-only term supplied."""
|
||||||
|
|
||||||
|
if reynolds_number <= 0.0:
|
||||||
|
return 64_000_000.0
|
||||||
|
laminar = 64.0 / reynolds_number
|
||||||
|
if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS:
|
||||||
|
return laminar
|
||||||
|
|
||||||
|
# Keep this arithmetic in the same order as AmesimPnl00r.friction_factor.
|
||||||
|
# The specialized fixed-point path only moves the rr-only logarithm out of
|
||||||
|
# the iteration; subclasses continue to use the public virtual method.
|
||||||
|
smooth_turbulent = 1.0 / (
|
||||||
|
-1.8 * log10(6.9 / reynolds_number)
|
||||||
|
) ** 2
|
||||||
|
if relative_roughness <= 0.0:
|
||||||
|
turbulent = smooth_turbulent
|
||||||
|
else:
|
||||||
|
assert fully_rough is not None
|
||||||
|
roughness_reynolds = reynolds_number * relative_roughness
|
||||||
|
roughness_reynolds_squared = roughness_reynolds * roughness_reynolds
|
||||||
|
roughness_weight = roughness_reynolds_squared / (
|
||||||
|
roughness_reynolds_squared + 180.0 * 180.0
|
||||||
|
)
|
||||||
|
turbulent = smooth_turbulent + roughness_weight * (
|
||||||
|
fully_rough - smooth_turbulent
|
||||||
|
)
|
||||||
|
transition_coordinate = (
|
||||||
|
(reynolds_number - _PN2PIPEFR_TRANSITION_START_REYNOLDS)
|
||||||
|
/ _PN2PIPEFR_TRANSITION_SCALE_REYNOLDS
|
||||||
|
)
|
||||||
|
transition_power = transition_coordinate**_PN2PIPEFR_TRANSITION_SHARPNESS
|
||||||
|
transition_weight = transition_power / (1.0 + transition_power)
|
||||||
|
return laminar + transition_weight * (turbulent - laminar)
|
||||||
|
|
||||||
|
|
||||||
|
def _reported_friction_factor(value: float) -> float:
|
||||||
|
return min(float(value), _MAX_REPORTED_FRICTION_FACTOR)
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_pipe_mass_flow_parameter(
|
||||||
|
mass_flow: float,
|
||||||
|
*,
|
||||||
|
area: float,
|
||||||
|
diameter: float,
|
||||||
|
upstream_pressure: float,
|
||||||
|
upstream_temperature: float,
|
||||||
|
resistance_length: float,
|
||||||
|
friction_factor: float,
|
||||||
|
) -> float:
|
||||||
|
"""Return Amesim's ``Cm`` without the pipe flow coefficient ``Cq``."""
|
||||||
|
|
||||||
|
flow_coefficient = sqrt(
|
||||||
|
diameter / (resistance_length * friction_factor)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
abs(mass_flow)
|
||||||
|
* sqrt(upstream_temperature)
|
||||||
|
/ max(
|
||||||
|
flow_coefficient * area * upstream_pressure,
|
||||||
|
1.0e-18,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition("mode", (1.0,))
|
_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition("mode", (1.0,))
|
||||||
_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition("mode", (2.0,))
|
_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition("mode", (2.0,))
|
||||||
_DYNAMIC_PIPE_PARAMETER_GROUPS = (
|
_DYNAMIC_PIPE_PARAMETER_GROUPS = (
|
||||||
@@ -43,13 +157,16 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
"""AMESim PNL00R pneumatic pipe friction resistance.
|
"""AMESim PNL00R pneumatic pipe friction resistance.
|
||||||
|
|
||||||
The public model exposes the AMESim PNL00R catalog/XML contract and uses
|
The public model exposes the AMESim PNL00R catalog/XML contract and uses
|
||||||
an auditable Darcy-Weisbach resistance with Reynolds/roughness-dependent
|
the `pn2pipefr` compressible gas relation with a Reynolds/roughness-
|
||||||
friction. Exact `pn2pipefr_` parity is left for the later model tuning pass.
|
dependent equivalent flow coefficient.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnl00r"
|
MODEL_TYPE = "amesim_pnl00r"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -157,6 +274,15 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
self.port_1.h_outflow = initial_h
|
self.port_1.h_outflow = initial_h
|
||||||
self.port_2 = self.register_declared_port("port_2")
|
self.port_2 = self.register_declared_port("port_2")
|
||||||
self.port_2.h_outflow = initial_h
|
self.port_2.h_outflow = initial_h
|
||||||
|
# A zero-volume two-port transports its stream outflow from the
|
||||||
|
# opposite connection, so ``port.h_outflow`` is deliberately crossed.
|
||||||
|
# Pressure loss, however, needs the enthalpy arriving at the same-side
|
||||||
|
# upstream connection. Keep that reference separate from the public
|
||||||
|
# connector outflow state.
|
||||||
|
self._connected_h = {
|
||||||
|
"port_1": initial_h,
|
||||||
|
"port_2": initial_h,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _integer_parameter(name: str, value: float) -> int:
|
def _integer_parameter(name: str, value: float) -> int:
|
||||||
@@ -187,7 +313,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
return max(
|
return max(
|
||||||
self.medium.temperature_from_pressure_enthalpy(
|
self.medium.temperature_from_pressure_enthalpy(
|
||||||
max(port.p, 1.0),
|
max(port.p, 1.0),
|
||||||
port.h_outflow,
|
self._connected_h[port_name],
|
||||||
),
|
),
|
||||||
1.0,
|
1.0,
|
||||||
)
|
)
|
||||||
@@ -203,7 +329,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
if reynolds_number <= 0.0:
|
if reynolds_number <= 0.0:
|
||||||
return 64_000_000.0
|
return 64_000_000.0
|
||||||
laminar = 64.0 / reynolds_number
|
laminar = 64.0 / reynolds_number
|
||||||
if reynolds_number <= 2300.0:
|
if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS:
|
||||||
return laminar
|
return laminar
|
||||||
|
|
||||||
# pn2pipefr does not apply the fully rough correction at every
|
# pn2pipefr does not apply the fully rough correction at every
|
||||||
@@ -233,10 +359,13 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
turbulent = smooth_turbulent + roughness_weight * (
|
turbulent = smooth_turbulent + roughness_weight * (
|
||||||
fully_rough - smooth_turbulent
|
fully_rough - smooth_turbulent
|
||||||
)
|
)
|
||||||
if reynolds_number >= 4000.0:
|
transition_coordinate = (
|
||||||
return turbulent
|
(reynolds_number - _PN2PIPEFR_TRANSITION_START_REYNOLDS)
|
||||||
fraction = (reynolds_number - 2300.0) / 1700.0
|
/ _PN2PIPEFR_TRANSITION_SCALE_REYNOLDS
|
||||||
return laminar + fraction**0.58 * (turbulent - laminar)
|
)
|
||||||
|
transition_power = transition_coordinate**_PN2PIPEFR_TRANSITION_SHARPNESS
|
||||||
|
transition_weight = transition_power / (1.0 + transition_power)
|
||||||
|
return laminar + transition_weight * (turbulent - laminar)
|
||||||
|
|
||||||
def darcy_pressure_drop(
|
def darcy_pressure_drop(
|
||||||
self,
|
self,
|
||||||
@@ -260,6 +389,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
return magnitude if mass_flow > 0.0 else -magnitude
|
return magnitude if mass_flow > 0.0 else -magnitude
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32768)
|
||||||
def _mass_flow_for_pressure_drop(
|
def _mass_flow_for_pressure_drop(
|
||||||
self,
|
self,
|
||||||
pressure_drop: float,
|
pressure_drop: float,
|
||||||
@@ -287,13 +417,17 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8):
|
if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8):
|
||||||
return 0.0
|
return 0.0
|
||||||
pressure_difference = p_1 - p_2
|
pressure_difference = p_1 - p_2
|
||||||
upstream_pressure = max(p_1, p_2, 1.0)
|
upstream_temperature = self._port_temperature(
|
||||||
upstream_temperature = self._port_temperature("port_1" if pressure_difference > 0.0 else "port_2")
|
"port_1" if pressure_difference > 0.0 else "port_2"
|
||||||
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
|
)
|
||||||
magnitude = self._mass_flow_for_pressure_drop(
|
magnitude = AmesimPnl0001._one_way_pn2pipefr_mass_flow(
|
||||||
abs(pressure_difference),
|
self,
|
||||||
density=density,
|
upstream_pressure=max(p_1, p_2),
|
||||||
temperature=upstream_temperature,
|
downstream_pressure=min(p_1, p_2),
|
||||||
|
upstream_temperature=upstream_temperature,
|
||||||
|
resistance_length=self.le,
|
||||||
|
max_iterations=64,
|
||||||
|
analytic_laminar=True,
|
||||||
)
|
)
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||||
|
|
||||||
@@ -305,17 +439,19 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
|
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
|
||||||
reynolds = self.reynolds_number(m_flow, upstream_temperature)
|
reynolds = self.reynolds_number(m_flow, upstream_temperature)
|
||||||
|
friction = self.friction_factor(reynolds)
|
||||||
|
flow_coefficient = sqrt(self.diam / (self.le * friction))
|
||||||
velocity = m_flow / (density * self.area)
|
velocity = m_flow / (density * self.area)
|
||||||
cm = (
|
cm = (
|
||||||
abs(m_flow)
|
abs(m_flow)
|
||||||
* sqrt(upstream_temperature)
|
* sqrt(upstream_temperature)
|
||||||
/ max(self.area * upstream_pressure, 1.0e-18)
|
/ max(flow_coefficient * self.area * upstream_pressure, 1.0e-18)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"re": reynolds,
|
"re": reynolds,
|
||||||
"cm": cm,
|
"cm": cm,
|
||||||
"v": velocity,
|
"v": velocity,
|
||||||
"ff": self.friction_factor(reynolds),
|
"ff": _reported_friction_factor(friction),
|
||||||
}
|
}
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
@@ -355,9 +491,16 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
self._connected_h = dict(connected_h)
|
||||||
self.port_1.h_outflow = connected_h["port_2"]
|
self.port_1.h_outflow = connected_h["port_2"]
|
||||||
self.port_2.h_outflow = connected_h["port_1"]
|
self.port_2.h_outflow = connected_h["port_1"]
|
||||||
|
|
||||||
|
def update_flow_temperature_references(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> None:
|
||||||
|
self._connected_h = dict(connected_h)
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnl0001(ThermodynamicVolumeComponent):
|
class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||||
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
|
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
|
||||||
@@ -705,6 +848,8 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
downstream_pressure: float,
|
downstream_pressure: float,
|
||||||
upstream_temperature: float,
|
upstream_temperature: float,
|
||||||
resistance_length: float,
|
resistance_length: float,
|
||||||
|
max_iterations: int = 16,
|
||||||
|
analytic_laminar: bool = False,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""AMESim pn2pipefr-style compressible friction flow."""
|
"""AMESim pn2pipefr-style compressible friction flow."""
|
||||||
|
|
||||||
@@ -775,29 +920,72 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
value *= tanh(max(smoothing_argument, 0.0))
|
value *= tanh(max(smoothing_argument, 0.0))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def target_flow(mass_flow: float) -> float:
|
pressure_ratio_flow_parameter = mass_flow_parameter(pressure_ratio)
|
||||||
reynolds = self.reynolds_number(mass_flow, T_up)
|
if analytic_laminar:
|
||||||
friction = self.friction_factor(reynolds)
|
viscosity = self._dynamic_viscosity(T_up)
|
||||||
flow_coefficient = sqrt(
|
laminar_mass_flow = (
|
||||||
self.diam / (resistance_length * friction)
|
self.area * p_up * pressure_ratio_flow_parameter
|
||||||
|
) ** 2 / (16.0 * pi * viscosity * resistance_length * T_up)
|
||||||
|
if (
|
||||||
|
self.reynolds_number(laminar_mass_flow, T_up)
|
||||||
|
<= _PN2PIPEFR_ANALYTIC_LAMINAR_MAX_REYNOLDS
|
||||||
|
):
|
||||||
|
return laminar_mass_flow
|
||||||
|
|
||||||
|
sqrt_temperature = sqrt(T_up)
|
||||||
|
compiled_reynolds = (
|
||||||
|
type(self).reynolds_number is AmesimPnl0001.reynolds_number
|
||||||
|
and type(self)._dynamic_viscosity is AmesimPnl0001._dynamic_viscosity
|
||||||
)
|
)
|
||||||
return (
|
reynolds_denominator = (
|
||||||
flow_coefficient
|
pi * self.diam * self._dynamic_viscosity(T_up)
|
||||||
* self.area
|
if max_iterations > 0 and compiled_reynolds
|
||||||
* p_up
|
else None
|
||||||
* mass_flow_parameter(pressure_ratio)
|
)
|
||||||
/ sqrt(T_up)
|
compiled_friction_factor = type(self) in (
|
||||||
|
AmesimPnl00r,
|
||||||
|
AmesimPnl0001,
|
||||||
|
AmesimPnl0002,
|
||||||
|
)
|
||||||
|
fully_rough = (
|
||||||
|
1.0 / (-2.0 * log10(self.rr / 3.7)) ** 2
|
||||||
|
if max_iterations > 0
|
||||||
|
and compiled_friction_factor
|
||||||
|
and self.rr > 0.0
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
flow_coefficient = sqrt(self.diam / (resistance_length * 0.02))
|
def target_flow(mass_flow: float) -> float:
|
||||||
magnitude = (
|
reynolds = (
|
||||||
flow_coefficient
|
4.0 * abs(mass_flow) / reynolds_denominator
|
||||||
|
if reynolds_denominator is not None
|
||||||
|
else self.reynolds_number(mass_flow, T_up)
|
||||||
|
)
|
||||||
|
friction = (
|
||||||
|
_pn2pipefr_friction_factor_with_precomputed_fully_rough(
|
||||||
|
reynolds,
|
||||||
|
relative_roughness=self.rr,
|
||||||
|
fully_rough=fully_rough,
|
||||||
|
)
|
||||||
|
if compiled_friction_factor
|
||||||
|
else self.friction_factor(reynolds)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
sqrt(self.diam / (resistance_length * friction))
|
||||||
* self.area
|
* self.area
|
||||||
* p_up
|
* p_up
|
||||||
* mass_flow_parameter(pressure_ratio)
|
* pressure_ratio_flow_parameter
|
||||||
/ sqrt(T_up)
|
/ sqrt_temperature
|
||||||
)
|
)
|
||||||
for _iteration in range(16):
|
|
||||||
|
magnitude = (
|
||||||
|
sqrt(self.diam / (resistance_length * 0.02))
|
||||||
|
* self.area
|
||||||
|
* p_up
|
||||||
|
* pressure_ratio_flow_parameter
|
||||||
|
/ sqrt_temperature
|
||||||
|
)
|
||||||
|
for _iteration in range(max_iterations):
|
||||||
next_magnitude = target_flow(magnitude)
|
next_magnitude = target_flow(magnitude)
|
||||||
if abs(next_magnitude - magnitude) <= max(
|
if abs(next_magnitude - magnitude) <= max(
|
||||||
1.0e-12,
|
1.0e-12,
|
||||||
@@ -821,12 +1009,112 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
)
|
)
|
||||||
return magnitude if pressure_difference > 0.0 else -magnitude
|
return magnitude if pressure_difference > 0.0 else -magnitude
|
||||||
|
|
||||||
|
def linearize_mass_flow(
|
||||||
|
self,
|
||||||
|
p_1: float,
|
||||||
|
p_2: float,
|
||||||
|
temperature: float,
|
||||||
|
*,
|
||||||
|
relative_step: float = 2.0 ** -26,
|
||||||
|
slope_relative_tolerance: float = 5.0e-3,
|
||||||
|
) -> Pnl0001MassFlowLinearization:
|
||||||
|
"""Audit local flow-law slopes without perturbing the full system RHS."""
|
||||||
|
|
||||||
|
p_1 = float(p_1)
|
||||||
|
p_2 = float(p_2)
|
||||||
|
temperature = float(temperature)
|
||||||
|
direction = "forward" if p_1 > p_2 else "reverse"
|
||||||
|
value = self.mass_flow(p_1, p_2, temperature)
|
||||||
|
|
||||||
|
def invalid(reason: str) -> Pnl0001MassFlowLinearization:
|
||||||
|
return Pnl0001MassFlowLinearization(
|
||||||
|
value=value,
|
||||||
|
partial_p_1=0.0,
|
||||||
|
partial_p_2=0.0,
|
||||||
|
partial_temperature=0.0,
|
||||||
|
valid=False,
|
||||||
|
reason=reason,
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all(isfinite(item) for item in (p_1, p_2, temperature, value)):
|
||||||
|
return invalid("non_finite_primal")
|
||||||
|
pressure_gap = abs(p_1 - p_2)
|
||||||
|
if pressure_gap <= 1.0e-8:
|
||||||
|
return invalid("flow_direction_boundary")
|
||||||
|
if temperature <= 1.0 * (1.0 + 1.0e-10):
|
||||||
|
return invalid("temperature_floor_boundary")
|
||||||
|
if relative_step <= 0.0 or slope_relative_tolerance <= 0.0:
|
||||||
|
raise ValueError("PNL0001 slope audit tolerances must be positive.")
|
||||||
|
|
||||||
|
pressure_step = min(
|
||||||
|
relative_step * max(abs(p_1), abs(p_2), 1.0),
|
||||||
|
0.25 * pressure_gap,
|
||||||
|
)
|
||||||
|
temperature_step = min(
|
||||||
|
relative_step * max(abs(temperature), 1.0),
|
||||||
|
0.25 * (temperature - 1.0),
|
||||||
|
)
|
||||||
|
if pressure_step <= 0.0 or temperature_step <= 0.0:
|
||||||
|
return invalid("unresolved_local_step")
|
||||||
|
|
||||||
|
arguments = (p_1, p_2, temperature)
|
||||||
|
argument_names = ("p_1", "p_2", "temperature")
|
||||||
|
steps = (pressure_step, pressure_step, temperature_step)
|
||||||
|
partials: list[float] = []
|
||||||
|
for argument_index, (argument, step) in enumerate(
|
||||||
|
zip(arguments, steps, strict=True)
|
||||||
|
):
|
||||||
|
lower = list(arguments)
|
||||||
|
upper = list(arguments)
|
||||||
|
lower[argument_index] = argument - step
|
||||||
|
upper[argument_index] = argument + step
|
||||||
|
lower_value = self.mass_flow(*lower)
|
||||||
|
upper_value = self.mass_flow(*upper)
|
||||||
|
left_slope = (value - lower_value) / step
|
||||||
|
right_slope = (upper_value - value) / step
|
||||||
|
slope_scale = max(
|
||||||
|
abs(left_slope),
|
||||||
|
abs(right_slope),
|
||||||
|
abs(value) / max(abs(argument), 1.0),
|
||||||
|
1.0e-12,
|
||||||
|
)
|
||||||
|
if not all(
|
||||||
|
isfinite(item)
|
||||||
|
for item in (
|
||||||
|
lower_value,
|
||||||
|
upper_value,
|
||||||
|
left_slope,
|
||||||
|
right_slope,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return invalid(
|
||||||
|
f"non_finite_local_slope:{argument_names[argument_index]}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
abs(left_slope - right_slope)
|
||||||
|
> slope_relative_tolerance * slope_scale
|
||||||
|
):
|
||||||
|
return invalid(
|
||||||
|
f"local_slope_disagreement:{argument_names[argument_index]}"
|
||||||
|
)
|
||||||
|
partials.append(0.5 * (left_slope + right_slope))
|
||||||
|
|
||||||
|
return Pnl0001MassFlowLinearization(
|
||||||
|
value=value,
|
||||||
|
partial_p_1=partials[0],
|
||||||
|
partial_p_2=partials[1],
|
||||||
|
partial_temperature=partials[2],
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
props = self.properties()
|
props = self.properties()
|
||||||
flow = self.mass_flow(self.port_1.p, props.p, props.T)
|
flow = self.mass_flow(self.port_1.p, props.p, props.T)
|
||||||
upstream_pressure = max(self.port_1.p, props.p, 1.0)
|
upstream_pressure = max(self.port_1.p, props.p, 1.0)
|
||||||
density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12)
|
density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12)
|
||||||
reynolds = self.reynolds_number(flow, props.T)
|
reynolds = self.reynolds_number(flow, props.T)
|
||||||
|
friction = self.friction_factor(reynolds)
|
||||||
return {
|
return {
|
||||||
"m": self.state.m,
|
"m": self.state.m,
|
||||||
"U": self.state.U,
|
"U": self.state.U,
|
||||||
@@ -836,13 +1124,17 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
"u": props.u,
|
"u": props.u,
|
||||||
"h": props.h,
|
"h": props.h,
|
||||||
"re": reynolds,
|
"re": reynolds,
|
||||||
"cm": (
|
"cm": _bare_pipe_mass_flow_parameter(
|
||||||
abs(flow)
|
flow,
|
||||||
* sqrt(props.T)
|
area=self.area,
|
||||||
/ max(self.area * upstream_pressure, 1.0e-18)
|
diameter=self.diam,
|
||||||
|
upstream_pressure=upstream_pressure,
|
||||||
|
upstream_temperature=props.T,
|
||||||
|
resistance_length=self.le,
|
||||||
|
friction_factor=friction,
|
||||||
),
|
),
|
||||||
"v": flow / (density * self.area),
|
"v": flow / (density * self.area),
|
||||||
"ff": self.friction_factor(reynolds),
|
"ff": _reported_friction_factor(friction),
|
||||||
}
|
}
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
@@ -852,6 +1144,23 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
self.port_1.m_flow - self.mass_flow(self.port_1.p, props.p, props.T),
|
self.port_1.m_flow - self.mass_flow(self.port_1.p, props.p, props.T),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def pressure_flow_equation_value_readers(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, Callable[[], float]]:
|
||||||
|
"""Expose the independently evaluable state-pressure residual."""
|
||||||
|
|
||||||
|
def pressure_state_residual() -> float:
|
||||||
|
props = self.medium.properties_from_mU(
|
||||||
|
self.state.m,
|
||||||
|
self.state.U,
|
||||||
|
self.volume,
|
||||||
|
)
|
||||||
|
return self.port_2.p - props.p
|
||||||
|
|
||||||
|
return {
|
||||||
|
f"{self.name}:port_2_pressure_state": pressure_state_residual,
|
||||||
|
}
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume)
|
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume)
|
||||||
return (
|
return (
|
||||||
@@ -905,6 +1214,98 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
)
|
)
|
||||||
return derivative.as_vector()
|
return derivative.as_vector()
|
||||||
|
|
||||||
|
def linearize_state_derivative(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
*,
|
||||||
|
state_mass_tangent: Sequence[float],
|
||||||
|
state_energy_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,
|
||||||
|
) -> Pnl0001DerivativeLinearization:
|
||||||
|
"""Linearize the pipe storage balance in a fixed stream mode."""
|
||||||
|
|
||||||
|
port_names = ("port_1", "port_2")
|
||||||
|
vectors = {
|
||||||
|
"state_mass": tuple(float(value) for value in state_mass_tangent),
|
||||||
|
"state_energy": tuple(float(value) for value in state_energy_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("PNL0001 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"
|
||||||
|
|
||||||
|
properties = property_linearization or self.medium.linearize_properties_from_mU(
|
||||||
|
self.state.m,
|
||||||
|
self.state.U,
|
||||||
|
self.volume,
|
||||||
|
vectors["state_mass"],
|
||||||
|
vectors["state_energy"],
|
||||||
|
(0.0,) * width,
|
||||||
|
)
|
||||||
|
if properties.tangents.width != width:
|
||||||
|
raise ValueError(
|
||||||
|
"PNL0001 property tangent width must match balance tangents."
|
||||||
|
)
|
||||||
|
props = properties.properties
|
||||||
|
if not properties.valid:
|
||||||
|
invalid_reason = invalid_reason or properties.reason
|
||||||
|
|
||||||
|
mass_derivative = self.port_1.m_flow + self.port_2.m_flow
|
||||||
|
energy_derivative = self.thermal_energy_flow_w(props.T)
|
||||||
|
mass_tangent = [0.0] * width
|
||||||
|
thermal_coefficient = (
|
||||||
|
0.0 if self.mode == 1 else self.kth * self.exchange_area
|
||||||
|
)
|
||||||
|
energy_tangent = [
|
||||||
|
-thermal_coefficient * properties.tangents.T[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 Pnl0001DerivativeLinearization(
|
||||||
|
derivative=(mass_derivative, energy_derivative),
|
||||||
|
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
|
||||||
|
properties=properties,
|
||||||
|
valid=invalid_reason is None,
|
||||||
|
reason=invalid_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnl0002(AmesimPnl0001):
|
class AmesimPnl0002(AmesimPnl0001):
|
||||||
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
|
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
|
||||||
@@ -1065,16 +1466,21 @@ class AmesimPnl0002(AmesimPnl0001):
|
|||||||
1.0e-12,
|
1.0e-12,
|
||||||
)
|
)
|
||||||
reynolds = self.reynolds_number(flow, upstream_temperature)
|
reynolds = self.reynolds_number(flow, upstream_temperature)
|
||||||
|
friction = self.friction_factor(reynolds)
|
||||||
resistance_diagnostics.append(
|
resistance_diagnostics.append(
|
||||||
(
|
(
|
||||||
reynolds,
|
reynolds,
|
||||||
(
|
_bare_pipe_mass_flow_parameter(
|
||||||
abs(flow)
|
flow,
|
||||||
* sqrt(upstream_temperature)
|
area=self.area,
|
||||||
/ max(self.area * upstream_pressure, 1.0e-18)
|
diameter=self.diam,
|
||||||
|
upstream_pressure=upstream_pressure,
|
||||||
|
upstream_temperature=upstream_temperature,
|
||||||
|
resistance_length=self.resistance_length,
|
||||||
|
friction_factor=friction,
|
||||||
),
|
),
|
||||||
abs(flow) / (density * self.area),
|
abs(flow) / (density * self.area),
|
||||||
self.friction_factor(reynolds),
|
friction,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
reynolds, cm, velocity, friction = (
|
reynolds, cm, velocity, friction = (
|
||||||
@@ -1092,7 +1498,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
|||||||
"re": reynolds,
|
"re": reynolds,
|
||||||
"cm": cm,
|
"cm": cm,
|
||||||
"v": velocity,
|
"v": velocity,
|
||||||
"ff": friction,
|
"ff": _reported_friction_factor(friction),
|
||||||
}
|
}
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
@@ -1353,6 +1759,16 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
viscosity = self._dynamic_viscosity(temperature)
|
viscosity = self._dynamic_viscosity(temperature)
|
||||||
return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity)
|
return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity)
|
||||||
|
|
||||||
|
def reported_reynolds_number(
|
||||||
|
self,
|
||||||
|
mass_flow: float,
|
||||||
|
temperature: float,
|
||||||
|
) -> float:
|
||||||
|
"""Return AMESim's derived re without changing pipe dynamics."""
|
||||||
|
|
||||||
|
viscosity = self.medium.diagnostic_dynamic_viscosity(temperature)
|
||||||
|
return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity)
|
||||||
|
|
||||||
def friction_factor(self, reynolds_number: float) -> float:
|
def friction_factor(self, reynolds_number: float) -> float:
|
||||||
return AmesimPnl00r.friction_factor(self, reynolds_number)
|
return AmesimPnl00r.friction_factor(self, reynolds_number)
|
||||||
|
|
||||||
@@ -1371,6 +1787,7 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
magnitude = friction * (self.le / self.diam) * density * velocity * velocity / 2.0
|
magnitude = friction * (self.le / self.diam) * density * velocity * velocity / 2.0
|
||||||
return magnitude if mass_flow > 0.0 else -magnitude
|
return magnitude if mass_flow > 0.0 else -magnitude
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32768)
|
||||||
def _mass_flow_for_pressure_drop(
|
def _mass_flow_for_pressure_drop(
|
||||||
self,
|
self,
|
||||||
pressure_drop: float,
|
pressure_drop: float,
|
||||||
@@ -1419,6 +1836,8 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
center_flow = self.resistance_mass_flow()
|
center_flow = self.resistance_mass_flow()
|
||||||
upstream = port_1 if center_flow >= 0.0 else port_2
|
upstream = port_1 if center_flow >= 0.0 else port_2
|
||||||
reynolds = self.reynolds_number(center_flow, upstream.T)
|
reynolds = self.reynolds_number(center_flow, upstream.T)
|
||||||
|
reported_reynolds = self.reported_reynolds_number(center_flow, upstream.T)
|
||||||
|
friction = self.friction_factor(reynolds)
|
||||||
return {
|
return {
|
||||||
"m1": self.state_1.m,
|
"m1": self.state_1.m,
|
||||||
"U1": self.state_1.U,
|
"U1": self.state_1.U,
|
||||||
@@ -1435,14 +1854,18 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
"u2": port_2.u,
|
"u2": port_2.u,
|
||||||
"h2": port_2.h,
|
"h2": port_2.h,
|
||||||
"dmctr": center_flow,
|
"dmctr": center_flow,
|
||||||
"re": reynolds,
|
"re": reported_reynolds,
|
||||||
"cm": (
|
"cm": _bare_pipe_mass_flow_parameter(
|
||||||
abs(center_flow)
|
center_flow,
|
||||||
* sqrt(upstream.T)
|
area=self.area,
|
||||||
/ max(self.area * max(port_1.p, port_2.p, 1.0), 1.0e-18)
|
diameter=self.diam,
|
||||||
|
upstream_pressure=max(port_1.p, port_2.p, 1.0),
|
||||||
|
upstream_temperature=upstream.T,
|
||||||
|
resistance_length=self.le,
|
||||||
|
friction_factor=friction,
|
||||||
),
|
),
|
||||||
"v": center_flow / (max(upstream.rho, 1.0e-12) * self.area),
|
"v": center_flow / (max(upstream.rho, 1.0e-12) * self.area),
|
||||||
"ff": self.friction_factor(reynolds),
|
"ff": _reported_friction_factor(friction),
|
||||||
}
|
}
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ from app.simulation.core.medium import IdealGasMedium
|
|||||||
from app.simulation.core.ports import PortDefinition, PortState
|
from app.simulation.core.ports import PortDefinition, PortState
|
||||||
|
|
||||||
|
|
||||||
|
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _regularized_inverse_outflow(flow: float, transition_flow: float) -> float:
|
||||||
|
"""Return a C1 inverse that tends to zero as a negative flow vanishes."""
|
||||||
|
|
||||||
|
if flow >= 0.0:
|
||||||
|
return 0.0
|
||||||
|
transition_flow = max(float(transition_flow), 1.0e-12)
|
||||||
|
if -flow >= transition_flow:
|
||||||
|
return 1.0 / flow
|
||||||
|
return (
|
||||||
|
flow
|
||||||
|
* (2.0 * transition_flow * transition_flow - flow * flow)
|
||||||
|
/ transition_flow**4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _AmesimPneumaticNode(AlgebraicComponent):
|
class _AmesimPneumaticNode(AlgebraicComponent):
|
||||||
"""Shared implementation for AMESim pneumatic junction submodels.
|
"""Shared implementation for AMESim pneumatic junction submodels.
|
||||||
|
|
||||||
@@ -102,7 +120,7 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
else self.temperature_reference_h
|
else self.temperature_reference_h
|
||||||
)
|
)
|
||||||
|
|
||||||
if reference_port.m_flow < -1e-12:
|
if reference_port.m_flow < 0.0:
|
||||||
energy_without_reference = sum(
|
energy_without_reference = sum(
|
||||||
port.m_flow
|
port.m_flow
|
||||||
* (
|
* (
|
||||||
@@ -113,8 +131,29 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
for name, port in self.ports.items()
|
for name, port in self.ports.items()
|
||||||
if name != self.REFERENCE_PORT
|
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 = (
|
reference_port.h_outflow = (
|
||||||
-energy_without_reference / reference_port.m_flow
|
mixed_h - energy_residual_at_mixed_h * inverse_flow
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +163,9 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
|||||||
MODEL_TYPE = "amesim_pn3node2"
|
MODEL_TYPE = "amesim_pn3node2"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -161,6 +203,9 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
|||||||
MODEL_TYPE = "amesim_p4node2"
|
MODEL_TYPE = "amesim_p4node2"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
from math import pi
|
from dataclasses import dataclass
|
||||||
|
from math import isfinite, pi
|
||||||
|
|
||||||
from app.simulation.components.amesim.gases import (
|
from app.simulation.components.amesim.gases import (
|
||||||
AMESIM_GAS_INDEX_PARAMETER,
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
@@ -18,6 +19,18 @@ from app.simulation.core.ports import PortDefinition
|
|||||||
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
|
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Pnrp17Linearization:
|
||||||
|
volume: float
|
||||||
|
volume_flow: float
|
||||||
|
pressure_force: float
|
||||||
|
volume_tangent: tuple[float, ...]
|
||||||
|
volume_flow_tangent: tuple[float, ...]
|
||||||
|
pressure_force_tangent: tuple[float, ...]
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnrp17(AlgebraicComponent):
|
class AmesimPnrp17(AlgebraicComponent):
|
||||||
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
|
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
|
||||||
|
|
||||||
@@ -230,6 +243,53 @@ class AmesimPnrp17(AlgebraicComponent):
|
|||||||
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
|
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
|
||||||
return {"port_1": (self.chamber_volume, self.chamber_volume_flow)}
|
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:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
self.port_1.h_outflow = connected_h.get(
|
self.port_1.h_outflow = connected_h.get(
|
||||||
"port_1",
|
"port_1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
from math import expm1
|
from dataclasses import dataclass
|
||||||
|
from math import expm1, isfinite
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||||
from app.simulation.core.catalog import (
|
from app.simulation.core.catalog import (
|
||||||
@@ -20,6 +21,24 @@ from app.simulation.core.medium import IdealGasMedium
|
|||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Mecmas21DerivativeLinearization:
|
||||||
|
derivative: tuple[float, float]
|
||||||
|
tangents: tuple[tuple[float, ...], tuple[float, ...]]
|
||||||
|
mode: str
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LstpContactForceLinearization:
|
||||||
|
force: float
|
||||||
|
force_tangent: tuple[float, ...]
|
||||||
|
mode: str
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
_MECMAS21_FRICTION_ENABLED = ParameterCondition("useFriction", (2.0,))
|
_MECMAS21_FRICTION_ENABLED = ParameterCondition("useFriction", (2.0,))
|
||||||
_MECMAS21_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0))
|
_MECMAS21_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0))
|
||||||
_MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0))
|
_MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0))
|
||||||
@@ -723,6 +742,204 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
)
|
)
|
||||||
return [self.acceleration(), velocity]
|
return [self.acceleration(), velocity]
|
||||||
|
|
||||||
|
def linearize_state_derivative(
|
||||||
|
self,
|
||||||
|
port_1_force_tangent: Sequence[float],
|
||||||
|
port_2_force_tangent: Sequence[float],
|
||||||
|
velocity_tangent: Sequence[float],
|
||||||
|
position_tangent: Sequence[float],
|
||||||
|
*,
|
||||||
|
constraint_mode: str = "current",
|
||||||
|
boundary_tolerance: float = 1.0e-12,
|
||||||
|
) -> Mecmas21DerivativeLinearization:
|
||||||
|
"""Linearize one inertia in a declared fixed mechanical mode."""
|
||||||
|
|
||||||
|
vectors = tuple(
|
||||||
|
tuple(float(value) for value in values)
|
||||||
|
for values in (
|
||||||
|
port_1_force_tangent,
|
||||||
|
port_2_force_tangent,
|
||||||
|
velocity_tangent,
|
||||||
|
position_tangent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
widths = {len(values) for values in vectors}
|
||||||
|
if len(widths) != 1:
|
||||||
|
raise ValueError("MECMAS21 tangent vectors must have equal lengths.")
|
||||||
|
width = len(vectors[0])
|
||||||
|
invalid_reason: str | None = None
|
||||||
|
if not all(isfinite(value) for values in vectors for value in values):
|
||||||
|
invalid_reason = "non_finite_tangent_input"
|
||||||
|
|
||||||
|
requested_mode = constraint_mode
|
||||||
|
if requested_mode == "current":
|
||||||
|
fixed = (
|
||||||
|
self._constraint_acceleration == 0.0
|
||||||
|
and self._constraint_velocity == 0.0
|
||||||
|
)
|
||||||
|
mode = "fixed" if fixed else "free"
|
||||||
|
if self._constraint_acceleration is not None and not fixed:
|
||||||
|
invalid_reason = invalid_reason or (
|
||||||
|
"group_acceleration_requires_aggregate"
|
||||||
|
)
|
||||||
|
elif requested_mode == "free":
|
||||||
|
mode = "free"
|
||||||
|
elif requested_mode in {"lower", "upper"}:
|
||||||
|
mode = requested_mode
|
||||||
|
fixed = (
|
||||||
|
self._constraint_acceleration == 0.0
|
||||||
|
and self._constraint_velocity == 0.0
|
||||||
|
)
|
||||||
|
if not fixed:
|
||||||
|
invalid_reason = invalid_reason or (
|
||||||
|
"constraint_mode_not_statically_fixed"
|
||||||
|
)
|
||||||
|
elif requested_mode == "uninitialized":
|
||||||
|
mode = requested_mode
|
||||||
|
invalid_reason = invalid_reason or "constraint_mode_uninitialized"
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"MECMAS21 constraint_mode must be current, free, lower, upper, "
|
||||||
|
"or uninitialized."
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode in {"fixed", "lower", "upper"}:
|
||||||
|
return Mecmas21DerivativeLinearization(
|
||||||
|
derivative=(self.acceleration(), 0.0),
|
||||||
|
tangents=((0.0,) * width, (0.0,) * width),
|
||||||
|
mode=mode,
|
||||||
|
valid=invalid_reason is None,
|
||||||
|
reason=invalid_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
force_1_tangent, force_2_tangent, dv, dx = vectors
|
||||||
|
acceleration_tangent = [
|
||||||
|
force_1_tangent[index] + force_2_tangent[index]
|
||||||
|
for index in range(width)
|
||||||
|
]
|
||||||
|
if self.use_friction:
|
||||||
|
for index in range(width):
|
||||||
|
acceleration_tangent[index] += (
|
||||||
|
-self.rvisc * dv[index]
|
||||||
|
- 2.0 * self.wind * abs(self.v) * dv[index]
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.fcoul != 0.0
|
||||||
|
and abs(self.v) <= boundary_tolerance
|
||||||
|
and any(value != 0.0 for value in dv)
|
||||||
|
):
|
||||||
|
invalid_reason = invalid_reason or "dry_friction_direction_boundary"
|
||||||
|
|
||||||
|
def add_limit_tangent(
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
stiffness: float,
|
||||||
|
damping: float,
|
||||||
|
damping_penetration: float,
|
||||||
|
bound: float,
|
||||||
|
damping_sign: float,
|
||||||
|
force_sign: float,
|
||||||
|
) -> None:
|
||||||
|
nonlocal invalid_reason
|
||||||
|
if int(self.stoptype) != 2:
|
||||||
|
return
|
||||||
|
penetration = (
|
||||||
|
bound - self.x if side == "lower" else self.x - bound
|
||||||
|
)
|
||||||
|
penetration_tangent = tuple(
|
||||||
|
(-value if side == "lower" else value) for value in dx
|
||||||
|
)
|
||||||
|
scale = max(abs(bound), abs(self.x), 1.0)
|
||||||
|
if penetration <= 0.0:
|
||||||
|
if (
|
||||||
|
abs(penetration) <= boundary_tolerance * scale
|
||||||
|
and any(value != 0.0 for value in penetration_tangent)
|
||||||
|
):
|
||||||
|
invalid_reason = invalid_reason or (
|
||||||
|
f"soft_endstop_mode_boundary:{side}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if damping_penetration > 0.0:
|
||||||
|
fraction = min(penetration / damping_penetration, 1.0)
|
||||||
|
if penetration < damping_penetration:
|
||||||
|
fraction_tangent = tuple(
|
||||||
|
value / damping_penetration
|
||||||
|
for value in penetration_tangent
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fraction_tangent = (0.0,) * width
|
||||||
|
if (
|
||||||
|
abs(penetration - damping_penetration)
|
||||||
|
<= boundary_tolerance
|
||||||
|
* max(abs(damping_penetration), 1.0)
|
||||||
|
and any(value != 0.0 for value in penetration_tangent)
|
||||||
|
):
|
||||||
|
invalid_reason = invalid_reason or (
|
||||||
|
f"soft_endstop_damping_boundary:{side}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fraction = 1.0
|
||||||
|
fraction_tangent = (0.0,) * width
|
||||||
|
|
||||||
|
raw_force = (
|
||||||
|
stiffness * penetration
|
||||||
|
+ damping_sign * fraction * damping * self.v
|
||||||
|
)
|
||||||
|
raw_tangent = tuple(
|
||||||
|
stiffness * penetration_tangent[index]
|
||||||
|
+ damping_sign
|
||||||
|
* damping
|
||||||
|
* (
|
||||||
|
fraction * dv[index]
|
||||||
|
+ self.v * fraction_tangent[index]
|
||||||
|
)
|
||||||
|
for index in range(width)
|
||||||
|
)
|
||||||
|
if int(self.discContactOption) != 1 and raw_force <= 0.0:
|
||||||
|
if (
|
||||||
|
abs(raw_force)
|
||||||
|
<= boundary_tolerance
|
||||||
|
* max(abs(stiffness * penetration), 1.0)
|
||||||
|
and any(value != 0.0 for value in raw_tangent)
|
||||||
|
):
|
||||||
|
invalid_reason = invalid_reason or (
|
||||||
|
f"soft_endstop_force_boundary:{side}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for index in range(width):
|
||||||
|
acceleration_tangent[index] += (
|
||||||
|
force_sign * raw_tangent[index]
|
||||||
|
)
|
||||||
|
|
||||||
|
add_limit_tangent(
|
||||||
|
side="lower",
|
||||||
|
stiffness=self.Kbmin,
|
||||||
|
damping=self.Dbmin,
|
||||||
|
damping_penetration=self.Pdmin,
|
||||||
|
bound=self.xmin,
|
||||||
|
damping_sign=-1.0,
|
||||||
|
force_sign=1.0,
|
||||||
|
)
|
||||||
|
add_limit_tangent(
|
||||||
|
side="upper",
|
||||||
|
stiffness=self.Kbmax,
|
||||||
|
damping=self.Dbmax,
|
||||||
|
damping_penetration=self.Pdmax,
|
||||||
|
bound=self.xmax,
|
||||||
|
damping_sign=1.0,
|
||||||
|
force_sign=-1.0,
|
||||||
|
)
|
||||||
|
acceleration_tangent = tuple(
|
||||||
|
value / self.mass for value in acceleration_tangent
|
||||||
|
)
|
||||||
|
return Mecmas21DerivativeLinearization(
|
||||||
|
derivative=(self.unconstrained_acceleration(), self.v),
|
||||||
|
tangents=(acceleration_tangent, tuple(dv)),
|
||||||
|
mode=mode,
|
||||||
|
valid=invalid_reason is None,
|
||||||
|
reason=invalid_reason,
|
||||||
|
)
|
||||||
|
|
||||||
def component_result_values(self) -> Mapping[str, float]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
return {
|
return {
|
||||||
"a": self.acceleration(),
|
"a": self.acceleration(),
|
||||||
@@ -979,6 +1196,112 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
return force if int(self.discContactOption) == 1 else max(force, 0.0)
|
return force if int(self.discContactOption) == 1 else max(force, 0.0)
|
||||||
|
|
||||||
|
def linearize_contact_force(
|
||||||
|
self,
|
||||||
|
port_1_x_tangent: Sequence[float],
|
||||||
|
port_2_x_tangent: Sequence[float],
|
||||||
|
port_1_velocity_tangent: Sequence[float],
|
||||||
|
port_2_velocity_tangent: Sequence[float],
|
||||||
|
*,
|
||||||
|
boundary_tolerance: float = 1.0e-12,
|
||||||
|
) -> LstpContactForceLinearization:
|
||||||
|
"""Linearize the elastic contact in its current unilateral mode."""
|
||||||
|
|
||||||
|
vectors = tuple(
|
||||||
|
tuple(float(value) for value in values)
|
||||||
|
for values in (
|
||||||
|
port_1_x_tangent,
|
||||||
|
port_2_x_tangent,
|
||||||
|
port_1_velocity_tangent,
|
||||||
|
port_2_velocity_tangent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
widths = {len(values) for values in vectors}
|
||||||
|
if len(widths) != 1:
|
||||||
|
raise ValueError("LSTP00A tangent vectors must have equal lengths.")
|
||||||
|
width = len(vectors[0])
|
||||||
|
if not all(isfinite(value) for values in vectors for value in values):
|
||||||
|
return LstpContactForceLinearization(
|
||||||
|
force=self.contact_force,
|
||||||
|
force_tangent=(0.0,) * width,
|
||||||
|
mode="invalid",
|
||||||
|
valid=False,
|
||||||
|
reason="non_finite_tangent_input",
|
||||||
|
)
|
||||||
|
|
||||||
|
dx_1, dx_2, dv_1, dv_2 = vectors
|
||||||
|
penetration_tangent = tuple(
|
||||||
|
left - right for left, right in zip(dx_1, dx_2, strict=True)
|
||||||
|
)
|
||||||
|
velocity_tangent = tuple(
|
||||||
|
left - right for left, right in zip(dv_1, dv_2, strict=True)
|
||||||
|
)
|
||||||
|
overlap = -self.gap
|
||||||
|
force = self.contact_force
|
||||||
|
scale = max(abs(self.gap0), abs(self.port_1.x), abs(self.port_2.x), 1.0)
|
||||||
|
if overlap <= 0.0:
|
||||||
|
on_boundary = abs(overlap) <= boundary_tolerance * scale
|
||||||
|
crossing = any(value != 0.0 for value in penetration_tangent)
|
||||||
|
return LstpContactForceLinearization(
|
||||||
|
force=force,
|
||||||
|
force_tangent=(0.0,) * width,
|
||||||
|
mode="boundary" if on_boundary else "inactive",
|
||||||
|
valid=not (on_boundary and crossing),
|
||||||
|
reason=(
|
||||||
|
"contact_mode_boundary"
|
||||||
|
if on_boundary and crossing
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
penetration = overlap
|
||||||
|
if self.Pdis > 0.0:
|
||||||
|
damping_fraction = -expm1(-penetration / self.Pdis)
|
||||||
|
damping_fraction_tangent = tuple(
|
||||||
|
(1.0 - damping_fraction) * value / self.Pdis
|
||||||
|
for value in penetration_tangent
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
damping_fraction = 1.0
|
||||||
|
damping_fraction_tangent = (0.0,) * width
|
||||||
|
relative_velocity = self.penetration_velocity
|
||||||
|
raw_force = (
|
||||||
|
self.kcont * penetration
|
||||||
|
+ damping_fraction * self.rcont * relative_velocity
|
||||||
|
)
|
||||||
|
raw_tangent = tuple(
|
||||||
|
self.kcont * penetration_tangent[index]
|
||||||
|
+ self.rcont
|
||||||
|
* (
|
||||||
|
damping_fraction * velocity_tangent[index]
|
||||||
|
+ relative_velocity * damping_fraction_tangent[index]
|
||||||
|
)
|
||||||
|
for index in range(width)
|
||||||
|
)
|
||||||
|
if int(self.discContactOption) != 1 and raw_force <= 0.0:
|
||||||
|
on_boundary = (
|
||||||
|
abs(raw_force)
|
||||||
|
<= boundary_tolerance
|
||||||
|
* max(abs(self.kcont * penetration), 1.0)
|
||||||
|
)
|
||||||
|
crossing = any(value != 0.0 for value in raw_tangent)
|
||||||
|
return LstpContactForceLinearization(
|
||||||
|
force=force,
|
||||||
|
force_tangent=(0.0,) * width,
|
||||||
|
mode="force_boundary" if on_boundary else "clamped",
|
||||||
|
valid=not (on_boundary and crossing),
|
||||||
|
reason=(
|
||||||
|
"contact_force_boundary"
|
||||||
|
if on_boundary and crossing
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return LstpContactForceLinearization(
|
||||||
|
force=force,
|
||||||
|
force_tangent=raw_tangent,
|
||||||
|
mode="active",
|
||||||
|
)
|
||||||
|
|
||||||
def clear_causal_contact(self) -> None:
|
def clear_causal_contact(self) -> None:
|
||||||
self._causal_penetration = None
|
self._causal_penetration = None
|
||||||
self._causal_contact_force = None
|
self._causal_contact_force = None
|
||||||
@@ -1063,6 +1386,9 @@ class AmesimLmechn1(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_lmechn1"
|
MODEL_TYPE = "amesim_lmechn1"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("force_balance",)
|
||||||
|
)
|
||||||
PORTS = tuple(
|
PORTS = tuple(
|
||||||
PortDefinition.mechanical_translational(f"port_{index}")
|
PortDefinition.mechanical_translational(f"port_{index}")
|
||||||
for index in range(1, 22)
|
for index in range(1, 22)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from math import exp, isfinite, log
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
@@ -9,6 +10,8 @@ from app.simulation.core.medium import (
|
|||||||
GasMedium,
|
GasMedium,
|
||||||
IdealGasMedium,
|
IdealGasMedium,
|
||||||
ThermodynamicProperties,
|
ThermodynamicProperties,
|
||||||
|
ThermodynamicPropertiesLinearization,
|
||||||
|
ThermodynamicPropertyTangents,
|
||||||
)
|
)
|
||||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||||
from app.simulation.performance import profile_property, record_property_iterations
|
from app.simulation.performance import profile_property, record_property_iterations
|
||||||
@@ -52,6 +55,12 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
|
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
|
||||||
nasa_cp_over_R: ClassVar[float] = 2.5
|
nasa_cp_over_R: ClassVar[float] = 2.5
|
||||||
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
|
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
|
||||||
|
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (
|
||||||
|
0.7501594,
|
||||||
|
35.76324,
|
||||||
|
-2212.129,
|
||||||
|
0.9212635,
|
||||||
|
)
|
||||||
|
|
||||||
name: str = "AMESimHeliumPengRobinson"
|
name: str = "AMESimHeliumPengRobinson"
|
||||||
R_gas: float = HELIUM_PR.specific_gas_constant
|
R_gas: float = HELIUM_PR.specific_gas_constant
|
||||||
@@ -70,6 +79,19 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
del T
|
del T
|
||||||
return self.cv
|
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")
|
@profile_property("density")
|
||||||
@cache_property_calculation("density")
|
@cache_property_calculation("density")
|
||||||
def density(self, p: float, T: float) -> float:
|
def density(self, p: float, T: float) -> float:
|
||||||
@@ -309,6 +331,153 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def linearize_properties_from_mU(
|
||||||
|
self,
|
||||||
|
m: float,
|
||||||
|
U: float,
|
||||||
|
V: float,
|
||||||
|
dm: Sequence[float],
|
||||||
|
dU: Sequence[float],
|
||||||
|
dV: Sequence[float],
|
||||||
|
*,
|
||||||
|
properties: ThermodynamicProperties | None = None,
|
||||||
|
) -> ThermodynamicPropertiesLinearization:
|
||||||
|
"""Implicitly differentiate the Peng-Robinson m/U/V recovery."""
|
||||||
|
|
||||||
|
dm_values = tuple(float(value) for value in dm)
|
||||||
|
dU_values = tuple(float(value) for value in dU)
|
||||||
|
dV_values = tuple(float(value) for value in dV)
|
||||||
|
if not (len(dm_values) == len(dU_values) == len(dV_values)):
|
||||||
|
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
|
||||||
|
props = properties or self.properties_from_mU(m, U, V)
|
||||||
|
width = len(dm_values)
|
||||||
|
|
||||||
|
def invalid(reason: str) -> ThermodynamicPropertiesLinearization:
|
||||||
|
return ThermodynamicPropertiesLinearization(
|
||||||
|
properties=props,
|
||||||
|
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||||
|
valid=False,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_density = m / V
|
||||||
|
expected_internal_energy = U / m
|
||||||
|
if (
|
||||||
|
abs(props.rho - expected_density)
|
||||||
|
> 1.0e-12 * max(abs(expected_density), 1.0)
|
||||||
|
or abs(props.u - expected_internal_energy)
|
||||||
|
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
|
||||||
|
):
|
||||||
|
return invalid("properties_primal_mismatch")
|
||||||
|
if not all(
|
||||||
|
isfinite(value)
|
||||||
|
for values in (dm_values, dU_values, dV_values)
|
||||||
|
for value in values
|
||||||
|
):
|
||||||
|
return invalid("non_finite_tangent_input")
|
||||||
|
if props.T <= 2.2 * (1.0 + 1.0e-10):
|
||||||
|
return invalid("temperature_floor_boundary")
|
||||||
|
|
||||||
|
pressure_temperature_derivative = (
|
||||||
|
self.fluid.pressure_temperature_derivative_at_density(
|
||||||
|
props.T,
|
||||||
|
props.rho,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pressure_density_derivative = (
|
||||||
|
self.fluid.pressure_density_derivative_at_temperature(
|
||||||
|
props.T,
|
||||||
|
props.rho,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cv = (
|
||||||
|
self.cv_at_temperature(props.T)
|
||||||
|
+ self.fluid.residual_isochoric_heat_capacity_at_density(
|
||||||
|
props.T,
|
||||||
|
props.rho,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recovered_internal_energy = (
|
||||||
|
self.specific_internal_energy(props.T)
|
||||||
|
+ self.fluid.residual_specific_internal_energy_at_density(
|
||||||
|
props.T,
|
||||||
|
props.rho,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recovery_scale = max(
|
||||||
|
abs(props.u),
|
||||||
|
abs(cv * props.T) if isfinite(cv) else 0.0,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not all(
|
||||||
|
isfinite(value)
|
||||||
|
for value in (
|
||||||
|
pressure_temperature_derivative,
|
||||||
|
pressure_density_derivative,
|
||||||
|
cv,
|
||||||
|
recovered_internal_energy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or cv <= 0.0
|
||||||
|
):
|
||||||
|
return invalid("invalid_peng_robinson_derivative")
|
||||||
|
if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale:
|
||||||
|
return invalid("properties_recovery_not_converged")
|
||||||
|
|
||||||
|
internal_energy_density_derivative = (
|
||||||
|
props.p - props.T * pressure_temperature_derivative
|
||||||
|
) / (props.rho * props.rho)
|
||||||
|
drho: list[float] = []
|
||||||
|
du: list[float] = []
|
||||||
|
dT: list[float] = []
|
||||||
|
dp: list[float] = []
|
||||||
|
dh: list[float] = []
|
||||||
|
for mass_tangent, energy_tangent, volume_tangent in zip(
|
||||||
|
dm_values,
|
||||||
|
dU_values,
|
||||||
|
dV_values,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
density_tangent = (
|
||||||
|
mass_tangent / V - m * volume_tangent / (V * V)
|
||||||
|
)
|
||||||
|
internal_energy_tangent = (
|
||||||
|
energy_tangent / m - U * mass_tangent / (m * m)
|
||||||
|
)
|
||||||
|
temperature_tangent = (
|
||||||
|
internal_energy_tangent
|
||||||
|
- internal_energy_density_derivative * density_tangent
|
||||||
|
) / cv
|
||||||
|
pressure_tangent = (
|
||||||
|
pressure_temperature_derivative * temperature_tangent
|
||||||
|
+ pressure_density_derivative * density_tangent
|
||||||
|
)
|
||||||
|
enthalpy_tangent = (
|
||||||
|
internal_energy_tangent
|
||||||
|
+ pressure_tangent / props.rho
|
||||||
|
- props.p * density_tangent / (props.rho * props.rho)
|
||||||
|
)
|
||||||
|
drho.append(density_tangent)
|
||||||
|
du.append(internal_energy_tangent)
|
||||||
|
dT.append(temperature_tangent)
|
||||||
|
dp.append(pressure_tangent)
|
||||||
|
dh.append(enthalpy_tangent)
|
||||||
|
|
||||||
|
tangent_values = (*drho, *du, *dT, *dp, *dh)
|
||||||
|
if not all(isfinite(value) for value in tangent_values):
|
||||||
|
return invalid("non_finite_property_tangent")
|
||||||
|
return ThermodynamicPropertiesLinearization(
|
||||||
|
properties=props,
|
||||||
|
tangents=ThermodynamicPropertyTangents(
|
||||||
|
p=tuple(dp),
|
||||||
|
T=tuple(dT),
|
||||||
|
rho=tuple(drho),
|
||||||
|
u=tuple(du),
|
||||||
|
h=tuple(dh),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AmesimGasPropertyModelSpec:
|
class AmesimGasPropertyModelSpec:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
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 (
|
from app.simulation.components.amesim.gases import (
|
||||||
AMESIM_GAS_INDEX_PARAMETER,
|
AMESIM_GAS_INDEX_PARAMETER,
|
||||||
@@ -14,11 +16,24 @@ from app.simulation.core.metadata import (
|
|||||||
ResultVariableDefinition,
|
ResultVariableDefinition,
|
||||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||||
)
|
)
|
||||||
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
|
from app.simulation.core.medium import (
|
||||||
|
GasMedium,
|
||||||
|
ThermodynamicProperties,
|
||||||
|
ThermodynamicPropertiesLinearization,
|
||||||
|
)
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
from app.simulation.core.state import VolumeState
|
from app.simulation.core.state import VolumeState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Pnch012DerivativeLinearization:
|
||||||
|
derivative: tuple[float, float]
|
||||||
|
tangents: tuple[tuple[float, ...], tuple[float, ...]]
|
||||||
|
properties: ThermodynamicPropertiesLinearization
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AmesimPnch023(ThermodynamicVolumeComponent):
|
class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||||
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
||||||
|
|
||||||
@@ -518,6 +533,132 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
energy_derivative -= props.p * self.total_volume_rate()
|
energy_derivative -= props.p * self.total_volume_rate()
|
||||||
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
|
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
|
||||||
|
|
||||||
|
def linearize_state_derivative(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
*,
|
||||||
|
state_mass_tangent: Sequence[float],
|
||||||
|
state_energy_tangent: Sequence[float],
|
||||||
|
external_volume_tangent: Sequence[float],
|
||||||
|
external_volume_rate_tangent: Sequence[float],
|
||||||
|
port_mass_flow_tangents: Mapping[str, Sequence[float]],
|
||||||
|
connected_h_tangents: Mapping[str, Sequence[float]],
|
||||||
|
property_linearization: ThermodynamicPropertiesLinearization | None = None,
|
||||||
|
flow_boundary_tolerance: float = 1.0e-12,
|
||||||
|
) -> Pnch012DerivativeLinearization:
|
||||||
|
"""Linearize the chamber balance while keeping stream modes fixed."""
|
||||||
|
|
||||||
|
port_names = ("port_1", "port_2", "port_3", "port_4")
|
||||||
|
vectors = {
|
||||||
|
"state_mass": tuple(float(value) for value in state_mass_tangent),
|
||||||
|
"state_energy": tuple(float(value) for value in state_energy_tangent),
|
||||||
|
"volume": tuple(float(value) for value in external_volume_tangent),
|
||||||
|
"volume_rate": tuple(
|
||||||
|
float(value) for value in external_volume_rate_tangent
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for port_name in port_names:
|
||||||
|
vectors[f"flow:{port_name}"] = tuple(
|
||||||
|
float(value) for value in port_mass_flow_tangents[port_name]
|
||||||
|
)
|
||||||
|
vectors[f"enthalpy:{port_name}"] = tuple(
|
||||||
|
float(value) for value in connected_h_tangents[port_name]
|
||||||
|
)
|
||||||
|
widths = {len(values) for values in vectors.values()}
|
||||||
|
if len(widths) != 1:
|
||||||
|
raise ValueError("PNCH012 tangent vectors must have equal lengths.")
|
||||||
|
width = len(vectors["state_mass"])
|
||||||
|
invalid_reason: str | None = None
|
||||||
|
if not all(isfinite(value) for values in vectors.values() for value in values):
|
||||||
|
invalid_reason = "non_finite_tangent_input"
|
||||||
|
|
||||||
|
raw_volume = (
|
||||||
|
self.cvol0
|
||||||
|
+ sum(self.external_volumes.values())
|
||||||
|
+ self.connected_external_volume()
|
||||||
|
)
|
||||||
|
minimum_volume = self.cvol0 / 100.0
|
||||||
|
volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18)
|
||||||
|
on_volume_boundary = (
|
||||||
|
abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale
|
||||||
|
)
|
||||||
|
supplied_volume_tangent = vectors["volume"]
|
||||||
|
if raw_volume < minimum_volume or on_volume_boundary:
|
||||||
|
used_volume_tangent = (0.0,) * width
|
||||||
|
used_volume_rate_tangent = (0.0,) * width
|
||||||
|
if on_volume_boundary and any(
|
||||||
|
value != 0.0
|
||||||
|
for value in (
|
||||||
|
*supplied_volume_tangent,
|
||||||
|
*vectors["volume_rate"],
|
||||||
|
)
|
||||||
|
):
|
||||||
|
invalid_reason = invalid_reason or "volume_floor_boundary"
|
||||||
|
else:
|
||||||
|
used_volume_tangent = supplied_volume_tangent
|
||||||
|
used_volume_rate_tangent = vectors["volume_rate"]
|
||||||
|
|
||||||
|
properties = property_linearization or self.medium.linearize_properties_from_mU(
|
||||||
|
self.state.m,
|
||||||
|
self.state.U,
|
||||||
|
self.total_volume(),
|
||||||
|
vectors["state_mass"],
|
||||||
|
vectors["state_energy"],
|
||||||
|
used_volume_tangent,
|
||||||
|
)
|
||||||
|
if properties.tangents.width != width:
|
||||||
|
raise ValueError(
|
||||||
|
"PNCH012 property tangent width must match balance tangents."
|
||||||
|
)
|
||||||
|
props = properties.properties
|
||||||
|
if not properties.valid:
|
||||||
|
invalid_reason = invalid_reason or properties.reason
|
||||||
|
|
||||||
|
mass_derivative = sum(
|
||||||
|
self.get_port(port_name).m_flow for port_name in port_names
|
||||||
|
)
|
||||||
|
volume_rate = self.total_volume_rate()
|
||||||
|
energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate
|
||||||
|
mass_tangent = [0.0] * width
|
||||||
|
energy_tangent = [
|
||||||
|
-self.kth * self.sth * properties.tangents.T[index]
|
||||||
|
- volume_rate * properties.tangents.p[index]
|
||||||
|
- props.p * used_volume_rate_tangent[index]
|
||||||
|
for index in range(width)
|
||||||
|
]
|
||||||
|
|
||||||
|
for port_name in port_names:
|
||||||
|
port = self.get_port(port_name)
|
||||||
|
flow_tangent = vectors[f"flow:{port_name}"]
|
||||||
|
if (
|
||||||
|
abs(port.m_flow) <= flow_boundary_tolerance
|
||||||
|
and any(value != 0.0 for value in flow_tangent)
|
||||||
|
):
|
||||||
|
invalid_reason = invalid_reason or (
|
||||||
|
f"flow_direction_boundary:{port_name}"
|
||||||
|
)
|
||||||
|
if port.m_flow > 0.0:
|
||||||
|
inlet_h = connected_h[port_name]
|
||||||
|
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
|
||||||
|
else:
|
||||||
|
inlet_h = props.h
|
||||||
|
inlet_h_tangent = properties.tangents.h
|
||||||
|
energy_derivative += port.m_flow * inlet_h
|
||||||
|
for index in range(width):
|
||||||
|
mass_tangent[index] += flow_tangent[index]
|
||||||
|
energy_tangent[index] += (
|
||||||
|
inlet_h * flow_tangent[index]
|
||||||
|
+ port.m_flow * inlet_h_tangent[index]
|
||||||
|
)
|
||||||
|
|
||||||
|
return Pnch012DerivativeLinearization(
|
||||||
|
derivative=(mass_derivative, energy_derivative),
|
||||||
|
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
|
||||||
|
properties=properties,
|
||||||
|
valid=invalid_reason is None,
|
||||||
|
reason=invalid_reason,
|
||||||
|
)
|
||||||
|
|
||||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
pressure = self.medium.properties_from_mU(
|
pressure = self.medium.properties_from_mU(
|
||||||
self.state.m,
|
self.state.m,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 元件建模规范与示例
|
# 元件建模规范与示例
|
||||||
|
|
||||||
规范的权威版本位于
|
规范的权威版本位于
|
||||||
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
|
[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
|
||||||
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
|
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
|
||||||
修改中同步,不能让示例形成另一套规则。
|
修改中同步,不能让示例形成另一套规则。
|
||||||
|
|
||||||
@@ -279,4 +279,4 @@ models=(
|
|||||||
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
||||||
|
|
||||||
组件库、分类和自动发现的完整规则参见
|
组件库、分类和自动发现的完整规则参见
|
||||||
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
|
[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。
|
||||||
@@ -17,6 +17,9 @@ class Orifice(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "orifice"
|
MODEL_TYPE = "orifice"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
@@ -116,4 +119,3 @@ class Orifice(AlgebraicComponent):
|
|||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
self.port_a.h_outflow = connected_h["port_b"]
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
self.port_b.h_outflow = connected_h["port_a"]
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
|
|
||||||
@@ -17,6 +17,9 @@ class ResistivePipe(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "pipe"
|
MODEL_TYPE = "pipe"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class Tee(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "tee"
|
MODEL_TYPE = "tee"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||||
@@ -28,6 +28,13 @@ class Component(ABC):
|
|||||||
# they override either stream hook, the closure planner retains the legacy
|
# they override either stream hook, the closure planner retains the legacy
|
||||||
# full-network thermofluid fixed point.
|
# full-network thermofluid fixed point.
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
|
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
|
||||||
|
# Exact residual suffixes whose declared variables are summed, in order,
|
||||||
|
# to form a ``sumToZero`` flow equation. The causal solver deliberately
|
||||||
|
# reads this capability from the concrete class ``__dict__``: subclasses
|
||||||
|
# must repeat the promise after changing any equation semantics.
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
|
||||||
|
frozenset[str]
|
||||||
|
] = frozenset()
|
||||||
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||||
@@ -245,6 +252,18 @@ class Component(ABC):
|
|||||||
for equation in self.pressure_flow_equation_residuals()
|
for equation in self.pressure_flow_equation_residuals()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def pressure_flow_equation_value_readers(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, Callable[[], float]]:
|
||||||
|
"""Return explicitly separable scalar residual readers.
|
||||||
|
|
||||||
|
The solver consumes this optional capability only when the concrete
|
||||||
|
component class declares the method itself. Subclasses therefore
|
||||||
|
cannot accidentally inherit an equation-purity promise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
"""Update connector outflow properties from current flow directions."""
|
"""Update connector outflow properties from current flow directions."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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.core.errors import RecoverableTrialStateError
|
||||||
from app.simulation.performance import profile_property
|
from app.simulation.performance import profile_property
|
||||||
@@ -16,6 +17,36 @@ class ThermodynamicProperties:
|
|||||||
h: float
|
h: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThermodynamicPropertyTangents:
|
||||||
|
"""Directional derivatives of a recovered thermodynamic state."""
|
||||||
|
|
||||||
|
p: tuple[float, ...]
|
||||||
|
T: tuple[float, ...]
|
||||||
|
rho: tuple[float, ...]
|
||||||
|
u: tuple[float, ...]
|
||||||
|
h: tuple[float, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return len(self.p)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
|
||||||
|
values = (0.0,) * width
|
||||||
|
return cls(p=values, T=values, rho=values, u=values, h=values)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThermodynamicPropertiesLinearization:
|
||||||
|
"""Primal properties and a validity-checked directional linearization."""
|
||||||
|
|
||||||
|
properties: ThermodynamicProperties
|
||||||
|
tangents: ThermodynamicPropertyTangents
|
||||||
|
valid: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GasMedium(Protocol):
|
class GasMedium(Protocol):
|
||||||
"""Thermodynamic contract required by pneumatic components.
|
"""Thermodynamic contract required by pneumatic components.
|
||||||
|
|
||||||
@@ -50,6 +81,8 @@ class GasMedium(Protocol):
|
|||||||
|
|
||||||
def dynamic_viscosity(self, T: float) -> 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(self, T: float) -> float: ...
|
||||||
|
|
||||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ...
|
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ...
|
||||||
@@ -75,6 +108,18 @@ class GasMedium(Protocol):
|
|||||||
V: float,
|
V: float,
|
||||||
) -> ThermodynamicProperties: ...
|
) -> ThermodynamicProperties: ...
|
||||||
|
|
||||||
|
def linearize_properties_from_mU(
|
||||||
|
self,
|
||||||
|
m: float,
|
||||||
|
U: float,
|
||||||
|
V: float,
|
||||||
|
dm: Sequence[float],
|
||||||
|
dU: Sequence[float],
|
||||||
|
dV: Sequence[float],
|
||||||
|
*,
|
||||||
|
properties: ThermodynamicProperties | None = None,
|
||||||
|
) -> ThermodynamicPropertiesLinearization: ...
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class IdealGasMedium:
|
class IdealGasMedium:
|
||||||
@@ -139,6 +184,16 @@ class IdealGasMedium:
|
|||||||
/ (T + self.sutherland_constant)
|
/ (T + self.sutherland_constant)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def diagnostic_dynamic_viscosity(self, T: float) -> float:
|
||||||
|
"""Return the viscosity convention used by derived diagnostics.
|
||||||
|
|
||||||
|
Most media use the same transport property for dynamics and reported
|
||||||
|
diagnostics. Reference-library media may override this without
|
||||||
|
changing a calibrated constitutive flow relation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.dynamic_viscosity(T)
|
||||||
|
|
||||||
@profile_property("specific_internal_energy")
|
@profile_property("specific_internal_energy")
|
||||||
def specific_internal_energy(self, T: float) -> float:
|
def specific_internal_energy(self, T: float) -> float:
|
||||||
delta_T = T - self.T_ref
|
delta_T = T - self.T_ref
|
||||||
@@ -224,3 +279,100 @@ class IdealGasMedium:
|
|||||||
u = U / m
|
u = U / m
|
||||||
h = self.specific_enthalpy(T)
|
h = self.specific_enthalpy(T)
|
||||||
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
||||||
|
|
||||||
|
def linearize_properties_from_mU(
|
||||||
|
self,
|
||||||
|
m: float,
|
||||||
|
U: float,
|
||||||
|
V: float,
|
||||||
|
dm: Sequence[float],
|
||||||
|
dU: Sequence[float],
|
||||||
|
dV: Sequence[float],
|
||||||
|
*,
|
||||||
|
properties: ThermodynamicProperties | None = None,
|
||||||
|
) -> ThermodynamicPropertiesLinearization:
|
||||||
|
"""Linearize properties_from_mU for several seed directions."""
|
||||||
|
|
||||||
|
dm_values = tuple(float(value) for value in dm)
|
||||||
|
dU_values = tuple(float(value) for value in dU)
|
||||||
|
dV_values = tuple(float(value) for value in dV)
|
||||||
|
if not (len(dm_values) == len(dU_values) == len(dV_values)):
|
||||||
|
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
|
||||||
|
props = properties or self.properties_from_mU(m, U, V)
|
||||||
|
width = len(dm_values)
|
||||||
|
expected_density = m / V
|
||||||
|
expected_internal_energy = U / m
|
||||||
|
if (
|
||||||
|
abs(props.rho - expected_density)
|
||||||
|
> 1.0e-12 * max(abs(expected_density), 1.0)
|
||||||
|
or abs(props.u - expected_internal_energy)
|
||||||
|
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
|
||||||
|
):
|
||||||
|
return ThermodynamicPropertiesLinearization(
|
||||||
|
properties=props,
|
||||||
|
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||||
|
valid=False,
|
||||||
|
reason="properties_primal_mismatch",
|
||||||
|
)
|
||||||
|
if not all(
|
||||||
|
isfinite(value)
|
||||||
|
for values in (dm_values, dU_values, dV_values)
|
||||||
|
for value in values
|
||||||
|
):
|
||||||
|
return ThermodynamicPropertiesLinearization(
|
||||||
|
properties=props,
|
||||||
|
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||||
|
valid=False,
|
||||||
|
reason="non_finite_tangent_input",
|
||||||
|
)
|
||||||
|
|
||||||
|
cv = self.cv_at_temperature(props.T)
|
||||||
|
cp = self.cp_at_temperature(props.T)
|
||||||
|
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
|
||||||
|
return ThermodynamicPropertiesLinearization(
|
||||||
|
properties=props,
|
||||||
|
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||||
|
valid=False,
|
||||||
|
reason="non_positive_heat_capacity",
|
||||||
|
)
|
||||||
|
|
||||||
|
drho: list[float] = []
|
||||||
|
du: list[float] = []
|
||||||
|
dT: list[float] = []
|
||||||
|
dp: list[float] = []
|
||||||
|
dh: list[float] = []
|
||||||
|
for mass_tangent, energy_tangent, volume_tangent in zip(
|
||||||
|
dm_values,
|
||||||
|
dU_values,
|
||||||
|
dV_values,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
|
||||||
|
internal_energy_tangent = (
|
||||||
|
energy_tangent / m - U * mass_tangent / (m * m)
|
||||||
|
)
|
||||||
|
temperature_tangent = internal_energy_tangent / cv
|
||||||
|
pressure_tangent = self.R_gas * (
|
||||||
|
props.T * density_tangent + props.rho * temperature_tangent
|
||||||
|
)
|
||||||
|
enthalpy_tangent = cp * temperature_tangent
|
||||||
|
drho.append(density_tangent)
|
||||||
|
du.append(internal_energy_tangent)
|
||||||
|
dT.append(temperature_tangent)
|
||||||
|
dp.append(pressure_tangent)
|
||||||
|
dh.append(enthalpy_tangent)
|
||||||
|
|
||||||
|
tangent_values = (*drho, *du, *dT, *dp, *dh)
|
||||||
|
valid = all(isfinite(value) for value in tangent_values)
|
||||||
|
return ThermodynamicPropertiesLinearization(
|
||||||
|
properties=props,
|
||||||
|
tangents=ThermodynamicPropertyTangents(
|
||||||
|
p=tuple(dp),
|
||||||
|
T=tuple(dT),
|
||||||
|
rho=tuple(drho),
|
||||||
|
u=tuple(du),
|
||||||
|
h=tuple(dh),
|
||||||
|
),
|
||||||
|
valid=valid,
|
||||||
|
reason=None if valid else "non_finite_property_tangent",
|
||||||
|
)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -15,7 +15,7 @@ DATA_DIR = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
||||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
|
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "data"
|
||||||
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
|
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
|
||||||
MODELICA_TESTMODEL_RESULT_PATH = (
|
MODELICA_TESTMODEL_RESULT_PATH = (
|
||||||
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,8 @@ class TestMqlComparisonMetric:
|
|||||||
max_abs_error: float
|
max_abs_error: float
|
||||||
mean_abs_error: float
|
mean_abs_error: float
|
||||||
max_rel_error: float
|
max_rel_error: float
|
||||||
|
undefined_rel_error_count: int
|
||||||
|
near_zero_baseline_count: int
|
||||||
final_abs_error: float
|
final_abs_error: float
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +46,10 @@ class TestMqlComparisonResult:
|
|||||||
def max_rel_error(self) -> float:
|
def max_rel_error(self) -> float:
|
||||||
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undefined_rel_error_count(self) -> int:
|
||||||
|
return sum(metric.undefined_rel_error_count for metric in self.metrics)
|
||||||
|
|
||||||
|
|
||||||
class TestMqlComparisonError(ValueError):
|
class TestMqlComparisonError(ValueError):
|
||||||
"""Raised when Python and AMESim series cannot be aligned."""
|
"""Raised when Python and AMESim series cannot be aligned."""
|
||||||
@@ -57,6 +63,16 @@ def compare_test_mql_series(
|
|||||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||||
relative_floor: float = 1.0e-12,
|
relative_floor: float = 1.0e-12,
|
||||||
) -> TestMqlComparisonResult:
|
) -> TestMqlComparisonResult:
|
||||||
|
"""Compare current values directly with AMESim simulation values.
|
||||||
|
|
||||||
|
``relative_floor`` only identifies near-zero baselines for reporting. It is
|
||||||
|
never substituted into the relative-error denominator. An exact zero
|
||||||
|
AMESim baseline has undefined relative error and is counted separately;
|
||||||
|
absolute error remains available for judgement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if relative_floor < 0.0:
|
||||||
|
raise TestMqlComparisonError("relative_floor cannot be negative.")
|
||||||
_validate_time_axis(python_times)
|
_validate_time_axis(python_times)
|
||||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
||||||
metrics = []
|
metrics = []
|
||||||
@@ -70,11 +86,18 @@ def compare_test_mql_series(
|
|||||||
amesim_values = amesim_results.series(data_path)
|
amesim_values = amesim_results.series(data_path)
|
||||||
abs_errors = []
|
abs_errors = []
|
||||||
rel_errors = []
|
rel_errors = []
|
||||||
|
undefined_rel_error_count = 0
|
||||||
|
near_zero_baseline_count = 0
|
||||||
for time_value, python_value in zip(python_times, python_values):
|
for time_value, python_value in zip(python_times, python_values):
|
||||||
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
||||||
abs_error = abs(python_value - amesim_value)
|
abs_error = abs(python_value - amesim_value)
|
||||||
abs_errors.append(abs_error)
|
abs_errors.append(abs_error)
|
||||||
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
|
if abs(amesim_value) <= relative_floor:
|
||||||
|
near_zero_baseline_count += 1
|
||||||
|
if amesim_value == 0.0:
|
||||||
|
undefined_rel_error_count += 1
|
||||||
|
else:
|
||||||
|
rel_errors.append(abs_error / abs(amesim_value))
|
||||||
final_amesim_value = interpolate_series_value(
|
final_amesim_value = interpolate_series_value(
|
||||||
amesim_results.times,
|
amesim_results.times,
|
||||||
amesim_values,
|
amesim_values,
|
||||||
@@ -87,6 +110,8 @@ def compare_test_mql_series(
|
|||||||
max_abs_error=max(abs_errors, default=0.0),
|
max_abs_error=max(abs_errors, default=0.0),
|
||||||
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
||||||
max_rel_error=max(rel_errors, default=0.0),
|
max_rel_error=max(rel_errors, default=0.0),
|
||||||
|
undefined_rel_error_count=undefined_rel_error_count,
|
||||||
|
near_zero_baseline_count=near_zero_baseline_count,
|
||||||
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -153,8 +178,19 @@ def write_test_mql_comparison_csv(
|
|||||||
float(time_value),
|
float(time_value),
|
||||||
)
|
)
|
||||||
abs_error = abs(python_value - amesim_value)
|
abs_error = abs(python_value - amesim_value)
|
||||||
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
|
rel_error = (
|
||||||
row.extend([python_value, amesim_value, abs_error, rel_error])
|
None
|
||||||
|
if amesim_value == 0.0
|
||||||
|
else abs_error / abs(amesim_value)
|
||||||
|
)
|
||||||
|
row.extend(
|
||||||
|
[
|
||||||
|
python_value,
|
||||||
|
amesim_value,
|
||||||
|
abs_error,
|
||||||
|
"" if rel_error is None else rel_error,
|
||||||
|
]
|
||||||
|
)
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
summary_lines = [
|
summary_lines = [
|
||||||
@@ -163,6 +199,7 @@ def write_test_mql_comparison_csv(
|
|||||||
f"max_abs_error={metric.max_abs_error:.12g}, "
|
f"max_abs_error={metric.max_abs_error:.12g}, "
|
||||||
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
||||||
f"max_rel_error={metric.max_rel_error:.12%}, "
|
f"max_rel_error={metric.max_rel_error:.12%}, "
|
||||||
|
f"undefined_rel_error_count={metric.undefined_rel_error_count}, "
|
||||||
f"final_abs_error={metric.final_abs_error:.12g}"
|
f"final_abs_error={metric.final_abs_error:.12g}"
|
||||||
)
|
)
|
||||||
for metric in comparison.metrics
|
for metric in comparison.metrics
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ from app.simulation.solvers.algebraic import (
|
|||||||
PRESSURE_LOWER_BOUND_PA,
|
PRESSURE_LOWER_BOUND_PA,
|
||||||
AlgebraicSolveDiagnostics,
|
AlgebraicSolveDiagnostics,
|
||||||
AlgebraicUnknown,
|
AlgebraicUnknown,
|
||||||
|
CausalFlowKernelStage,
|
||||||
ExplicitFlowStage,
|
ExplicitFlowStage,
|
||||||
PressureFlowSolver,
|
PressureFlowSolver,
|
||||||
)
|
)
|
||||||
@@ -237,6 +238,27 @@ class StreamPressureBlockSolver:
|
|||||||
)
|
)
|
||||||
for stage in pressure_flow_solver._explicit_flow_plan
|
for stage in pressure_flow_solver._explicit_flow_plan
|
||||||
)
|
)
|
||||||
|
secondary_coordinate = 0
|
||||||
|
secondary_kernel_plan: list[CausalFlowKernelStage] = []
|
||||||
|
for stage in self._selected_explicit_flow_plan:
|
||||||
|
coordinate_indices = tuple(
|
||||||
|
range(
|
||||||
|
secondary_coordinate,
|
||||||
|
secondary_coordinate + len(stage.assignments),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
secondary_coordinate += len(stage.assignments)
|
||||||
|
secondary_kernel_plan.append(
|
||||||
|
CausalFlowKernelStage(
|
||||||
|
stage=stage,
|
||||||
|
coordinate_indices=coordinate_indices,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._causal_secondary_flow_kernel_plan = tuple(secondary_kernel_plan)
|
||||||
|
self._causal_secondary_coordinate_values = [0.0] * secondary_coordinate
|
||||||
|
self._causal_v2_entry_values = [0.0] * len(
|
||||||
|
self._selected_flow_unknowns
|
||||||
|
)
|
||||||
self._selected_equation_evaluation = (
|
self._selected_equation_evaluation = (
|
||||||
self._compile_selected_equation_evaluation()
|
self._compile_selected_equation_evaluation()
|
||||||
if self.blocks
|
if self.blocks
|
||||||
@@ -259,7 +281,11 @@ class StreamPressureBlockSolver:
|
|||||||
self._causal_full_residual_audit_count = 0
|
self._causal_full_residual_audit_count = 0
|
||||||
self._causal_audit_failure_count = 0
|
self._causal_audit_failure_count = 0
|
||||||
self._causal_legacy_fallback_count = 0
|
self._causal_legacy_fallback_count = 0
|
||||||
|
self._causal_v2_fast_solve_count = 0
|
||||||
|
self._causal_v2_runtime_validation_failure_count = 0
|
||||||
|
self._causal_coordinate_fast_solve_count = 0
|
||||||
self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None
|
self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||||
|
self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@@ -273,6 +299,13 @@ class StreamPressureBlockSolver:
|
|||||||
and self.pressure_flow_solver.causal_fast_path_enabled
|
and self.pressure_flow_solver.causal_fast_path_enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def causal_executor_v2_enabled(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.causal_fast_path_enabled
|
||||||
|
and self.pressure_flow_solver._causal_executor_v2_environment_enabled
|
||||||
|
)
|
||||||
|
|
||||||
def request_causal_audit(self) -> None:
|
def request_causal_audit(self) -> None:
|
||||||
self._causal_audit_required = True
|
self._causal_audit_required = True
|
||||||
|
|
||||||
@@ -280,7 +313,9 @@ class StreamPressureBlockSolver:
|
|||||||
parent = self.pressure_flow_solver.causal_execution_diagnostics()
|
parent = self.pressure_flow_solver.causal_execution_diagnostics()
|
||||||
disabled_reason = self._causal_runtime_disabled_reason
|
disabled_reason = self._causal_runtime_disabled_reason
|
||||||
if not bool(parent["enabled"]):
|
if not bool(parent["enabled"]):
|
||||||
disabled_reason = str(parent["disabledReason"] or "parentCausalPathDisabled")
|
disabled_reason = str(
|
||||||
|
parent["disabledReason"] or "parentCausalPathDisabled"
|
||||||
|
)
|
||||||
elif not self._causal_fast_path_eligible:
|
elif not self._causal_fast_path_eligible:
|
||||||
disabled_reason = self._causal_fast_path_fallback_reason
|
disabled_reason = self._causal_fast_path_fallback_reason
|
||||||
verified = self._causal_last_verified_diagnostics
|
verified = self._causal_last_verified_diagnostics
|
||||||
@@ -298,6 +333,34 @@ class StreamPressureBlockSolver:
|
|||||||
"lastVerifiedMaxScaledResidual": (
|
"lastVerifiedMaxScaledResidual": (
|
||||||
verified.max_scaled_residual if verified is not None else None
|
verified.max_scaled_residual if verified is not None else None
|
||||||
),
|
),
|
||||||
|
"executorV2Configured": (
|
||||||
|
self.pressure_flow_solver._causal_executor_v2_environment_enabled
|
||||||
|
),
|
||||||
|
"executorV2Enabled": self.causal_executor_v2_enabled,
|
||||||
|
"executorV2FastSolveCount": self._causal_v2_fast_solve_count,
|
||||||
|
"executorV2RuntimeValidationFailureCount": (
|
||||||
|
self._causal_v2_runtime_validation_failure_count
|
||||||
|
),
|
||||||
|
"coordinateKernelConfigured": (
|
||||||
|
self.pressure_flow_solver._causal_coordinate_kernel_environment_enabled
|
||||||
|
),
|
||||||
|
"coordinateKernelEnabled": (
|
||||||
|
self.causal_executor_v2_enabled
|
||||||
|
and self.pressure_flow_solver.causal_coordinate_kernel_enabled
|
||||||
|
),
|
||||||
|
"coordinateKernelFastSolveCount": (
|
||||||
|
self._causal_coordinate_fast_solve_count
|
||||||
|
),
|
||||||
|
"compiledEffortUnknownCount": len(
|
||||||
|
self._causal_effort_entry_positions
|
||||||
|
),
|
||||||
|
"compiledFlowAssignmentCount": len(
|
||||||
|
self._causal_expected_flow_equation_ids
|
||||||
|
),
|
||||||
|
"compiledAssignmentCount": (
|
||||||
|
len(self._causal_effort_entry_positions)
|
||||||
|
+ len(self._causal_expected_flow_equation_ids)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _disable_causal_fast_path(self, reason: str) -> None:
|
def _disable_causal_fast_path(self, reason: str) -> None:
|
||||||
@@ -324,7 +387,7 @@ class StreamPressureBlockSolver:
|
|||||||
return failed(str(parent_reason or "parentCausalPathIneligible"))
|
return failed(str(parent_reason or "parentCausalPathIneligible"))
|
||||||
if solver._closed_resistance_pressure_plan:
|
if solver._closed_resistance_pressure_plan:
|
||||||
return failed("specialClosedResistancePressureSeed")
|
return failed("specialClosedResistancePressureSeed")
|
||||||
if solver._pnor_pnl0001_series_plan:
|
if solver._resistance_pnl0001_series_plan:
|
||||||
return failed("specialSeriesPressureSeed")
|
return failed("specialSeriesPressureSeed")
|
||||||
selected = self._selected_equation_evaluation
|
selected = self._selected_equation_evaluation
|
||||||
if selected is None:
|
if selected is None:
|
||||||
@@ -405,6 +468,28 @@ class StreamPressureBlockSolver:
|
|||||||
self._causal_solves_since_audit = 0
|
self._causal_solves_since_audit = 0
|
||||||
self._causal_audit_required = False
|
self._causal_audit_required = False
|
||||||
self._causal_last_verified_diagnostics = diagnostics
|
self._causal_last_verified_diagnostics = diagnostics
|
||||||
|
self._causal_cached_fast_diagnostics = replace(
|
||||||
|
diagnostics,
|
||||||
|
message=(
|
||||||
|
"Compiled causal stream-pressure block completed; residuals "
|
||||||
|
"reuse the latest full audit."
|
||||||
|
),
|
||||||
|
evaluations=0,
|
||||||
|
residual_evaluations=0,
|
||||||
|
dense_fallback_used=False,
|
||||||
|
nonlinear_block_count=0,
|
||||||
|
nonlinear_block_unknown_count=0,
|
||||||
|
block_fallback_used=False,
|
||||||
|
block_fallback_reason=None,
|
||||||
|
residual_verified_this_solve=False,
|
||||||
|
causal_fast_path_used=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _causal_v2_fast_diagnostics(self) -> AlgebraicSolveDiagnostics:
|
||||||
|
cached = self._causal_cached_fast_diagnostics
|
||||||
|
if cached is None:
|
||||||
|
raise RuntimeError("Stream causal execution has no residual audit.")
|
||||||
|
return cached
|
||||||
|
|
||||||
def _causal_fast_diagnostics(
|
def _causal_fast_diagnostics(
|
||||||
self,
|
self,
|
||||||
@@ -725,10 +810,10 @@ class StreamPressureBlockSolver:
|
|||||||
f"{binding.neighbor.name}.{binding.neighbor_port}.p",
|
f"{binding.neighbor.name}.{binding.neighbor_port}.p",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for binding in solver._pnor_pnl0001_series_plan:
|
for binding in solver._resistance_pnl0001_series_plan:
|
||||||
special_seed_target_ids.extend(
|
special_seed_target_ids.extend(
|
||||||
(
|
(
|
||||||
f"{binding.orifice.name}.{binding.orifice_port}.p",
|
f"{binding.resistance.name}.{binding.resistance_port}.p",
|
||||||
f"{binding.pipe.name}.{binding.pipe_port}.p",
|
f"{binding.pipe.name}.{binding.pipe_port}.p",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -774,19 +859,12 @@ class StreamPressureBlockSolver:
|
|||||||
# closure deliberately mirrors ``solver.solve(effort_variables=())``:
|
# closure deliberately mirrors ``solver.solve(effort_variables=())``:
|
||||||
# the primary global solve has already propagated equal pressures.
|
# the primary global solve has already propagated equal pressures.
|
||||||
solver._seed_closed_resistance_pressures()
|
solver._seed_closed_resistance_pressures()
|
||||||
solver._seed_pnor_pnl0001_series_pressures()
|
solver._seed_resistance_pnl0001_series_pressures()
|
||||||
for unknown in self._selected_flow_unknowns:
|
for unknown in self._selected_flow_unknowns:
|
||||||
unknown.write(0.0)
|
unknown.write(0.0)
|
||||||
for stage in self._selected_explicit_flow_plan:
|
for stage in self._selected_explicit_flow_plan:
|
||||||
values = solver._evaluate_explicit_flow_stage(stage)
|
values = solver._evaluate_explicit_flow_stage(stage)
|
||||||
targets = tuple(
|
for assignment, target_value in zip(stage.assignments, values):
|
||||||
(
|
|
||||||
assignment,
|
|
||||||
assignment.unknown.read() - value,
|
|
||||||
)
|
|
||||||
for assignment, value in zip(stage.assignments, values)
|
|
||||||
)
|
|
||||||
for assignment, target_value in targets:
|
|
||||||
if isfinite(target_value):
|
if isfinite(target_value):
|
||||||
assignment.unknown.write(target_value)
|
assignment.unknown.write(target_value)
|
||||||
seeded_equation_ids.add(assignment.equation_id)
|
seeded_equation_ids.add(assignment.equation_id)
|
||||||
@@ -795,6 +873,45 @@ class StreamPressureBlockSolver:
|
|||||||
unknown.write(entry_values[position])
|
unknown.write(entry_values[position])
|
||||||
return frozenset(seeded_equation_ids)
|
return frozenset(seeded_equation_ids)
|
||||||
|
|
||||||
|
def _execute_compiled_secondary_flow_plan(self) -> str | None:
|
||||||
|
"""Execute selected flow assignments without equation-id sets."""
|
||||||
|
|
||||||
|
if self.pressure_flow_solver.causal_coordinate_kernel_enabled:
|
||||||
|
failure = (
|
||||||
|
self.pressure_flow_solver._execute_causal_coordinate_flow_stages(
|
||||||
|
self._causal_secondary_flow_kernel_plan,
|
||||||
|
self._causal_secondary_coordinate_values,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if failure == "causalFlowAssignmentCountMismatch":
|
||||||
|
return "causalSecondaryFlowAssignmentCountMismatch"
|
||||||
|
if failure == "nonFiniteCausalFlowAssignment":
|
||||||
|
return "nonFiniteCausalSecondaryFlowAssignment"
|
||||||
|
if failure and failure.startswith("causalFlowEvaluationFailed:"):
|
||||||
|
return "causalSecondaryFlowEvaluationFailed:" + failure.rsplit(
|
||||||
|
":", 1
|
||||||
|
)[-1]
|
||||||
|
return failure
|
||||||
|
|
||||||
|
for unknown in self._selected_flow_unknowns:
|
||||||
|
unknown.write(0.0)
|
||||||
|
for stage in self._selected_explicit_flow_plan:
|
||||||
|
try:
|
||||||
|
values = self.pressure_flow_solver._evaluate_explicit_flow_stage(
|
||||||
|
stage
|
||||||
|
)
|
||||||
|
except MemoryError:
|
||||||
|
raise
|
||||||
|
except (ArithmeticError, RuntimeError, ValueError) as exc:
|
||||||
|
return f"causalSecondaryFlowEvaluationFailed:{type(exc).__name__}"
|
||||||
|
if len(values) != len(stage.assignments):
|
||||||
|
return "causalSecondaryFlowAssignmentCountMismatch"
|
||||||
|
for assignment, target_value in zip(stage.assignments, values):
|
||||||
|
if not isfinite(target_value):
|
||||||
|
return "nonFiniteCausalSecondaryFlowAssignment"
|
||||||
|
assignment.unknown.write(target_value)
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _equation_scales_from_specs(
|
def _equation_scales_from_specs(
|
||||||
specs: tuple[_EquationScaleSpec, ...],
|
specs: tuple[_EquationScaleSpec, ...],
|
||||||
@@ -1090,11 +1207,62 @@ class StreamPressureBlockSolver:
|
|||||||
scale_context: Mapping[str, float] | None = None,
|
scale_context: Mapping[str, float] | None = None,
|
||||||
) -> StreamBlockSolveResult:
|
) -> StreamBlockSolveResult:
|
||||||
solver = self.pressure_flow_solver
|
solver = self.pressure_flow_solver
|
||||||
context = dict(scale_context or solver.scale_context())
|
|
||||||
causal_candidate = self.causal_fast_path_enabled
|
causal_candidate = self.causal_fast_path_enabled
|
||||||
causal_audit_due = (
|
causal_audit_due = (
|
||||||
self._causal_audit_is_due() if causal_candidate else False
|
self._causal_audit_is_due() if causal_candidate else False
|
||||||
)
|
)
|
||||||
|
causal_v2_candidate = (
|
||||||
|
causal_candidate
|
||||||
|
and solver._causal_executor_v2_environment_enabled
|
||||||
|
and not causal_audit_due
|
||||||
|
)
|
||||||
|
if causal_v2_candidate:
|
||||||
|
# The secondary causal proof rejects every special pressure seed,
|
||||||
|
# so this executor mutates selected flow coordinates only. Keep
|
||||||
|
# the minimal transactional snapshot for the rare fallback path.
|
||||||
|
v2_entry_values = self._causal_v2_entry_values
|
||||||
|
for position, unknown in enumerate(self._selected_flow_unknowns):
|
||||||
|
v2_entry_values[position] = unknown.state.m_flow
|
||||||
|
|
||||||
|
def restore_v2_entry_mutations() -> None:
|
||||||
|
for unknown, value in zip(
|
||||||
|
self._selected_flow_unknowns,
|
||||||
|
v2_entry_values,
|
||||||
|
):
|
||||||
|
unknown.state.m_flow = value
|
||||||
|
|
||||||
|
try:
|
||||||
|
v2_failure_reason = (
|
||||||
|
self._execute_compiled_secondary_flow_plan()
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
restore_v2_entry_mutations()
|
||||||
|
raise
|
||||||
|
if v2_failure_reason is None:
|
||||||
|
diagnostics = self._causal_v2_fast_diagnostics()
|
||||||
|
self._causal_fast_solve_count += 1
|
||||||
|
self._causal_v2_fast_solve_count += 1
|
||||||
|
if solver.causal_coordinate_kernel_enabled:
|
||||||
|
self._causal_coordinate_fast_solve_count += 1
|
||||||
|
self._causal_solves_since_audit += 1
|
||||||
|
selected = self._selected_equation_evaluation
|
||||||
|
assert selected is not None
|
||||||
|
return StreamBlockSolveResult(
|
||||||
|
diagnostics=(diagnostics,),
|
||||||
|
scopes=(selected.scope_components,),
|
||||||
|
used_global_fallback=False,
|
||||||
|
)
|
||||||
|
restore_v2_entry_mutations()
|
||||||
|
self._causal_v2_runtime_validation_failure_count += 1
|
||||||
|
self._causal_legacy_fallback_count += 1
|
||||||
|
self._disable_causal_fast_path(v2_failure_reason)
|
||||||
|
causal_candidate = False
|
||||||
|
causal_audit_due = False
|
||||||
|
|
||||||
|
# Keep scale construction and the full mutation snapshot off the v2
|
||||||
|
# success path. Callers may still precompute a shared scale mapping;
|
||||||
|
# avoiding that producer requires a later Generic-system API change.
|
||||||
|
context = dict(scale_context or solver.scale_context())
|
||||||
entry_values = tuple(
|
entry_values = tuple(
|
||||||
unknown.read() for unknown in self._entry_mutated_unknowns
|
unknown.read() for unknown in self._entry_mutated_unknowns
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from math import isfinite
|
||||||
|
import os
|
||||||
from typing import Callable, Literal, Mapping, Sequence
|
from typing import Callable, Literal, Mapping, Sequence
|
||||||
|
|
||||||
from app.simulation.components.amesim.mechanical.translational import (
|
from app.simulation.components.amesim.mechanical.translational import (
|
||||||
|
AmesimLstp00a,
|
||||||
AmesimMecmas21,
|
AmesimMecmas21,
|
||||||
)
|
)
|
||||||
from app.simulation.core.base import DynamicComponent
|
from app.simulation.core.base import DynamicComponent
|
||||||
@@ -12,15 +15,114 @@ from app.simulation.systems.network import SimulationNetwork
|
|||||||
|
|
||||||
|
|
||||||
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
|
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
|
||||||
|
MechanicalAbsoluteToleranceMode = Literal["legacy", "contact-aware-v1"]
|
||||||
DenseState = Callable[[float], Sequence[float]]
|
DenseState = Callable[[float], Sequence[float]]
|
||||||
|
|
||||||
|
|
||||||
|
MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE = (
|
||||||
|
"SIMULATION_MECHANICAL_ATOL_MODE"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _requested_mechanical_absolute_tolerance_mode(
|
||||||
|
) -> MechanicalAbsoluteToleranceMode:
|
||||||
|
value = os.environ.get(
|
||||||
|
MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE,
|
||||||
|
"legacy",
|
||||||
|
).strip().lower()
|
||||||
|
if value == "legacy":
|
||||||
|
return "legacy"
|
||||||
|
if value in {"contact-aware-v1", "contact_aware_v1", "contact-aware"}:
|
||||||
|
return "contact-aware-v1"
|
||||||
|
raise ValueError(
|
||||||
|
f"{MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE} must be "
|
||||||
|
"'legacy' or 'contact-aware-v1'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MechanicalToleranceGroupPlan:
|
||||||
|
"""One rigid-coordinate group's state tolerances and proof result."""
|
||||||
|
|
||||||
|
components: tuple[str, ...]
|
||||||
|
contacts: tuple[str, ...]
|
||||||
|
eligible: bool
|
||||||
|
reason: str
|
||||||
|
velocity_atol: float
|
||||||
|
position_atol: float
|
||||||
|
minimum_dvel: float | None
|
||||||
|
minimum_contact_damping_length: float | None
|
||||||
|
minimum_damping_strength_ratio: float | None
|
||||||
|
minimum_force_limited_velocity_atol: float | None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"components": list(self.components),
|
||||||
|
"contacts": list(self.contacts),
|
||||||
|
"eligible": self.eligible,
|
||||||
|
"reason": self.reason,
|
||||||
|
"velocityAtol": self.velocity_atol,
|
||||||
|
"positionAtol": self.position_atol,
|
||||||
|
"minimumDvel": self.minimum_dvel,
|
||||||
|
"minimumContactDampingLength": (
|
||||||
|
self.minimum_contact_damping_length
|
||||||
|
),
|
||||||
|
"minimumDampingStrengthRatio": (
|
||||||
|
self.minimum_damping_strength_ratio
|
||||||
|
),
|
||||||
|
"minimumForceLimitedVelocityAtol": (
|
||||||
|
self.minimum_force_limited_velocity_atol
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MechanicalAbsoluteTolerancePlan:
|
||||||
|
"""State-aligned absolute tolerances with auditable group proofs."""
|
||||||
|
|
||||||
|
mode: MechanicalAbsoluteToleranceMode
|
||||||
|
default_atol: float
|
||||||
|
legacy_mechanical_atol: float
|
||||||
|
values: tuple[float, ...]
|
||||||
|
groups: tuple[MechanicalToleranceGroupPlan, ...]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
legacy_value = min(
|
||||||
|
self.default_atol,
|
||||||
|
self.legacy_mechanical_atol,
|
||||||
|
)
|
||||||
|
relaxed_groups = tuple(
|
||||||
|
group
|
||||||
|
for group in self.groups
|
||||||
|
if group.velocity_atol > legacy_value
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": self.mode,
|
||||||
|
"defaultAtol": self.default_atol,
|
||||||
|
"legacyMechanicalAtol": self.legacy_mechanical_atol,
|
||||||
|
"stateCount": len(self.values),
|
||||||
|
"groupCount": len(self.groups),
|
||||||
|
"eligibleGroupCount": sum(group.eligible for group in self.groups),
|
||||||
|
"relaxedVelocityGroupCount": len(relaxed_groups),
|
||||||
|
"relaxedVelocityStateCount": len(relaxed_groups),
|
||||||
|
"relaxedPositionStateCount": 0,
|
||||||
|
"minimumEffectiveAtol": (
|
||||||
|
min(self.values) if self.values else None
|
||||||
|
),
|
||||||
|
"maximumEffectiveAtol": (
|
||||||
|
max(self.values) if self.values else None
|
||||||
|
),
|
||||||
|
"groups": [group.as_dict() for group in self.groups],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MechanicalConstraintGroup:
|
class MechanicalConstraintGroup:
|
||||||
"""MECMAS21 inertias that share one rigid translational coordinate."""
|
"""MECMAS21 inertias that share one rigid translational coordinate."""
|
||||||
|
|
||||||
components: tuple[AmesimMecmas21, ...]
|
components: tuple[AmesimMecmas21, ...]
|
||||||
mode: ConstraintMode = "uninitialized"
|
mode: ConstraintMode = "uninitialized"
|
||||||
|
contact_components: tuple[AmesimLstp00a, ...] = ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def representative(self) -> AmesimMecmas21:
|
def representative(self) -> AmesimMecmas21:
|
||||||
@@ -361,9 +463,52 @@ class MechanicalStateReducer:
|
|||||||
roots = (find("x", first_port), find("v", first_port))
|
roots = (find("x", first_port), find("v", first_port))
|
||||||
masses_by_roots.setdefault(roots, []).append(component)
|
masses_by_roots.setdefault(roots, []).append(component)
|
||||||
|
|
||||||
|
contacts_by_roots: dict[
|
||||||
|
tuple[tuple[str, str], tuple[str, str]],
|
||||||
|
dict[str, AmesimLstp00a],
|
||||||
|
] = {}
|
||||||
|
for component in self.network.components.values():
|
||||||
|
if not isinstance(component, AmesimLstp00a):
|
||||||
|
continue
|
||||||
|
contact_roots = tuple(
|
||||||
|
(
|
||||||
|
find("x", (component.name, definition.name)),
|
||||||
|
find("v", (component.name, definition.name)),
|
||||||
|
)
|
||||||
|
for definition in component.active_port_definitions
|
||||||
|
if (
|
||||||
|
definition.kind == "physical"
|
||||||
|
and definition.domain == "mechanical"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
len({roots[0] for roots in contact_roots}) < 2
|
||||||
|
or len({roots[1] for roots in contact_roots}) < 2
|
||||||
|
):
|
||||||
|
# A compliant contact whose two ports resolve to the same
|
||||||
|
# rigid coordinate cannot damp that coordinate. Treating the
|
||||||
|
# self-loop as proof would relax an unrelated velocity state.
|
||||||
|
continue
|
||||||
|
for definition in component.active_port_definitions:
|
||||||
|
if (
|
||||||
|
definition.kind != "physical"
|
||||||
|
or definition.domain != "mechanical"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
endpoint = (component.name, definition.name)
|
||||||
|
roots = (find("x", endpoint), find("v", endpoint))
|
||||||
|
contacts_by_roots.setdefault(roots, {})[
|
||||||
|
component.name
|
||||||
|
] = component
|
||||||
|
|
||||||
return tuple(
|
return tuple(
|
||||||
MechanicalConstraintGroup(tuple(components))
|
MechanicalConstraintGroup(
|
||||||
for components in masses_by_roots.values()
|
components=tuple(components),
|
||||||
|
contact_components=tuple(
|
||||||
|
contacts_by_roots.get(roots, {}).values()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for roots, components in masses_by_roots.items()
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_state_entries(self) -> tuple[StateEntry, ...]:
|
def _build_state_entries(self) -> tuple[StateEntry, ...]:
|
||||||
@@ -391,26 +536,253 @@ class MechanicalStateReducer:
|
|||||||
def has_state_events(self) -> bool:
|
def has_state_events(self) -> bool:
|
||||||
return any(group.discrete_endstop_components for group in self.groups)
|
return any(group.discrete_endstop_components for group in self.groups)
|
||||||
|
|
||||||
|
def absolute_tolerance_plan(
|
||||||
|
self,
|
||||||
|
default: float,
|
||||||
|
*,
|
||||||
|
mechanical: float = 1.0e-12,
|
||||||
|
mode: MechanicalAbsoluteToleranceMode | None = None,
|
||||||
|
) -> MechanicalAbsoluteTolerancePlan:
|
||||||
|
"""Compile state tolerances without weakening non-smooth coordinates.
|
||||||
|
|
||||||
|
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
|
||||||
|
endstop position across the much smaller unilateral boundary band while
|
||||||
|
constructing finite-difference Jacobians. Positions and ideal endstop
|
||||||
|
states therefore retain the legacy machine-scale tolerance.
|
||||||
|
|
||||||
|
Strongly damped, compliant LSTP contact can instead drive a *free*
|
||||||
|
velocity close to zero for hundreds of accepted steps. Only a
|
||||||
|
compile-proven smooth-contact group may use the bounded velocity floor;
|
||||||
|
the contact position coordinate remains unchanged.
|
||||||
|
"""
|
||||||
|
default_atol = float(default)
|
||||||
|
mechanical_atol = float(mechanical)
|
||||||
|
if not isfinite(default_atol) or default_atol <= 0.0:
|
||||||
|
raise ValueError(
|
||||||
|
"default absolute tolerance must be finite and positive."
|
||||||
|
)
|
||||||
|
if not isfinite(mechanical_atol) or mechanical_atol <= 0.0:
|
||||||
|
raise ValueError(
|
||||||
|
"mechanical absolute tolerance must be finite and positive."
|
||||||
|
)
|
||||||
|
selected_mode = mode or _requested_mechanical_absolute_tolerance_mode()
|
||||||
|
if selected_mode not in {"legacy", "contact-aware-v1"}:
|
||||||
|
raise ValueError(
|
||||||
|
"mechanical absolute tolerance mode must be 'legacy' or "
|
||||||
|
"'contact-aware-v1'."
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_atol = min(default_atol, mechanical_atol)
|
||||||
|
values: list[float] = []
|
||||||
|
group_plans: list[MechanicalToleranceGroupPlan] = []
|
||||||
|
for entry in self.state_entries:
|
||||||
|
if not isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
values.extend([default_atol] * entry.state_size)
|
||||||
|
continue
|
||||||
|
|
||||||
|
components = entry.components
|
||||||
|
contacts = entry.contact_components
|
||||||
|
positive_dvel = tuple(
|
||||||
|
float(component.dvel)
|
||||||
|
for component in components
|
||||||
|
if isfinite(float(component.dvel))
|
||||||
|
and float(component.dvel) > 0.0
|
||||||
|
)
|
||||||
|
positive_pdis = tuple(
|
||||||
|
float(contact.Pdis)
|
||||||
|
for contact in contacts
|
||||||
|
if isfinite(float(contact.Pdis))
|
||||||
|
and float(contact.Pdis) > 0.0
|
||||||
|
)
|
||||||
|
minimum_dvel = min(positive_dvel, default=None)
|
||||||
|
minimum_pdis = min(positive_pdis, default=None)
|
||||||
|
contact_scale_valid = True
|
||||||
|
contact_force_velocity_limits_list: list[float] = []
|
||||||
|
damping_strength_ratios_list: list[float] = []
|
||||||
|
if selected_mode == "contact-aware-v1" and minimum_dvel is not None:
|
||||||
|
for contact in contacts:
|
||||||
|
stiffness = float(contact.kcont)
|
||||||
|
damping_length = float(contact.Pdis)
|
||||||
|
damping = float(contact.rcont)
|
||||||
|
if not (
|
||||||
|
isfinite(stiffness)
|
||||||
|
and stiffness > 0.0
|
||||||
|
and isfinite(damping_length)
|
||||||
|
and damping_length > 0.0
|
||||||
|
and isfinite(damping)
|
||||||
|
and damping > 0.0
|
||||||
|
):
|
||||||
|
contact_scale_valid = False
|
||||||
|
continue
|
||||||
|
elastic_force_scale = stiffness * damping_length
|
||||||
|
damping_force_scale = damping * minimum_dvel
|
||||||
|
if not (
|
||||||
|
isfinite(elastic_force_scale)
|
||||||
|
and elastic_force_scale > 0.0
|
||||||
|
and isfinite(damping_force_scale)
|
||||||
|
and damping_force_scale > 0.0
|
||||||
|
):
|
||||||
|
contact_scale_valid = False
|
||||||
|
continue
|
||||||
|
force_velocity_limit = (
|
||||||
|
1.0e-3 * elastic_force_scale / damping
|
||||||
|
)
|
||||||
|
damping_strength_ratio = (
|
||||||
|
damping_force_scale / elastic_force_scale
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
isfinite(force_velocity_limit)
|
||||||
|
and force_velocity_limit > 0.0
|
||||||
|
and isfinite(damping_strength_ratio)
|
||||||
|
and damping_strength_ratio > 0.0
|
||||||
|
):
|
||||||
|
contact_scale_valid = False
|
||||||
|
continue
|
||||||
|
contact_force_velocity_limits_list.append(
|
||||||
|
force_velocity_limit
|
||||||
|
)
|
||||||
|
damping_strength_ratios_list.append(
|
||||||
|
damping_strength_ratio
|
||||||
|
)
|
||||||
|
contact_force_velocity_limits = tuple(
|
||||||
|
contact_force_velocity_limits_list
|
||||||
|
)
|
||||||
|
minimum_force_velocity_atol = min(
|
||||||
|
contact_force_velocity_limits,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
damping_strength_ratios = tuple(
|
||||||
|
damping_strength_ratios_list
|
||||||
|
)
|
||||||
|
minimum_damping_strength_ratio = min(
|
||||||
|
damping_strength_ratios,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_mode == "legacy":
|
||||||
|
eligible = False
|
||||||
|
reason = "legacyMode"
|
||||||
|
elif entry.discrete_endstop_components:
|
||||||
|
eligible = False
|
||||||
|
reason = "discreteEndstop"
|
||||||
|
elif any(int(component.stoptype) != 4 for component in components):
|
||||||
|
eligible = False
|
||||||
|
reason = "unsupportedStopType"
|
||||||
|
elif any(
|
||||||
|
component.use_friction and float(component.fcoul) != 0.0
|
||||||
|
for component in components
|
||||||
|
):
|
||||||
|
eligible = False
|
||||||
|
reason = "dryFriction"
|
||||||
|
elif not contacts:
|
||||||
|
eligible = False
|
||||||
|
reason = "noFlexibleContact"
|
||||||
|
elif any(
|
||||||
|
not isfinite(float(contact.Pdis))
|
||||||
|
or float(contact.Pdis) <= 0.0
|
||||||
|
for contact in contacts
|
||||||
|
):
|
||||||
|
eligible = False
|
||||||
|
reason = "nonSmoothContactDampingLength"
|
||||||
|
elif any(
|
||||||
|
not isfinite(float(contact.rcont))
|
||||||
|
or float(contact.rcont) <= 0.0
|
||||||
|
for contact in contacts
|
||||||
|
):
|
||||||
|
eligible = False
|
||||||
|
reason = "undampedContact"
|
||||||
|
elif any(
|
||||||
|
not isfinite(float(contact.kcont))
|
||||||
|
or float(contact.kcont) <= 0.0
|
||||||
|
for contact in contacts
|
||||||
|
):
|
||||||
|
eligible = False
|
||||||
|
reason = "invalidContactStiffness"
|
||||||
|
elif not contact_scale_valid:
|
||||||
|
eligible = False
|
||||||
|
reason = "invalidContactScale"
|
||||||
|
elif any(
|
||||||
|
int(contact.discContactOption) != 1
|
||||||
|
for contact in contacts
|
||||||
|
):
|
||||||
|
eligible = False
|
||||||
|
reason = "clampedContactForce"
|
||||||
|
elif len(positive_dvel) != len(components):
|
||||||
|
eligible = False
|
||||||
|
reason = "invalidVelocityScale"
|
||||||
|
elif (
|
||||||
|
len(damping_strength_ratios) != len(contacts)
|
||||||
|
or minimum_damping_strength_ratio is None
|
||||||
|
or minimum_damping_strength_ratio < 1.0
|
||||||
|
):
|
||||||
|
eligible = False
|
||||||
|
reason = "weakContactDamping"
|
||||||
|
else:
|
||||||
|
eligible = True
|
||||||
|
reason = "eligibleFlexibleContact"
|
||||||
|
|
||||||
|
velocity_atol = legacy_atol
|
||||||
|
if eligible:
|
||||||
|
assert minimum_dvel is not None
|
||||||
|
assert minimum_force_velocity_atol is not None
|
||||||
|
velocity_atol = min(
|
||||||
|
default_atol,
|
||||||
|
max(
|
||||||
|
mechanical_atol,
|
||||||
|
min(
|
||||||
|
1.0e-9,
|
||||||
|
1.0e-3 * minimum_dvel,
|
||||||
|
minimum_force_velocity_atol,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
position_atol = legacy_atol
|
||||||
|
values.extend((velocity_atol, position_atol))
|
||||||
|
group_plans.append(
|
||||||
|
MechanicalToleranceGroupPlan(
|
||||||
|
components=tuple(
|
||||||
|
component.name for component in components
|
||||||
|
),
|
||||||
|
contacts=tuple(contact.name for contact in contacts),
|
||||||
|
eligible=eligible,
|
||||||
|
reason=reason,
|
||||||
|
velocity_atol=velocity_atol,
|
||||||
|
position_atol=position_atol,
|
||||||
|
minimum_dvel=minimum_dvel,
|
||||||
|
minimum_contact_damping_length=minimum_pdis,
|
||||||
|
minimum_damping_strength_ratio=(
|
||||||
|
minimum_damping_strength_ratio
|
||||||
|
),
|
||||||
|
minimum_force_limited_velocity_atol=(
|
||||||
|
minimum_force_velocity_atol
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return MechanicalAbsoluteTolerancePlan(
|
||||||
|
mode=selected_mode,
|
||||||
|
default_atol=default_atol,
|
||||||
|
legacy_mechanical_atol=mechanical_atol,
|
||||||
|
values=tuple(values),
|
||||||
|
groups=tuple(group_plans),
|
||||||
|
)
|
||||||
|
|
||||||
def absolute_tolerances(
|
def absolute_tolerances(
|
||||||
self,
|
self,
|
||||||
default: float,
|
default: float,
|
||||||
*,
|
*,
|
||||||
mechanical: float = 1.0e-12,
|
mechanical: float = 1.0e-12,
|
||||||
|
mode: MechanicalAbsoluteToleranceMode | None = None,
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
"""Return state-aligned tolerances with machine-scale mechanics.
|
"""Return state-aligned values from the auditable tolerance plan."""
|
||||||
|
|
||||||
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
|
return list(
|
||||||
endstop position across the much smaller unilateral boundary band while
|
self.absolute_tolerance_plan(
|
||||||
constructing finite-difference Jacobians. Mechanical coordinates need
|
default,
|
||||||
a tighter floor; thermodynamic states retain the caller's tolerance.
|
mechanical=mechanical,
|
||||||
"""
|
mode=mode,
|
||||||
values: list[float] = []
|
).values
|
||||||
for entry in self.state_entries:
|
)
|
||||||
if isinstance(entry, MechanicalConstraintGroup):
|
|
||||||
values.extend([min(default, mechanical)] * 2)
|
|
||||||
else:
|
|
||||||
values.extend([default] * entry.state_size)
|
|
||||||
return values
|
|
||||||
|
|
||||||
def reset_constraint_modes(self) -> None:
|
def reset_constraint_modes(self) -> None:
|
||||||
for group in self.groups:
|
for group in self.groups:
|
||||||
|
|||||||
@@ -12,6 +12,136 @@ CancellationCheck = Callable[[], bool]
|
|||||||
AcceptedStepCallback = Callable[[float], None]
|
AcceptedStepCallback = Callable[[float], None]
|
||||||
IntegrationStatus = Literal["completed", "cancelled", "failed"]
|
IntegrationStatus = Literal["completed", "cancelled", "failed"]
|
||||||
DenseState = Callable[[float], list[float]]
|
DenseState = Callable[[float], list[float]]
|
||||||
|
JacobianCallable = Callable[[float, object], object]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolverActivitySnapshot:
|
||||||
|
"""Low-cost, additive view of work inside an integration task.
|
||||||
|
|
||||||
|
``accepted_time`` deliberately changes only after an accepted solver step.
|
||||||
|
Trial evaluations may continue to advance ``activity_sequence`` and
|
||||||
|
``current_trial_time`` while that public progress value stays fixed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
activity_sequence: int
|
||||||
|
activity_kind: str
|
||||||
|
current_trial_time: float | None
|
||||||
|
rhs_call_count: int
|
||||||
|
accepted_step_sequence: int
|
||||||
|
accepted_time: float | None
|
||||||
|
solver_step_sequence: int
|
||||||
|
jacobian_evaluation_count: int
|
||||||
|
thermofluid_closure_count: int
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"activitySequence": self.activity_sequence,
|
||||||
|
"activityKind": self.activity_kind,
|
||||||
|
"currentTrialTime": self.current_trial_time,
|
||||||
|
"rhsCallCount": self.rhs_call_count,
|
||||||
|
"acceptedStepSequence": self.accepted_step_sequence,
|
||||||
|
"acceptedTime": self.accepted_time,
|
||||||
|
"solverStepSequence": self.solver_step_sequence,
|
||||||
|
"jacobianEvaluationCount": self.jacobian_evaluation_count,
|
||||||
|
"thermofluidClosureCount": self.thermofluid_closure_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SolverActivityTracker:
|
||||||
|
"""Single-writer activity telemetry for a solver worker.
|
||||||
|
|
||||||
|
The solver thread is the only writer and the stream thread only snapshots
|
||||||
|
scalar attributes. The sequence is published last, so a reader never
|
||||||
|
treats partially published fields as a newer completed activity update.
|
||||||
|
Passing no tracker to :func:`integrate_ode` is the zero-cost opt-out path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_accepted_step_sequence",
|
||||||
|
"_accepted_time",
|
||||||
|
"_activity_kind",
|
||||||
|
"_activity_sequence",
|
||||||
|
"_current_trial_time",
|
||||||
|
"_jacobian_evaluation_count",
|
||||||
|
"_rhs_call_count",
|
||||||
|
"_solver_step_sequence",
|
||||||
|
"_thermofluid_closure_count",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._activity_sequence = 0
|
||||||
|
self._activity_kind = "idle"
|
||||||
|
self._current_trial_time: float | None = None
|
||||||
|
self._rhs_call_count = 0
|
||||||
|
self._accepted_step_sequence = 0
|
||||||
|
self._accepted_time: float | None = None
|
||||||
|
self._solver_step_sequence = 0
|
||||||
|
self._jacobian_evaluation_count = 0
|
||||||
|
self._thermofluid_closure_count = 0
|
||||||
|
|
||||||
|
def _publish(self, kind: str, time: float | None = None) -> None:
|
||||||
|
self._activity_kind = kind
|
||||||
|
if time is not None:
|
||||||
|
self._current_trial_time = float(time)
|
||||||
|
self._activity_sequence += 1
|
||||||
|
|
||||||
|
def start_integration(self, time: float) -> None:
|
||||||
|
self._accepted_time = float(time)
|
||||||
|
self._publish("solver_initialization", time)
|
||||||
|
|
||||||
|
def record_phase(self, kind: str, time: float | None = None) -> None:
|
||||||
|
self._publish(kind, time)
|
||||||
|
|
||||||
|
def record_solver_step(self, time: float) -> None:
|
||||||
|
self._solver_step_sequence += 1
|
||||||
|
self._publish("solver_step", time)
|
||||||
|
|
||||||
|
def record_rhs(self, time: float) -> None:
|
||||||
|
self._rhs_call_count += 1
|
||||||
|
self._publish("rhs", time)
|
||||||
|
|
||||||
|
def record_jacobian(self, time: float) -> None:
|
||||||
|
self._jacobian_evaluation_count += 1
|
||||||
|
self._publish("jacobian", time)
|
||||||
|
|
||||||
|
def record_thermofluid_closure(self, time: float) -> None:
|
||||||
|
self._thermofluid_closure_count += 1
|
||||||
|
self._publish("thermofluid_closure", time)
|
||||||
|
|
||||||
|
def record_accepted_step(self, time: float) -> None:
|
||||||
|
accepted_time = float(time)
|
||||||
|
if (
|
||||||
|
self._accepted_time is not None
|
||||||
|
and accepted_time <= self._accepted_time
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._accepted_step_sequence += 1
|
||||||
|
self._accepted_time = accepted_time
|
||||||
|
self._publish("accepted_step", accepted_time)
|
||||||
|
|
||||||
|
def snapshot(self) -> SolverActivitySnapshot:
|
||||||
|
# ``activity_sequence`` is read last because writers publish it last.
|
||||||
|
activity_kind = self._activity_kind
|
||||||
|
current_trial_time = self._current_trial_time
|
||||||
|
rhs_call_count = self._rhs_call_count
|
||||||
|
accepted_step_sequence = self._accepted_step_sequence
|
||||||
|
accepted_time = self._accepted_time
|
||||||
|
solver_step_sequence = self._solver_step_sequence
|
||||||
|
jacobian_evaluation_count = self._jacobian_evaluation_count
|
||||||
|
thermofluid_closure_count = self._thermofluid_closure_count
|
||||||
|
activity_sequence = self._activity_sequence
|
||||||
|
return SolverActivitySnapshot(
|
||||||
|
activity_sequence=activity_sequence,
|
||||||
|
activity_kind=activity_kind,
|
||||||
|
current_trial_time=current_trial_time,
|
||||||
|
rhs_call_count=rhs_call_count,
|
||||||
|
accepted_step_sequence=accepted_step_sequence,
|
||||||
|
accepted_time=accepted_time,
|
||||||
|
solver_step_sequence=solver_step_sequence,
|
||||||
|
jacobian_evaluation_count=jacobian_evaluation_count,
|
||||||
|
thermofluid_closure_count=thermofluid_closure_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,9 +158,13 @@ StateTransitionHandler = Callable[
|
|||||||
]
|
]
|
||||||
|
|
||||||
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
|
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
|
||||||
|
_MAX_RECOVERABLE_RETRIES = 16
|
||||||
|
_RECOVERABLE_RETRY_FACTOR = 0.5
|
||||||
|
|
||||||
|
|
||||||
class _IntegrationCancelled(Exception):
|
class IntegrationCancelled(Exception):
|
||||||
|
"""Internal control-flow signal shared by RHS and Jacobian evaluation."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +179,29 @@ class SolveIVPConfig:
|
|||||||
first_step: float | None = None
|
first_step: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecoverableRetryDiagnostics:
|
||||||
|
"""One recoverable trial failure and the step cap chosen for its retry."""
|
||||||
|
|
||||||
|
phase: Literal["constructor", "step", "solver-status"]
|
||||||
|
attempted_step: float
|
||||||
|
reason: str
|
||||||
|
next_max_step: float | None = None
|
||||||
|
next_first_step: float | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"phase": self.phase,
|
||||||
|
"attemptedStep": self.attempted_step,
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
if self.next_max_step is not None:
|
||||||
|
result["nextMaxStep"] = self.next_max_step
|
||||||
|
if self.next_first_step is not None:
|
||||||
|
result["nextFirstStep"] = self.next_first_step
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SolverSegmentDiagnostics:
|
class SolverSegmentDiagnostics:
|
||||||
"""Work performed by implicit solver instances inside one event segment."""
|
"""Work performed by implicit solver instances inside one event segment."""
|
||||||
@@ -58,10 +215,22 @@ class SolverSegmentDiagnostics:
|
|||||||
accepted_step_count: int = 0
|
accepted_step_count: int = 0
|
||||||
solver_start_count: int = 0
|
solver_start_count: int = 0
|
||||||
state_transition_count: int = 0
|
state_transition_count: int = 0
|
||||||
|
state_transition_times: tuple[float, ...] = ()
|
||||||
recoverable_retry_count: int = 0
|
recoverable_retry_count: int = 0
|
||||||
|
jacobian_evaluation_count: int = 0
|
||||||
|
jacobian_full_build_count: int = 0
|
||||||
|
jacobian_secant_reuse_count: int = 0
|
||||||
|
jacobian_audit_failure_count: int = 0
|
||||||
|
finite_difference_rhs_evaluation_count: int = 0
|
||||||
|
jacobian_base_rhs_evaluation_count: int = 0
|
||||||
|
jacobian_jv_audit_rhs_evaluation_count: int = 0
|
||||||
|
exact_column_build_count: int = 0
|
||||||
|
exact_column_fallback_count: int = 0
|
||||||
|
jacobian_assembly_seconds: float = 0.0
|
||||||
|
recoverable_retries: tuple[RecoverableRetryDiagnostics, ...] = ()
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, float | int]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
result: dict[str, object] = {
|
||||||
"startTime": self.start_time,
|
"startTime": self.start_time,
|
||||||
"requestedStopTime": self.requested_stop_time,
|
"requestedStopTime": self.requested_stop_time,
|
||||||
"simulatedUntil": self.simulated_until,
|
"simulatedUntil": self.simulated_until,
|
||||||
@@ -73,6 +242,143 @@ class SolverSegmentDiagnostics:
|
|||||||
"stateTransitionCount": self.state_transition_count,
|
"stateTransitionCount": self.state_transition_count,
|
||||||
"recoverableRetryCount": self.recoverable_retry_count,
|
"recoverableRetryCount": self.recoverable_retry_count,
|
||||||
}
|
}
|
||||||
|
if self.state_transition_times:
|
||||||
|
result["stateTransitionTimes"] = list(
|
||||||
|
self.state_transition_times
|
||||||
|
)
|
||||||
|
if self.recoverable_retries:
|
||||||
|
result["recoverableRetries"] = [
|
||||||
|
retry.as_dict() for retry in self.recoverable_retries
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
self.jacobian_evaluation_count
|
||||||
|
or self.finite_difference_rhs_evaluation_count
|
||||||
|
or self.jacobian_assembly_seconds
|
||||||
|
):
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"jacobianEvaluationCount": self.jacobian_evaluation_count,
|
||||||
|
"jacobianFullBuildCount": self.jacobian_full_build_count,
|
||||||
|
"jacobianSecantReuseCount": self.jacobian_secant_reuse_count,
|
||||||
|
"jacobianAuditFailureCount": self.jacobian_audit_failure_count,
|
||||||
|
"finiteDifferenceRhsEvaluationCount": (
|
||||||
|
self.finite_difference_rhs_evaluation_count
|
||||||
|
),
|
||||||
|
"jacobianBaseRhsEvaluationCount": (
|
||||||
|
self.jacobian_base_rhs_evaluation_count
|
||||||
|
),
|
||||||
|
"jacobianJvAuditRhsEvaluationCount": (
|
||||||
|
self.jacobian_jv_audit_rhs_evaluation_count
|
||||||
|
),
|
||||||
|
"exactColumnBuildCount": self.exact_column_build_count,
|
||||||
|
"exactColumnFallbackCount": (
|
||||||
|
self.exact_column_fallback_count
|
||||||
|
),
|
||||||
|
"jacobianAssemblySeconds": self.jacobian_assembly_seconds,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_JACOBIAN_DIAGNOSTIC_KEYS = (
|
||||||
|
"jacobianEvaluationCount",
|
||||||
|
"fullBuildCount",
|
||||||
|
"secantReuseCount",
|
||||||
|
"auditFailureCount",
|
||||||
|
"finiteDifferenceRhsEvaluationCount",
|
||||||
|
"baseRhsEvaluationCount",
|
||||||
|
"jvAuditEvaluationCount",
|
||||||
|
"exactColumnBuildCount",
|
||||||
|
"exactColumnFallbackCount",
|
||||||
|
"assemblySeconds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _jacobian_diagnostic_snapshot(
|
||||||
|
jac: JacobianCallable | None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
diagnostics = getattr(jac, "diagnostics", None)
|
||||||
|
if diagnostics is None:
|
||||||
|
return {key: 0.0 for key in _JACOBIAN_DIAGNOSTIC_KEYS}
|
||||||
|
values = diagnostics()
|
||||||
|
return {
|
||||||
|
key: float(values.get(key, 0.0))
|
||||||
|
for key in _JACOBIAN_DIAGNOSTIC_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_finite_step(value: object) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
candidate = abs(float(value))
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
return candidate if candidate > 0.0 and math.isfinite(candidate) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _smallest_positive_finite_step(*values: object) -> float:
|
||||||
|
"""Return a conservative step bound from configuration candidates."""
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
candidate
|
||||||
|
for value in values
|
||||||
|
if (candidate := _positive_finite_step(value)) is not None
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError("No positive finite integration step is available.")
|
||||||
|
return min(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _solver_attempted_step(
|
||||||
|
solver: object,
|
||||||
|
*,
|
||||||
|
segment_max_step: float,
|
||||||
|
remaining_interval: float,
|
||||||
|
) -> float:
|
||||||
|
"""Snapshot the real trial scale before calling ``solver.step()``.
|
||||||
|
|
||||||
|
SciPy exposes the proposed step as ``h_abs``. ``step_size`` is the prior
|
||||||
|
accepted step, so it is only a fallback for solvers without a valid
|
||||||
|
``h_abs``; it must not reduce an otherwise valid failed-trial estimate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
configured_cap = _smallest_positive_finite_step(
|
||||||
|
segment_max_step,
|
||||||
|
remaining_interval,
|
||||||
|
)
|
||||||
|
for attribute in ("h_abs", "step_size"):
|
||||||
|
try:
|
||||||
|
candidate = _positive_finite_step(
|
||||||
|
getattr(solver, attribute, None)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# A third-party OdeSolver may implement these as fragile
|
||||||
|
# properties. The configured cap remains a safe fallback.
|
||||||
|
continue
|
||||||
|
if candidate is not None:
|
||||||
|
return min(candidate, configured_cap)
|
||||||
|
return configured_cap
|
||||||
|
|
||||||
|
|
||||||
|
def _recoverable_retry_steps(
|
||||||
|
attempted_step: float,
|
||||||
|
*,
|
||||||
|
last_accepted_time: float,
|
||||||
|
) -> tuple[float, float] | None:
|
||||||
|
"""Return strictly smaller max/first steps, or None at machine precision."""
|
||||||
|
|
||||||
|
next_step = _RECOVERABLE_RETRY_FACTOR * attempted_step
|
||||||
|
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
|
||||||
|
if (
|
||||||
|
not math.isfinite(next_step)
|
||||||
|
or next_step <= minimum_step
|
||||||
|
or next_step >= attempted_step
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
# This first step is intentionally one-shot. Keeping it equal to the new
|
||||||
|
# cap makes both controls strictly smaller than the failed trial scale.
|
||||||
|
return next_step, next_step
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -309,7 +615,7 @@ def _runge_kutta_4(
|
|||||||
for target_time in t_eval[1:]:
|
for target_time in t_eval[1:]:
|
||||||
while current_time < target_time:
|
while current_time < target_time:
|
||||||
if cancel_check is not None and cancel_check():
|
if cancel_check is not None and cancel_check():
|
||||||
raise _IntegrationCancelled
|
raise IntegrationCancelled
|
||||||
dt = min(config.max_step, target_time - current_time)
|
dt = min(config.max_step, target_time - current_time)
|
||||||
k1 = rhs(current_time, state)
|
k1 = rhs(current_time, state)
|
||||||
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
||||||
@@ -374,7 +680,7 @@ def _runge_kutta_4(
|
|||||||
report_step(current_time)
|
report_step(current_time)
|
||||||
|
|
||||||
_append_solution_sample(times, states, target_time, state)
|
_append_solution_sample(times, states, target_time, state)
|
||||||
except _IntegrationCancelled:
|
except IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = "Simulation was stopped before reaching the requested end time."
|
message = "Simulation was stopped before reaching the requested end time."
|
||||||
_append_solution_sample(times, states, current_time, state)
|
_append_solution_sample(times, states, current_time, state)
|
||||||
@@ -451,7 +757,7 @@ def _runge_kutta_4_segmented(
|
|||||||
nonlocal current_time, last_transition, same_time_transition_count, state
|
nonlocal current_time, last_transition, same_time_transition_count, state
|
||||||
while current_time < target_time:
|
while current_time < target_time:
|
||||||
if cancel_check is not None and cancel_check():
|
if cancel_check is not None and cancel_check():
|
||||||
raise _IntegrationCancelled
|
raise IntegrationCancelled
|
||||||
dt = min(config.max_step, target_time - current_time)
|
dt = min(config.max_step, target_time - current_time)
|
||||||
k1 = rhs(current_time, state)
|
k1 = rhs(current_time, state)
|
||||||
k2 = rhs(
|
k2 = rhs(
|
||||||
@@ -565,7 +871,7 @@ def _runge_kutta_4_segmented(
|
|||||||
sample_time = float(sample_times[sample_index])
|
sample_time = float(sample_times[sample_index])
|
||||||
_append_solution_sample(times, states, sample_time, state)
|
_append_solution_sample(times, states, sample_time, state)
|
||||||
sample_index += 1
|
sample_index += 1
|
||||||
except _IntegrationCancelled:
|
except IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = "Simulation was stopped before reaching the requested end time."
|
message = "Simulation was stopped before reaching the requested end time."
|
||||||
_append_solution_sample(times, states, current_time, state)
|
_append_solution_sample(times, states, current_time, state)
|
||||||
@@ -595,6 +901,8 @@ def _integrate_scipy_stepwise(
|
|||||||
breakpoints: Sequence[float] = (),
|
breakpoints: Sequence[float] = (),
|
||||||
state_transition_handler: StateTransitionHandler | None = None,
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
jac_sparsity=None,
|
jac_sparsity=None,
|
||||||
|
jac: JacobianCallable | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
"""Initial stepwise integration path for breakpoints and state resets.
|
"""Initial stepwise integration path for breakpoints and state resets.
|
||||||
|
|
||||||
@@ -617,6 +925,19 @@ def _integrate_scipy_stepwise(
|
|||||||
solver_type = solver_types.get(config.method)
|
solver_type = solver_types.get(config.method)
|
||||||
if solver_type is None:
|
if solver_type is None:
|
||||||
raise ValueError(f"Unsupported integration method: {config.method}")
|
raise ValueError(f"Unsupported integration method: {config.method}")
|
||||||
|
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
|
||||||
|
solver_jac = implicit_jac
|
||||||
|
if implicit_jac is not None and activity_tracker is not None:
|
||||||
|
original_jacobian = implicit_jac
|
||||||
|
|
||||||
|
def activity_jacobian(time, state):
|
||||||
|
activity_tracker.record_jacobian(float(time))
|
||||||
|
try:
|
||||||
|
return original_jacobian(time, state)
|
||||||
|
finally:
|
||||||
|
activity_tracker.record_phase("solver_step", float(time))
|
||||||
|
|
||||||
|
solver_jac = activity_jacobian
|
||||||
|
|
||||||
times = [float(config.t_start)]
|
times = [float(config.t_start)]
|
||||||
states = [[float(value)] for value in initial_state]
|
states = [[float(value)] for value in initial_state]
|
||||||
@@ -632,8 +953,13 @@ def _integrate_scipy_stepwise(
|
|||||||
|
|
||||||
def cancellable_rhs(time, state):
|
def cancellable_rhs(time, state):
|
||||||
if cancel_check():
|
if cancel_check():
|
||||||
raise _IntegrationCancelled
|
raise IntegrationCancelled
|
||||||
return rhs(float(time), [float(value) for value in state])
|
normalized_state = [float(value) for value in state]
|
||||||
|
derivative = rhs(float(time), normalized_state)
|
||||||
|
observer = getattr(implicit_jac, "observe", None)
|
||||||
|
if observer is not None:
|
||||||
|
observer(float(time), normalized_state, derivative)
|
||||||
|
return derivative
|
||||||
|
|
||||||
status: IntegrationStatus = "completed"
|
status: IntegrationStatus = "completed"
|
||||||
message = "The solver successfully reached the end of the integration interval."
|
message = "The solver successfully reached the end of the integration interval."
|
||||||
@@ -676,13 +1002,19 @@ def _integrate_scipy_stepwise(
|
|||||||
segment_max_step = float(config.max_step)
|
segment_max_step = float(config.max_step)
|
||||||
recoverable_retry_count = 0
|
recoverable_retry_count = 0
|
||||||
last_recoverable_error: RecoverableTrialStateError | None = None
|
last_recoverable_error: RecoverableTrialStateError | None = None
|
||||||
|
retry_first_step: float | None = None
|
||||||
segment_nfev = 0
|
segment_nfev = 0
|
||||||
segment_njev = 0
|
segment_njev = 0
|
||||||
segment_nlu = 0
|
segment_nlu = 0
|
||||||
segment_accepted_steps = 0
|
segment_accepted_steps = 0
|
||||||
segment_solver_starts = 0
|
segment_solver_starts = 0
|
||||||
segment_state_transitions = 0
|
segment_state_transitions = 0
|
||||||
|
segment_state_transition_times: list[float] = []
|
||||||
segment_recoverable_retries = 0
|
segment_recoverable_retries = 0
|
||||||
|
segment_recoverable_retry_diagnostics: list[
|
||||||
|
RecoverableRetryDiagnostics
|
||||||
|
] = []
|
||||||
|
jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac)
|
||||||
|
|
||||||
while has_integration_interval and last_accepted_time < integration_end:
|
while has_integration_interval and last_accepted_time < integration_end:
|
||||||
if cancel_check():
|
if cancel_check():
|
||||||
@@ -695,11 +1027,14 @@ def _integrate_scipy_stepwise(
|
|||||||
"atol": config.atol,
|
"atol": config.atol,
|
||||||
"max_step": segment_max_step,
|
"max_step": segment_max_step,
|
||||||
}
|
}
|
||||||
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
if config.method in {"BDF", "Radau"}:
|
||||||
|
if solver_jac is not None:
|
||||||
|
solver_options["jac"] = solver_jac
|
||||||
|
elif jac_sparsity is not None:
|
||||||
solver_options["jac_sparsity"] = jac_sparsity
|
solver_options["jac_sparsity"] = jac_sparsity
|
||||||
requested_first_step = (
|
requested_first_step = (
|
||||||
0.1 * segment_max_step
|
retry_first_step
|
||||||
if last_recoverable_error is not None
|
if retry_first_step is not None
|
||||||
else config.first_step
|
else config.first_step
|
||||||
)
|
)
|
||||||
if requested_first_step is not None:
|
if requested_first_step is not None:
|
||||||
@@ -707,8 +1042,20 @@ def _integrate_scipy_stepwise(
|
|||||||
requested_first_step,
|
requested_first_step,
|
||||||
integration_end - last_accepted_time,
|
integration_end - last_accepted_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
constructor_attempted_step = _smallest_positive_finite_step(
|
||||||
|
segment_max_step,
|
||||||
|
integration_end - last_accepted_time,
|
||||||
|
solver_options.get("first_step"),
|
||||||
|
)
|
||||||
|
start_segment = getattr(implicit_jac, "start_segment", None)
|
||||||
|
if start_segment is not None:
|
||||||
|
start_segment()
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
"solver_initialization",
|
||||||
|
last_accepted_time,
|
||||||
|
)
|
||||||
solver = solver_type(
|
solver = solver_type(
|
||||||
cancellable_rhs,
|
cancellable_rhs,
|
||||||
last_accepted_time,
|
last_accepted_time,
|
||||||
@@ -716,7 +1063,7 @@ def _integrate_scipy_stepwise(
|
|||||||
integration_end,
|
integration_end,
|
||||||
**solver_options,
|
**solver_options,
|
||||||
)
|
)
|
||||||
except _IntegrationCancelled:
|
except IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = cancellation_message()
|
message = cancellation_message()
|
||||||
break
|
break
|
||||||
@@ -724,14 +1071,33 @@ def _integrate_scipy_stepwise(
|
|||||||
recoverable_retry_count += 1
|
recoverable_retry_count += 1
|
||||||
segment_recoverable_retries += 1
|
segment_recoverable_retries += 1
|
||||||
last_recoverable_error = exc
|
last_recoverable_error = exc
|
||||||
next_step = 0.5 * segment_max_step
|
retry_steps = (
|
||||||
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
|
_recoverable_retry_steps(
|
||||||
if recoverable_retry_count > 16 or next_step <= minimum_step:
|
constructor_attempted_step,
|
||||||
|
last_accepted_time=last_accepted_time,
|
||||||
|
)
|
||||||
|
if recoverable_retry_count <= _MAX_RECOVERABLE_RETRIES
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
segment_recoverable_retry_diagnostics.append(
|
||||||
|
RecoverableRetryDiagnostics(
|
||||||
|
phase="constructor",
|
||||||
|
attempted_step=constructor_attempted_step,
|
||||||
|
reason=str(exc),
|
||||||
|
next_max_step=(
|
||||||
|
retry_steps[0] if retry_steps is not None else None
|
||||||
|
),
|
||||||
|
next_first_step=(
|
||||||
|
retry_steps[1] if retry_steps is not None else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if retry_steps is None:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
error = exc
|
error = exc
|
||||||
break
|
break
|
||||||
segment_max_step = next_step
|
segment_max_step, retry_first_step = retry_steps
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
@@ -754,8 +1120,19 @@ def _integrate_scipy_stepwise(
|
|||||||
step_start_time = last_accepted_time
|
step_start_time = last_accepted_time
|
||||||
step_start_state = list(last_accepted_state)
|
step_start_state = list(last_accepted_state)
|
||||||
try:
|
try:
|
||||||
|
attempted_step = _solver_attempted_step(
|
||||||
|
solver,
|
||||||
|
segment_max_step=segment_max_step,
|
||||||
|
remaining_interval=(
|
||||||
|
integration_end - last_accepted_time
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_solver_step(
|
||||||
|
last_accepted_time
|
||||||
|
)
|
||||||
step_message = solver.step()
|
step_message = solver.step()
|
||||||
except _IntegrationCancelled:
|
except IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = (
|
message = (
|
||||||
"Simulation was stopped before reaching the requested end time."
|
"Simulation was stopped before reaching the requested end time."
|
||||||
@@ -765,17 +1142,38 @@ def _integrate_scipy_stepwise(
|
|||||||
recoverable_retry_count += 1
|
recoverable_retry_count += 1
|
||||||
segment_recoverable_retries += 1
|
segment_recoverable_retries += 1
|
||||||
last_recoverable_error = exc
|
last_recoverable_error = exc
|
||||||
attempted_step = segment_max_step
|
retry_steps = (
|
||||||
next_step = 0.5 * attempted_step
|
_recoverable_retry_steps(
|
||||||
minimum_step = 64.0 * math.ulp(
|
attempted_step,
|
||||||
max(abs(last_accepted_time), 1.0)
|
last_accepted_time=last_accepted_time,
|
||||||
)
|
)
|
||||||
if recoverable_retry_count > 16 or next_step <= minimum_step:
|
if recoverable_retry_count
|
||||||
|
<= _MAX_RECOVERABLE_RETRIES
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
segment_recoverable_retry_diagnostics.append(
|
||||||
|
RecoverableRetryDiagnostics(
|
||||||
|
phase="step",
|
||||||
|
attempted_step=attempted_step,
|
||||||
|
reason=str(exc),
|
||||||
|
next_max_step=(
|
||||||
|
retry_steps[0]
|
||||||
|
if retry_steps is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
next_first_step=(
|
||||||
|
retry_steps[1]
|
||||||
|
if retry_steps is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if retry_steps is None:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
error = exc
|
error = exc
|
||||||
break
|
break
|
||||||
segment_max_step = next_step
|
segment_max_step, retry_first_step = retry_steps
|
||||||
restart_after_recoverable = True
|
restart_after_recoverable = True
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -789,21 +1187,62 @@ def _integrate_scipy_stepwise(
|
|||||||
if last_recoverable_error is not None:
|
if last_recoverable_error is not None:
|
||||||
recoverable_retry_count += 1
|
recoverable_retry_count += 1
|
||||||
segment_recoverable_retries += 1
|
segment_recoverable_retries += 1
|
||||||
next_step = 0.5 * segment_max_step
|
retry_steps = (
|
||||||
minimum_step = 64.0 * math.ulp(
|
_recoverable_retry_steps(
|
||||||
max(abs(last_accepted_time), 1.0)
|
attempted_step,
|
||||||
|
last_accepted_time=last_accepted_time,
|
||||||
)
|
)
|
||||||
if (
|
if recoverable_retry_count
|
||||||
recoverable_retry_count <= 16
|
<= _MAX_RECOVERABLE_RETRIES
|
||||||
and next_step > minimum_step
|
else None
|
||||||
):
|
)
|
||||||
segment_max_step = next_step
|
failure_reason = str(
|
||||||
|
step_message or last_recoverable_error
|
||||||
|
)
|
||||||
|
segment_recoverable_retry_diagnostics.append(
|
||||||
|
RecoverableRetryDiagnostics(
|
||||||
|
phase="solver-status",
|
||||||
|
attempted_step=attempted_step,
|
||||||
|
reason=failure_reason,
|
||||||
|
next_max_step=(
|
||||||
|
retry_steps[0]
|
||||||
|
if retry_steps is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
next_first_step=(
|
||||||
|
retry_steps[1]
|
||||||
|
if retry_steps is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if retry_steps is not None:
|
||||||
|
segment_max_step, retry_first_step = retry_steps
|
||||||
restart_after_recoverable = True
|
restart_after_recoverable = True
|
||||||
break
|
break
|
||||||
status = "failed"
|
status = "failed"
|
||||||
message = str(step_message or "Integration step failed.")
|
message = str(step_message or "Integration step failed.")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# A returned running/finished status means this step was
|
||||||
|
# accepted. Any prior recoverable failure is now historical:
|
||||||
|
# it must not influence an event restart or an ordinary later
|
||||||
|
# solver failure. The reduced cap is local to the failed
|
||||||
|
# trial: after one accepted retry step, let this solver grow
|
||||||
|
# adaptively again and ensure a later event restart receives
|
||||||
|
# the configured maximum. The retry-specific first step is
|
||||||
|
# likewise strictly one-shot.
|
||||||
|
if retry_first_step is not None:
|
||||||
|
segment_max_step = float(config.max_step)
|
||||||
|
try:
|
||||||
|
solver.max_step = segment_max_step
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
# Third-party OdeSolver-compatible test doubles may not
|
||||||
|
# expose a writable cap. SciPy's supported solvers do.
|
||||||
|
pass
|
||||||
|
last_recoverable_error = None
|
||||||
|
retry_first_step = None
|
||||||
|
recoverable_retry_count = 0
|
||||||
segment_accepted_steps += 1
|
segment_accepted_steps += 1
|
||||||
step_end_time = float(solver.t)
|
step_end_time = float(solver.t)
|
||||||
step_end_state = [float(value) for value in solver.y]
|
step_end_state = [float(value) for value in solver.y]
|
||||||
@@ -858,6 +1297,9 @@ def _integrate_scipy_stepwise(
|
|||||||
|
|
||||||
if transition is not None:
|
if transition is not None:
|
||||||
segment_state_transitions += 1
|
segment_state_transitions += 1
|
||||||
|
segment_state_transition_times.append(
|
||||||
|
float(transition.time)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
same_time_transition_count = (
|
same_time_transition_count = (
|
||||||
_next_same_time_transition_count(
|
_next_same_time_transition_count(
|
||||||
@@ -915,7 +1357,6 @@ def _integrate_scipy_stepwise(
|
|||||||
|
|
||||||
last_accepted_time = step_end_time
|
last_accepted_time = step_end_time
|
||||||
last_accepted_state = step_end_state
|
last_accepted_state = step_end_state
|
||||||
recoverable_retry_count = 0
|
|
||||||
reported_time = (
|
reported_time = (
|
||||||
float(segment_end)
|
float(segment_end)
|
||||||
if is_breakpoint and solver.status == "finished"
|
if is_breakpoint and solver.status == "finished"
|
||||||
@@ -957,6 +1398,11 @@ def _integrate_scipy_stepwise(
|
|||||||
if not restart_at_transition:
|
if not restart_at_transition:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
jacobian_work_end = _jacobian_diagnostic_snapshot(implicit_jac)
|
||||||
|
jacobian_work = {
|
||||||
|
key: jacobian_work_end[key] - jacobian_work_start[key]
|
||||||
|
for key in _JACOBIAN_DIAGNOSTIC_KEYS
|
||||||
|
}
|
||||||
solver_segments.append(
|
solver_segments.append(
|
||||||
SolverSegmentDiagnostics(
|
SolverSegmentDiagnostics(
|
||||||
start_time=float(segment_start_time),
|
start_time=float(segment_start_time),
|
||||||
@@ -970,7 +1416,41 @@ def _integrate_scipy_stepwise(
|
|||||||
accepted_step_count=segment_accepted_steps,
|
accepted_step_count=segment_accepted_steps,
|
||||||
solver_start_count=segment_solver_starts,
|
solver_start_count=segment_solver_starts,
|
||||||
state_transition_count=segment_state_transitions,
|
state_transition_count=segment_state_transitions,
|
||||||
|
state_transition_times=tuple(
|
||||||
|
segment_state_transition_times
|
||||||
|
),
|
||||||
recoverable_retry_count=segment_recoverable_retries,
|
recoverable_retry_count=segment_recoverable_retries,
|
||||||
|
recoverable_retries=tuple(
|
||||||
|
segment_recoverable_retry_diagnostics
|
||||||
|
),
|
||||||
|
jacobian_evaluation_count=int(
|
||||||
|
jacobian_work["jacobianEvaluationCount"]
|
||||||
|
),
|
||||||
|
jacobian_full_build_count=int(
|
||||||
|
jacobian_work["fullBuildCount"]
|
||||||
|
),
|
||||||
|
jacobian_secant_reuse_count=int(
|
||||||
|
jacobian_work["secantReuseCount"]
|
||||||
|
),
|
||||||
|
jacobian_audit_failure_count=int(
|
||||||
|
jacobian_work["auditFailureCount"]
|
||||||
|
),
|
||||||
|
finite_difference_rhs_evaluation_count=int(
|
||||||
|
jacobian_work["finiteDifferenceRhsEvaluationCount"]
|
||||||
|
),
|
||||||
|
jacobian_base_rhs_evaluation_count=int(
|
||||||
|
jacobian_work["baseRhsEvaluationCount"]
|
||||||
|
),
|
||||||
|
jacobian_jv_audit_rhs_evaluation_count=int(
|
||||||
|
jacobian_work["jvAuditEvaluationCount"]
|
||||||
|
),
|
||||||
|
exact_column_build_count=int(
|
||||||
|
jacobian_work["exactColumnBuildCount"]
|
||||||
|
),
|
||||||
|
exact_column_fallback_count=int(
|
||||||
|
jacobian_work["exactColumnFallbackCount"]
|
||||||
|
),
|
||||||
|
jacobian_assembly_seconds=jacobian_work["assemblySeconds"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status != "completed":
|
if status != "completed":
|
||||||
@@ -1034,6 +1514,9 @@ def integrate_ode(
|
|||||||
breakpoints: Sequence[float] | None = None,
|
breakpoints: Sequence[float] | None = None,
|
||||||
state_transition_handler: StateTransitionHandler | None = None,
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
jac_sparsity=None,
|
jac_sparsity=None,
|
||||||
|
jac: JacobianCallable | None = None,
|
||||||
|
recoverable_trial_retries: bool = False,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
):
|
):
|
||||||
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
||||||
|
|
||||||
@@ -1045,8 +1528,39 @@ def integrate_ode(
|
|||||||
interpolant. When it returns a transition, samples before the event retain
|
interpolant. When it returns a transition, samples before the event retain
|
||||||
the pre-event trajectory, the reset state is stored at the event, and a fresh
|
the pre-event trajectory, the reset state is stored at the event, and a fresh
|
||||||
solver continues from that state.
|
solver continues from that state.
|
||||||
|
|
||||||
|
``recoverable_trial_retries`` opts an eventless/cancellation-free caller
|
||||||
|
into the stepwise path so a ``RecoverableTrialStateError`` can rebuild the
|
||||||
|
solver from its last accepted state. It defaults to false to preserve the
|
||||||
|
direct ``solve_ivp`` path for ordinary callers.
|
||||||
|
|
||||||
|
``activity_tracker`` is optional and additive. When omitted, the numerical
|
||||||
|
call path and callback behavior are unchanged.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
integration_rhs = rhs
|
||||||
|
integration_accepted_step_callback = accepted_step_callback
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.start_integration(config.t_start)
|
||||||
|
original_rhs = rhs
|
||||||
|
|
||||||
|
def activity_rhs(time, state):
|
||||||
|
numeric_time = float(time)
|
||||||
|
activity_tracker.record_rhs(numeric_time)
|
||||||
|
try:
|
||||||
|
return original_rhs(time, state)
|
||||||
|
finally:
|
||||||
|
activity_tracker.record_phase("solver_step", numeric_time)
|
||||||
|
|
||||||
|
integration_rhs = activity_rhs
|
||||||
|
|
||||||
|
def activity_accepted_step(time: float) -> None:
|
||||||
|
activity_tracker.record_accepted_step(float(time))
|
||||||
|
if accepted_step_callback is not None:
|
||||||
|
accepted_step_callback(float(time))
|
||||||
|
|
||||||
|
integration_accepted_step_callback = activity_accepted_step
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state_transition_handler is not None
|
state_transition_handler is not None
|
||||||
and config.t_stop < config.t_start
|
and config.t_stop < config.t_start
|
||||||
@@ -1070,22 +1584,22 @@ def integrate_ode(
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
if normalized_breakpoints:
|
if normalized_breakpoints:
|
||||||
return _runge_kutta_4_segmented(
|
return _runge_kutta_4_segmented(
|
||||||
rhs,
|
integration_rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
accepted_step_callback,
|
integration_accepted_step_callback,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
return _runge_kutta_4(
|
return _runge_kutta_4(
|
||||||
rhs,
|
integration_rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
accepted_step_callback,
|
integration_accepted_step_callback,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1093,21 +1607,52 @@ def integrate_ode(
|
|||||||
cancel_check is not None
|
cancel_check is not None
|
||||||
or normalized_breakpoints
|
or normalized_breakpoints
|
||||||
or state_transition_handler is not None
|
or state_transition_handler is not None
|
||||||
|
or recoverable_trial_retries
|
||||||
):
|
):
|
||||||
return _integrate_scipy_stepwise(
|
return _integrate_scipy_stepwise(
|
||||||
rhs,
|
integration_rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
cancel_check or (lambda: False),
|
cancel_check or (lambda: False),
|
||||||
accepted_step_callback,
|
integration_accepted_step_callback,
|
||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
jac_sparsity,
|
jac_sparsity,
|
||||||
|
jac,
|
||||||
|
activity_tracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
|
||||||
|
solve_rhs = integration_rhs
|
||||||
|
if implicit_jac is not None:
|
||||||
|
observer = getattr(implicit_jac, "observe", None)
|
||||||
|
if observer is not None:
|
||||||
|
def observed_rhs(time, state):
|
||||||
|
derivative = integration_rhs(time, state)
|
||||||
|
observer(float(time), state, derivative)
|
||||||
|
return derivative
|
||||||
|
|
||||||
|
solve_rhs = observed_rhs
|
||||||
|
start_segment = getattr(implicit_jac, "start_segment", None)
|
||||||
|
if start_segment is not None:
|
||||||
|
start_segment()
|
||||||
|
|
||||||
|
solve_jac = implicit_jac
|
||||||
|
if implicit_jac is not None and activity_tracker is not None:
|
||||||
|
original_jacobian = implicit_jac
|
||||||
|
|
||||||
|
def activity_jacobian(time, state):
|
||||||
|
activity_tracker.record_jacobian(float(time))
|
||||||
|
try:
|
||||||
|
return original_jacobian(time, state)
|
||||||
|
finally:
|
||||||
|
activity_tracker.record_phase("solver_step", float(time))
|
||||||
|
|
||||||
|
solve_jac = activity_jacobian
|
||||||
|
|
||||||
solve_options = {
|
solve_options = {
|
||||||
"fun": rhs,
|
"fun": solve_rhs,
|
||||||
"t_span": (config.t_start, config.t_stop),
|
"t_span": (config.t_start, config.t_stop),
|
||||||
"y0": initial_state,
|
"y0": initial_state,
|
||||||
"method": config.method,
|
"method": config.method,
|
||||||
@@ -1118,6 +1663,11 @@ def integrate_ode(
|
|||||||
}
|
}
|
||||||
if config.first_step is not None:
|
if config.first_step is not None:
|
||||||
solve_options["first_step"] = config.first_step
|
solve_options["first_step"] = config.first_step
|
||||||
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
if solve_jac is not None:
|
||||||
|
solve_options["jac"] = solve_jac
|
||||||
|
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
||||||
solve_options["jac_sparsity"] = jac_sparsity
|
solve_options["jac_sparsity"] = jac_sparsity
|
||||||
return solve_ivp(**solve_options)
|
direct_solution = solve_ivp(**solve_options)
|
||||||
|
if activity_tracker is not None and len(direct_solution.t):
|
||||||
|
activity_tracker.record_accepted_step(float(direct_solution.t[-1]))
|
||||||
|
return direct_solution
|
||||||
@@ -60,6 +60,16 @@ class StreamResolver:
|
|||||||
for component in self._components
|
for component in self._components
|
||||||
if not isinstance(component, DynamicComponent)
|
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(
|
self._ports = tuple(
|
||||||
(component.name, port_name, port)
|
(component.name, port_name, port)
|
||||||
for component in self._components
|
for component in self._components
|
||||||
@@ -122,6 +132,16 @@ class StreamResolver:
|
|||||||
)
|
)
|
||||||
return values
|
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")
|
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||||
def _refresh_dynamic_components(self) -> None:
|
def _refresh_dynamic_components(self) -> None:
|
||||||
for component in self._dynamic_components:
|
for component in self._dynamic_components:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from math import floor, isfinite
|
from math import floor, isfinite
|
||||||
|
import os
|
||||||
|
from sys import maxsize
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from app.simulation.core.base import Component, DynamicComponent
|
from app.simulation.core.base import Component, DynamicComponent
|
||||||
@@ -12,6 +14,10 @@ from app.simulation.performance import performance_span, profile_phase
|
|||||||
from app.simulation.property_cache import with_property_cache
|
from app.simulation.property_cache import with_property_cache
|
||||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||||
from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver
|
from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver
|
||||||
|
from app.simulation.solvers.jacobian import (
|
||||||
|
SparseJacobianCompatibilityError,
|
||||||
|
SparseSecantJacobian,
|
||||||
|
)
|
||||||
from app.simulation.solvers.mechanical import (
|
from app.simulation.solvers.mechanical import (
|
||||||
MechanicalConstraintGroup,
|
MechanicalConstraintGroup,
|
||||||
MechanicalStateReducer,
|
MechanicalStateReducer,
|
||||||
@@ -21,15 +27,66 @@ from app.simulation.solvers.pneumatic_storage import (
|
|||||||
ideal_storage_group_is_reducible,
|
ideal_storage_group_is_reducible,
|
||||||
)
|
)
|
||||||
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
|
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
|
||||||
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
from app.simulation.solvers.solver import (
|
||||||
|
IntegrationCancelled,
|
||||||
|
ODESolution,
|
||||||
|
SolveIVPConfig,
|
||||||
|
SolverActivityTracker,
|
||||||
|
integrate_ode,
|
||||||
|
)
|
||||||
from app.simulation.solvers.signal import SignalResolver
|
from app.simulation.solvers.signal import SignalResolver
|
||||||
from app.simulation.solvers.stream import StreamResolver
|
from app.simulation.solvers.stream import StreamResolver
|
||||||
|
from app.simulation.solvers.tangent import (
|
||||||
|
ThreePistonTangentCompilation,
|
||||||
|
ThreePistonTangentProvider,
|
||||||
|
compile_supported_piston_tangent_provider,
|
||||||
|
)
|
||||||
|
from app.simulation.solvers.thermofluid import (
|
||||||
|
ThermofluidClosureDiagnostics,
|
||||||
|
ThermofluidClosureError,
|
||||||
|
ThermofluidClosureFailure,
|
||||||
|
ThermofluidClosureSuccess,
|
||||||
|
ThermofluidTransactionPlan,
|
||||||
|
)
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
SimulationProgressCallback = Callable[[float, str], None]
|
SimulationProgressCallback = Callable[[float, str], None]
|
||||||
SimulationCancellationCheck = Callable[[], bool]
|
SimulationCancellationCheck = Callable[[], bool]
|
||||||
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
||||||
|
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE = "SIMULATION_ODE_JACOBIAN_MODE"
|
||||||
|
|
||||||
|
|
||||||
|
def _requested_ode_jacobian_mode() -> Literal[
|
||||||
|
"optimized",
|
||||||
|
"hybrid",
|
||||||
|
"semi-analytic",
|
||||||
|
"scipy",
|
||||||
|
]:
|
||||||
|
value = os.getenv(
|
||||||
|
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE,
|
||||||
|
"scipy",
|
||||||
|
).strip().lower()
|
||||||
|
if value in {"optimized", "colored"}:
|
||||||
|
return "optimized"
|
||||||
|
if value in {"hybrid", "secant"}:
|
||||||
|
return "hybrid"
|
||||||
|
if value in {"semi-analytic", "semi_analytic", "analytic"}:
|
||||||
|
return "semi-analytic"
|
||||||
|
if value in {
|
||||||
|
"scipy",
|
||||||
|
"native",
|
||||||
|
"finite-difference",
|
||||||
|
"0",
|
||||||
|
"false",
|
||||||
|
"no",
|
||||||
|
"off",
|
||||||
|
}:
|
||||||
|
return "scipy"
|
||||||
|
raise ValueError(
|
||||||
|
f"{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE} must be "
|
||||||
|
"'optimized', 'hybrid', 'semi-analytic', or 'scipy'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -66,10 +123,6 @@ class SimulationPreparationError(ValueError):
|
|||||||
self.issues = issues
|
self.issues = issues
|
||||||
|
|
||||||
|
|
||||||
class ThermofluidClosureError(RuntimeError):
|
|
||||||
"""Raised when stream enthalpy and pressure-flow do not reach one fixed point."""
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationSampleTimeError(ValueError):
|
class SimulationSampleTimeError(ValueError):
|
||||||
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
|
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
|
||||||
|
|
||||||
@@ -261,14 +314,7 @@ def simulation_preparation_issues(
|
|||||||
def simulation_sample_times(
|
def simulation_sample_times(
|
||||||
config: SolveIVPConfig,
|
config: SolveIVPConfig,
|
||||||
step: float,
|
step: float,
|
||||||
*,
|
|
||||||
max_points: int = 10001,
|
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
if max_points < 2:
|
|
||||||
raise SimulationSampleTimeError(
|
|
||||||
"SIMULATION_SAMPLE_LIMIT_INVALID",
|
|
||||||
"Simulation sample limit must allow at least two points.",
|
|
||||||
)
|
|
||||||
t_start = float(config.t_start)
|
t_start = float(config.t_start)
|
||||||
t_stop = float(config.t_stop)
|
t_stop = float(config.t_stop)
|
||||||
if not isfinite(t_start) or not isfinite(t_stop):
|
if not isfinite(t_start) or not isfinite(t_stop):
|
||||||
@@ -293,33 +339,31 @@ def simulation_sample_times(
|
|||||||
"Simulation stop time must be greater than start time.",
|
"Simulation stop time must be greater than start time.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bound the grid before dividing by a potentially tiny step or allocating
|
|
||||||
# the result list. This avoids both float-to-int overflow and an OOM-sized
|
|
||||||
# ``range``/list when input comes from an external System XML document.
|
|
||||||
maximum_interval_count = max_points - 1
|
|
||||||
if step < duration / maximum_interval_count:
|
|
||||||
raise SimulationSampleTimeError(
|
|
||||||
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
|
|
||||||
f"Simulation sample count exceeds the limit of {max_points}; "
|
|
||||||
"increase sampleStep.",
|
|
||||||
)
|
|
||||||
|
|
||||||
ratio = duration / step
|
ratio = duration / step
|
||||||
if not isfinite(ratio):
|
if not isfinite(ratio):
|
||||||
raise SimulationSampleTimeError(
|
raise SimulationSampleTimeError(
|
||||||
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
|
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
||||||
f"Simulation sample count exceeds the limit of {max_points}; "
|
"Simulation sample count cannot be represented by this runtime; "
|
||||||
|
"increase sampleStep.",
|
||||||
|
)
|
||||||
|
interval_count = floor(ratio)
|
||||||
|
# There is no product-level point cap. Still reject a collection that the
|
||||||
|
# Python runtime cannot index before multiplying by the potentially huge
|
||||||
|
# interval count or allocating the output grid.
|
||||||
|
if interval_count > maxsize - 2:
|
||||||
|
raise SimulationSampleTimeError(
|
||||||
|
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
||||||
|
"Simulation sample count cannot be represented by this runtime; "
|
||||||
"increase sampleStep.",
|
"increase sampleStep.",
|
||||||
)
|
)
|
||||||
interval_count = int(floor(ratio))
|
|
||||||
last_regular_time = t_start + interval_count * step
|
last_regular_time = t_start + interval_count * step
|
||||||
append_stop = last_regular_time < t_stop
|
append_stop = last_regular_time < t_stop
|
||||||
requested_point_count = interval_count + 1 + int(append_stop)
|
requested_point_count = interval_count + 1 + int(append_stop)
|
||||||
if requested_point_count > max_points:
|
if requested_point_count > maxsize:
|
||||||
raise SimulationSampleTimeError(
|
raise SimulationSampleTimeError(
|
||||||
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
|
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
||||||
f"Simulation requests {requested_point_count} samples; "
|
"Simulation sample count cannot be represented by this runtime; "
|
||||||
f"the limit is {max_points}.",
|
"increase sampleStep.",
|
||||||
)
|
)
|
||||||
|
|
||||||
times = [t_start]
|
times = [t_start]
|
||||||
@@ -380,6 +424,17 @@ class GenericFluidSystem:
|
|||||||
self.signal_resolver = SignalResolver(network)
|
self.signal_resolver = SignalResolver(network)
|
||||||
self.stream_resolver = StreamResolver(network)
|
self.stream_resolver = StreamResolver(network)
|
||||||
self._thermofluid_closure_plan = self._build_thermofluid_closure_plan()
|
self._thermofluid_closure_plan = self._build_thermofluid_closure_plan()
|
||||||
|
self._thermofluid_transaction_plan = ThermofluidTransactionPlan.compile(
|
||||||
|
network,
|
||||||
|
diagnostic_owners=(
|
||||||
|
self.signal_resolver,
|
||||||
|
self.pneumatic_volume_resolver,
|
||||||
|
self.stream_resolver,
|
||||||
|
self.pressure_flow_solver,
|
||||||
|
*self._thermofluid_closure_plan.secondary_pressure_solvers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._thermofluid_closure_diagnostics = ThermofluidClosureDiagnostics()
|
||||||
self.algebraic_solve_count = 0
|
self.algebraic_solve_count = 0
|
||||||
self.algebraic_seeded_solve_count = 0
|
self.algebraic_seeded_solve_count = 0
|
||||||
self.algebraic_nonlinear_solve_count = 0
|
self.algebraic_nonlinear_solve_count = 0
|
||||||
@@ -398,6 +453,8 @@ class GenericFluidSystem:
|
|||||||
self.signal_propagation_count = 0
|
self.signal_propagation_count = 0
|
||||||
self.pneumatic_volume_propagation_count = 0
|
self.pneumatic_volume_propagation_count = 0
|
||||||
self._jacobian_sparsity = None
|
self._jacobian_sparsity = None
|
||||||
|
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
|
||||||
|
self._activity_tracker: SolverActivityTracker | None = None
|
||||||
|
|
||||||
def _request_causal_residual_audit(self) -> None:
|
def _request_causal_residual_audit(self) -> None:
|
||||||
"""Make topology or mode boundaries verify the next causal closure."""
|
"""Make topology or mode boundaries verify the next causal closure."""
|
||||||
@@ -874,12 +931,69 @@ class GenericFluidSystem:
|
|||||||
"colorGroupCount": group_count,
|
"colorGroupCount": group_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _exact_ode_jacobian_rows(self) -> dict[int, dict[int, float]]:
|
||||||
|
"""Return mode-independent kinematic rows safe to evaluate exactly."""
|
||||||
|
|
||||||
|
rows: dict[int, dict[int, float]] = {}
|
||||||
|
cursor = 0
|
||||||
|
for entry in self.mechanical_state_reducer.state_entries:
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
# A discrete endstop can replace x' = v with x' = 0 for the
|
||||||
|
# active constrained mode. Keep those rows numerical; free
|
||||||
|
# mechanical groups always have d(x')/d(v) = 1.
|
||||||
|
if not entry.discrete_endstop_components:
|
||||||
|
rows[cursor + 1] = {cursor: 1.0}
|
||||||
|
cursor += 2
|
||||||
|
else:
|
||||||
|
cursor += entry.state_size
|
||||||
|
return rows
|
||||||
|
|
||||||
@profile_phase(
|
@profile_phase(
|
||||||
"simulation.closure",
|
"simulation.closure",
|
||||||
minimum_mode="audit",
|
minimum_mode="audit",
|
||||||
reset_property_shadow=True,
|
reset_property_shadow=True,
|
||||||
)
|
)
|
||||||
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
|
def _close_current_state(
|
||||||
|
self,
|
||||||
|
time: float,
|
||||||
|
*,
|
||||||
|
record_rhs_outcome: bool = False,
|
||||||
|
) -> dict[str, dict[str, float]]:
|
||||||
|
if self._activity_tracker is not None:
|
||||||
|
self._activity_tracker.record_thermofluid_closure(time)
|
||||||
|
transaction = self._thermofluid_transaction_plan.capture()
|
||||||
|
last_algebraic_diagnostics = self._last_algebraic_diagnostics
|
||||||
|
last_algebraic_scope = self._last_algebraic_scope
|
||||||
|
try:
|
||||||
|
connected_h, success = self._close_current_state_unchecked(time)
|
||||||
|
except ThermofluidClosureError as exc:
|
||||||
|
transaction.restore()
|
||||||
|
self._last_algebraic_diagnostics = last_algebraic_diagnostics
|
||||||
|
self._last_algebraic_scope = last_algebraic_scope
|
||||||
|
self._request_causal_residual_audit()
|
||||||
|
if record_rhs_outcome:
|
||||||
|
failure = self._thermofluid_closure_diagnostics.record_failure(
|
||||||
|
exc.diagnostics
|
||||||
|
)
|
||||||
|
raise ThermofluidClosureError(failure) from None
|
||||||
|
raise
|
||||||
|
except BaseException:
|
||||||
|
transaction.restore()
|
||||||
|
self._last_algebraic_diagnostics = last_algebraic_diagnostics
|
||||||
|
self._last_algebraic_scope = last_algebraic_scope
|
||||||
|
self._request_causal_residual_audit()
|
||||||
|
raise
|
||||||
|
if record_rhs_outcome:
|
||||||
|
self._thermofluid_closure_diagnostics.record_success(success)
|
||||||
|
return connected_h
|
||||||
|
|
||||||
|
def _close_current_state_unchecked(
|
||||||
|
self,
|
||||||
|
time: float,
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, dict[str, float]],
|
||||||
|
ThermofluidClosureSuccess,
|
||||||
|
]:
|
||||||
signal = self.signal_resolver.solve(time)
|
signal = self.signal_resolver.solve(time)
|
||||||
self.signal_propagation_count += signal.propagated
|
self.signal_propagation_count += signal.propagated
|
||||||
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
|
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
|
||||||
@@ -905,27 +1019,22 @@ class GenericFluidSystem:
|
|||||||
closure_plan = self._thermofluid_closure_plan
|
closure_plan = self._thermofluid_closure_plan
|
||||||
self._last_algebraic_diagnostics = initial_algebraic
|
self._last_algebraic_diagnostics = initial_algebraic
|
||||||
self._last_algebraic_scope = closure_plan.global_component_group
|
self._last_algebraic_scope = closure_plan.global_component_group
|
||||||
physical_ports = closure_plan.physical_ports
|
|
||||||
secondary_pressure_solvers = closure_plan.secondary_pressure_solvers
|
secondary_pressure_solvers = closure_plan.secondary_pressure_solvers
|
||||||
secondary_block_solvers = closure_plan.secondary_block_solvers
|
secondary_block_solvers = closure_plan.secondary_block_solvers
|
||||||
connected_h: dict[str, dict[str, float]] = {}
|
connected_h: dict[str, dict[str, float]] = {}
|
||||||
stream_diagnostics = []
|
stream_diagnostics = []
|
||||||
|
coupling_deltas = []
|
||||||
max_coupling_iterations = 25
|
max_coupling_iterations = 25
|
||||||
flow_relative_tolerance = 1.0e-12
|
flow_relative_tolerance = 1.0e-12
|
||||||
for coupling_iteration in range(1, max_coupling_iterations + 1):
|
for coupling_iteration in range(1, max_coupling_iterations + 1):
|
||||||
previous_flows = tuple(port.m_flow for port in physical_ports)
|
previous_flows = self._thermofluid_transaction_plan.flow_values()
|
||||||
stream, connected_h = self.stream_resolver.solve(
|
stream, connected_h = self.stream_resolver.solve(
|
||||||
dynamic_ports_are_current=True,
|
dynamic_ports_are_current=True,
|
||||||
)
|
)
|
||||||
stream_diagnostics.append(stream)
|
stream_diagnostics.append(stream)
|
||||||
temperature_reference_h = (
|
|
||||||
self.stream_resolver.connected_temperature_reference_enthalpies()
|
|
||||||
)
|
|
||||||
for component in self.dynamic_components:
|
for component in self.dynamic_components:
|
||||||
component.update_stream_outflows(connected_h[component.name])
|
component.update_stream_outflows(connected_h[component.name])
|
||||||
component.update_flow_temperature_references(
|
self.stream_resolver.refresh_flow_temperature_references()
|
||||||
temperature_reference_h[component.name]
|
|
||||||
)
|
|
||||||
if secondary_pressure_solvers:
|
if secondary_pressure_solvers:
|
||||||
self.thermofluid_pressure_pass_count += 1
|
self.thermofluid_pressure_pass_count += 1
|
||||||
block_scale_context = (
|
block_scale_context = (
|
||||||
@@ -977,26 +1086,23 @@ class GenericFluidSystem:
|
|||||||
self._last_algebraic_diagnostics = algebraic
|
self._last_algebraic_diagnostics = algebraic
|
||||||
self._last_algebraic_scope = component_group
|
self._last_algebraic_scope = component_group
|
||||||
pressure_flow_solve_count += 1
|
pressure_flow_solve_count += 1
|
||||||
current_flows = tuple(port.m_flow for port in physical_ports)
|
coupling_delta = self._thermofluid_transaction_plan.measure_flow_delta(
|
||||||
flow_scale = max(
|
previous_flows,
|
||||||
[abs(value) for value in (*previous_flows, *current_flows)] + [1.0]
|
iteration=coupling_iteration,
|
||||||
)
|
relative_tolerance=flow_relative_tolerance,
|
||||||
max_flow_delta = max(
|
|
||||||
(
|
|
||||||
abs(current - previous)
|
|
||||||
for previous, current in zip(previous_flows, current_flows)
|
|
||||||
),
|
|
||||||
default=0.0,
|
|
||||||
)
|
)
|
||||||
|
coupling_deltas.append(coupling_delta)
|
||||||
if (
|
if (
|
||||||
not secondary_pressure_solvers
|
not secondary_pressure_solvers
|
||||||
or max_flow_delta <= flow_relative_tolerance * flow_scale
|
or coupling_delta.max_delta <= coupling_delta.tolerance
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise ThermofluidClosureError(
|
raise ThermofluidClosureError(
|
||||||
"Stream enthalpy and pressure-flow coupling did not converge "
|
ThermofluidClosureFailure.from_iterations(
|
||||||
f"after {max_coupling_iterations} iterations."
|
time,
|
||||||
|
coupling_deltas,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.max_thermofluid_iterations = max(
|
self.max_thermofluid_iterations = max(
|
||||||
self.max_thermofluid_iterations,
|
self.max_thermofluid_iterations,
|
||||||
@@ -1039,7 +1145,10 @@ class GenericFluidSystem:
|
|||||||
self.max_stream_iterations,
|
self.max_stream_iterations,
|
||||||
*(item.iterations for item in stream_diagnostics),
|
*(item.iterations for item in stream_diagnostics),
|
||||||
)
|
)
|
||||||
return connected_h
|
return connected_h, ThermofluidClosureSuccess.from_iteration(
|
||||||
|
time,
|
||||||
|
coupling_deltas[-1],
|
||||||
|
)
|
||||||
|
|
||||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||||
def _refresh_dynamic_components(self) -> None:
|
def _refresh_dynamic_components(self) -> None:
|
||||||
@@ -1051,6 +1160,8 @@ class GenericFluidSystem:
|
|||||||
self,
|
self,
|
||||||
connected_h: dict[str, dict[str, float]],
|
connected_h: dict[str, dict[str, float]],
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
|
if self._activity_tracker is not None:
|
||||||
|
self._activity_tracker.record_phase("state_derivatives")
|
||||||
return self.pneumatic_storage_reducer.coupled_derivatives(
|
return self.pneumatic_storage_reducer.coupled_derivatives(
|
||||||
self.mechanical_state_reducer.state_derivatives(connected_h)
|
self.mechanical_state_reducer.state_derivatives(connected_h)
|
||||||
)
|
)
|
||||||
@@ -1064,8 +1175,15 @@ class GenericFluidSystem:
|
|||||||
@profile_phase("simulation.rhs", minimum_mode="audit")
|
@profile_phase("simulation.rhs", minimum_mode="audit")
|
||||||
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||||
self.apply_state_vector(state_vector)
|
self.apply_state_vector(state_vector)
|
||||||
connected_h = self._close_current_state(_time)
|
connected_h = self._close_current_state(
|
||||||
return self._state_derivatives(connected_h)
|
_time,
|
||||||
|
record_rhs_outcome=True,
|
||||||
|
)
|
||||||
|
derivatives = self._state_derivatives(connected_h)
|
||||||
|
provider = self._ode_tangent_provider
|
||||||
|
if provider is not None:
|
||||||
|
provider.record_primal(_time, state_vector, connected_h)
|
||||||
|
return derivatives
|
||||||
|
|
||||||
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
||||||
for component in self.network.components.values():
|
for component in self.network.components.values():
|
||||||
@@ -1082,7 +1200,32 @@ class GenericFluidSystem:
|
|||||||
sample_step: float,
|
sample_step: float,
|
||||||
progress_callback: SimulationProgressCallback | None = None,
|
progress_callback: SimulationProgressCallback | None = None,
|
||||||
cancel_check: SimulationCancellationCheck | None = None,
|
cancel_check: SimulationCancellationCheck | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> GenericSimulationResult:
|
) -> GenericSimulationResult:
|
||||||
|
previous_activity_tracker = self._activity_tracker
|
||||||
|
self._activity_tracker = activity_tracker
|
||||||
|
try:
|
||||||
|
return self._simulate(
|
||||||
|
config,
|
||||||
|
sample_step=sample_step,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
activity_tracker=activity_tracker,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._activity_tracker = previous_activity_tracker
|
||||||
|
|
||||||
|
def _simulate(
|
||||||
|
self,
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
*,
|
||||||
|
sample_step: float,
|
||||||
|
progress_callback: SimulationProgressCallback | None = None,
|
||||||
|
cancel_check: SimulationCancellationCheck | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
|
) -> GenericSimulationResult:
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase("initializing", config.t_start)
|
||||||
last_reported_progress = -1.0
|
last_reported_progress = -1.0
|
||||||
last_reported_phase = ""
|
last_reported_phase = ""
|
||||||
|
|
||||||
@@ -1111,12 +1254,21 @@ class GenericFluidSystem:
|
|||||||
report_progress(0.0, "initializing", force=True)
|
report_progress(0.0, "initializing", force=True)
|
||||||
with performance_span("simulation.sample_initialization"):
|
with performance_span("simulation.sample_initialization"):
|
||||||
integration_config = config
|
integration_config = config
|
||||||
|
mechanical_tolerance_plan = None
|
||||||
if isinstance(config.atol, (int, float)):
|
if isinstance(config.atol, (int, float)):
|
||||||
|
mechanical_tolerance_plan = (
|
||||||
|
self.mechanical_state_reducer.absolute_tolerance_plan(
|
||||||
|
float(config.atol),
|
||||||
|
mode=(
|
||||||
|
None
|
||||||
|
if config.method == "BDF"
|
||||||
|
else "legacy"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
integration_config = replace(
|
integration_config = replace(
|
||||||
config,
|
config,
|
||||||
atol=self.mechanical_state_reducer.absolute_tolerances(
|
atol=list(mechanical_tolerance_plan.values),
|
||||||
float(config.atol)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
t_eval = simulation_sample_times(config, sample_step)
|
t_eval = simulation_sample_times(config, sample_step)
|
||||||
signal_event_times = self.signal_resolver.event_times(
|
signal_event_times = self.signal_resolver.event_times(
|
||||||
@@ -1156,12 +1308,94 @@ class GenericFluidSystem:
|
|||||||
report_solver_time(time)
|
report_solver_time(time)
|
||||||
return self.rhs(time, state_vector)
|
return self.rhs(time, state_vector)
|
||||||
|
|
||||||
|
jacobian = None
|
||||||
|
jacobian_fallback_reason: str | None = None
|
||||||
|
tangent_compilation: ThreePistonTangentCompilation | None = None
|
||||||
|
selected_tangent_provider: ThreePistonTangentProvider | None = None
|
||||||
|
self._ode_tangent_provider = None
|
||||||
|
requested_jacobian_mode = (
|
||||||
|
_requested_ode_jacobian_mode()
|
||||||
|
if jac_sparsity is not None
|
||||||
|
else "scipy"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
jac_sparsity is not None
|
||||||
|
and requested_jacobian_mode
|
||||||
|
in {"optimized", "hybrid", "semi-analytic"}
|
||||||
|
):
|
||||||
|
state_count = int(jac_sparsity.shape[0])
|
||||||
|
if (
|
||||||
|
requested_jacobian_mode == "hybrid"
|
||||||
|
and not self.pressure_flow_solver.causal_fast_path_enabled
|
||||||
|
):
|
||||||
|
jacobian_fallback_reason = "causalAlgebraicExecutionUnavailable"
|
||||||
|
elif int(jac_sparsity.nnz) >= state_count * state_count:
|
||||||
|
jacobian_fallback_reason = "denseStateDependencyPattern"
|
||||||
|
else:
|
||||||
|
exact_columns = None
|
||||||
|
if requested_jacobian_mode == "semi-analytic":
|
||||||
|
tangent_compilation = (
|
||||||
|
compile_supported_piston_tangent_provider(self)
|
||||||
|
)
|
||||||
|
if tangent_compilation.eligible:
|
||||||
|
provider = tangent_compilation.provider
|
||||||
|
if provider is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"An eligible tangent compilation has no provider."
|
||||||
|
)
|
||||||
|
selected_tangent_provider = provider
|
||||||
|
exact_columns = (
|
||||||
|
tangent_compilation.columns,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
jacobian_fallback_reason = (
|
||||||
|
f"semiAnalytic:{tangent_compilation.reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
requested_jacobian_mode != "semi-analytic"
|
||||||
|
or tangent_compilation is not None
|
||||||
|
and tangent_compilation.eligible
|
||||||
|
):
|
||||||
|
def evaluate_jacobian_rhs(time, state):
|
||||||
|
if cancel_check is not None and cancel_check():
|
||||||
|
raise IntegrationCancelled
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_rhs(float(time))
|
||||||
|
return monitored_rhs(
|
||||||
|
time,
|
||||||
|
[float(value) for value in state],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
jacobian = SparseSecantJacobian(
|
||||||
|
evaluate_jacobian_rhs,
|
||||||
|
jac_sparsity,
|
||||||
|
integration_config.atol,
|
||||||
|
exact_rows=self._exact_ode_jacobian_rows(),
|
||||||
|
exact_columns=exact_columns,
|
||||||
|
max_consecutive_reuses=(
|
||||||
|
1
|
||||||
|
if requested_jacobian_mode == "hybrid"
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._ode_tangent_provider = (
|
||||||
|
selected_tangent_provider
|
||||||
|
)
|
||||||
|
except SparseJacobianCompatibilityError as exc:
|
||||||
|
jacobian_fallback_reason = (
|
||||||
|
f"scipyCompatibility:{type(exc).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
def handle_state_transition(*args):
|
def handle_state_transition(*args):
|
||||||
transition = self.mechanical_state_reducer.state_transition(*args)
|
transition = self.mechanical_state_reducer.state_transition(*args)
|
||||||
if transition is not None:
|
if transition is not None:
|
||||||
self._request_causal_residual_audit()
|
self._request_causal_residual_audit()
|
||||||
return transition
|
return transition
|
||||||
|
|
||||||
|
try:
|
||||||
solution = integrate_ode(
|
solution = integrate_ode(
|
||||||
rhs=monitored_rhs,
|
rhs=monitored_rhs,
|
||||||
initial_state=initial_state,
|
initial_state=initial_state,
|
||||||
@@ -1178,7 +1412,12 @@ class GenericFluidSystem:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
jac_sparsity=jac_sparsity,
|
jac_sparsity=jac_sparsity,
|
||||||
|
jac=jacobian,
|
||||||
|
recoverable_trial_retries=True,
|
||||||
|
activity_tracker=activity_tracker,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
self._ode_tangent_provider = None
|
||||||
if isinstance(solution, ODESolution):
|
if isinstance(solution, ODESolution):
|
||||||
run_status: SimulationRunStatus = solution.status
|
run_status: SimulationRunStatus = solution.status
|
||||||
integration_error = solution.error
|
integration_error = solution.error
|
||||||
@@ -1192,6 +1431,11 @@ class GenericFluidSystem:
|
|||||||
else max(0.0, last_reported_progress)
|
else max(0.0, last_reported_progress)
|
||||||
)
|
)
|
||||||
report_progress(postprocess_progress, "postprocessing", force=True)
|
report_progress(postprocess_progress, "postprocessing", force=True)
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
"postprocessing",
|
||||||
|
furthest_solver_time,
|
||||||
|
)
|
||||||
times = [float(value) for value in solution.t]
|
times = [float(value) for value in solution.t]
|
||||||
if isinstance(solution, ODESolution):
|
if isinstance(solution, ODESolution):
|
||||||
solver_segment_diagnostics = [
|
solver_segment_diagnostics = [
|
||||||
@@ -1212,6 +1456,44 @@ class GenericFluidSystem:
|
|||||||
"recoverableRetryCount": 0,
|
"recoverableRetryCount": 0,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
if jacobian is not None:
|
||||||
|
direct_jacobian = jacobian.diagnostics()
|
||||||
|
solver_segment_diagnostics[0].update(
|
||||||
|
{
|
||||||
|
"jacobianEvaluationCount": int(
|
||||||
|
direct_jacobian["jacobianEvaluationCount"]
|
||||||
|
),
|
||||||
|
"jacobianFullBuildCount": int(
|
||||||
|
direct_jacobian["fullBuildCount"]
|
||||||
|
),
|
||||||
|
"jacobianSecantReuseCount": int(
|
||||||
|
direct_jacobian["secantReuseCount"]
|
||||||
|
),
|
||||||
|
"jacobianAuditFailureCount": int(
|
||||||
|
direct_jacobian["auditFailureCount"]
|
||||||
|
),
|
||||||
|
"finiteDifferenceRhsEvaluationCount": int(
|
||||||
|
direct_jacobian[
|
||||||
|
"finiteDifferenceRhsEvaluationCount"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"jacobianBaseRhsEvaluationCount": int(
|
||||||
|
direct_jacobian["baseRhsEvaluationCount"]
|
||||||
|
),
|
||||||
|
"jacobianJvAuditRhsEvaluationCount": int(
|
||||||
|
direct_jacobian["jvAuditEvaluationCount"]
|
||||||
|
),
|
||||||
|
"exactColumnBuildCount": int(
|
||||||
|
direct_jacobian["exactColumnBuildCount"]
|
||||||
|
),
|
||||||
|
"exactColumnFallbackCount": int(
|
||||||
|
direct_jacobian["exactColumnFallbackCount"]
|
||||||
|
),
|
||||||
|
"jacobianAssemblySeconds": float(
|
||||||
|
direct_jacobian["assemblySeconds"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
solver_total_keys = (
|
solver_total_keys = (
|
||||||
"nfev",
|
"nfev",
|
||||||
"njev",
|
"njev",
|
||||||
@@ -1225,21 +1507,123 @@ class GenericFluidSystem:
|
|||||||
key: sum(int(segment[key]) for segment in solver_segment_diagnostics)
|
key: sum(int(segment[key]) for segment in solver_segment_diagnostics)
|
||||||
for key in solver_total_keys
|
for key in solver_total_keys
|
||||||
}
|
}
|
||||||
|
jacobian_work_keys = (
|
||||||
|
"jacobianEvaluationCount",
|
||||||
|
"jacobianFullBuildCount",
|
||||||
|
"jacobianSecantReuseCount",
|
||||||
|
"jacobianAuditFailureCount",
|
||||||
|
"finiteDifferenceRhsEvaluationCount",
|
||||||
|
"jacobianBaseRhsEvaluationCount",
|
||||||
|
"jacobianJvAuditRhsEvaluationCount",
|
||||||
|
"exactColumnBuildCount",
|
||||||
|
"exactColumnFallbackCount",
|
||||||
|
"jacobianAssemblySeconds",
|
||||||
|
)
|
||||||
|
for key in jacobian_work_keys:
|
||||||
|
if any(key in segment for segment in solver_segment_diagnostics):
|
||||||
|
solver_totals[key] = sum(
|
||||||
|
segment.get(key, 0)
|
||||||
|
for segment in solver_segment_diagnostics
|
||||||
|
)
|
||||||
jacobian_diagnostics = (
|
jacobian_diagnostics = (
|
||||||
self.jacobian_sparsity_diagnostics()
|
self.jacobian_sparsity_diagnostics()
|
||||||
if integration_config.method in {"BDF", "Radau"}
|
if integration_config.method in {"BDF", "Radau"}
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
runtime_jacobian_diagnostics: dict[str, object] | None = None
|
||||||
if jacobian_diagnostics is not None:
|
if jacobian_diagnostics is not None:
|
||||||
color_group_count = int(jacobian_diagnostics["colorGroupCount"])
|
color_group_count = int(jacobian_diagnostics["colorGroupCount"])
|
||||||
|
if jacobian is None:
|
||||||
for segment in solver_segment_diagnostics:
|
for segment in solver_segment_diagnostics:
|
||||||
segment["finiteDifferenceRhsEstimate"] = (
|
segment["finiteDifferenceRhsEstimate"] = (
|
||||||
int(segment["njev"]) * color_group_count
|
int(segment["njev"]) * color_group_count
|
||||||
)
|
)
|
||||||
|
runtime_jacobian_diagnostics = {
|
||||||
|
"mode": "scipySparseFiniteDifference",
|
||||||
|
"fallbackReason": jacobian_fallback_reason,
|
||||||
|
"jacobianEvaluationCount": int(solver_totals["njev"]),
|
||||||
|
"fullBuildCount": int(solver_totals["njev"]),
|
||||||
|
"finiteDifferenceRhsEstimateIsExact": False,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
for segment in solver_segment_diagnostics:
|
||||||
|
segment["finiteDifferenceRhsEstimate"] = int(
|
||||||
|
segment.get("finiteDifferenceRhsEvaluationCount", 0)
|
||||||
|
) + int(
|
||||||
|
segment.get("jacobianJvAuditRhsEvaluationCount", 0)
|
||||||
|
)
|
||||||
|
runtime_jacobian_diagnostics = dict(jacobian.diagnostics())
|
||||||
|
runtime_jacobian_diagnostics.update(
|
||||||
|
{
|
||||||
|
"fallbackReason": jacobian_fallback_reason,
|
||||||
|
"finiteDifferenceRhsEstimateIsExact": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if tangent_compilation is not None:
|
||||||
|
runtime_jacobian_diagnostics["tangentCompilation"] = (
|
||||||
|
tangent_compilation.diagnostics()
|
||||||
|
)
|
||||||
|
if tangent_compilation.eligible and jacobian is not None:
|
||||||
|
runtime_jacobian_diagnostics["mode"] = (
|
||||||
|
"semiAnalyticExactColumns"
|
||||||
|
)
|
||||||
|
exact_builds = int(
|
||||||
|
runtime_jacobian_diagnostics[
|
||||||
|
"exactColumnBuildCount"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
exact_fallbacks = int(
|
||||||
|
runtime_jacobian_diagnostics[
|
||||||
|
"exactColumnFallbackCount"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if exact_builds == 0 and exact_fallbacks == 0:
|
||||||
|
effective_mode = "notEvaluated"
|
||||||
|
elif exact_builds == 0:
|
||||||
|
effective_mode = "numericalFallbackOnly"
|
||||||
|
elif exact_fallbacks:
|
||||||
|
effective_mode = "mixedExactAndNumericalFallback"
|
||||||
|
else:
|
||||||
|
effective_mode = "exactColumns"
|
||||||
|
runtime_jacobian_diagnostics["effectiveMode"] = (
|
||||||
|
effective_mode
|
||||||
|
)
|
||||||
|
if exact_fallbacks:
|
||||||
|
runtime_jacobian_diagnostics[
|
||||||
|
"runtimeFallbackReason"
|
||||||
|
] = runtime_jacobian_diagnostics[
|
||||||
|
"lastExactColumnFallbackReason"
|
||||||
|
]
|
||||||
solver_totals["finiteDifferenceRhsEstimate"] = sum(
|
solver_totals["finiteDifferenceRhsEstimate"] = sum(
|
||||||
int(segment["finiteDifferenceRhsEstimate"])
|
int(segment["finiteDifferenceRhsEstimate"])
|
||||||
for segment in solver_segment_diagnostics
|
for segment in solver_segment_diagnostics
|
||||||
)
|
)
|
||||||
|
if jacobian is None:
|
||||||
|
solver_totals["jacobianRhsEvaluationCountEstimate"] = (
|
||||||
|
int(solver_totals["finiteDifferenceRhsEstimate"])
|
||||||
|
+ int(solver_totals["njev"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
solver_totals["jacobianRhsEvaluationCount"] = (
|
||||||
|
int(
|
||||||
|
solver_totals.get(
|
||||||
|
"finiteDifferenceRhsEvaluationCount",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ int(
|
||||||
|
solver_totals.get(
|
||||||
|
"jacobianBaseRhsEvaluationCount",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ int(
|
||||||
|
solver_totals.get(
|
||||||
|
"jacobianJvAuditRhsEvaluationCount",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
with performance_span("simulation.postprocessing"):
|
with performance_span("simulation.postprocessing"):
|
||||||
series: dict[str, list[float]] = {"time": []}
|
series: dict[str, list[float]] = {"time": []}
|
||||||
@@ -1265,6 +1649,11 @@ class GenericFluidSystem:
|
|||||||
self._close_current_state(times[time_index])
|
self._close_current_state(times[time_index])
|
||||||
self._append_current_state(series)
|
self._append_current_state(series)
|
||||||
series["time"].append(times[time_index])
|
series["time"].append(times[time_index])
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
"postprocessing",
|
||||||
|
times[time_index],
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
run_status = "failed"
|
run_status = "failed"
|
||||||
result_message = str(exc)
|
result_message = str(exc)
|
||||||
@@ -1285,7 +1674,16 @@ class GenericFluidSystem:
|
|||||||
diagnostics = {
|
diagnostics = {
|
||||||
"integration": {
|
"integration": {
|
||||||
"method": integration_config.method,
|
"method": integration_config.method,
|
||||||
|
"mechanicalAbsoluteTolerance": (
|
||||||
|
mechanical_tolerance_plan.as_dict()
|
||||||
|
if mechanical_tolerance_plan is not None
|
||||||
|
else {
|
||||||
|
"mode": "callerVector",
|
||||||
|
"stateCount": len(initial_state),
|
||||||
|
}
|
||||||
|
),
|
||||||
"jacobianSparsity": jacobian_diagnostics,
|
"jacobianSparsity": jacobian_diagnostics,
|
||||||
|
"jacobian": runtime_jacobian_diagnostics,
|
||||||
"segmentCount": len(solver_segment_diagnostics),
|
"segmentCount": len(solver_segment_diagnostics),
|
||||||
"segments": solver_segment_diagnostics,
|
"segments": solver_segment_diagnostics,
|
||||||
"totals": solver_totals,
|
"totals": solver_totals,
|
||||||
@@ -1356,6 +1754,12 @@ class GenericFluidSystem:
|
|||||||
"stream": {
|
"stream": {
|
||||||
"maxIterationsPerSolve": self.max_stream_iterations,
|
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||||
"maxThermofluidIterations": self.max_thermofluid_iterations,
|
"maxThermofluidIterations": self.max_thermofluid_iterations,
|
||||||
|
"thermofluidClosure": {
|
||||||
|
**self._thermofluid_closure_diagnostics.as_dict(),
|
||||||
|
"transaction": (
|
||||||
|
self._thermofluid_transaction_plan.diagnostics()
|
||||||
|
),
|
||||||
|
},
|
||||||
"last": (
|
"last": (
|
||||||
self.stream_resolver.last_diagnostics.as_dict()
|
self.stream_resolver.last_diagnostics.as_dict()
|
||||||
if self.stream_resolver.last_diagnostics is not None
|
if self.stream_resolver.last_diagnostics is not None
|
||||||
@@ -1387,6 +1791,19 @@ class GenericFluidSystem:
|
|||||||
for variable in self.network.result_variable_metadata()
|
for variable in self.network.result_variable_metadata()
|
||||||
if variable.key in series
|
if variable.key in series
|
||||||
)
|
)
|
||||||
|
final_simulated_time = (
|
||||||
|
float(series["time"][-1])
|
||||||
|
if series["time"]
|
||||||
|
else float(config.t_start)
|
||||||
|
)
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
"complete" if run_status == "completed" else run_status,
|
||||||
|
final_simulated_time,
|
||||||
|
)
|
||||||
|
diagnostics["activity"] = (
|
||||||
|
activity_tracker.snapshot().as_dict()
|
||||||
|
)
|
||||||
report_progress(
|
report_progress(
|
||||||
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||||
"complete" if run_status == "completed" else run_status,
|
"complete" if run_status == "completed" else run_status,
|
||||||
@@ -1397,9 +1814,7 @@ class GenericFluidSystem:
|
|||||||
status=run_status,
|
status=run_status,
|
||||||
message=result_message,
|
message=result_message,
|
||||||
simulated_until=(
|
simulated_until=(
|
||||||
float(series["time"][-1])
|
final_simulated_time
|
||||||
if series["time"]
|
|
||||||
else float(config.t_start)
|
|
||||||
),
|
),
|
||||||
requested_stop_time=float(config.t_stop),
|
requested_stop_time=float(config.t_stop),
|
||||||
variables=variables,
|
variables=variables,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
|
||||||
|
title SystemSimulationApp Launcher
|
||||||
|
|
||||||
|
set "BACKEND_SCRIPT=%~dp0start-backend.bat"
|
||||||
|
set "FRONTEND_SCRIPT=%~dp0start-reactflow.bat"
|
||||||
|
|
||||||
|
if not exist "%BACKEND_SCRIPT%" (
|
||||||
|
echo [ERROR] start-backend.bat was not found:
|
||||||
|
echo %BACKEND_SCRIPT%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%FRONTEND_SCRIPT%" (
|
||||||
|
echo [ERROR] start-reactflow.bat was not found:
|
||||||
|
echo %FRONTEND_SCRIPT%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting FastAPI and ReactFlow in separate windows...
|
||||||
|
|
||||||
|
set "LAUNCH_ERROR=0"
|
||||||
|
ver >nul
|
||||||
|
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%BACKEND_SCRIPT%"
|
||||||
|
if errorlevel 1 set "LAUNCH_ERROR=1"
|
||||||
|
ver >nul
|
||||||
|
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%FRONTEND_SCRIPT%"
|
||||||
|
if errorlevel 1 set "LAUNCH_ERROR=1"
|
||||||
|
|
||||||
|
if "%LAUNCH_ERROR%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] One or more service windows could not be created.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
exit /b 0
|
||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
|
||||||
|
echo "[ERROR] start-all.sh requires Bash 4.3 or newer." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
BACKEND_SCRIPT="$SCRIPT_DIR/start-backend.sh"
|
||||||
|
FRONTEND_SCRIPT="$SCRIPT_DIR/start-reactflow.sh"
|
||||||
|
|
||||||
|
if [[ ! -x "$BACKEND_SCRIPT" ]]; then
|
||||||
|
echo "[ERROR] Backend start script is missing or not executable:" >&2
|
||||||
|
echo " $BACKEND_SCRIPT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$FRONTEND_SCRIPT" ]]; then
|
||||||
|
echo "[ERROR] Frontend start script is missing or not executable:" >&2
|
||||||
|
echo " $FRONTEND_SCRIPT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_PID=""
|
||||||
|
FRONTEND_PID=""
|
||||||
|
|
||||||
|
signal_process_group() {
|
||||||
|
local signal="$1"
|
||||||
|
local pid="$2"
|
||||||
|
|
||||||
|
[[ -n "$pid" ]] || return 0
|
||||||
|
kill "-$signal" -- "-$pid" 2>/dev/null || kill "-$signal" "$pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
process_group_is_running() {
|
||||||
|
local pid="$1"
|
||||||
|
|
||||||
|
[[ -n "$pid" ]] && kill -0 -- "-$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local backend_cleared=false
|
||||||
|
local force_kill=false
|
||||||
|
local frontend_cleared=false
|
||||||
|
local launcher_pid=$$
|
||||||
|
local timer_pid
|
||||||
|
|
||||||
|
trap '' INT TERM HUP
|
||||||
|
trap - EXIT
|
||||||
|
trap 'force_kill=true' ALRM
|
||||||
|
|
||||||
|
signal_process_group TERM "$BACKEND_PID"
|
||||||
|
signal_process_group TERM "$FRONTEND_PID"
|
||||||
|
|
||||||
|
(
|
||||||
|
sleep 5
|
||||||
|
kill -ALRM "$launcher_pid" 2>/dev/null || true
|
||||||
|
) &
|
||||||
|
timer_pid=$!
|
||||||
|
|
||||||
|
if [[ -n "$BACKEND_PID" ]]; then
|
||||||
|
wait "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$force_kill" == false && -n "$FRONTEND_PID" ]]; then
|
||||||
|
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ "$force_kill" == false ]]; do
|
||||||
|
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
|
||||||
|
backend_cleared=true
|
||||||
|
fi
|
||||||
|
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
|
||||||
|
frontend_cleared=true
|
||||||
|
fi
|
||||||
|
if [[ "$backend_cleared" == true && "$frontend_cleared" == true ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
kill -KILL "$timer_pid" 2>/dev/null || true
|
||||||
|
wait "$timer_pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ "$force_kill" == true ]]; then
|
||||||
|
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
|
||||||
|
backend_cleared=true
|
||||||
|
fi
|
||||||
|
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
|
||||||
|
frontend_cleared=true
|
||||||
|
fi
|
||||||
|
if [[ "$backend_cleared" == false ]]; then
|
||||||
|
signal_process_group KILL "$BACKEND_PID"
|
||||||
|
fi
|
||||||
|
if [[ "$frontend_cleared" == false ]]; then
|
||||||
|
signal_process_group KILL "$FRONTEND_PID"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$BACKEND_PID" ]]; then
|
||||||
|
wait "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -n "$FRONTEND_PID" ]]; then
|
||||||
|
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - ALRM
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
|
||||||
|
echo "Starting FastAPI and ReactFlow..."
|
||||||
|
echo "Press Ctrl+C to stop both services."
|
||||||
|
echo
|
||||||
|
|
||||||
|
set -m
|
||||||
|
"$BACKEND_SCRIPT" &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
"$FRONTEND_SCRIPT" &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
set +m
|
||||||
|
|
||||||
|
wait -n
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
exit "$EXIT_CODE"
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
@echo off
|
@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
|
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%" (
|
if not exist "%PYTHON_EXE%" (
|
||||||
echo [ERROR] Python virtual environment was not found:
|
echo [ERROR] Python virtual environment was not found:
|
||||||
echo %PYTHON_EXE%
|
echo %PYTHON_EXE%
|
||||||
echo.
|
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
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
@@ -27,4 +40,5 @@ if not "%EXIT_CODE%"=="0" (
|
|||||||
pause
|
pause
|
||||||
)
|
)
|
||||||
|
|
||||||
|
popd
|
||||||
exit /b %EXIT_CODE%
|
exit /b %EXIT_CODE%
|
||||||
Executable
+29
@@ -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
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions DisableDelayedExpansion
|
||||||
|
|
||||||
|
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
|
||||||
|
set "FRONTEND_DIR=%REPO_ROOT%\frontend"
|
||||||
|
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
|
||||||
|
|
||||||
|
if not exist "%FRONTEND_DIR%\package.json" (
|
||||||
|
echo [ERROR] Frontend package.json was not found:
|
||||||
|
echo %FRONTEND_DIR%\package.json
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "NODE_DIR="
|
||||||
|
for /d %%D in ("%REPO_ROOT%\.tools\node-*-win-x64") do (
|
||||||
|
if not defined NODE_DIR if exist "%%~fD\node.exe" if exist "%%~fD\npm.cmd" (
|
||||||
|
"%%~fD\node.exe" -e "v=process.versions.node.split('.');M=+v[0];m=+v[1];process.exit((M===20&&m>=19)||(M===22&&m>=12)||M>=23?0:1)" >nul 2>&1
|
||||||
|
if not errorlevel 1 set "NODE_DIR=%%~fD"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined NODE_DIR (
|
||||||
|
echo [ERROR] A compatible Node.js portable runtime was not found under:
|
||||||
|
echo %REPO_ROOT%\.tools
|
||||||
|
echo Vite requires Node.js 20.19+ or 22.12+.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "NPM_EXE=%NODE_DIR%\npm.cmd"
|
||||||
|
if not exist "%NPM_EXE%" (
|
||||||
|
echo [ERROR] npm.cmd was not found:
|
||||||
|
echo %NPM_EXE%
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%FRONTEND_DIR%\node_modules\.bin\vite.cmd" (
|
||||||
|
echo [ERROR] Frontend dependencies are not installed.
|
||||||
|
echo Run the following command first:
|
||||||
|
echo cd /d "%FRONTEND_DIR%"
|
||||||
|
echo call "%NPM_EXE%" ci
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
pushd "%FRONTEND_DIR%" >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Unable to enter the frontend directory:
|
||||||
|
echo %FRONTEND_DIR%
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PATH=%NODE_DIR%;%PATH%"
|
||||||
|
|
||||||
|
echo Starting ReactFlow at http://127.0.0.1:5173
|
||||||
|
echo Press Ctrl+C to stop the service.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
call "%NPM_EXE%" run dev -- --strictPort
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
popd
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
FRONTEND_DIR="$REPO_ROOT/frontend"
|
||||||
|
|
||||||
|
node_version_supported() {
|
||||||
|
local version="$1"
|
||||||
|
local major
|
||||||
|
local minor
|
||||||
|
|
||||||
|
[[ "$version" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1
|
||||||
|
major=$((10#${BASH_REMATCH[1]}))
|
||||||
|
minor=$((10#${BASH_REMATCH[2]}))
|
||||||
|
(( (major == 20 && minor >= 19) || (major == 22 && minor >= 12) || major >= 23 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -f "$FRONTEND_DIR/package.json" ]]; then
|
||||||
|
echo "[ERROR] Frontend package.json was not found:" >&2
|
||||||
|
echo " $FRONTEND_DIR/package.json" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_EXE=""
|
||||||
|
NPM_EXE=""
|
||||||
|
for candidate in "$REPO_ROOT"/.tools/node-*-linux-x64/bin; do
|
||||||
|
if [[ -x "$candidate/node" && -x "$candidate/npm" ]]; then
|
||||||
|
CANDIDATE_VERSION="$("$candidate/node" --version 2>/dev/null || true)"
|
||||||
|
if node_version_supported "$CANDIDATE_VERSION"; then
|
||||||
|
NODE_EXE="$candidate/node"
|
||||||
|
NPM_EXE="$candidate/npm"
|
||||||
|
PATH="$candidate:${PATH:-}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
if [[ -z "$NODE_EXE" ]]; then
|
||||||
|
NODE_EXE="$(command -v node || true)"
|
||||||
|
NPM_EXE="$(command -v npm || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$NODE_EXE" || -z "$NPM_EXE" ]]; then
|
||||||
|
echo "[ERROR] Node.js and npm were not found." >&2
|
||||||
|
echo "Install Node.js 20.19+ or 22.12+ and make node/npm available on PATH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_VERSION="$("$NODE_EXE" --version 2>/dev/null || true)"
|
||||||
|
if ! node_version_supported "$NODE_VERSION"; then
|
||||||
|
echo "[ERROR] Unsupported Node.js version: $NODE_VERSION" >&2
|
||||||
|
echo "Vite requires Node.js 20.19+ or 22.12+ (Node.js 21 is not supported)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$FRONTEND_DIR/node_modules/.bin/vite" ]]; then
|
||||||
|
echo "[ERROR] Frontend dependencies are not installed." >&2
|
||||||
|
echo "Run the following command first:" >&2
|
||||||
|
echo " cd \"$FRONTEND_DIR\" && \"$NPM_EXE\" ci" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
|
||||||
|
echo "Starting ReactFlow at http://127.0.0.1:5173"
|
||||||
|
echo "Press Ctrl+C to stop the service."
|
||||||
|
echo
|
||||||
|
|
||||||
|
exec "$NPM_EXE" run dev -- --strictPort
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
-50
@@ -1,65 +1,41 @@
|
|||||||
# 开发文档索引
|
# 文档目录说明
|
||||||
|
|
||||||
本目录是 SystemSimulationApp 协议和开发规范的统一入口。
|
本目录是 SystemSimulationApp 更新日志、现行标准和其他技术报告的统一入口。
|
||||||
|
|
||||||
跨 HTTP、组件目录、模型合同和 System XML 的版本边界统一见
|
## 目录职责
|
||||||
[后端接口版本与定义规范 v1](backend-interface-version-spec-v1.md)。
|
|
||||||
|
|
||||||
## 更新记录
|
| 目录 | 职责 |
|
||||||
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| [`update-log/`](update-log/) | 按日期保存每日更新日志,记录当天已经完成的上传、修改及其影响。 |
|
||||||
|
| [`standard/`](standard/) | 保存当前采用的标准、协议和开发规范。实现、评审和 AI 修改代码时,应优先以这里的文档为准。 |
|
||||||
|
| [`other/`](other/) | 保存不属于现行标准的其他文档,例如性能仿真报告、优化报告、调研记录和技术总结。这些文档主要用于分析和参考,不默认作为强制规范。 |
|
||||||
|
|
||||||
- [更新日志 2026-08-15](更新日志-2026-08-15.md)
|
新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
|
||||||
|
|
||||||
## 求解与性能
|
## 重点实施计划
|
||||||
|
|
||||||
- [后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
|
- [C 语言数值内核实施计划与可行性评估](other/C语言数值内核实施计划与可行性评估.md):后端数值内核的分阶段迁移顺序、验收门、风险和可行性评估。
|
||||||
- [仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)
|
|
||||||
|
|
||||||
## 模型开发
|
## `update-log` 书写规范
|
||||||
|
|
||||||
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 资料、氦气参数、索引映射和首版计算边界。
|
|
||||||
|
|
||||||
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
|
1. 按日期填写日志。每个文件只记录一天的更新,文件名使用 `更新日志-YYYY-MM-DD.md`,标题使用 `# 更新日志 YYYY-MM-DD`。
|
||||||
模型。不要从前端兜底数据反推后端物理契约。
|
2. 同一天内有多次上传或更新时,按各项工作的实际完成时间分段记录。每段使用北京时间、24 小时制的 `## HH:mm` 标注时间,并按完成时间从早到晚排列。
|
||||||
|
3. 不同时间完成的内容应分别记录,不要合并到同一时间段。一次上传或更新包含多项相关修改时,可以写在同一时间段内。
|
||||||
|
4. 语言应简短且信息充分,让 AI 和人都能快速理解。优先说明完成了什么、结果是什么、影响哪些范围,删除重复描述和无关过程。
|
||||||
|
5. 尽量使用通俗易懂的语言,减少难以理解的术语。必须使用专业术语时,应提供必要的简短说明。
|
||||||
|
|
||||||
## System XML
|
推荐格式:
|
||||||
|
|
||||||
- [System XML v3 协议(当前规范)](system-xml-v3.md)
|
```markdown
|
||||||
- [System XML v3 XSD(当前 Schema)](../schemas/system-simulation-v3.xsd)
|
# 更新日志 YYYY-MM-DD
|
||||||
|
|
||||||
新增模型时,模型类和组件库清单是后端事实来源;System XML v3 只保存求解所需的
|
## HH:mm
|
||||||
组件实例、模型版本、参数、连接和仿真设置。端口契约由注册模型恢复,画布位置、图标
|
|
||||||
方向等编辑信息只属于工程 JSON。XML 解析器不能自行创造模型端口或参数。
|
|
||||||
|
|
||||||
## 当前代码入口
|
- 完成的修改、结果及影响范围。
|
||||||
|
|
||||||
| 目的 | 文件 |
|
## HH:mm
|
||||||
| --- | --- |
|
|
||||||
| 组件基类 | [`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) |
|
|
||||||
| System XML v3 解析与语义校验 | [`app/system_xml.py`](../app/system_xml.py) |
|
|
||||||
| System XML v3 XSD | [`schemas/system-simulation-v3.xsd`](../schemas/system-simulation-v3.xsd) |
|
|
||||||
|
|
||||||
## AI 使用原则
|
- 完成的修改、结果及影响范围。
|
||||||
|
```
|
||||||
- 先读规范和相邻模型,再改代码。
|
|
||||||
- 只从 `library.py` 受控登记公开模型。
|
|
||||||
- 不在前端复制后端端口、参数或默认值作为正式来源。
|
|
||||||
- 不覆盖用户已有改动。
|
|
||||||
- 不自行猜测缺失的物理方程。
|
|
||||||
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
|
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
# C 语言数值内核实施计划与可行性评估
|
||||||
|
|
||||||
|
> 文档状态:待评审实施方案
|
||||||
|
> 建立日期:2026-09-02
|
||||||
|
> 适用分支:`model-development`
|
||||||
|
> 关联文档:[求解器性能优化任务清单](求解器性能优化任务清单.md)、[后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
|
||||||
|
> 范围:后端数值执行内核;不包含前端重写,也不主张把整个 Python 后端改写为 C
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
这条路线值得实施,但正确的目标不是“把现有 Python 代码逐行翻译成 C”,而是:
|
||||||
|
|
||||||
|
1. 保留 Python 作为模型解析、网络编译、任务管理、结果服务和正确性参考;
|
||||||
|
2. 先把完整的一次 RHS 计算编译成无 Python 回调的扁平数值 IR;
|
||||||
|
3. 再让 C 内核一次完成整次 RHS、stream、物性、Jacobian 和事件计算;
|
||||||
|
4. Python 旧引擎长期保留为不支持模型的兼容路径和故障回退路径。
|
||||||
|
|
||||||
|
总体技术可行性为**中高**。仓库已经具备因果计划、参考 IR、稀疏 Jacobian 结构、性能埋点和 AMESim 回归基础,因此不是从零开始。主要难点不是 C 语法,而是完整冻结当前模型的计算顺序、副作用、事件和回滚语义。
|
||||||
|
|
||||||
|
“实现稳定的 C 数值后端”具有较高可能性;“仅靠换成 C 就达到接近 AMESim 的速度”目前不能承诺。高刚度 LSTP 接触会让积分器产生大量纳秒级小步,有限差分 Jacobian 又会重复调用 RHS。C 能降低每次计算的成本,但不会自动减少这些计算次数。真正的大幅收益需要同时完成:
|
||||||
|
|
||||||
|
- 完整扁平 IR;
|
||||||
|
- 整体 C RHS;
|
||||||
|
- 稀疏解析或半解析 Jacobian;
|
||||||
|
- 正确的接触事件和模式切换;
|
||||||
|
- 必要时再评估原生积分器。
|
||||||
|
|
||||||
|
## 2. 推荐的最终边界
|
||||||
|
|
||||||
|
```text
|
||||||
|
XML / JSON
|
||||||
|
↓
|
||||||
|
Python:解析、校验、组件注册、网络编译
|
||||||
|
↓
|
||||||
|
完整扁平 IR:槽位、阶段、依赖、模式、事件、Jacobian 结构
|
||||||
|
↓ 一次跨语言调用完成整次计算
|
||||||
|
C 数值内核:RHS / stream / 物性 / Jacobian / Event
|
||||||
|
↓
|
||||||
|
SciPy BDF(首阶段保留)或后续通过验证的原生积分器
|
||||||
|
↓
|
||||||
|
Python:采样、结果编码、API、任务状态和诊断
|
||||||
|
```
|
||||||
|
|
||||||
|
以下边界必须坚持:
|
||||||
|
|
||||||
|
- 不在每个组件上来回调用 Python 和 C;一次 RHS 最多进行一次主要跨语言调用。
|
||||||
|
- C 兼容模型的热路径内不得调用 Python callback。
|
||||||
|
- C 内核不直接解析 XML,不管理 HTTP,也不承担组件编辑和数据库职责。
|
||||||
|
- 自定义 Python 组件不能静默降级为跨语言逐组件调用;只要整模型不满足原生能力合同,就明确走 Python 引擎。
|
||||||
|
- 迁移期间始终保留 `python`、`native`、`shadow` 三种模式;通过长时发布门后才能增加 `auto` 默认选择。
|
||||||
|
|
||||||
|
建议统一配置为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `shadow` 由 Python 控制正式积分,C 只接收相同输入并逐阶段比较,不允许影响 Python 工作区。
|
||||||
|
|
||||||
|
## 3. 当前基础与首个阻断
|
||||||
|
|
||||||
|
### 3.1 已有基础
|
||||||
|
|
||||||
|
- `causal_ir.py` 已有 schema v1、结构签名、预分配工作区、阶段观察和事务回滚原型。
|
||||||
|
- 当前因果执行器已经消去一部分重复逻辑坐标,并证明了数组化和预编译执行计划的方向有效。
|
||||||
|
- 已有 Jacobian 稀疏图、局部切向原语和显式实验回退路径。
|
||||||
|
- 已有 AMESim 结果读取、物理投影、递进时域 runner、性能统计、取消和故障注入测试。
|
||||||
|
- 当前主模型约有 156 个运行组件、20 种组件类型和 132 个连续状态,适合用“能力矩阵 + 分阶段覆盖”推进。
|
||||||
|
|
||||||
|
### 3.2 当前 IR 的缺口
|
||||||
|
|
||||||
|
现有 schema v1 只覆盖全局因果代数计划,绑定仍依赖 Python 的 reader、writer 和 evaluator 回调。以下内容尚未进入同一个无回调执行计划:
|
||||||
|
|
||||||
|
- 状态写入和信号传播;
|
||||||
|
- dynamic volume 和物性状态包;
|
||||||
|
- secondary 压力/流量块;
|
||||||
|
- stream SCC/DAG 和热流体外层闭合;
|
||||||
|
- 状态导数;
|
||||||
|
- 离散 mode、事件检测、reset 和重启;
|
||||||
|
- Jacobian 数值填充;
|
||||||
|
- 结果投影。
|
||||||
|
|
||||||
|
因此,当前 IR 不能直接包一层 C 接口后就获得预期收益。
|
||||||
|
|
||||||
|
### 3.3 P0 跨平台换行阻断(已解决)
|
||||||
|
|
||||||
|
当前 Windows 工作树中的权威输入按原始字节计算时与 manifest 不一致;进一步核对确认,差异全部来自 Git 检出后的 `CRLF` 换行,而不是模型内容变化:
|
||||||
|
|
||||||
|
| 文件 | Windows 工作树原始字节 | 将 `CRLF` 还原为 `LF` 后 | manifest 记录值 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `tests/data/test-mql-8.xml` | 106,188 B;2,078 个 `CRLF`;SHA-256 `e7eef641...b801` | 104,110 B;`0a2d9331...7b0b` | 104,110 B;`0a2d9331...7b0b` |
|
||||||
|
| `tests/data/test-mql-8.json` | 282,796 B;10,204 个 `CRLF`;SHA-256 `51e1acb3...11e2` | 272,592 B;`b44bf540...fbe0` | 272,592 B;`b44bf540...fbe0` |
|
||||||
|
| `AmesimModels/test_mql.ame` | 21,708,800 B;`cbc3aadd...c20fbb` | 不适用 | 一致 |
|
||||||
|
|
||||||
|
因此没有证据表明 fixture 语义发生漂移,也不应仅因这一差异重建 golden。真正的问题是:manifest 按原始字节锁定输入,而 `.gitattributes` 没有固定这两个文本 fixture 的换行;当前加载器又直接校验工作树原始字节,所以同一提交在 Windows 上可能被拒绝、在 Linux 上通过。
|
||||||
|
|
||||||
|
该阻断已于 2026-09-02 完成本地修复:`.gitattributes` 已将两个主模型输入、历史 0.81 s 输入和 `tests/baselines/simulation` 下的 JSON 证据固定为 `LF`,当前 Windows 工作树也已从 Git 索引重新检出为 `LF`。自动测试会同时检查属性规则、禁止原始 `CR` 字节,并按 manifest 复核输入大小和 SHA-256;CI 已配置为在 Ubuntu 和 Windows 上分别执行这一合同,远端运行证据待提交后取得。
|
||||||
|
|
||||||
|
本地验收显示这些文件均为 `index=LF / worktree=LF / eol=lf`,两个 regression manifest 可在 Windows 正常完整加载,现有 manifest 和 golden 无需更新。未来只有在规范化后的内容确实变化时,才进入“模型变更、重建 manifest/golden”的流程。历史性能数字仍需按其代码版本看待,但不因换行差异失效。
|
||||||
|
|
||||||
|
## 4. 按重要性排序的实施计划
|
||||||
|
|
||||||
|
优先级定义:
|
||||||
|
|
||||||
|
- `P0`:正确性和架构前置,不完成就不能安全编写生产 C 内核;
|
||||||
|
- `P1`:形成可用且有明显收益的 C 后端;
|
||||||
|
- `P2`:P1 证明有效后再实施的增强项。
|
||||||
|
|
||||||
|
表中的工期是单名熟悉现有求解器的开发者的粗略有效工作量,不是交付承诺,也不包含长时仿真排队时间。
|
||||||
|
|
||||||
|
| 顺序 | ID | 优先级 | 工作项 | 可行性 | 粗略工作量 | 主要依赖 |
|
||||||
|
| ---: | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | C-00 | P0 | 固定权威基准的跨平台合同,建立双引擎与回滚合同 | 高 | 1–2 人周 | 现有 OPT-00 |
|
||||||
|
| 2 | C-01 | P0 | 定义完整数值 IR schema v2 | 高 | 3–5 人周 | C-00、OPT-01/02 |
|
||||||
|
| 3 | C-02 | P0 | 建立纯数值组件 kernel 合同和能力矩阵 | 中高 | 3–6 人周 | C-01 |
|
||||||
|
| 4 | C-03 | P0 | 完成 Python 扁平参考执行器与 Shadow 差分 | 中高 | 3–5 人周 | C-01、C-02 |
|
||||||
|
| 5 | C-04 | P1 | 稳定 C ABI、构建链和隔离 worker | 高 | 3–5 人周 | C-01,可并行 |
|
||||||
|
| 6 | C-05 | P1 | 完成一个闭环代表子系统的 C 纵向切片 | 高 | 2–4 人周 | C-03、C-04 |
|
||||||
|
| 7 | C-06 | P1 | 扩展到完整内置 RHS、stream 和物性 | 中高 | 5–9 人周 | C-05、OPT-04 |
|
||||||
|
| 8 | C-07 | P1 | 稀疏解析/半解析 Jacobian 与局部回退 | 中 | 6–12 人周 | C-02、C-06、OPT-03 |
|
||||||
|
| 9 | C-08 | P1 | LSTP/MECMAS 事件、模式和默认灰度 | 中 | 4–8 人周 | C-06、C-07、OPT-05/06 |
|
||||||
|
| 10 | C-09 | P2 | 模型专用 C 代码生成和编译缓存 | 中 | 4–8 人周 | C-06~C-08 |
|
||||||
|
| 11 | C-10 | P2 | 评估 CVODE;有真实 DAE 需求时再评估 IDA | 中/当前低 | 4–10 人周 | C-06~C-08 |
|
||||||
|
| 12 | C-11 | P2 | 输出、部署、并发和发布收口 | 中高 | 3–6 人周 | 原生默认候选 |
|
||||||
|
|
||||||
|
### 4.1 P0:先固定语义和参考答案
|
||||||
|
|
||||||
|
#### C-00 固定权威基准、双引擎和回滚合同
|
||||||
|
|
||||||
|
工作内容:
|
||||||
|
|
||||||
|
- [x] 固定 `test-mql-8.xml/json`、历史输入和回归证据的跨平台 `LF` 字节合同,实际重新规范化当前 Windows 工作树,并用 Git EOL 状态、原始 SHA-256 和双平台 CI 保护该合同;未重建语义未变的 golden。
|
||||||
|
- 重新加载 manifest 并执行 Python `0.01 s` smoke;只有发现规范化后的内容或物理结果确实变化时,才重新生成结构清单和 golden。
|
||||||
|
- 冻结当前 Python 引擎为参考实现,定义 `python/native/shadow/auto` 的启用条件。
|
||||||
|
- 原生不支持的模型只允许在编译或加载阶段整模型回退;正式验收时禁止静默回退。
|
||||||
|
- 每个阶段使用独立小提交,记录输入哈希、环境、二进制哈希和回滚开关。
|
||||||
|
|
||||||
|
完成标准:相同输入能够稳定强制走 Python;关闭原生后行为与当前参考提交一致;模型、环境和结果来源都能追溯。
|
||||||
|
|
||||||
|
#### C-01 完整数值 IR schema v2
|
||||||
|
|
||||||
|
完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
state scatter
|
||||||
|
→ signal
|
||||||
|
→ mechanical equivalence / dynamic volume
|
||||||
|
→ property bundle
|
||||||
|
→ first pressure-flow closure
|
||||||
|
→ stream SCC/DAG
|
||||||
|
→ temperature reference update
|
||||||
|
→ sensitive-island closure / thermofluid fixed point
|
||||||
|
→ mechanical acceleration
|
||||||
|
```
|
||||||
|
|
||||||
|
四类入口分别编译自己的依赖切片:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eval_rhs = required primal stages → derivative
|
||||||
|
eval_events = required primal stages → event values
|
||||||
|
eval_jacobian = RHS primal / local derivative or JVP → CSR values
|
||||||
|
eval_outputs = required primal stages → output projection
|
||||||
|
```
|
||||||
|
|
||||||
|
IR 至少描述:
|
||||||
|
|
||||||
|
- 连续状态、代数坐标、参数、常量、离散模式和工作区槽位;
|
||||||
|
- 每个阶段和 opcode 的读写集合、单位、缩放、上下界和错误来源;
|
||||||
|
- stream 图、SCC、物性状态包、外层固定点和事务恢复集合;
|
||||||
|
- event ID、左/右状态、reset、Jacobian 失效和模式计划;
|
||||||
|
- 固定的 CSR Jacobian 结构和 output projection;
|
||||||
|
- IR schema、native ABI、组件模型/实现、介质、编译器、dtype、平台和构建选项组成的结构签名。
|
||||||
|
|
||||||
|
原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。
|
||||||
|
|
||||||
|
完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;缺少能力时明确拒绝编译。
|
||||||
|
|
||||||
|
#### C-02 纯数值组件合同
|
||||||
|
|
||||||
|
每个内置组件需声明:
|
||||||
|
|
||||||
|
- `kernel_id` 和实现版本;
|
||||||
|
- 输入、输出、参数、状态和 mode 槽位;
|
||||||
|
- primal、局部导数/JVP、event 和 reset 能力;
|
||||||
|
- 支持的正反流、接触和物性范围;
|
||||||
|
- 类型化错误码和局部数值差分回退边界。
|
||||||
|
|
||||||
|
组件 kernel 必须满足“相同输入得到相同输出”,不能依赖隐藏的 Python 对象写入。旧组件类继续作为参考实现。
|
||||||
|
|
||||||
|
首批纵向切片建议选择一条完整的 `MECMAS21 → PNRP17 → PNCH012 → PNL0001/LSTP00A` 支路及其介质/stream 闭合,因为它同时覆盖机械、气动、物性、流量和高刚度接触。
|
||||||
|
|
||||||
|
#### C-03 Python 扁平参考执行器
|
||||||
|
|
||||||
|
先用 Python/NumPy 完整执行 schema v2,目标是验证语义,而不是追求最终速度。它应摆脱 `PortState` 作为主语义载体,能在每个阶段与当前对象引擎比较首个差异。
|
||||||
|
|
||||||
|
完成标准:代表模型在普通点、反向流点、事件左右和 Jacobian 扰动点逐槽一致;`0.01/0.2/0.81/2.10 s` 的状态、事件、守恒和物理投影满足现有合同。
|
||||||
|
|
||||||
|
### 4.2 P1:形成真正有价值的 C 后端
|
||||||
|
|
||||||
|
#### C-04 稳定 C ABI、构建链和隔离 worker
|
||||||
|
|
||||||
|
底层使用稳定纯 C ABI,Python 绑定保持很薄。最小接口应包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
model_create
|
||||||
|
eval_rhs
|
||||||
|
eval_jacobian
|
||||||
|
eval_events
|
||||||
|
apply_event
|
||||||
|
eval_outputs
|
||||||
|
model_destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
接口使用不透明 `ModelContext`、固定宽度整数、连续 `float64` 数组和显式长度;CSR 结构在编译时固定,运行时只填 values。每个任务独占可变 context/workspace/cache/mode,不使用可变全局缓存,热路径预分配且不进行常规堆分配。
|
||||||
|
|
||||||
|
首阶段仍由 SciPy BDF 积分,只替换整个 RHS/Jacobian 计算。构建链需覆盖 Windows x64 和 Linux x86_64,固定构建依赖,并禁止 `/fp:fast`、`-ffast-math`、`-march=native` 等会改变事件边界或破坏可移植性的默认选项。
|
||||||
|
|
||||||
|
原生访问违规、崩溃或死循环不能在同一 Python 进程中安全恢复。因此原生默认启用前,仿真必须运行在可硬终止的隔离 worker 中;C 同时接收取消标志并在组件阶段、stream SCC、Jacobian 和迭代处有界检查。
|
||||||
|
|
||||||
|
#### C-05 闭环纵向切片和首次 Go/No-Go
|
||||||
|
|
||||||
|
一次跨语言调用应完成代表子系统的整次 RHS,禁止按组件往返。先为该闭环子系统建立独立代表 fixture,执行 Shadow 和短时积分,不立即扩大组件覆盖。由于此阶段尚未覆盖主模型的全部组件,`test-mql-8` 整模型按能力合同回退 Python 是预期行为,不能拿它衡量 C-05 的端到端收益。
|
||||||
|
|
||||||
|
继续扩面的最低门槛:
|
||||||
|
|
||||||
|
- RHS 微基准至少达到 Python 的 `2×`;目标值为 `3×` 以上;
|
||||||
|
- 独立代表 fixture 的短时完整仿真中位时间至少降低 25%;
|
||||||
|
- 代表 fixture 的状态、事件、模式和守恒门全部通过;存在对应 AMESim 投影时也必须通过;
|
||||||
|
- `nfev/njev/nlu` 和接受步等工作量没有无法解释的变化;
|
||||||
|
- 无 Python callback、无静默回退、无内存错误。
|
||||||
|
|
||||||
|
如果 RHS 已快很多而完整仿真几乎不变,应先检查 Jacobian、微步数和跨语言边界,而不是直接继续扩大 C 代码。
|
||||||
|
|
||||||
|
#### C-06 完整内置 RHS、stream 和物性
|
||||||
|
|
||||||
|
- 把所有受支持的内置组件纳入能力矩阵;一个组件不支持时整模型明确走 Python。
|
||||||
|
- 将 stream 图编译成 SCC 和缩点 DAG:无环部分一次传播,只在循环 SCC 中迭代。
|
||||||
|
- 将 `p/T/rho/u/h` 及其导数组成同一物性状态包,按精确输入和阶段统一复用。
|
||||||
|
- 移除每轮临时字典/列表,使用预分配数组和原地误差统计。
|
||||||
|
- 保留试探状态事务回滚、反向流、温度参考更新和类型化可恢复失败语义。
|
||||||
|
|
||||||
|
完成标准:主目标使用的全部内置组件可原生执行;stream 迭代、压力流量残差和物性结果不恶化;跨平台字节合同修复后的同一权威输入通过短程与中程回归,且 `0.2 s` 完整仿真中位时间至少降低 30%。
|
||||||
|
|
||||||
|
#### C-07 稀疏解析/半解析 Jacobian
|
||||||
|
|
||||||
|
这是获得大幅端到端收益的关键步骤。当前历史报告中,有限差分 Jacobian 会贡献大量额外 RHS 调用;仅把 primal RHS 改成 C 仍会重复执行它。
|
||||||
|
|
||||||
|
- 组件局部导数沿完整 IR 传播,直接填充固定 CSR values。
|
||||||
|
- 不支持或非光滑点只对局部组件、状态列或 SCC 做数值差分,不能让一个局部问题恢复全网有限差分。
|
||||||
|
- 每次构建记录解析列、局部差分列、回退位置、JVP 审计和装配时间。
|
||||||
|
- LSTP 接触、流向切换、饱和及临界流动使用分段导数,并在边界上明确选择事件分段或局部回退。
|
||||||
|
|
||||||
|
完成标准:有限差分附加 RHS 至少减少 50%,相对完整 C RHS 阶段再降低至少 20% 总时间;事件顺序、模式和物理投影不变。
|
||||||
|
|
||||||
|
#### C-08 事件、模式和默认灰度
|
||||||
|
|
||||||
|
- 将 LSTP/MECMAS 的 event、mode、reset、Jacobian 失效和 solver restart 纳入正式 IR/C 合同。
|
||||||
|
- 对接触进入、保持、释放,上下端挡、回弹、同时事件、擦边事件和防抖分别测试。
|
||||||
|
- 按 `shadow → 显式 native → 模型签名白名单 → auto` 逐级启用。
|
||||||
|
- 只有当前权威 `10 s` 连续三次通过后,才允许受支持模型默认走 C。
|
||||||
|
|
||||||
|
需要特别注意:C-08 不只是把同一接触公式写成 C,还要减少因接触表达方式造成的不必要纳秒级微步。任何软化、容差或事件改动都属于数值算法变化,必须与纯执行优化分开提交和验收。
|
||||||
|
|
||||||
|
### 4.3 P2:证明 P1 有效后再做
|
||||||
|
|
||||||
|
#### C-09 模型专用 C 代码生成
|
||||||
|
|
||||||
|
通用 C IR 解释器稳定后,若 opcode 分派仍是明确热点,再为固定模型生成专用 C。XML 文本不得直接拼入源码;生成器只消费校验后的数值 IR。编译缓存键必须包含完整结构签名、编译器和 flags。
|
||||||
|
|
||||||
|
只有相对通用 C 执行器额外获得至少约 `1.5×` 的稳定收益,并且编译成本能在重复运行中摊销时才继续。否则保留通用执行器,放弃这一层复杂度。
|
||||||
|
|
||||||
|
#### C-10 原生积分器
|
||||||
|
|
||||||
|
- 当前系统是“RHS 内完成代数闭合”的半显式 ODE,先评估 CVODE,不直接切换为 DASSL/IDA。
|
||||||
|
- 只有 C RHS、Jacobian、事件、可恢复缩步、partial result、activity 和 cancel 合同稳定后,才对 CVODE 做独立 A/B。
|
||||||
|
- CVODE 在相同正确性门下额外降低至少 25% 总时间才值得替换 SciPy。
|
||||||
|
- IDA/DASSL 需要新的 `F(t,y,ydot)=0`、代数变量布局、一致初始化、指数和质量矩阵合同。只有真实模型证明无法稳健因果化或 ODE 化时再立项,当前不把它视为提速开关。
|
||||||
|
|
||||||
|
#### C-11 输出、部署和发布收口
|
||||||
|
|
||||||
|
吸收按需结果变量、列式输出、结果分块、编译缓存、并发限流、worker 硬终止和原生崩溃恢复。确保 native 崩溃只影响当前任务,服务仍能接收新任务。
|
||||||
|
|
||||||
|
## 5. 分阶段验证与 Go/No-Go 门
|
||||||
|
|
||||||
|
| 验证阶段 | 主要内容 | 通过条件 | 未通过时的处理 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| V0 | fixture、环境、Python 和 AMESim 基线 | 来源一致;Python 连续 3 次稳定;现有测试通过 | 基线仍漂移时停止 C 默认路径开发 |
|
||||||
|
| V1 | 完整 Python IR | 槽位、阶段、读写集合、事件和回滚差分通过 | 先修隐藏副作用和 IR 合同 |
|
||||||
|
| V2 | C 纵向切片 | 单组件/逐阶段通过;RHS 至少 `2×` | 未达门槛则检查 IR/FFI,必要时停止扩面 |
|
||||||
|
| V3 | 完整 C RHS + SciPy | `0.01/0.2 s` 通过;总时间至少降低 30%;工作量变化不超过可解释范围 | 不作为纯执行优化合入默认路径 |
|
||||||
|
| V4 | C Jacobian/Event | FD 附加 RHS 至少减少 50%;相对 V3 再降低 20%;事件/模式一致 | 保留局部差分,禁止整网静默回退 |
|
||||||
|
| V5 | 长时与鲁棒性 | `1/5/10 s` 递进;10 s 连续 3 次;无泄漏、无静默回退 | 任一短时前驱失败即停止后续长跑 |
|
||||||
|
| V6 | 默认启用 | Windows/Linux 发布通过;支持模型走 C,不支持模型明确走 Python;可一键关闭 | 保持显式 opt-in |
|
||||||
|
|
||||||
|
正式性能比较统一要求:预热 1 次、完整仿真至少测量 3 次并比较中位数、固定机器与电源模式,冷编译和热缓存分开报告。至少记录:
|
||||||
|
|
||||||
|
- 总时间、积分、RHS、闭合、stream、物性、Jacobian 和输出时间;
|
||||||
|
- `nfev/njev/nlu`、接受/拒绝步、solver 启动和事件次数;
|
||||||
|
- Jacobian 附加 RHS、局部数值差分和回退原因;
|
||||||
|
- Python/C 边界调用次数和耗时;
|
||||||
|
- 峰值 RSS、C 工作区、IR/ABI 版本、编译器、flags 和二进制哈希。
|
||||||
|
|
||||||
|
附加硬门:关闭 C 时 Python 路径额外开销不超过 2%;纯执行层替换若使 `nfev/njev/nlu` 或接受步数变化超过 2%,必须按数值语义变化单独调查,不能直接归为性能优化;峰值 RSS 不超过 Python 基线的 110%;10 s 中 C 固定工作区不随步数增长,只有结果数组可随采样数增长。
|
||||||
|
|
||||||
|
## 6. 正确性验证范围
|
||||||
|
|
||||||
|
### 6.1 Shadow 阶段观察点
|
||||||
|
|
||||||
|
两个引擎必须接收完全相同的 `time/state/mode/parameters`,按第 4.1 节的 RHS 阶段逐项比较。差异报告至少包含:阶段、槽位、组件、输入、Python 值、C 值、绝对/相对误差和当前模式。
|
||||||
|
|
||||||
|
- 槽位布局、模式码、事件 ID、执行顺序和迭代次数要求一致。
|
||||||
|
- 连续量使用“每种物理量绝对容差 + 相对容差”,不能用一个绝对容差覆盖压力、流量、温度和位移。
|
||||||
|
- NaN/Inf、压力越界、符号错误和模式不同立即失败。
|
||||||
|
- 压力流量最大缩放残差继续使用现有 `1e-7` 门。
|
||||||
|
- 事件点分别比较左极限、事件处理结果和右极限,不扩大容差掩盖不连续。
|
||||||
|
|
||||||
|
差分语料至少覆盖:初始状态、接受状态、Jacobian 扰动状态、固定随机种子扰动、压力近似相等、流量换向、接触启停、端挡释放、物性边界,以及历史 `0.69/0.81/2.05 s` 区域。
|
||||||
|
|
||||||
|
### 6.2 AMESim 物理门
|
||||||
|
|
||||||
|
Python golden 只用于发现实现漂移,AMESim 结果仍是外部物理基线。正式默认前,应在现有 physical-state-v2.1 基础上至少覆盖:
|
||||||
|
|
||||||
|
- 代表性气室压力、温度和质量;
|
||||||
|
- PNL 管路压力、流量及换向;
|
||||||
|
- PNRP 活塞力;
|
||||||
|
- MECMAS 位移、速度和模式;
|
||||||
|
- LSTP 间隙、接触力和接触切换;
|
||||||
|
- 每个主要支路至少一个代表量。
|
||||||
|
|
||||||
|
检查点至少包含 `0`、`0.04 s` 左右、`0.2`、`0.8 s` 左右、`1`、`2`、`5` 和 `10 s`。AMESim 未保存的内部守恒量继续执行独立绝对残差门。
|
||||||
|
|
||||||
|
### 6.3 错误、取消和并发
|
||||||
|
|
||||||
|
必须覆盖 C 返回非有限值、物性域错误、闭合不收敛、未知 opcode、ABI 不匹配、损坏二进制、失败后事务回滚、编译失败、并发 context 隔离、单任务取消、重复创建/销毁无内存增长和 native 崩溃后的服务存活。
|
||||||
|
|
||||||
|
回退分为三层:
|
||||||
|
|
||||||
|
1. **启动前自动选择**:编译或加载时发现不支持能力,整次任务在开始积分前明确选择 Python;这是正式模式唯一允许的自动换引擎路径。
|
||||||
|
2. **受控运行时错误**:native 已开始积分后返回错误时,当前 native 任务直接失败并保留诊断。灰度验证可以从不可变原始请求的 `tStart` 另起一个 Python 重放任务,但必须标为独立重放,不能算作 native 成功。没有完整保存 BDF 历史、事件上下文和已有输出前,禁止从某个 `(t,y,mode)` 假装无缝续跑。
|
||||||
|
3. **进程级故障**:访问违规或无法返回时,由父进程硬终止 worker,禁止在受损进程中继续;如需 Python 对照,同样从原始请求重新执行。
|
||||||
|
|
||||||
|
正式 C 验收要求异常 `fallbackCount=0`。声明过的局部 Jacobian 数值差分不是异常回退,但必须单独计数。
|
||||||
|
|
||||||
|
## 7. 对现有优化清单的取舍
|
||||||
|
|
||||||
|
现有优化清单不能整体放弃,应按新路线重组:
|
||||||
|
|
||||||
|
| 现有任务 | 决策 | 在新路线中的位置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OPT-00 基线与回归 | 保留并加强 | C-00 和全部发布门 |
|
||||||
|
| OPT-01 因果代数内核 | 冻结成果 | 作为 IR 编译输入和 Python 回退,不再继续零散微调 |
|
||||||
|
| OPT-02 扁平 IR | 升为主线 | C-01、C-03、C-05 |
|
||||||
|
| OPT-03 Jacobian | 升为主线 | C-07 |
|
||||||
|
| OPT-04 stream/物性 | 升为主线 | C-06 |
|
||||||
|
| OPT-05 步长、接触和鲁棒性 | 必须保留 | C-08;C 不能消除微步根因 |
|
||||||
|
| OPT-06 dense output | 部分前置、其余后置 | 事件合同进入 C-01/C-08,插值微调后置 |
|
||||||
|
| OPT-07 输出与内存 | 暂缓但不删除 | C-11 |
|
||||||
|
| OPT-08 取消与并发 | 必须保留并提前 | C-04/C-11;原生代码更需要进程隔离 |
|
||||||
|
| OPT-09 10 s 验收 | 必须保留 | V5/V6 发布门 |
|
||||||
|
| OPT-10 高指数 DAE | 有条件暂缓 | 只有真实 DAE 需求时进入 C-10 |
|
||||||
|
|
||||||
|
可以停止继续投入的方向:
|
||||||
|
|
||||||
|
- 在现有对象热路径上继续做零散字典和属性访问微优化;
|
||||||
|
- 继续叠加缺少统一失效合同的通用缓存;
|
||||||
|
- 优化目标模型中未触发的 `least_squares` 回退;
|
||||||
|
- 不断增加少量特例半解析列,而不先建立通用组件导数合同;
|
||||||
|
- 直接对当前对象图使用 Numba;
|
||||||
|
- 通过修改容差、软化物理或寻找“幸运 maxStep”伪装性能收益。
|
||||||
|
|
||||||
|
Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为长期生产依赖。若完整数组 RHS 在 Numba 原型中仍没有明显改善,应先修正 IR 和算法,而不是立即开始大规模 C 重写。
|
||||||
|
|
||||||
|
## 8. 可行性评估
|
||||||
|
|
||||||
|
| 目标 | 当前判断 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 验证框架、双引擎和基准恢复 | 高 | 现有 runner、golden、AMESim 读取和埋点可复用 |
|
||||||
|
| 完整 Python 扁平 IR | 中高 | 结构基础已有,主要工作是显式化隐藏副作用和事件语义 |
|
||||||
|
| 代表子系统通用 C RHS | 高 | 数值边界清楚,一次调用可避免 FFI 碎片化 |
|
||||||
|
| 当前全部内置组件的 C RHS | 中高 | 约 20 种类型可逐类迁移,但 stream/物性/模式较复杂 |
|
||||||
|
| 可维护的稀疏解析/半解析 Jacobian | 中 | 收益大,但非光滑接触、流向切换和导数覆盖难度最高 |
|
||||||
|
| 模型专用 C 代码生成 | 中 | 收益上限高,但构建、缓存、安全和诊断成本明显增加 |
|
||||||
|
| CVODE 替换 SciPy | 中,且后置 | 可能减少调度开销,但不会自动解决错误方程或接触微步 |
|
||||||
|
| IDA/DASSL 直接提速 | 当前低 | 目前缺少真正 DAE 的残差、一致初始化和质量矩阵合同 |
|
||||||
|
| 接近 AMESim 的总速度 | 中低、待实测 | 缺少同机 AMESim 墙钟基线,且当前主要瓶颈同时包含 Jacobian 和接触微步数 |
|
||||||
|
|
||||||
|
从项目节奏看,建议把目标分成三档;这里与第 4 节一样使用“人周”,多人并行时日历时间可短于人周总量:
|
||||||
|
|
||||||
|
1. **约 15–27 人周:技术决策闭环。** 完成 C-00~C-05,回答完整 IR 是否正确、C RHS 是否有足够收益。
|
||||||
|
2. **累计约 20–36 人周:可选原生后端。** 完成 C-06,使主要内置 RHS、stream/物性、双平台构建和受控回退可由用户显式启用。
|
||||||
|
3. **累计约 30–56 人周:默认候选。** 完成 C-07/C-08、10 s 长时、并发隔离和发布门。
|
||||||
|
|
||||||
|
模型专用代码生成和原生积分器属于额外阶段,不应计入首个可用 C 后端的承诺。多人并行可以缩短日历时间,但 IR、组件合同、Jacobian 和事件语义存在强依赖,不能按人数等比例压缩。
|
||||||
|
|
||||||
|
## 9. 建议立即开展的第一批工作
|
||||||
|
|
||||||
|
第一批只做 P0,不直接开始大规模 C 编码:
|
||||||
|
|
||||||
|
1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。
|
||||||
|
2. 生成当前模型的组件类型、槽位、阶段、副作用和事件能力矩阵。
|
||||||
|
3. 将 schema v2 写成独立规范,先冻结 RHS 阶段、错误、事务、事件和结构签名。
|
||||||
|
4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。
|
||||||
|
5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。
|
||||||
|
6. 评审通过后,再建立最小 C ABI 和纵向切片。
|
||||||
|
|
||||||
|
第一批的退出条件不是“已经写了多少 C”,而是:当前基准可信、同一计算能被无回调 IR 完整表达、差异能定位到具体阶段和槽位。只有达到这个条件,后续 C 工作才具有可预测的收益和可控的回滚成本。
|
||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system`
|
适用模型:`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,7 +38,7 @@
|
|||||||
| `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入名为 `res` 的标量信号输入端口,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 |
|
| `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入名为 `res` 的标量信号输入端口,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 |
|
||||||
| `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_pn3node2`,`junctions` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 |
|
| `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_pn3node2`,`junctions` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 |
|
||||||
| `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_p4node2`,`junctions` | 已接入四端等压、流量守恒基础版;仍需复核 port 2 参考温压和多支路混合。 |
|
| `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 多方语义。 |
|
| `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 多方语义。 |
|
| `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 误差仍留后续修模。 |
|
| `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 |
|
||||||
@@ -89,6 +89,6 @@
|
|||||||
## 下一步执行建议
|
## 下一步执行建议
|
||||||
|
|
||||||
1. 以 `app.simulation.components.amesim.library` 的 22 个模型为公开清单唯一来源。
|
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 编译和最小仿真测试。
|
3. 每次调整模型都同步补充目录校验、参数边界、System XML 编译和最小仿真测试。
|
||||||
4. 用完整或代表性的 `test_mql` 画布校准 `PNRP17 + PNCH012` 的压力、力、位移和容积轨迹,并补齐事件边界语义。
|
4. 用完整或代表性的 `test_mql` 画布校准 `PNRP17 + PNCH012` 的压力、力、位移和容积轨迹,并补齐事件边界语义。
|
||||||
File renamed without changes.
File renamed without changes.
@@ -263,11 +263,11 @@ signal、stream 和外部 volume resolver 已把静态组件列表、端口引
|
|||||||
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
|
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
|
||||||
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
|
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
|
||||||
| stream 容差/迭代 | `1e-9 / 100` | 焓传播固定点 |
|
| stream 容差/迭代 | `1e-9 / 100` | 焓传播固定点 |
|
||||||
| 采样数上限 | `10001` | 限制输出样本,不限制 RHS 次数或事件数 |
|
| 采样数上限 | 无固定业务上限 | 输出规模受运行时可表示范围和可用资源约束,不限制 RHS 次数或事件数 |
|
||||||
|
|
||||||
前端/Pydantic 默认值见 `frontend/src/App.tsx` 的仿真默认配置和 `app/main.py:131-137`;通用路径构造 `SolveIVPConfig` 见 `app/main.py:684-701`;采样网格见 `app/simulation/systems/generic.py:204-225`。
|
前端/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`,并在超过 10001 点时拒绝;它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。
|
**[已实现]** `sampleStep` 生成的采样网格会确保包含 `t_stop`,不再设置固定点数上限;仅在数值非有限、当前运行时无法表示点数或时间无法严格递增时预先拒绝。它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。
|
||||||
|
|
||||||
### 6.3 逐步推进分派
|
### 6.3 逐步推进分派
|
||||||
|
|
||||||
@@ -384,10 +384,10 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
|
|||||||
前端规则:
|
前端规则:
|
||||||
|
|
||||||
- 30 秒没有收到任何字节:连接超时;
|
- 30 秒没有收到任何字节:连接超时;
|
||||||
- 60 秒只收到心跳而没有真实积分进度:判定 stalled 并请求取消;
|
- 15 分钟只收到心跳而没有真实积分进度:判定 stalled 并请求取消;
|
||||||
- 正常运行不是轮询,轮询仅用于异常恢复。
|
- 正常运行不是轮询,轮询仅用于异常恢复。
|
||||||
|
|
||||||
**[发现]** 合法但单个已接受步/闭合超过 60 秒时,前端可能误判停滞。后端结果事件的 `phase` 使用 `completed/stopped/stalled/failed`,前端事件类型却声明 `"complete"`;运行时当前没有按该字段做严格校验,所以契约漂移尚未直接报错(`app/main.py:825-840`、`frontend/src/App.tsx` 的流式事件类型)。
|
**[已缓解]** 合法但单个已接受步/闭合超过原 60 秒阈值时,前端会误判停滞;浏览器警钟现延长为 15 分钟,30 秒断流检测保持不变。该警钟仍以“最后一次非心跳积分进度”为依据,尚未细分 RHS、Jacobian 和闭合活动。后端结果事件的 `phase` 使用 `completed/stopped/stalled/failed`,前端事件类型却声明 `"complete"`;运行时当前没有按该字段做严格校验,所以契约漂移尚未直接报错(`app/main.py:825-840`、`frontend/src/App.tsx` 的流式事件类型)。
|
||||||
|
|
||||||
### 9.2 开发和部署连接数
|
### 9.2 开发和部署连接数
|
||||||
|
|
||||||
@@ -406,8 +406,8 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
|
|||||||
|
|
||||||
### 10.1 进程与线程
|
### 10.1 进程与线程
|
||||||
|
|
||||||
- `start-all.bat` 分别启动 Vite 与 FastAPI(`start-all.bat:19-22`)。
|
- `bat/start-all.bat` 与 `bat/start-all.sh` 分别启动 Vite 与 FastAPI。
|
||||||
- `start-backend.bat` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker(`start-backend.bat:17-21`)。
|
- `bat/start-backend.bat` 与 `bat/start-backend.sh` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker。
|
||||||
- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。
|
- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。
|
||||||
- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。
|
- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。
|
||||||
- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。
|
- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。
|
||||||
@@ -424,7 +424,7 @@ O(组件 + 连接 + 代数结构)
|
|||||||
+ O(采样数 × 公开结果变量数)
|
+ O(采样数 × 公开结果变量数)
|
||||||
```
|
```
|
||||||
|
|
||||||
当前采样上限是 10001。放大因素包括:
|
当前不设置固定采样点数上限,调用方必须根据模型输出变量数和可用内存选择 `sampleStep`。放大因素包括:
|
||||||
|
|
||||||
- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在;
|
- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在;
|
||||||
- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行;
|
- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行;
|
||||||
@@ -593,5 +593,5 @@ O(组件 + 连接 + 代数结构)
|
|||||||
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
|
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
|
||||||
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
|
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
|
||||||
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
|
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
|
||||||
| 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 |
|
| 启动方式 | `bat/start-backend.bat`、`bat/start-backend.sh` | Uvicorn 单 worker 命令 |
|
||||||
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
|
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
| 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 |
|
| 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 |
|
||||||
| 5 | JSON/XML | `ReactFlowPortDefinition`、System XML v3 XSD | JSON 搬运画布和端口显示快照;XML 只搬运可执行模型,端口合同由注册表恢复 |
|
| 5 | JSON/XML | `ReactFlowPortDefinition`、System XML v3 XSD | JSON 搬运画布和端口显示快照;XML 只搬运可执行模型,端口合同由注册表恢复 |
|
||||||
|
|
||||||
**[约定]** `docs/README.md:28-29` 和组件建模规范都说明:组件模型类及受控库清单是后端事实来源。XML 或前端不能凭空创造一个模型没有声明的端口。
|
**[约定]** [组件模型建模规范 v1](../standard/component-model-authoring-spec-v1.md)说明:组件模型类及受控库清单是后端事实来源。XML 或前端不能凭空创造一个模型没有声明的端口。
|
||||||
|
|
||||||
## 2. 常见术语翻译表
|
## 2. 常见术语翻译表
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ amesim_step0.out ──> amesim_forc.res [力源] amesim_forc.port_2 ── 机
|
|||||||
|
|
||||||
### 5.3 System XML v3:交给后端的精简求解清单
|
### 5.3 System XML v3:交给后端的精简求解清单
|
||||||
|
|
||||||
XML v3 和工程 JSON 不再追求“保存同一份完整工程”。两者分工很明确:工程 JSON 保存怎样编辑和显示,XML v3 保存后端求解什么。当前结构由 `schemas/system-simulation-v3.xsd` 定义;完整规范见 `docs/system-xml-v3.md`。
|
XML v3 和工程 JSON 不再追求“保存同一份完整工程”。两者分工很明确:工程 JSON 保存怎样编辑和显示,XML v3 保存后端求解什么。当前结构由 `schemas/system-simulation-v3.xsd` 定义;完整规范见 `docs/standard/system-xml-v3.md`。
|
||||||
|
|
||||||
#### 5.3.1 生成出来的 XML 是什么结构
|
#### 5.3.1 生成出来的 XML 是什么结构
|
||||||
|
|
||||||
@@ -554,7 +554,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解
|
|||||||
|
|
||||||
### 7.6 不从旧格式推断当前行为
|
### 7.6 不从旧格式推断当前行为
|
||||||
|
|
||||||
当前格式只看 `docs/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。旧格式中的 `Port`、布局字段、端点 `role`、`Simulation/@step` 和“旋转改变力方向”都不能继续套用到 v3。
|
当前格式只看 `docs/standard/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。旧格式中的 `Port`、布局字段、端点 `role`、`Simulation/@step` 和“旋转改变力方向”都不能继续套用到 v3。
|
||||||
|
|
||||||
## 8. 当前模型覆盖范围
|
## 8. 当前模型覆盖范围
|
||||||
|
|
||||||
@@ -629,7 +629,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解
|
|||||||
| 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` |
|
| 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` |
|
||||||
| JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` |
|
| JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` |
|
||||||
| XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` |
|
| XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` |
|
||||||
| XML v3 当前格式 | `schemas/system-simulation-v3.xsd`、`docs/system-xml-v3.md` | `sampleStep`、`modelVersion`、`Parameter`、`Endpoint` |
|
| 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` |
|
| 目录 JSON 格式 | `schemas/component-catalog-v1.schema.json:52-160` | `$defs.portVariable`、`$defs.port` |
|
||||||
| 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` |
|
| 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` |
|
||||||
| 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` |
|
| 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` |
|
||||||
@@ -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)
|
||||||
+9
-1
@@ -45,6 +45,7 @@ FastAPI 自动生成的 OpenAPI 当前可能显示默认 `info.version=0.1.0`;
|
|||||||
| 组件库及分类 | 各库 `library.py` |
|
| 组件库及分类 | 各库 `library.py` |
|
||||||
| 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema |
|
| 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema |
|
||||||
| System XML | v3 XSD、`app/system_xml.py` |
|
| System XML | v3 XSD、`app/system_xml.py` |
|
||||||
|
| ReactFlow 参数表达式 | `app/parameter_expression.py`、`frontend/src/parameterExpression.ts` 及相应合同测试 |
|
||||||
| 网络最终连接检查 | `SimulationNetwork.connect()` |
|
| 网络最终连接检查 | `SimulationNetwork.connect()` |
|
||||||
| HTTP 路由和请求模型 | `app/main.py` |
|
| HTTP 路由和请求模型 | `app/main.py` |
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ System XML v3 是当前唯一支持的 XML 求解输入。根元素固定使用
|
|||||||
|
|
||||||
`modelVersion` 必须与当前注册模型完全一致。版本不一致时返回
|
`modelVersion` 必须与当前注册模型完全一致。版本不一致时返回
|
||||||
`COMPONENT_MODEL_VERSION_MISMATCH`,不会静默使用当前模型解释旧输入。完整结构见
|
`COMPONENT_MODEL_VERSION_MISMATCH`,不会静默使用当前模型解释旧输入。完整结构见
|
||||||
`docs/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。
|
`docs/standard/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。
|
||||||
|
|
||||||
## 6. HTTP API
|
## 6. HTTP API
|
||||||
|
|
||||||
@@ -151,6 +152,13 @@ OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、C
|
|||||||
- XML 和求解参数统一使用 SI 基准值;
|
- XML 和求解参数统一使用 SI 基准值;
|
||||||
- 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。
|
- 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。
|
||||||
|
|
||||||
|
ReactFlow 工程 JSON 的连续数值参数可保存前端既有的受限算术表达式。
|
||||||
|
编译或 JSON→XML 时,后端在内存中安全求值,再按 `parameterUnits` 从
|
||||||
|
显示单位换算为 SI。普通数值及数值字符串仍按已存储的 SI 值解释,避免
|
||||||
|
二次换算;原表达式不回写工程 JSON。离散选项参数和任意代码不属于该合同。
|
||||||
|
这是补齐已有工程 JSON v1 前端语义的兼容性修复,不改变 System XML v3:
|
||||||
|
XML 仍只保存最终 SI 数值。
|
||||||
|
|
||||||
System XML 校验问题统一包含:
|
System XML 校验问题统一包含:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -247,7 +247,7 @@ class ExampleComponent(Component):
|
|||||||
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
|
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
|
||||||
|
|
||||||
完整方程示例参见
|
完整方程示例参见
|
||||||
[`app/simulation/components/example.md`](../app/simulation/components/example.md)。
|
[`app/simulation/components/example.md`](../../app/simulation/components/example.md)。
|
||||||
|
|
||||||
## 7. 界面显示声明
|
## 7. 界面显示声明
|
||||||
|
|
||||||
+9
-9
@@ -47,7 +47,7 @@
|
|||||||
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
|
应放在对应 `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 新增物理域
|
### 2.4 新增物理域
|
||||||
|
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
1. 本文档。
|
1. 本文档。
|
||||||
2. 目标库的 `library.py`。
|
2. 目标库的 `library.py`。
|
||||||
3. 同分类中物理行为最接近的现有模型。
|
3. 同分类中物理行为最接近的现有模型。
|
||||||
4. [`core/base.py`](../app/simulation/core/base.py)。
|
4. [`core/base.py`](../../app/simulation/core/base.py)。
|
||||||
5. [`core/ports.py`](../app/simulation/core/ports.py)。
|
5. [`core/ports.py`](../../app/simulation/core/ports.py)。
|
||||||
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。
|
6. [`core/metadata.py`](../../app/simulation/core/metadata.py)。
|
||||||
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。
|
7. [`core/catalog.py`](../../app/simulation/core/catalog.py)。
|
||||||
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。
|
8. [`registry.py`](../../app/simulation/registry.py) 中的启动校验。
|
||||||
9. 与目标模型最接近的测试。
|
9. 与目标模型最接近的测试。
|
||||||
|
|
||||||
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
|
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
|
||||||
@@ -564,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. 注册模型
|
## 14. 注册模型
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。
|
System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。
|
||||||
|
|
||||||
机器可读结构见 [`schemas/system-simulation-v3.xsd`](../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。
|
机器可读结构见 [`schemas/system-simulation-v3.xsd`](../../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。
|
||||||
|
|
||||||
## 1. 设计边界
|
## 1. 设计边界
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ System
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tStart` | 仿真开始时刻 | 必须是有限数值 |
|
| `tStart` | 仿真开始时刻 | 必须是有限数值 |
|
||||||
| `tStop` | 仿真结束时刻 | 必须有限且大于 `tStart` |
|
| `tStop` | 仿真结束时刻 | 必须有限且大于 `tStart` |
|
||||||
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0,且整个区间最多生成 10001 个采样点 |
|
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0;不设置固定的采样点数上限 |
|
||||||
| `maxStep` | 自适应积分器单个内部步的上限 | 必须大于 0 |
|
| `maxStep` | 自适应积分器单个内部步的上限 | 必须大于 0 |
|
||||||
| `method` | 积分方法 | `RK45/RK23/DOP853/Radau/BDF/LSODA` |
|
| `method` | 积分方法 | `RK45/RK23/DOP853/Radau/BDF/LSODA` |
|
||||||
|
|
||||||
@@ -111,9 +111,10 @@ System
|
|||||||
- `maxStep` 限制求解器内部一次最多前进多久;
|
- `maxStep` 限制求解器内部一次最多前进多久;
|
||||||
- 自适应求解器可以因为误差、事件或试探状态失败而走得比 `maxStep` 更短。
|
- 自适应求解器可以因为误差、事件或试探状态失败而走得比 `maxStep` 更短。
|
||||||
|
|
||||||
采样点数量会在创建时间数组前计算。若区间长度不可表示为有限数、请求超过 10001
|
采样点数量会在创建时间数组前计算。若区间长度不可表示为有限数、点数超过当前
|
||||||
点,或在当前浮点精度下无法得到包含 `tStart/tStop` 的严格递增时间序列,输入会在
|
运行时可表示的集合大小,或在当前浮点精度下无法得到包含 `tStart/tStop` 的严格
|
||||||
仿真前被拒绝,不会把超大或重复的 `t_eval` 交给积分器。
|
递增时间序列,输入会在仿真前被拒绝。采样点不再受固定业务上限约束,但结果内存、
|
||||||
|
序列化体积和浏览器负载仍会随“采样点数 × 输出变量数”线性增长。
|
||||||
|
|
||||||
工程 JSON 为兼容现有前端仍把采样字段命名为 `simulation.step`;导出 v3 时必须映射为 `Simulation/@sampleStep`。
|
工程 JSON 为兼容现有前端仍把采样字段命名为 `simulation.step`;导出 v3 时必须映射为 `Simulation/@sampleStep`。
|
||||||
|
|
||||||
File renamed without changes.
@@ -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 生产构建通过,差异格式检查无异常。
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
+1042
-231
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,59 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
getSmoothStepPath,
|
useReactFlow,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
type EdgeTypes,
|
type EdgeTypes,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import {
|
||||||
|
createOrthogonalSegmentDetour,
|
||||||
|
edgeSegmentAxis,
|
||||||
|
moveOrthogonalCorner,
|
||||||
|
moveOrthogonalSegment,
|
||||||
|
orthogonalEdgePoints,
|
||||||
|
orthogonalPolylinePath,
|
||||||
|
type EdgeRouteData,
|
||||||
|
type EdgeRoutePoint,
|
||||||
|
} from "./edgeRouting";
|
||||||
|
|
||||||
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
||||||
|
|
||||||
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,
|
data,
|
||||||
id,
|
id,
|
||||||
interactionWidth,
|
interactionWidth,
|
||||||
markerEnd,
|
markerEnd,
|
||||||
markerStart,
|
markerStart,
|
||||||
|
selected,
|
||||||
sourcePosition,
|
sourcePosition,
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
@@ -21,21 +62,119 @@ export function ContactAwareEdge({
|
|||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const isContactEdge = data?.isContactEdge === true;
|
const reactFlow = useReactFlow();
|
||||||
if (isContactEdge) {
|
const dragRef = useRef<RouteDrag | null>(null);
|
||||||
return null;
|
const edgeData = (data ?? {}) as EdgeRouteData;
|
||||||
}
|
useEffect(
|
||||||
|
() => () => {
|
||||||
const [edgePath] = getSmoothStepPath({
|
dragRef.current?.cleanup();
|
||||||
|
dragRef.current = null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const points = orthogonalEdgePoints({
|
||||||
|
routePoints: edgeData.routePoints,
|
||||||
sourcePosition,
|
sourcePosition,
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<BaseEdge
|
<BaseEdge
|
||||||
id={id}
|
id={id}
|
||||||
interactionWidth={interactionWidth}
|
interactionWidth={interactionWidth}
|
||||||
@@ -44,9 +183,77 @@ export function ContactAwareEdge({
|
|||||||
path={edgePath}
|
path={edgePath}
|
||||||
style={style}
|
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 = {
|
export const contactAwareEdgeTypes: EdgeTypes = {
|
||||||
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
|
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
|
||||||
};
|
};
|
||||||
+1737
-224
File diff suppressed because it is too large.
Load diff
@@ -205,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() {
|
export function AmesimPnl0001Symbol() {
|
||||||
return (
|
return (
|
||||||
<SymbolSvg className="component-symbol-amesim-pnl0001-svg" domain="pneumatic">
|
<SymbolSvg className="component-symbol-amesim-pnl0001-svg" domain="pneumatic">
|
||||||
<PipeNetworkBody sections={["C", "R"]} />
|
<PipeNetworkBody sections={["R", "C"]} />
|
||||||
</SymbolSvg>
|
</SymbolSvg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
type PortSideSnapshot = {
|
||||||
|
name: string;
|
||||||
|
side: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PNVO001_MODEL_TYPES = new Set([
|
||||||
|
"amesim_pnvo001_fixed",
|
||||||
|
"amesim_pnvo001",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function portUsesSide(
|
||||||
|
ports: readonly PortSideSnapshot[],
|
||||||
|
portName: string,
|
||||||
|
side: string,
|
||||||
|
) {
|
||||||
|
return ports.some((port) => port.name === portName && port.side === side);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migratedPnvo001Mirrored(
|
||||||
|
modelType: string,
|
||||||
|
savedPorts: readonly PortSideSnapshot[],
|
||||||
|
currentPorts: readonly PortSideSnapshot[] | undefined,
|
||||||
|
mirrored: boolean,
|
||||||
|
) {
|
||||||
|
if (!PNVO001_MODEL_TYPES.has(modelType) || !currentPorts) {
|
||||||
|
return mirrored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedUsesLegacySides =
|
||||||
|
portUsesSide(savedPorts, "port_2", "left") &&
|
||||||
|
portUsesSide(savedPorts, "port_3", "right");
|
||||||
|
const catalogUsesAmesimSides =
|
||||||
|
portUsesSide(currentPorts, "port_2", "right") &&
|
||||||
|
portUsesSide(currentPorts, "port_3", "left");
|
||||||
|
|
||||||
|
return savedUsesLegacySides && catalogUsesAmesimSides
|
||||||
|
? !mirrored
|
||||||
|
: mirrored;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
export type StateTransitionDiagnostics = {
|
||||||
|
integration?: {
|
||||||
|
segments?: Array<{
|
||||||
|
stateTransitionTimes?: number[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventSeriesVariable = {
|
||||||
|
scope: "component" | "port";
|
||||||
|
portName: string | null;
|
||||||
|
quantity: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventSeriesComponent = {
|
||||||
|
data: {
|
||||||
|
ports: Array<{
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ISOLATED_EVENT_FORCE_RATIO = 100;
|
||||||
|
|
||||||
|
export function resultStateTransitionSampleIndices(
|
||||||
|
time: number[],
|
||||||
|
diagnostics: StateTransitionDiagnostics,
|
||||||
|
) {
|
||||||
|
const indices = new Set<number>();
|
||||||
|
for (const segment of diagnostics.integration?.segments ?? []) {
|
||||||
|
for (const eventTime of segment.stateTransitionTimes ?? []) {
|
||||||
|
if (!Number.isFinite(eventTime)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let index = time.indexOf(eventTime);
|
||||||
|
if (index < 0) {
|
||||||
|
const tolerance = 1e-12 * Math.max(Math.abs(eventTime), 1);
|
||||||
|
index = time.findIndex(
|
||||||
|
(sampleTime) => Math.abs(sampleTime - eventTime) <= tolerance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (index >= 0) {
|
||||||
|
indices.add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function continuousMechanicalForceChartValues(
|
||||||
|
variable: EventSeriesVariable,
|
||||||
|
component: EventSeriesComponent | undefined,
|
||||||
|
values: number[],
|
||||||
|
stateTransitionSampleIndices: ReadonlySet<number>,
|
||||||
|
) {
|
||||||
|
const componentHasMechanicalPort = Boolean(
|
||||||
|
component?.data.ports.some((port) => port.domain === "mechanical"),
|
||||||
|
);
|
||||||
|
const forceBelongsToMechanicalDomain =
|
||||||
|
variable.scope === "component"
|
||||||
|
? variable.category === "derived" && componentHasMechanicalPort
|
||||||
|
: variable.portName !== null &&
|
||||||
|
Boolean(
|
||||||
|
component?.data.ports.some(
|
||||||
|
(port) =>
|
||||||
|
port.name === variable.portName &&
|
||||||
|
port.domain === "mechanical",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (variable.quantity !== "force" || !forceBelongsToMechanicalDomain) {
|
||||||
|
return { values, separatedEventSampleCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isolatedEventIndices: number[] = [];
|
||||||
|
for (const index of stateTransitionSampleIndices) {
|
||||||
|
if (
|
||||||
|
index < 0 ||
|
||||||
|
index >= values.length ||
|
||||||
|
!isolatedEventForceSample(
|
||||||
|
values,
|
||||||
|
index,
|
||||||
|
stateTransitionSampleIndices,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
isolatedEventIndices.push(index);
|
||||||
|
}
|
||||||
|
if (isolatedEventIndices.length === 0) {
|
||||||
|
return { values, separatedEventSampleCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayValues = values.slice();
|
||||||
|
for (const index of isolatedEventIndices) {
|
||||||
|
displayValues[index] = Number.NaN;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
values: displayValues,
|
||||||
|
separatedEventSampleCount: isolatedEventIndices.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isolatedEventForceSample(
|
||||||
|
values: number[],
|
||||||
|
index: number,
|
||||||
|
stateTransitionSampleIndices: ReadonlySet<number>,
|
||||||
|
) {
|
||||||
|
const value = Number(values[index]);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const previous = finiteNonEventNeighbor(
|
||||||
|
values,
|
||||||
|
index - 1,
|
||||||
|
-1,
|
||||||
|
stateTransitionSampleIndices,
|
||||||
|
);
|
||||||
|
const next = finiteNonEventNeighbor(
|
||||||
|
values,
|
||||||
|
index + 1,
|
||||||
|
1,
|
||||||
|
stateTransitionSampleIndices,
|
||||||
|
);
|
||||||
|
if (previous === null || next === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localScale = Math.max(
|
||||||
|
Math.abs(previous),
|
||||||
|
Math.abs(next),
|
||||||
|
Math.abs(next - previous),
|
||||||
|
1e-9,
|
||||||
|
);
|
||||||
|
const localEstimate = (previous + next) / 2;
|
||||||
|
return (
|
||||||
|
Math.abs(value) > ISOLATED_EVENT_FORCE_RATIO * localScale &&
|
||||||
|
Math.abs(value - localEstimate) >
|
||||||
|
ISOLATED_EVENT_FORCE_RATIO * localScale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNonEventNeighbor(
|
||||||
|
values: number[],
|
||||||
|
startIndex: number,
|
||||||
|
direction: -1 | 1,
|
||||||
|
stateTransitionSampleIndices: ReadonlySet<number>,
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let index = startIndex;
|
||||||
|
index >= 0 && index < values.length;
|
||||||
|
index += direction
|
||||||
|
) {
|
||||||
|
if (stateTransitionSampleIndices.has(index)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = Number(values[index]);
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentedChartPath(
|
||||||
|
time: number[],
|
||||||
|
values: number[],
|
||||||
|
xPosition: (value: number) => number,
|
||||||
|
yPosition: (value: number) => number,
|
||||||
|
) {
|
||||||
|
const commands: string[] = [];
|
||||||
|
let continuesSegment = false;
|
||||||
|
for (let index = 0; index < time.length; index += 1) {
|
||||||
|
const x = Number(time[index]);
|
||||||
|
const y = Number(values[index]);
|
||||||
|
if (!Number.isFinite(x)) {
|
||||||
|
continuesSegment = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(y)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
commands.push(
|
||||||
|
`${continuesSegment ? "L" : "M"} ${xPosition(x).toFixed(2)} ${yPosition(y).toFixed(2)}`,
|
||||||
|
);
|
||||||
|
continuesSegment = true;
|
||||||
|
}
|
||||||
|
return commands.join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES = 15;
|
||||||
|
export const SIMULATION_SOLVER_STALL_TIMEOUT_MS =
|
||||||
|
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES * 60_000;
|
||||||
|
export const SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS = 60;
|
||||||
|
export const SIMULATION_ACTIVITY_STALL_TIMEOUT_MS =
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS * 1_000;
|
||||||
|
|
||||||
|
export function solverStallTimeoutReached(
|
||||||
|
lastSolverProgressAt: number,
|
||||||
|
now: number = Date.now(),
|
||||||
|
) {
|
||||||
|
return now - lastSolverProgressAt >= SIMULATION_SOLVER_STALL_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function solverStallTimeoutMessage(
|
||||||
|
context: "detected" | "recovering",
|
||||||
|
) {
|
||||||
|
const prefix = `求解器连续 ${SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES} 分钟没有接受新的积分步`;
|
||||||
|
return context === "recovering"
|
||||||
|
? `${prefix},正在终止任务并恢复部分结果`
|
||||||
|
: `${prefix},任务可能已经卡死`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function solverActivityStallTimeoutMessage(
|
||||||
|
context: "detected" | "recovering",
|
||||||
|
) {
|
||||||
|
const prefix = `求解器的接受步和内部活动均连续 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒没有变化`;
|
||||||
|
return context === "recovering"
|
||||||
|
? `${prefix},正在终止任务并恢复部分结果`
|
||||||
|
: `${prefix},任务可能已经卡死`;
|
||||||
|
}
|
||||||
@@ -3647,3 +3647,133 @@ textarea {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Result chart viewport controls and AMESim-style box zoom. */
|
||||||
|
.result-chart-window-header button:disabled,
|
||||||
|
.result-chart-window-header button:disabled:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
color: #8a97a6;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header button.zoom.active {
|
||||||
|
border-color: #74a9d7;
|
||||||
|
color: #0f5f9f;
|
||||||
|
background: #e8f3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-body > svg.zoom-enabled {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.result-chart-body > svg.zoom-enabled[data-panning="true"],
|
||||||
|
.result-chart-body > svg.zoom-enabled[data-panning="true"] .result-chart-zoom-axis-hit {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.result-chart-zoom-axis-hit {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-zoom-axis-hit.x {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-zoom-axis-hit.y {
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-zoom-axis-hit:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-body > svg:focus,
|
||||||
|
.result-chart-body > svg:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-zoom-selection {
|
||||||
|
fill: rgba(29, 111, 184, 0.16);
|
||||||
|
stroke: #1d6fb8;
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AMESim-style click routing, segment editing and circuit crossing bridges. */
|
||||||
|
.manual-connection-draft {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-connection-draft .react-flow__connection-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: #1675c1;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-connection-waypoint {
|
||||||
|
fill: #ffffff;
|
||||||
|
stroke: #1675c1;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-segment-handle {
|
||||||
|
stroke: transparent;
|
||||||
|
stroke-width: 14;
|
||||||
|
pointer-events: stroke;
|
||||||
|
touch-action: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-segment-guide {
|
||||||
|
stroke: rgba(22, 117, 193, 0.34);
|
||||||
|
stroke-width: 1.25;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
pointer-events: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-segment-handle:hover + .manual-edge-segment-guide,
|
||||||
|
.manual-edge-segment-handle:active + .manual-edge-segment-guide {
|
||||||
|
stroke: rgba(15, 95, 159, 0.78);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-segment-handle.horizontal {
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-segment-handle.vertical {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-route-handle,
|
||||||
|
.manual-edge-corner-handle {
|
||||||
|
fill: #ffffff;
|
||||||
|
stroke: #1675c1;
|
||||||
|
stroke-width: 2;
|
||||||
|
cursor: move;
|
||||||
|
pointer-events: all;
|
||||||
|
touch-action: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-edge-corner-handle:hover,
|
||||||
|
.manual-edge-route-handle:hover {
|
||||||
|
fill: #d9edff;
|
||||||
|
stroke: #0f5f9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-canvas.connection-planning .react-flow__pane {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
powershell -ExecutionPolicy Bypass -File "%~dp0start-dev.ps1"
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
Set-Location $PSScriptRoot
|
|
||||||
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
|
|
||||||
Sort-Object Name -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
|
|
||||||
if (-not $nodeDir) {
|
|
||||||
throw "Node.js portable runtime was not found under .tools."
|
|
||||||
}
|
|
||||||
|
|
||||||
$env:Path = "$($nodeDir.FullName);$env:Path"
|
|
||||||
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
|
|
||||||
@@ -107,15 +107,15 @@ function portsForSymbol(modelType: string) {
|
|||||||
}
|
}
|
||||||
if (modelType === "amesim_pnvo001_fixed") {
|
if (modelType === "amesim_pnvo001_fixed") {
|
||||||
return [
|
return [
|
||||||
physicalPort("port_2", "left", 10),
|
physicalPort("port_2", "right", 10),
|
||||||
physicalPort("port_3", "right", 20),
|
physicalPort("port_3", "left", 20),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (modelType === "amesim_pnvo001") {
|
if (modelType === "amesim_pnvo001") {
|
||||||
return [
|
return [
|
||||||
signalPort("res", "left", "input", 5),
|
signalPort("res", "left", "input", 5),
|
||||||
physicalPort("port_2", "left", 10),
|
physicalPort("port_2", "right", 10),
|
||||||
physicalPort("port_3", "right", 20),
|
physicalPort("port_3", "left", 20),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -784,8 +784,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
anchors: [
|
anchors: [
|
||||||
{ portName: "port_2", x: 12.8, y: 24 },
|
{ portName: "port_3", x: 12.8, y: 24 },
|
||||||
{ portName: "port_3", x: 51.2, y: 24 },
|
{ portName: "port_2", x: 51.2, y: 24 },
|
||||||
],
|
],
|
||||||
control: "fixed",
|
control: "fixed",
|
||||||
modelType: "amesim_pnvo001_fixed",
|
modelType: "amesim_pnvo001_fixed",
|
||||||
@@ -794,8 +794,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async
|
|||||||
{
|
{
|
||||||
anchors: [
|
anchors: [
|
||||||
{ portName: "res", x: 27.35, y: 43.2 },
|
{ portName: "res", x: 27.35, y: 43.2 },
|
||||||
{ portName: "port_2", x: 12.8, y: 24 },
|
{ portName: "port_3", x: 12.8, y: 24 },
|
||||||
{ portName: "port_3", x: 51.2, y: 24 },
|
{ portName: "port_2", x: 51.2, y: 24 },
|
||||||
],
|
],
|
||||||
control: "signal",
|
control: "signal",
|
||||||
modelType: "amesim_pnvo001",
|
modelType: "amesim_pnvo001",
|
||||||
@@ -2575,6 +2575,14 @@ test("PNL00R 与 PNL0001-0003 使用 standard 画布和 60% 工作区占比", as
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(geometry.occupancy).toBeCloseTo(0.6, 4);
|
expect(geometry.occupancy).toBeCloseTo(0.6, 4);
|
||||||
|
if (modelType === "amesim_pnl0001") {
|
||||||
|
const causalLabels = symbol.locator("text");
|
||||||
|
await expect(causalLabels).toHaveText(["R", "C"]);
|
||||||
|
const labelPositions = await causalLabels.evaluateAll((labels) =>
|
||||||
|
labels.map((label) => Number(label.getAttribute("x"))),
|
||||||
|
);
|
||||||
|
expect(labelPositions[0]).toBeLessThan(labelPositions[1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3069,10 +3077,25 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
|||||||
await page.locator(".flow-canvas .react-flow__pane").click({
|
await page.locator(".flow-canvas .react-flow__pane").click({
|
||||||
position: { x: 20, y: 20 },
|
position: { x: 20, y: 20 },
|
||||||
});
|
});
|
||||||
|
await expect(firstPhysicalOutput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
const manualDraft = page.locator(".flow-canvas .manual-connection-draft");
|
||||||
|
await expect(manualDraft).toBeVisible();
|
||||||
|
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
|
||||||
|
"data-waypoint-count",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
await expect(firstPhysicalOutput).toHaveAttribute(
|
await expect(firstPhysicalOutput).toHaveAttribute(
|
||||||
"data-connection-state",
|
"data-connection-state",
|
||||||
"idle",
|
"idle",
|
||||||
);
|
);
|
||||||
|
await expect(manualDraft).toHaveCount(0);
|
||||||
|
|
||||||
await compatiblePhysicalPort.click();
|
await compatiblePhysicalPort.click();
|
||||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
await expect(compatiblePhysicalPort).toHaveAttribute(
|
await expect(compatiblePhysicalPort).toHaveAttribute(
|
||||||
@@ -3086,8 +3109,16 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
|||||||
);
|
);
|
||||||
|
|
||||||
await firstPhysicalOutput.click();
|
await firstPhysicalOutput.click();
|
||||||
|
await page.locator(".flow-canvas .react-flow__pane").click({
|
||||||
|
position: { x: 70, y: 90 },
|
||||||
|
});
|
||||||
|
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
|
||||||
|
"data-waypoint-count",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
await compatiblePhysicalPort.click();
|
await compatiblePhysicalPort.click();
|
||||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(manualDraft).toHaveCount(0);
|
||||||
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
|
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
|
||||||
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
|
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
|
||||||
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
|
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
|
||||||
@@ -3099,6 +3130,15 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
|||||||
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
|
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
|
||||||
await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1);
|
await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1);
|
||||||
await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/);
|
await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/);
|
||||||
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||||
|
const routedEdge = await page.evaluate(() => {
|
||||||
|
const raw = window.localStorage.getItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
);
|
||||||
|
const project = raw ? JSON.parse(raw) : null;
|
||||||
|
return project?.edges?.[0] ?? null;
|
||||||
|
});
|
||||||
|
expect(routedEdge?.data?.routePoints?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
await signalOutput.click();
|
await signalOutput.click();
|
||||||
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
|
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
|
||||||
|
|||||||
@@ -0,0 +1,719 @@
|
|||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { prepareApp, wideProject } from "./fixtures";
|
||||||
|
|
||||||
|
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function locatorCenter(locator: Locator) {
|
||||||
|
const bounds = await locator.boundingBox();
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
return {
|
||||||
|
x: bounds!.x + bounds!.width / 2,
|
||||||
|
y: bounds!.y + bounds!.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function distanceBetween(first: Locator, second: Locator) {
|
||||||
|
const [firstCenter, secondCenter] = await Promise.all([
|
||||||
|
locatorCenter(first),
|
||||||
|
locatorCenter(second),
|
||||||
|
]);
|
||||||
|
return Math.hypot(
|
||||||
|
secondCenter.x - firstCenter.x,
|
||||||
|
secondCenter.y - firstCenter.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragGenericToCanvas(
|
||||||
|
page: Page,
|
||||||
|
targetPosition: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
const currentCount = await page
|
||||||
|
.locator('.flow-canvas .react-flow__node[data-id^="generic_sensor_"]')
|
||||||
|
.count();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /通用测试元件/ })
|
||||||
|
.dragTo(page.locator(".flow-canvas .react-flow__pane"), { targetPosition });
|
||||||
|
const node = page.locator(
|
||||||
|
`.flow-canvas .react-flow__node[data-id="generic_sensor_${currentCount + 1}"]`,
|
||||||
|
);
|
||||||
|
await expect(node).toBeVisible();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveNodePortNearTarget(
|
||||||
|
page: Page,
|
||||||
|
movingNode: Locator,
|
||||||
|
movingPort: Locator,
|
||||||
|
targetPort: Locator,
|
||||||
|
remainingGap = 8,
|
||||||
|
) {
|
||||||
|
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
|
||||||
|
locatorCenter(movingNode.locator(".sim-node")),
|
||||||
|
locatorCenter(movingPort),
|
||||||
|
locatorCenter(targetPort),
|
||||||
|
]);
|
||||||
|
const deltaX = targetPortCenter.x - movingPortCenter.x;
|
||||||
|
const deltaY = targetPortCenter.y - movingPortCenter.y;
|
||||||
|
const distance = Math.hypot(deltaX, deltaY);
|
||||||
|
const ratio = (distance - remainingGap) / distance;
|
||||||
|
let pointerX = dragStart.x + deltaX * ratio;
|
||||||
|
let pointerY = dragStart.y + deltaY * ratio;
|
||||||
|
|
||||||
|
await page.mouse.move(dragStart.x, dragStart.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(pointerX, pointerY, { steps: 12 });
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
const [movingCenter, targetCenter] = await Promise.all([
|
||||||
|
locatorCenter(movingPort),
|
||||||
|
locatorCenter(targetPort),
|
||||||
|
]);
|
||||||
|
const correctionX = targetCenter.x - movingCenter.x;
|
||||||
|
const correctionY = targetCenter.y - movingCenter.y;
|
||||||
|
const correctionDistance = Math.hypot(correctionX, correctionY);
|
||||||
|
if (correctionDistance <= remainingGap + 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const correctionRatio =
|
||||||
|
(correctionDistance - remainingGap) / correctionDistance;
|
||||||
|
pointerX += correctionX * correctionRatio;
|
||||||
|
pointerY += correctionY * correctionRatio;
|
||||||
|
await page.mouse.move(pointerX, pointerY, { steps: 4 });
|
||||||
|
}
|
||||||
|
return { pointerX, pointerY };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function movePointerUntilPortsMeet(
|
||||||
|
page: Page,
|
||||||
|
pointer: { x: number; y: number },
|
||||||
|
movingPort: Locator,
|
||||||
|
targetPort: Locator,
|
||||||
|
) {
|
||||||
|
const nextPointer = { ...pointer };
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const [movingCenter, targetCenter] = await Promise.all([
|
||||||
|
locatorCenter(movingPort),
|
||||||
|
locatorCenter(targetPort),
|
||||||
|
]);
|
||||||
|
const correction = {
|
||||||
|
x: targetCenter.x - movingCenter.x,
|
||||||
|
y: targetCenter.y - movingCenter.y,
|
||||||
|
};
|
||||||
|
if (Math.hypot(correction.x, correction.y) < 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextPointer.x += correction.x;
|
||||||
|
nextPointer.y += correction.y;
|
||||||
|
await page.mouse.move(nextPointer.x, nextPointer.y, { steps: 6 });
|
||||||
|
}
|
||||||
|
return nextPointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectNode(id: string, x: number, y: number) {
|
||||||
|
const template = structuredClone(wideProject.nodes[0]);
|
||||||
|
return {
|
||||||
|
...template,
|
||||||
|
id,
|
||||||
|
position: { x, y },
|
||||||
|
data: {
|
||||||
|
...template.data,
|
||||||
|
label: id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("多选块拖动会即时断开边界接触,并由块内空闲端口整体吸附", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||||
|
|
||||||
|
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
|
||||||
|
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
|
||||||
|
const secondMovingNode = await dragGenericToCanvas(page, { x: 410, y: 430 });
|
||||||
|
const snapTargetNode = await dragGenericToCanvas(page, { x: 720, y: 430 });
|
||||||
|
const fixedPort = fixedNode.locator('.port-handle[data-port-name="port_b"]');
|
||||||
|
const boundaryPort = firstMovingNode.locator(
|
||||||
|
'.port-handle[data-port-name="port_a"]',
|
||||||
|
);
|
||||||
|
const blockFreePort = secondMovingNode.locator(
|
||||||
|
'.port-handle[data-port-name="port_b"]',
|
||||||
|
);
|
||||||
|
const targetFreePort = snapTargetNode.locator(
|
||||||
|
'.port-handle[data-port-name="port_a"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
await moveNodePortNearTarget(
|
||||||
|
page,
|
||||||
|
firstMovingNode,
|
||||||
|
boundaryPort,
|
||||||
|
fixedPort,
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => distanceBetween(boundaryPort, fixedPort)).toBeLessThan(2);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||||
|
|
||||||
|
await firstMovingNode.locator(".sim-node").click();
|
||||||
|
await secondMovingNode.locator(".sim-node").click({ modifiers: ["Control"] });
|
||||||
|
await expect(firstMovingNode).toHaveClass(/selected/);
|
||||||
|
await expect(secondMovingNode).toHaveClass(/selected/);
|
||||||
|
const [firstBefore, secondBefore] = await Promise.all([
|
||||||
|
firstMovingNode.boundingBox(),
|
||||||
|
secondMovingNode.boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(firstBefore).not.toBeNull();
|
||||||
|
expect(secondBefore).not.toBeNull();
|
||||||
|
|
||||||
|
await moveNodePortNearTarget(
|
||||||
|
page,
|
||||||
|
firstMovingNode,
|
||||||
|
blockFreePort,
|
||||||
|
targetFreePort,
|
||||||
|
);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await expect(blockFreePort).toHaveAttribute("data-connection-state", "origin");
|
||||||
|
await expect(targetFreePort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect.poll(() => distanceBetween(blockFreePort, targetFreePort)).toBeLessThan(2);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||||
|
await expect(boundaryPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(fixedPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(blockFreePort).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(targetFreePort).toHaveAttribute("data-connected", "true");
|
||||||
|
|
||||||
|
const [firstAfter, secondAfter] = await Promise.all([
|
||||||
|
firstMovingNode.boundingBox(),
|
||||||
|
secondMovingNode.boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(firstAfter).not.toBeNull();
|
||||||
|
expect(secondAfter).not.toBeNull();
|
||||||
|
expect(firstAfter!.x - firstBefore!.x).toBeCloseTo(
|
||||||
|
secondAfter!.x - secondBefore!.x,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(firstAfter!.y - firstBefore!.y).toBeCloseTo(
|
||||||
|
secondAfter!.y - secondBefore!.y,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("复制块保留内部连接,同时未连接端口仍参与待放置吸附", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||||
|
|
||||||
|
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
|
||||||
|
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
|
||||||
|
const target = await dragGenericToCanvas(page, { x: 720, y: 370 });
|
||||||
|
await first.locator('.port-handle[data-port-name="port_b"]').click();
|
||||||
|
await second.locator('.port-handle[data-port-name="port_a"]').click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
|
||||||
|
await first.locator(".sim-node").click();
|
||||||
|
await second.locator(".sim-node").click({ modifiers: ["Control"] });
|
||||||
|
await expect(first).toHaveClass(/selected/);
|
||||||
|
await expect(second).toHaveClass(/selected/);
|
||||||
|
await page.keyboard.press("Control+c");
|
||||||
|
|
||||||
|
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
|
||||||
|
expect(paneBounds).not.toBeNull();
|
||||||
|
let pointer = {
|
||||||
|
x: paneBounds!.x + paneBounds!.width * 0.5,
|
||||||
|
y: paneBounds!.y + paneBounds!.height * 0.72,
|
||||||
|
};
|
||||||
|
await page.mouse.move(pointer.x, pointer.y);
|
||||||
|
await page.keyboard.press("Control+v");
|
||||||
|
|
||||||
|
const firstPreview = page.locator(
|
||||||
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_4"]',
|
||||||
|
);
|
||||||
|
const secondPreview = page.locator(
|
||||||
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_5"]',
|
||||||
|
);
|
||||||
|
await expect(firstPreview).toHaveClass(/pending-paste-node/);
|
||||||
|
await expect(secondPreview).toHaveClass(/pending-paste-node/);
|
||||||
|
await expect(page.locator(".flow-canvas .pending-paste-edge")).toHaveCount(1);
|
||||||
|
await expect(
|
||||||
|
firstPreview.locator('.port-handle[data-port-name="port_b"]'),
|
||||||
|
).toHaveAttribute("data-connection-state", "connected");
|
||||||
|
await expect(
|
||||||
|
secondPreview.locator('.port-handle[data-port-name="port_a"]'),
|
||||||
|
).toHaveAttribute("data-connection-state", "connected");
|
||||||
|
|
||||||
|
const copiedFreePort = secondPreview.locator(
|
||||||
|
'.port-handle[data-port-name="port_b"]',
|
||||||
|
);
|
||||||
|
const targetPort = target.locator('.port-handle[data-port-name="port_a"]');
|
||||||
|
pointer = await movePointerUntilPortsMeet(
|
||||||
|
page,
|
||||||
|
pointer,
|
||||||
|
copiedFreePort,
|
||||||
|
targetPort,
|
||||||
|
);
|
||||||
|
await expect.poll(() => distanceBetween(copiedFreePort, targetPort)).toBeLessThan(2);
|
||||||
|
await expect(copiedFreePort).toHaveAttribute("data-connection-state", "origin");
|
||||||
|
await expect(targetPort).toHaveAttribute("data-connection-state", "compatible");
|
||||||
|
|
||||||
|
await page.mouse.click(pointer.x, pointer.y);
|
||||||
|
await expect(firstPreview).not.toHaveClass(/pending-paste-node/);
|
||||||
|
await expect(secondPreview).not.toHaveClass(/pending-paste-node/);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(3);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(2);
|
||||||
|
await expect(copiedFreePort).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(targetPort).toHaveAttribute("data-connected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const project = {
|
||||||
|
...structuredClone(wideProject),
|
||||||
|
nodes: [
|
||||||
|
projectNode("left", 0, 260),
|
||||||
|
projectNode("right", 700, 260),
|
||||||
|
projectNode("top", 350, 0),
|
||||||
|
projectNode("bottom", 350, 520),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "edge-horizontal",
|
||||||
|
source: "left",
|
||||||
|
target: "right",
|
||||||
|
sourceHandle: "port_b",
|
||||||
|
targetHandle: "port_a",
|
||||||
|
data: {
|
||||||
|
isContactEdge: false,
|
||||||
|
routePoints: [
|
||||||
|
{ x: 250, y: 360 },
|
||||||
|
{ x: 650, y: 360 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edge-vertical",
|
||||||
|
source: "top",
|
||||||
|
target: "bottom",
|
||||||
|
sourceHandle: "port_b",
|
||||||
|
targetHandle: "port_a",
|
||||||
|
data: {
|
||||||
|
isContactEdge: false,
|
||||||
|
routePoints: [
|
||||||
|
{ x: 450, y: 150 },
|
||||||
|
{ x: 450, y: 550 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await page.addInitScript((storedProject) => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
JSON.stringify(storedProject),
|
||||||
|
);
|
||||||
|
}, project);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||||
|
|
||||||
|
const horizontalEdge = page.locator(
|
||||||
|
'.flow-canvas .react-flow__edge[data-id="edge-horizontal"]',
|
||||||
|
);
|
||||||
|
const verticalEdge = page.locator(
|
||||||
|
'.flow-canvas .react-flow__edge[data-id="edge-vertical"]',
|
||||||
|
);
|
||||||
|
await expect(horizontalEdge).toBeVisible();
|
||||||
|
await expect(verticalEdge).toBeVisible();
|
||||||
|
const horizontalPath = horizontalEdge.locator(".react-flow__edge-path");
|
||||||
|
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
||||||
|
await expect(verticalEdge.locator(".react-flow__edge-path")).not.toHaveAttribute(
|
||||||
|
"d",
|
||||||
|
/ Q /,
|
||||||
|
);
|
||||||
|
|
||||||
|
await horizontalEdge
|
||||||
|
.locator(".react-flow__edge-interaction")
|
||||||
|
.dispatchEvent("click");
|
||||||
|
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
||||||
|
const horizontalSegment = horizontalEdge
|
||||||
|
.locator(".manual-edge-segment-handle.horizontal")
|
||||||
|
.first();
|
||||||
|
const verticalSegment = horizontalEdge
|
||||||
|
.locator(".manual-edge-segment-handle.vertical")
|
||||||
|
.first();
|
||||||
|
await expect(horizontalSegment).toHaveCount(1);
|
||||||
|
await expect(verticalSegment).toHaveCount(1);
|
||||||
|
await expect(horizontalSegment).toHaveCSS("pointer-events", "stroke");
|
||||||
|
await expect(verticalSegment).toHaveCSS("pointer-events", "stroke");
|
||||||
|
|
||||||
|
const horizontalDragPoint = await horizontalSegment.evaluate(
|
||||||
|
(line: SVGLineElement) => {
|
||||||
|
const matrix = line.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取水平连接线段坐标");
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(
|
||||||
|
line.x1.baseVal.value * 0.75 + line.x2.baseVal.value * 0.25,
|
||||||
|
line.y1.baseVal.value * 0.75 + line.y2.baseVal.value * 0.25,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pathBeforeHorizontalDrag = await horizontalPath.getAttribute("d");
|
||||||
|
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y + 36, {
|
||||||
|
steps: 6,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(horizontalPath).not.toHaveAttribute(
|
||||||
|
"d",
|
||||||
|
pathBeforeHorizontalDrag ?? "",
|
||||||
|
);
|
||||||
|
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
||||||
|
|
||||||
|
const verticalDragPoint = await verticalSegment.evaluate(
|
||||||
|
(line: SVGLineElement) => {
|
||||||
|
const matrix = line.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取竖直连接线段坐标");
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(
|
||||||
|
(line.x1.baseVal.value + line.x2.baseVal.value) / 2,
|
||||||
|
(line.y1.baseVal.value + line.y2.baseVal.value) / 2,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pathBeforeVerticalDrag = await horizontalPath.getAttribute("d");
|
||||||
|
await page.mouse.move(verticalDragPoint.x, verticalDragPoint.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(verticalDragPoint.x + 28, verticalDragPoint.y, {
|
||||||
|
steps: 6,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(horizontalPath).not.toHaveAttribute(
|
||||||
|
"d",
|
||||||
|
pathBeforeVerticalDrag ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cornerHandle = horizontalEdge.locator(
|
||||||
|
'.manual-edge-corner-handle[data-corner-index="2"]',
|
||||||
|
);
|
||||||
|
await expect(cornerHandle).toHaveCount(1);
|
||||||
|
const cornerBefore = await cornerHandle.evaluate(
|
||||||
|
(circle: SVGCircleElement) => ({
|
||||||
|
flowX: circle.cx.baseVal.value,
|
||||||
|
flowY: circle.cy.baseVal.value,
|
||||||
|
screen: (() => {
|
||||||
|
const matrix = circle.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取连接线拐点坐标");
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(
|
||||||
|
circle.cx.baseVal.value,
|
||||||
|
circle.cy.baseVal.value,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
})(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const pathBeforeCornerDrag = await horizontalPath.getAttribute("d");
|
||||||
|
await page.mouse.move(cornerBefore.screen.x, cornerBefore.screen.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
cornerBefore.screen.x + 32,
|
||||||
|
cornerBefore.screen.y + 24,
|
||||||
|
{ steps: 6 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(horizontalPath).not.toHaveAttribute(
|
||||||
|
"d",
|
||||||
|
pathBeforeCornerDrag ?? "",
|
||||||
|
);
|
||||||
|
const cornerAfter = await cornerHandle.evaluate(
|
||||||
|
(circle: SVGCircleElement) => ({
|
||||||
|
flowX: circle.cx.baseVal.value,
|
||||||
|
flowY: circle.cy.baseVal.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(Math.abs(cornerAfter.flowX - cornerBefore.flowX)).toBeGreaterThan(1);
|
||||||
|
expect(Math.abs(cornerAfter.flowY - cornerBefore.flowY)).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+z");
|
||||||
|
await expect(horizontalPath).toHaveAttribute(
|
||||||
|
"d",
|
||||||
|
pathBeforeCornerDrag ?? "",
|
||||||
|
);
|
||||||
|
await horizontalEdge
|
||||||
|
.locator(".react-flow__edge-interaction")
|
||||||
|
.dispatchEvent("click");
|
||||||
|
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
||||||
|
|
||||||
|
const boundaryCorner = horizontalEdge.locator(
|
||||||
|
'.manual-edge-corner-handle[data-corner-index="1"]',
|
||||||
|
);
|
||||||
|
await expect(boundaryCorner).toHaveCount(1);
|
||||||
|
const boundaryStart = await boundaryCorner.evaluate(
|
||||||
|
(circle: SVGCircleElement) => {
|
||||||
|
const matrix = circle.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取端点相邻拐点坐标");
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(
|
||||||
|
circle.cx.baseVal.value,
|
||||||
|
circle.cy.baseVal.value,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const boundaryPathLocator = horizontalEdge.locator(".react-flow__edge-path");
|
||||||
|
const boundaryPathBefore = await boundaryPathLocator.getAttribute("d");
|
||||||
|
const boundaryTarget = {
|
||||||
|
x: boundaryStart.x + 38,
|
||||||
|
y: boundaryStart.y + 30,
|
||||||
|
};
|
||||||
|
await page.mouse.move(boundaryStart.x, boundaryStart.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
(boundaryStart.x + boundaryTarget.x) / 2,
|
||||||
|
(boundaryStart.y + boundaryTarget.y) / 2,
|
||||||
|
{ steps: 4 },
|
||||||
|
);
|
||||||
|
await page.mouse.move(boundaryTarget.x, boundaryTarget.y, { steps: 4 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(boundaryPathLocator).not.toHaveAttribute(
|
||||||
|
"d",
|
||||||
|
boundaryPathBefore ?? "",
|
||||||
|
);
|
||||||
|
await expect(boundaryPathLocator).toHaveAttribute("d", / Q /);
|
||||||
|
|
||||||
|
const cornerScreenPoints = await horizontalEdge
|
||||||
|
.locator(".manual-edge-corner-handle")
|
||||||
|
.evaluateAll((circles: SVGCircleElement[]) =>
|
||||||
|
circles.map((circle) => {
|
||||||
|
const matrix = circle.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取调整后的拐点坐标");
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(
|
||||||
|
circle.cx.baseVal.value,
|
||||||
|
circle.cy.baseVal.value,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const closestCornerDistance = Math.min(
|
||||||
|
...cornerScreenPoints.map((point) =>
|
||||||
|
Math.hypot(point.x - boundaryTarget.x, point.y - boundaryTarget.y),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
closestCornerDistance,
|
||||||
|
JSON.stringify({ boundaryStart, boundaryTarget, cornerScreenPoints }),
|
||||||
|
).toBeLessThan(3);
|
||||||
|
|
||||||
|
const boundaryPathAfter = await boundaryPathLocator.getAttribute("d");
|
||||||
|
const linePoints = [
|
||||||
|
...(boundaryPathAfter ?? "").matchAll(
|
||||||
|
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
||||||
|
),
|
||||||
|
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
||||||
|
expect(linePoints.length).toBeGreaterThan(2);
|
||||||
|
linePoints.slice(1).forEach((point, index) => {
|
||||||
|
const previous = linePoints[index];
|
||||||
|
expect(
|
||||||
|
Math.abs(point.x - previous.x) < 0.01 ||
|
||||||
|
Math.abs(point.y - previous.y) < 0.01,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||||
|
const savedRoute = await page.evaluate((projectKey) => {
|
||||||
|
const raw = window.localStorage.getItem(projectKey);
|
||||||
|
const saved = raw ? JSON.parse(raw) : null;
|
||||||
|
return saved?.edges?.find(
|
||||||
|
(edge: { id?: string }) => edge.id === "edge-horizontal",
|
||||||
|
)?.data?.routePoints ?? null;
|
||||||
|
}, PROJECT_KEY);
|
||||||
|
expect(savedRoute).not.toEqual(project.edges[0].data.routePoints);
|
||||||
|
expect(savedRoute?.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("直线首次折弯后即使原手柄卸载,拖动仍连续且保持正交", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const project = {
|
||||||
|
...structuredClone(wideProject),
|
||||||
|
nodes: [
|
||||||
|
projectNode("straight-source", 0, 260),
|
||||||
|
projectNode("straight-target", 700, 260),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "straight-edge",
|
||||||
|
source: "straight-source",
|
||||||
|
target: "straight-target",
|
||||||
|
sourceHandle: "port_b",
|
||||||
|
targetHandle: "port_a",
|
||||||
|
data: { isContactEdge: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await page.addInitScript((storedProject) => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
JSON.stringify(storedProject),
|
||||||
|
);
|
||||||
|
}, project);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||||
|
|
||||||
|
const edge = page.locator(
|
||||||
|
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
|
||||||
|
);
|
||||||
|
const edgePath = edge.locator(".react-flow__edge-path");
|
||||||
|
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
|
||||||
|
const routeHandle = edge.locator(".manual-edge-route-handle");
|
||||||
|
await expect(routeHandle).toHaveCount(1);
|
||||||
|
const dragStart = await routeHandle.evaluate((circle: SVGCircleElement) => {
|
||||||
|
const matrix = circle.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取直线路由手柄坐标");
|
||||||
|
}
|
||||||
|
const point = new DOMPoint(
|
||||||
|
circle.cx.baseVal.value,
|
||||||
|
circle.cy.baseVal.value,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
});
|
||||||
|
const pathBefore = await edgePath.getAttribute("d");
|
||||||
|
await page.mouse.move(dragStart.x, dragStart.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
|
||||||
|
|
||||||
|
await expect(routeHandle).toHaveCount(0);
|
||||||
|
const verticalSegment = edge
|
||||||
|
.locator(".manual-edge-segment-handle.vertical")
|
||||||
|
.first();
|
||||||
|
await expect(verticalSegment).toHaveCount(1);
|
||||||
|
const segmentXAtHalfMove = await verticalSegment.evaluate(
|
||||||
|
(line: SVGLineElement) => {
|
||||||
|
const matrix = line.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取首次折弯后的竖直线段坐标");
|
||||||
|
}
|
||||||
|
return new DOMPoint(
|
||||||
|
line.x1.baseVal.value,
|
||||||
|
line.y1.baseVal.value,
|
||||||
|
).matrixTransform(matrix).x;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
|
||||||
|
const segmentXAtFullMove = await verticalSegment.evaluate(
|
||||||
|
(line: SVGLineElement) => {
|
||||||
|
const matrix = line.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("无法获取连续拖动后的竖直线段坐标");
|
||||||
|
}
|
||||||
|
return new DOMPoint(
|
||||||
|
line.x1.baseVal.value,
|
||||||
|
line.y1.baseVal.value,
|
||||||
|
).matrixTransform(matrix).x;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
|
||||||
|
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
|
||||||
|
await expect(
|
||||||
|
edge.locator(".manual-edge-segment-handle.horizontal").first(),
|
||||||
|
).toHaveCount(1);
|
||||||
|
|
||||||
|
const routeAfter = await edgePath.getAttribute("d");
|
||||||
|
const routePoints = [
|
||||||
|
...(routeAfter ?? "").matchAll(
|
||||||
|
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
||||||
|
),
|
||||||
|
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
||||||
|
routePoints.slice(1).forEach((point, index) => {
|
||||||
|
const previous = routePoints[index];
|
||||||
|
expect(
|
||||||
|
Math.abs(point.x - previous.x) < 0.01 ||
|
||||||
|
Math.abs(point.y - previous.y) < 0.01,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同一组件不同端口发出的线路在远端交叉时仍显示跨线桥", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const project = {
|
||||||
|
...structuredClone(wideProject),
|
||||||
|
nodes: [
|
||||||
|
projectNode("shared-source", 0, 260),
|
||||||
|
projectNode("right-target", 700, 260),
|
||||||
|
projectNode("bottom-target", 350, 520),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "shared-horizontal",
|
||||||
|
source: "shared-source",
|
||||||
|
target: "right-target",
|
||||||
|
sourceHandle: "port_b",
|
||||||
|
targetHandle: "port_a",
|
||||||
|
data: {
|
||||||
|
isContactEdge: false,
|
||||||
|
routePoints: [
|
||||||
|
{ x: 250, y: 360 },
|
||||||
|
{ x: 650, y: 360 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shared-vertical",
|
||||||
|
source: "shared-source",
|
||||||
|
target: "bottom-target",
|
||||||
|
sourceHandle: "port_a",
|
||||||
|
targetHandle: "port_a",
|
||||||
|
data: {
|
||||||
|
isContactEdge: false,
|
||||||
|
routePoints: [
|
||||||
|
{ x: 250, y: 360 },
|
||||||
|
{ x: 250, y: 180 },
|
||||||
|
{ x: 450, y: 180 },
|
||||||
|
{ x: 450, y: 540 },
|
||||||
|
{ x: 350, y: 540 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await page.addInitScript((storedProject) => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
JSON.stringify(storedProject),
|
||||||
|
);
|
||||||
|
}, project);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||||
|
|
||||||
|
const horizontalPath = page.locator(
|
||||||
|
'.flow-canvas .react-flow__edge[data-id="shared-horizontal"] .react-flow__edge-path',
|
||||||
|
);
|
||||||
|
const verticalPath = page.locator(
|
||||||
|
'.flow-canvas .react-flow__edge[data-id="shared-vertical"] .react-flow__edge-path',
|
||||||
|
);
|
||||||
|
await expect(horizontalPath).toBeVisible();
|
||||||
|
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
||||||
|
await expect(verticalPath).not.toHaveAttribute("d", / Q /);
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
expectAllNodesInsideCanvas,
|
expectAllNodesInsideCanvas,
|
||||||
prepareApp,
|
prepareApp,
|
||||||
@@ -6,6 +8,35 @@ import {
|
|||||||
wideProject,
|
wideProject,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
|
||||||
|
type ImportedProjectEdge = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
sourceHandle: string;
|
||||||
|
targetHandle: string;
|
||||||
|
data?: { isContactEdge?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportedProject = {
|
||||||
|
nodes: Array<{ id: string }>;
|
||||||
|
edges: ImportedProjectEdge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MQL_8_PROJECT_PATHS = [
|
||||||
|
new URL("../../../tests/data/test-mql-8%20.json", import.meta.url),
|
||||||
|
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
|
||||||
|
].map(fileURLToPath);
|
||||||
|
const MQL_8_PROJECT_PATH =
|
||||||
|
MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0];
|
||||||
|
const mql8Project = JSON.parse(
|
||||||
|
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
|
||||||
|
) as ImportedProject;
|
||||||
|
const mql8ResultSnapshot = {
|
||||||
|
...resultSnapshot,
|
||||||
|
id: "e2e-mql-8-result-system-bridges",
|
||||||
|
project: mql8Project as unknown as typeof resultSnapshot.project,
|
||||||
|
};
|
||||||
|
|
||||||
async function readViewport(page: Page, canvasSelector: string) {
|
async function readViewport(page: Page, canvasSelector: string) {
|
||||||
return page
|
return page
|
||||||
.locator(`${canvasSelector} .react-flow__viewport`)
|
.locator(`${canvasSelector} .react-flow__viewport`)
|
||||||
@@ -26,6 +57,110 @@ async function readModelingViewport(page: Page) {
|
|||||||
return readViewport(page, ".flow-canvas");
|
return readViewport(page, ".flow-canvas");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readVisibleEdgeEndpointAlignment(
|
||||||
|
page: Page,
|
||||||
|
edges: ImportedProjectEdge[],
|
||||||
|
) {
|
||||||
|
return page.evaluate((projectEdges) => {
|
||||||
|
const nodeElements = new Map(
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
".flow-canvas .react-flow__node[data-id]",
|
||||||
|
),
|
||||||
|
).flatMap((element) =>
|
||||||
|
element.dataset.id ? [[element.dataset.id, element] as const] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const edgeElements = new Map(
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll<SVGGElement>(
|
||||||
|
".flow-canvas .react-flow__edge[data-id]",
|
||||||
|
),
|
||||||
|
).flatMap((element) =>
|
||||||
|
element.dataset.id ? [[element.dataset.id, element] as const] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const measurements: Array<{
|
||||||
|
edgeId: string;
|
||||||
|
sourceDistance: number;
|
||||||
|
targetDistance: number;
|
||||||
|
}> = [];
|
||||||
|
const missingHandles: string[] = [];
|
||||||
|
|
||||||
|
for (const edge of projectEdges) {
|
||||||
|
if (edge.data?.isContactEdge === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const path = edgeElements
|
||||||
|
.get(edge.id)
|
||||||
|
?.querySelector<SVGPathElement>(".react-flow__edge-path");
|
||||||
|
if (!path) {
|
||||||
|
// A geometrically touching edge is intentionally rendered without a path.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sourceHandle = Array.from(
|
||||||
|
nodeElements
|
||||||
|
.get(edge.source)
|
||||||
|
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
|
||||||
|
).find((handle) => handle.dataset.portName === edge.sourceHandle);
|
||||||
|
const targetHandle = Array.from(
|
||||||
|
nodeElements
|
||||||
|
.get(edge.target)
|
||||||
|
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
|
||||||
|
).find((handle) => handle.dataset.portName === edge.targetHandle);
|
||||||
|
const screenMatrix = path.getScreenCTM();
|
||||||
|
if (!sourceHandle || !targetHandle || !screenMatrix) {
|
||||||
|
missingHandles.push(edge.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathLength = path.getTotalLength();
|
||||||
|
const sourcePoint = path.getPointAtLength(0).matrixTransform(screenMatrix);
|
||||||
|
const targetPoint = path
|
||||||
|
.getPointAtLength(pathLength)
|
||||||
|
.matrixTransform(screenMatrix);
|
||||||
|
const sourceBounds = sourceHandle.getBoundingClientRect();
|
||||||
|
const targetBounds = targetHandle.getBoundingClientRect();
|
||||||
|
const sourceCenter = {
|
||||||
|
x: sourceBounds.left + sourceBounds.width / 2,
|
||||||
|
y: sourceBounds.top + sourceBounds.height / 2,
|
||||||
|
};
|
||||||
|
const targetCenter = {
|
||||||
|
x: targetBounds.left + targetBounds.width / 2,
|
||||||
|
y: targetBounds.top + targetBounds.height / 2,
|
||||||
|
};
|
||||||
|
measurements.push({
|
||||||
|
edgeId: edge.id,
|
||||||
|
sourceDistance: Math.hypot(
|
||||||
|
sourcePoint.x - sourceCenter.x,
|
||||||
|
sourcePoint.y - sourceCenter.y,
|
||||||
|
),
|
||||||
|
targetDistance: Math.hypot(
|
||||||
|
targetPoint.x - targetCenter.x,
|
||||||
|
targetPoint.y - targetCenter.y,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const worst = measurements
|
||||||
|
.map((measurement) => ({
|
||||||
|
...measurement,
|
||||||
|
maxDistance: Math.max(
|
||||||
|
measurement.sourceDistance,
|
||||||
|
measurement.targetDistance,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((first, second) => second.maxDistance - first.maxDistance)
|
||||||
|
.slice(0, 8);
|
||||||
|
return {
|
||||||
|
checkedEdgeCount: measurements.length,
|
||||||
|
maxDistance: worst[0]?.maxDistance ?? Number.POSITIVE_INFINITY,
|
||||||
|
missingHandles,
|
||||||
|
worst,
|
||||||
|
};
|
||||||
|
}, edges);
|
||||||
|
}
|
||||||
|
|
||||||
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
|
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
|
||||||
await prepareApp(page);
|
await prepareApp(page);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
@@ -225,6 +360,50 @@ test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
|
|||||||
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH);
|
||||||
|
|
||||||
|
await expectAllNodesInsideCanvas(
|
||||||
|
page,
|
||||||
|
".flow-canvas",
|
||||||
|
mql8Project.nodes.length,
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await readVisibleEdgeEndpointAlignment(page, mql8Project.edges)
|
||||||
|
).checkedEdgeCount,
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(20);
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const alignment = await readVisibleEdgeEndpointAlignment(
|
||||||
|
page,
|
||||||
|
mql8Project.edges,
|
||||||
|
);
|
||||||
|
expect(alignment.missingHandles).toEqual([]);
|
||||||
|
expect(alignment.checkedEdgeCount).toBeGreaterThan(20);
|
||||||
|
expect(
|
||||||
|
alignment.maxDistance,
|
||||||
|
`偏移最大的连线端点:${JSON.stringify(alignment.worst, null, 2)}`,
|
||||||
|
// React Flow anchors to the outer edge of the 6 px port glyph rather
|
||||||
|
// than its visual center; allow that radius plus subpixel rounding.
|
||||||
|
).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
|
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
|
||||||
await prepareApp(page);
|
await prepareApp(page);
|
||||||
await page.addInitScript((project) => {
|
await page.addInitScript((project) => {
|
||||||
@@ -434,6 +613,95 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.addInitScript((snapshot) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
}, mql8ResultSnapshot);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("tab", { name: "结果", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator(".results-system-canvas .react-flow__node"),
|
||||||
|
).toHaveCount(mql8Project.nodes.length);
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .react-flow__edge-path',
|
||||||
|
),
|
||||||
|
).toHaveAttribute("d", / Q /);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("从结果页返回建模页后保留用户调整的建模视口", async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.addInitScript(({ project, snapshot }) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
JSON.stringify(project),
|
||||||
|
);
|
||||||
|
}, { project: wideProject, snapshot: resultSnapshot });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "加载工程" }).click();
|
||||||
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||||
|
|
||||||
|
const modelingPane = page.locator(".flow-canvas .react-flow__pane");
|
||||||
|
const modelingPaneBox = await modelingPane.boundingBox();
|
||||||
|
expect(modelingPaneBox).not.toBeNull();
|
||||||
|
const fittedViewport = await readModelingViewport(page);
|
||||||
|
const pointer = {
|
||||||
|
x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55,
|
||||||
|
y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45,
|
||||||
|
};
|
||||||
|
await page.mouse.move(pointer.x, pointer.y);
|
||||||
|
await page.mouse.down({ button: "middle" });
|
||||||
|
await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 });
|
||||||
|
await page.mouse.up({ button: "middle" });
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const viewport = await readModelingViewport(page);
|
||||||
|
return Math.hypot(
|
||||||
|
viewport.x - fittedViewport.x,
|
||||||
|
viewport.y - fittedViewport.y,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(100);
|
||||||
|
|
||||||
|
const beforeZoom = await readModelingViewport(page);
|
||||||
|
await page.keyboard.down("Control");
|
||||||
|
await page.mouse.wheel(0, -180);
|
||||||
|
await page.keyboard.up("Control");
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readModelingViewport(page)).zoom)
|
||||||
|
.not.toBeCloseTo(beforeZoom.zoom, 2);
|
||||||
|
const adjustedViewport = await readModelingViewport(page);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||||
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||||
|
await page.getByRole("tab", { name: "建模" }).click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||||
|
wideProject.nodes.length,
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
|
const restoredViewport = await readModelingViewport(page);
|
||||||
|
expect(
|
||||||
|
Math.hypot(
|
||||||
|
restoredViewport.x - adjustedViewport.x,
|
||||||
|
restoredViewport.y - adjustedViewport.y,
|
||||||
|
),
|
||||||
|
).toBeLessThan(2);
|
||||||
|
expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
|
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { migratedPnvo001Mirrored } from "../../src/pnvoPortDisplayMigration";
|
||||||
|
|
||||||
|
const legacyPorts = [
|
||||||
|
{ name: "res", side: "left" },
|
||||||
|
{ name: "port_2", side: "left" },
|
||||||
|
{ name: "port_3", side: "right" },
|
||||||
|
];
|
||||||
|
const amesimPorts = [
|
||||||
|
{ name: "res", side: "left" },
|
||||||
|
{ name: "port_2", side: "right" },
|
||||||
|
{ name: "port_3", side: "left" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("旧 PNVO001 端口快照只迁移一次镜像方向", () => {
|
||||||
|
for (const modelType of [
|
||||||
|
"amesim_pnvo001_fixed",
|
||||||
|
"amesim_pnvo001",
|
||||||
|
]) {
|
||||||
|
const migrated = migratedPnvo001Mirrored(
|
||||||
|
modelType,
|
||||||
|
legacyPorts,
|
||||||
|
amesimPorts,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(migrated).toBe(false);
|
||||||
|
expect(
|
||||||
|
migratedPnvo001Mirrored(
|
||||||
|
modelType,
|
||||||
|
amesimPorts,
|
||||||
|
amesimPorts,
|
||||||
|
migrated,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
migratedPnvo001Mirrored(
|
||||||
|
modelType,
|
||||||
|
legacyPorts,
|
||||||
|
amesimPorts,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
migratedPnvo001Mirrored(
|
||||||
|
"amesim_pnor001",
|
||||||
|
legacyPorts,
|
||||||
|
amesimPorts,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
continuousMechanicalForceChartValues,
|
||||||
|
resultStateTransitionSampleIndices,
|
||||||
|
segmentedChartPath,
|
||||||
|
} from "../../src/resultEventSeries";
|
||||||
|
|
||||||
|
const mechanicalComponent = {
|
||||||
|
data: {
|
||||||
|
ports: [{ name: "port_2", domain: "mechanical" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mechanicalPortForce = {
|
||||||
|
scope: "port" as const,
|
||||||
|
portName: "port_2",
|
||||||
|
quantity: "force",
|
||||||
|
category: "flow",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("只分离机械事件中的孤立力尖峰,同时保留连续曲线和原始值", () => {
|
||||||
|
const time = [0, 0.5, 0.75, 1, 1.5];
|
||||||
|
const eventIndices = resultStateTransitionSampleIndices(time, {
|
||||||
|
integration: {
|
||||||
|
segments: [
|
||||||
|
{ stateTransitionTimes: [0.75] },
|
||||||
|
{ stateTransitionTimes: [0.7500000000000001] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect([...eventIndices]).toEqual([2]);
|
||||||
|
|
||||||
|
const rawForce = [1, 2, 1e12, 3, 4];
|
||||||
|
const displayedForce = continuousMechanicalForceChartValues(
|
||||||
|
mechanicalPortForce,
|
||||||
|
mechanicalComponent,
|
||||||
|
rawForce,
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(displayedForce.separatedEventSampleCount).toBe(1);
|
||||||
|
expect(Number.isNaN(displayedForce.values[2])).toBe(true);
|
||||||
|
expect(Math.max(...displayedForce.values.filter(Number.isFinite))).toBe(4);
|
||||||
|
expect(rawForce[2]).toBe(1e12);
|
||||||
|
|
||||||
|
const path = segmentedChartPath(
|
||||||
|
time,
|
||||||
|
displayedForce.values,
|
||||||
|
(value) => value,
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(path.match(/\bM /g)).toHaveLength(1);
|
||||||
|
|
||||||
|
const smoothForce = [1, 2, 2.5, 3, 4];
|
||||||
|
const displayedSmoothForce = continuousMechanicalForceChartValues(
|
||||||
|
mechanicalPortForce,
|
||||||
|
mechanicalComponent,
|
||||||
|
smoothForce,
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(displayedSmoothForce.values).toBe(smoothForce);
|
||||||
|
expect(displayedSmoothForce.separatedEventSampleCount).toBe(0);
|
||||||
|
|
||||||
|
const componentForce = continuousMechanicalForceChartValues(
|
||||||
|
{
|
||||||
|
scope: "component",
|
||||||
|
portName: null,
|
||||||
|
quantity: "force",
|
||||||
|
category: "derived",
|
||||||
|
},
|
||||||
|
mechanicalComponent,
|
||||||
|
[1, 2, -1e12, 3, 4],
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(componentForce.separatedEventSampleCount).toBe(1);
|
||||||
|
expect(Number.isNaN(componentForce.values[2])).toBe(true);
|
||||||
|
|
||||||
|
const signalForce = continuousMechanicalForceChartValues(
|
||||||
|
{
|
||||||
|
scope: "component",
|
||||||
|
portName: null,
|
||||||
|
quantity: "force",
|
||||||
|
category: "signal",
|
||||||
|
},
|
||||||
|
mechanicalComponent,
|
||||||
|
[1, 2, -1e12, 3, 4],
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(signalForce.values[2]).toBe(-1e12);
|
||||||
|
expect(signalForce.separatedEventSampleCount).toBe(0);
|
||||||
|
|
||||||
|
const stateValues = [0, 0.5, 1e9, 1, 1.5];
|
||||||
|
const displayedState = continuousMechanicalForceChartValues(
|
||||||
|
{ ...mechanicalPortForce, quantity: "velocity" },
|
||||||
|
mechanicalComponent,
|
||||||
|
stateValues,
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(displayedState.values).toBe(stateValues);
|
||||||
|
expect(displayedState.values[2]).toBe(1e9);
|
||||||
|
|
||||||
|
const pneumaticForce = continuousMechanicalForceChartValues(
|
||||||
|
mechanicalPortForce,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
ports: [{ name: "port_2", domain: "pneumatic" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rawForce,
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(pneumaticForce.values).toBe(rawForce);
|
||||||
|
|
||||||
|
const persistentContactForce = [0, 0, 1e6, 1e6, 1e6];
|
||||||
|
const displayedPersistentContactForce =
|
||||||
|
continuousMechanicalForceChartValues(
|
||||||
|
mechanicalPortForce,
|
||||||
|
mechanicalComponent,
|
||||||
|
persistentContactForce,
|
||||||
|
eventIndices,
|
||||||
|
);
|
||||||
|
expect(displayedPersistentContactForce.values).toBe(
|
||||||
|
persistentContactForce,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
displayedPersistentContactForce.separatedEventSampleCount,
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
resultStateTransitionSampleIndices(time, {}),
|
||||||
|
).toEqual(new Set<number>());
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import { prepareApp, resultSnapshot } from "./fixtures";
|
import { prepareApp, resultSnapshot } from "./fixtures";
|
||||||
|
|
||||||
@@ -34,11 +34,173 @@ const chartWindowSnapshot = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const eventForceSnapshot = {
|
||||||
|
...chartWindowSnapshot,
|
||||||
|
id: "e2e-mechanical-event-force-chart",
|
||||||
|
project: {
|
||||||
|
...chartWindowSnapshot.project,
|
||||||
|
nodes: chartWindowSnapshot.project.nodes.map((node, nodeIndex) =>
|
||||||
|
nodeIndex === 0
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
ports: node.data.ports.map((port, portIndex) =>
|
||||||
|
portIndex === 0
|
||||||
|
? { ...port, domain: "mechanical" }
|
||||||
|
: port,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
simulation: {
|
||||||
|
...chartWindowSnapshot.project.simulation,
|
||||||
|
t_stop: 2,
|
||||||
|
step: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
...chartWindowSnapshot.result,
|
||||||
|
simulatedUntil: 2,
|
||||||
|
requestedStopTime: 2,
|
||||||
|
variables: [
|
||||||
|
...chartWindowSnapshot.result.variables,
|
||||||
|
{
|
||||||
|
key: "generic_sensor_1.port_a.f",
|
||||||
|
componentId: "generic_sensor_1",
|
||||||
|
componentType: "generic_sensor",
|
||||||
|
scope: "port",
|
||||||
|
portName: "port_a",
|
||||||
|
name: "f",
|
||||||
|
label: "力",
|
||||||
|
quantity: "force",
|
||||||
|
unit: "N",
|
||||||
|
category: "flow",
|
||||||
|
order: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "generic_sensor_1.force",
|
||||||
|
componentId: "generic_sensor_1",
|
||||||
|
componentType: "generic_sensor",
|
||||||
|
scope: "component",
|
||||||
|
portName: null,
|
||||||
|
name: "force",
|
||||||
|
label: "接触力",
|
||||||
|
quantity: "force",
|
||||||
|
unit: "N",
|
||||||
|
category: "derived",
|
||||||
|
order: 31,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
final: {
|
||||||
|
...chartWindowSnapshot.result.final,
|
||||||
|
"generic_sensor_1.value": 2,
|
||||||
|
"generic_sensor_1.port_a.f": 5,
|
||||||
|
"generic_sensor_1.force": 5,
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
...chartWindowSnapshot.result.series,
|
||||||
|
time: [0, 0.5, 0.75, 1, 1.5, 2],
|
||||||
|
"generic_sensor_1.value": [0, 0.5, 1e9, 1, 1.5, 2],
|
||||||
|
"generic_sensor_1.port_a.f": [1, 2, 1e12, 3, 4, 5],
|
||||||
|
"generic_sensor_1.force": [1, 2, -1e12, 3, 4, 5],
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
...chartWindowSnapshot.result.diagnostics,
|
||||||
|
integration: {
|
||||||
|
segments: [{ stateTransitionTimes: [0.75] }],
|
||||||
|
},
|
||||||
|
sampleCount: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
async function openResults(page: Page) {
|
async function openResults(page: Page) {
|
||||||
await page.getByRole("tab", { name: "结果", exact: true }).click();
|
await page.getByRole("tab", { name: "结果", exact: true }).click();
|
||||||
await expect(page.locator(".results-chart-workspace")).toBeVisible();
|
await expect(page.locator(".results-chart-workspace")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readChartDomain(chart: Locator) {
|
||||||
|
return chart.evaluate((element) => ({
|
||||||
|
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||||
|
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||||
|
yMin: Number(element.getAttribute("data-view-y-min")),
|
||||||
|
yMax: Number(element.getAttribute("data-view-y-max")),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragChartSelection(
|
||||||
|
page: Page,
|
||||||
|
chart: Locator,
|
||||||
|
start: { x: number; y: number },
|
||||||
|
end: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
const bounds = await chart.boundingBox();
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
await page.mouse.move(
|
||||||
|
bounds!.x + bounds!.width * start.x,
|
||||||
|
bounds!.y + bounds!.height * start.y,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
bounds!.x + bounds!.width * end.x,
|
||||||
|
bounds!.y + bounds!.height * end.y,
|
||||||
|
{ steps: 5 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wheelAtLocator(
|
||||||
|
page: Page,
|
||||||
|
target: Locator,
|
||||||
|
deltaY = -240,
|
||||||
|
position = { x: 0.5, y: 0.5 },
|
||||||
|
) {
|
||||||
|
const bounds = await target.boundingBox();
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
await page.mouse.move(
|
||||||
|
bounds!.x + bounds!.width * position.x,
|
||||||
|
bounds!.y + bounds!.height * position.y,
|
||||||
|
);
|
||||||
|
await page.mouse.wheel(0, deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragMiddleAtLocator(
|
||||||
|
page: Page,
|
||||||
|
target: Locator,
|
||||||
|
delta = { x: 0.08, y: 0.08 },
|
||||||
|
) {
|
||||||
|
const bounds = await target.boundingBox();
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
const startX = bounds!.x + bounds!.width * 0.5;
|
||||||
|
const startY = bounds!.y + bounds!.height * 0.5;
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down({ button: "middle" });
|
||||||
|
await page.mouse.move(
|
||||||
|
startX + bounds!.width * delta.x,
|
||||||
|
startY + bounds!.height * delta.y,
|
||||||
|
{ steps: 5 },
|
||||||
|
);
|
||||||
|
await page.mouse.up({ button: "middle" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFlowViewport(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator(".results-system-canvas .react-flow__viewport")
|
||||||
|
.evaluate((viewport) => {
|
||||||
|
const values =
|
||||||
|
getComputedStyle(viewport)
|
||||||
|
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
|
||||||
|
?.map(Number) ?? [];
|
||||||
|
return {
|
||||||
|
x: values[4] ?? 0,
|
||||||
|
y: values[5] ?? 0,
|
||||||
|
zoom: values[0] ?? 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await prepareApp(page);
|
await prepareApp(page);
|
||||||
await page.addInitScript((snapshot) => {
|
await page.addInitScript((snapshot) => {
|
||||||
@@ -49,6 +211,95 @@ test.beforeEach(async ({ page }) => {
|
|||||||
}, chartWindowSnapshot);
|
}, chartWindowSnapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("机械状态事件力点不参与连续曲线纵轴,但原始数据与非力状态保持不变", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((snapshot) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
}, eventForceSnapshot);
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "port_a · 力" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const forceWindow = page
|
||||||
|
.locator('.result-chart-window[data-chart-kind="single"]')
|
||||||
|
.filter({ hasText: "port_a.f" });
|
||||||
|
await expect(forceWindow).toHaveAttribute(
|
||||||
|
"data-separated-event-samples",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
await expect(forceWindow).toContainText("已隐藏 1 个孤立事件力尖峰");
|
||||||
|
|
||||||
|
const forceChart = forceWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
const forceDomain = await readChartDomain(forceChart);
|
||||||
|
expect(forceDomain.yMax).toBeLessThan(10);
|
||||||
|
const forcePath = await forceChart
|
||||||
|
.locator('path[data-chart-clipped-series="true"]')
|
||||||
|
.getAttribute("d");
|
||||||
|
expect(forcePath?.match(/\bM /g)).toHaveLength(1);
|
||||||
|
|
||||||
|
const storedEventForce = await page.evaluate(() => {
|
||||||
|
const raw = sessionStorage.getItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
);
|
||||||
|
return raw
|
||||||
|
? JSON.parse(raw).result.series["generic_sensor_1.port_a.f"][2]
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
expect(storedEventForce).toBe(1e12);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "接触力" })
|
||||||
|
.click();
|
||||||
|
const componentForceWindow = page
|
||||||
|
.locator('.result-chart-window[data-chart-kind="single"]')
|
||||||
|
.filter({ hasText: "generic_sensor_1.force" });
|
||||||
|
await expect(componentForceWindow).toHaveAttribute(
|
||||||
|
"data-separated-event-samples",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
const componentForceDomain = await readChartDomain(
|
||||||
|
componentForceWindow.locator('svg[data-result-chart="true"]'),
|
||||||
|
);
|
||||||
|
expect(componentForceDomain.yMin).toBeGreaterThan(-10);
|
||||||
|
|
||||||
|
const storedComponentForce = await page.evaluate(() => {
|
||||||
|
const raw = sessionStorage.getItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
);
|
||||||
|
return raw
|
||||||
|
? JSON.parse(raw).result.series["generic_sensor_1.force"][2]
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
expect(storedComponentForce).toBe(-1e12);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "数值" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const stateWindow = page
|
||||||
|
.locator('.result-chart-window[data-chart-kind="single"]')
|
||||||
|
.filter({ hasText: "数值" });
|
||||||
|
await expect(stateWindow).toHaveAttribute(
|
||||||
|
"data-separated-event-samples",
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
const stateDomain = await readChartDomain(
|
||||||
|
stateWindow.locator('svg[data-result-chart="true"]'),
|
||||||
|
);
|
||||||
|
expect(stateDomain.yMax).toBeGreaterThan(1e8);
|
||||||
|
});
|
||||||
|
|
||||||
test("组合曲线窗口先创建为空窗,并在删除最后一条曲线后恢复待定", async ({
|
test("组合曲线窗口先创建为空窗,并在删除最后一条曲线后恢复待定", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -155,6 +406,553 @@ test("a pending window recovered from stale variable keys accepts a new first cu
|
|||||||
);
|
);
|
||||||
await expect(pendingWindow).toHaveAttribute("data-pending", "true");
|
await expect(pendingWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
|
||||||
await page.locator(".results-variable-list button").first().dragTo(pendingWindow);
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.first()
|
||||||
|
.dragTo(pendingWindow);
|
||||||
await expect(pendingWindow).toHaveAttribute("data-pending", "false");
|
await expect(pendingWindow).toHaveAttribute("data-pending", "false");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("single curve supports box zoom, original-size restore, and persisted interaction mode", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "数值" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
let chartWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="single"]',
|
||||||
|
);
|
||||||
|
let chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
await expect(chart).toBeVisible();
|
||||||
|
const initial = await readChartDomain(chart);
|
||||||
|
|
||||||
|
const zoomButton = chartWindow.getByRole("button", {
|
||||||
|
name: "打开 数值 曲线缩放",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await zoomButton.click();
|
||||||
|
await expect(
|
||||||
|
chartWindow.getByRole("button", {
|
||||||
|
name: "关闭 数值 曲线缩放",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
await dragChartSelection(
|
||||||
|
page,
|
||||||
|
chart,
|
||||||
|
{ x: 0.32, y: 0.3 },
|
||||||
|
{ x: 0.76, y: 0.72 },
|
||||||
|
);
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||||
|
await expect(chart.locator(".result-chart-zoom-selection")).toHaveCount(0);
|
||||||
|
await expect(chart.locator('[data-chart-clipped-series="true"]')).toHaveCount(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const zoomed = await readChartDomain(chart);
|
||||||
|
expect(zoomed.xMin).toBeGreaterThan(initial.xMin);
|
||||||
|
expect(zoomed.xMax).toBeLessThan(initial.xMax);
|
||||||
|
expect(zoomed.yMin).toBeGreaterThan(initial.yMin);
|
||||||
|
expect(zoomed.yMax).toBeLessThan(initial.yMax);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate((snapshotId) => {
|
||||||
|
const raw = sessionStorage.getItem(
|
||||||
|
`system-simulation-flow:result-layout:${snapshotId}`,
|
||||||
|
);
|
||||||
|
const windows = raw ? JSON.parse(raw) : [];
|
||||||
|
return windows[0]?.viewport?.x?.start ?? 0;
|
||||||
|
}, chartWindowSnapshot.id),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "建模", exact: true }).click();
|
||||||
|
await openResults(page);
|
||||||
|
chartWindow = page.locator('.result-chart-window[data-chart-kind="single"]');
|
||||||
|
chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||||
|
const restored = await readChartDomain(chart);
|
||||||
|
expect(restored.xMin).toBeCloseTo(zoomed.xMin, 8);
|
||||||
|
expect(restored.xMax).toBeCloseTo(zoomed.xMax, 8);
|
||||||
|
await expect(
|
||||||
|
chartWindow.getByRole("button", {
|
||||||
|
name: "关闭 数值 曲线缩放",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "打开 数值 曲线游标", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
chartWindow.getByRole("button", {
|
||||||
|
name: "打开 数值 曲线缩放",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", {
|
||||||
|
name: "恢复 数值 原始尺寸",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "false");
|
||||||
|
const undone = await readChartDomain(chart);
|
||||||
|
expect(undone.xMin).toBeCloseTo(initial.xMin, 8);
|
||||||
|
expect(undone.xMax).toBeCloseTo(initial.xMax, 8);
|
||||||
|
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
|
||||||
|
.click();
|
||||||
|
await dragChartSelection(
|
||||||
|
page,
|
||||||
|
chart,
|
||||||
|
{ x: 0.3, y: 0.28 },
|
||||||
|
{ x: 0.7, y: 0.68 },
|
||||||
|
);
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combined curves share X zoom while stacked Y zoom stays in the selected band", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
const valueVariable = page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "数值" })
|
||||||
|
.first();
|
||||||
|
const pressureVariable = page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "压力" })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true })
|
||||||
|
.click();
|
||||||
|
const multiWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="multi"]',
|
||||||
|
);
|
||||||
|
await valueVariable.dragTo(multiWindow);
|
||||||
|
const multiChart = multiWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
await multiWindow
|
||||||
|
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
|
||||||
|
.click();
|
||||||
|
await dragChartSelection(
|
||||||
|
page,
|
||||||
|
multiChart,
|
||||||
|
{ x: 0.31, y: 0.34 },
|
||||||
|
{ x: 0.73, y: 0.72 },
|
||||||
|
);
|
||||||
|
await expect(multiChart).toHaveAttribute("data-zoomed", "true");
|
||||||
|
await expect(
|
||||||
|
multiChart.locator('[data-chart-clipped-series="true"] path'),
|
||||||
|
).toHaveCount(1);
|
||||||
|
|
||||||
|
const multiCursor = multiWindow.locator("button.cursor");
|
||||||
|
await multiCursor.click();
|
||||||
|
await expect(multiWindow.locator(".result-chart-cursor-panel")).toBeVisible();
|
||||||
|
await multiWindow
|
||||||
|
.getByRole("button", { name: "管理多曲线窗口中的曲线", exact: true })
|
||||||
|
.click();
|
||||||
|
const visibilityToggle = multiWindow.locator(
|
||||||
|
'.result-chart-multi-menu-row input[type="checkbox"]',
|
||||||
|
);
|
||||||
|
await visibilityToggle.click();
|
||||||
|
await expect(multiCursor).toBeDisabled();
|
||||||
|
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await visibilityToggle.click();
|
||||||
|
await expect(multiCursor).toBeEnabled();
|
||||||
|
await expect(multiCursor).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "新建异单位上下对比窗口", exact: true })
|
||||||
|
.click();
|
||||||
|
const mixedWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="mixed"]',
|
||||||
|
);
|
||||||
|
await valueVariable.dragTo(mixedWindow);
|
||||||
|
await pressureVariable.dragTo(mixedWindow);
|
||||||
|
const mixedChart = mixedWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
const beforeXRanges = await mixedChart.evaluate((element) => ({
|
||||||
|
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||||
|
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||||
|
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||||
|
}));
|
||||||
|
await mixedWindow
|
||||||
|
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
|
||||||
|
.click();
|
||||||
|
await dragChartSelection(
|
||||||
|
page,
|
||||||
|
mixedChart,
|
||||||
|
{ x: 0.31, y: 0.13 },
|
||||||
|
{ x: 0.72, y: 0.42 },
|
||||||
|
);
|
||||||
|
await expect(mixedChart).toHaveAttribute("data-zoomed", "true");
|
||||||
|
const afterRanges = await mixedChart.evaluate((element) => ({
|
||||||
|
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||||
|
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||||
|
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||||
|
}));
|
||||||
|
expect(afterRanges.xMin).toBeGreaterThan(beforeXRanges.xMin);
|
||||||
|
expect(afterRanges.xMax).toBeLessThan(beforeXRanges.xMax);
|
||||||
|
expect(afterRanges.y["generic_sensor_1.value"]).not.toEqual(
|
||||||
|
beforeXRanges.y["generic_sensor_1.value"],
|
||||||
|
);
|
||||||
|
expect(afterRanges.y["generic_sensor_1.pressure"]).toEqual(
|
||||||
|
beforeXRanges.y["generic_sensor_1.pressure"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondYAxis = mixedChart.locator('[data-chart-zoom-axis="y"]').nth(1);
|
||||||
|
await expect(secondYAxis).toBeVisible();
|
||||||
|
await wheelAtLocator(page, secondYAxis);
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
mixedChart.evaluate((element) => {
|
||||||
|
const ranges = JSON.parse(
|
||||||
|
element.getAttribute("data-view-y-ranges") ?? "{}",
|
||||||
|
);
|
||||||
|
return JSON.stringify(ranges["generic_sensor_1.pressure"]);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.not.toBe(JSON.stringify(afterRanges.y["generic_sensor_1.pressure"]));
|
||||||
|
const axisWheelRanges = await mixedChart.evaluate((element) => ({
|
||||||
|
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||||
|
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||||
|
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(axisWheelRanges.xMin).toBeCloseTo(afterRanges.xMin, 8);
|
||||||
|
expect(axisWheelRanges.xMax).toBeCloseTo(afterRanges.xMax, 8);
|
||||||
|
expect(axisWheelRanges.y["generic_sensor_1.value"]).toEqual(
|
||||||
|
afterRanges.y["generic_sensor_1.value"],
|
||||||
|
);
|
||||||
|
expect(axisWheelRanges.y["generic_sensor_1.pressure"]).not.toEqual(
|
||||||
|
afterRanges.y["generic_sensor_1.pressure"],
|
||||||
|
);
|
||||||
|
await dragMiddleAtLocator(page, secondYAxis, { x: 0, y: 0.08 });
|
||||||
|
const bandPannedRanges = await mixedChart.evaluate((element) => ({
|
||||||
|
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||||
|
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||||
|
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||||
|
}));
|
||||||
|
expect(bandPannedRanges.xMin).toBeCloseTo(axisWheelRanges.xMin, 8);
|
||||||
|
expect(bandPannedRanges.xMax).toBeCloseTo(axisWheelRanges.xMax, 8);
|
||||||
|
expect(bandPannedRanges.y["generic_sensor_1.value"]).toEqual(
|
||||||
|
axisWheelRanges.y["generic_sensor_1.value"],
|
||||||
|
);
|
||||||
|
const beforePressure = axisWheelRanges.y["generic_sensor_1.pressure"];
|
||||||
|
const afterPressure = bandPannedRanges.y["generic_sensor_1.pressure"];
|
||||||
|
expect(afterPressure[1] - afterPressure[0]).toBeCloseTo(
|
||||||
|
beforePressure[1] - beforePressure[0],
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(afterPressure[0]).toBeGreaterThan(beforePressure[0]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mixedChart.locator('[data-chart-clipped-series="true"]'),
|
||||||
|
).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("result system viewport survives modeling and results tab switches", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
const pane = page.locator(".results-system-canvas .react-flow__pane");
|
||||||
|
const paneBounds = await pane.boundingBox();
|
||||||
|
expect(paneBounds).not.toBeNull();
|
||||||
|
await page.mouse.move(
|
||||||
|
paneBounds!.x + paneBounds!.width * 0.55,
|
||||||
|
paneBounds!.y + paneBounds!.height * 0.5,
|
||||||
|
);
|
||||||
|
const fitted = await readFlowViewport(page);
|
||||||
|
await page.mouse.wheel(0, -260);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFlowViewport(page)).zoom)
|
||||||
|
.not.toBeCloseTo(fitted.zoom, 3);
|
||||||
|
const adjusted = await readFlowViewport(page);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate((snapshotId) => {
|
||||||
|
const raw = sessionStorage.getItem(
|
||||||
|
`system-simulation-flow:result-system-viewport:${snapshotId}`,
|
||||||
|
);
|
||||||
|
return raw ? JSON.parse(raw).zoom : 0;
|
||||||
|
}, chartWindowSnapshot.id),
|
||||||
|
)
|
||||||
|
.toBeCloseTo(adjusted.zoom, 3);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "建模", exact: true }).click();
|
||||||
|
await openResults(page);
|
||||||
|
const restored = await readFlowViewport(page);
|
||||||
|
expect(restored.x).toBeCloseTo(adjusted.x, 1);
|
||||||
|
expect(restored.y).toBeCloseTo(adjusted.y, 1);
|
||||||
|
expect(restored.zoom).toBeCloseTo(adjusted.zoom, 3);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "适应系统图窗口", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readFlowViewport(page)).zoom)
|
||||||
|
.not.toBeCloseTo(restored.zoom, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zoom wheel follows the pointer and axis hit areas while cursor keeps the viewport", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "数值" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const chartWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="single"]',
|
||||||
|
);
|
||||||
|
const chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
await expect(chart).toBeVisible();
|
||||||
|
const cursorButton = chartWindow.getByRole("button", {
|
||||||
|
name: "打开 数值 曲线游标",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await cursorButton.click();
|
||||||
|
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
|
||||||
|
const zoomButton = chartWindow.getByRole("button", {
|
||||||
|
name: "打开 数值 曲线缩放",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await zoomButton.click();
|
||||||
|
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initial = await readChartDomain(chart);
|
||||||
|
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
|
||||||
|
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
|
||||||
|
await expect(xAxis).toBeVisible();
|
||||||
|
await expect(yAxis).toBeVisible();
|
||||||
|
|
||||||
|
const xAxisBounds = await xAxis.boundingBox();
|
||||||
|
const yAxisBounds = await yAxis.boundingBox();
|
||||||
|
expect(xAxisBounds).not.toBeNull();
|
||||||
|
expect(yAxisBounds).not.toBeNull();
|
||||||
|
const plotPointer = { x: 0.25, y: 0.35 };
|
||||||
|
const initialXAnchor =
|
||||||
|
initial.xMin + (initial.xMax - initial.xMin) * plotPointer.x;
|
||||||
|
const initialYAnchor =
|
||||||
|
initial.yMax - (initial.yMax - initial.yMin) * plotPointer.y;
|
||||||
|
await page.mouse.move(
|
||||||
|
xAxisBounds!.x + xAxisBounds!.width * plotPointer.x,
|
||||||
|
yAxisBounds!.y + yAxisBounds!.height * plotPointer.y,
|
||||||
|
);
|
||||||
|
await page.mouse.wheel(0, -240);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const current = await readChartDomain(chart);
|
||||||
|
return current.xMax - current.xMin;
|
||||||
|
})
|
||||||
|
.toBeLessThan(initial.xMax - initial.xMin);
|
||||||
|
const plotZoomed = await readChartDomain(chart);
|
||||||
|
expect(plotZoomed.yMax - plotZoomed.yMin).toBeLessThan(
|
||||||
|
initial.yMax - initial.yMin,
|
||||||
|
);
|
||||||
|
const zoomedXAnchor =
|
||||||
|
plotZoomed.xMin + (plotZoomed.xMax - plotZoomed.xMin) * plotPointer.x;
|
||||||
|
const zoomedYAnchor =
|
||||||
|
plotZoomed.yMax - (plotZoomed.yMax - plotZoomed.yMin) * plotPointer.y;
|
||||||
|
expect(
|
||||||
|
Math.abs(zoomedXAnchor - initialXAnchor) / (initial.xMax - initial.xMin),
|
||||||
|
).toBeLessThan(0.002);
|
||||||
|
expect(
|
||||||
|
Math.abs(zoomedYAnchor - initialYAnchor) / (initial.yMax - initial.yMin),
|
||||||
|
).toBeLessThan(0.002);
|
||||||
|
|
||||||
|
await wheelAtLocator(page, xAxis);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const current = await readChartDomain(chart);
|
||||||
|
return current.xMax - current.xMin;
|
||||||
|
})
|
||||||
|
.toBeLessThan(plotZoomed.xMax - plotZoomed.xMin);
|
||||||
|
const xOnlyZoomed = await readChartDomain(chart);
|
||||||
|
expect(xOnlyZoomed.yMin).toBeCloseTo(plotZoomed.yMin, 8);
|
||||||
|
expect(xOnlyZoomed.yMax).toBeCloseTo(plotZoomed.yMax, 8);
|
||||||
|
|
||||||
|
await wheelAtLocator(page, yAxis);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const current = await readChartDomain(chart);
|
||||||
|
return current.yMax - current.yMin;
|
||||||
|
})
|
||||||
|
.toBeLessThan(xOnlyZoomed.yMax - xOnlyZoomed.yMin);
|
||||||
|
const yOnlyZoomed = await readChartDomain(chart);
|
||||||
|
expect(yOnlyZoomed.xMin).toBeCloseTo(xOnlyZoomed.xMin, 8);
|
||||||
|
expect(yOnlyZoomed.xMax).toBeCloseTo(xOnlyZoomed.xMax, 8);
|
||||||
|
|
||||||
|
const beforePlotPan = yOnlyZoomed;
|
||||||
|
await dragMiddleAtLocator(page, chart, { x: 0.05, y: 0.04 });
|
||||||
|
const plotPanned = await readChartDomain(chart);
|
||||||
|
expect(plotPanned.xMax - plotPanned.xMin).toBeCloseTo(
|
||||||
|
beforePlotPan.xMax - beforePlotPan.xMin,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(plotPanned.yMax - plotPanned.yMin).toBeCloseTo(
|
||||||
|
beforePlotPan.yMax - beforePlotPan.yMin,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(plotPanned.xMin).toBeLessThan(beforePlotPan.xMin);
|
||||||
|
expect(plotPanned.yMin).toBeGreaterThan(beforePlotPan.yMin);
|
||||||
|
|
||||||
|
const beforeXAxisPan = plotPanned;
|
||||||
|
await dragMiddleAtLocator(page, xAxis, { x: 0.06, y: 0 });
|
||||||
|
const xAxisPanned = await readChartDomain(chart);
|
||||||
|
expect(xAxisPanned.xMax - xAxisPanned.xMin).toBeCloseTo(
|
||||||
|
beforeXAxisPan.xMax - beforeXAxisPan.xMin,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(xAxisPanned.xMin).toBeLessThan(beforeXAxisPan.xMin);
|
||||||
|
expect(xAxisPanned.yMin).toBeCloseTo(beforeXAxisPan.yMin, 8);
|
||||||
|
expect(xAxisPanned.yMax).toBeCloseTo(beforeXAxisPan.yMax, 8);
|
||||||
|
|
||||||
|
const beforeYAxisPan = xAxisPanned;
|
||||||
|
await dragMiddleAtLocator(page, yAxis, { x: 0, y: 0.06 });
|
||||||
|
const yAxisPanned = await readChartDomain(chart);
|
||||||
|
expect(yAxisPanned.yMax - yAxisPanned.yMin).toBeCloseTo(
|
||||||
|
beforeYAxisPan.yMax - beforeYAxisPan.yMin,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(yAxisPanned.yMin).toBeGreaterThan(beforeYAxisPan.yMin);
|
||||||
|
expect(yAxisPanned.xMin).toBeCloseTo(beforeYAxisPan.xMin, 8);
|
||||||
|
expect(yAxisPanned.xMax).toBeCloseTo(beforeYAxisPan.xMax, 8);
|
||||||
|
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.mouse.move(
|
||||||
|
xAxisBounds!.x + xAxisBounds!.width * 0.5,
|
||||||
|
yAxisBounds!.y + yAxisBounds!.height * 0.5,
|
||||||
|
);
|
||||||
|
await page.mouse.wheel(0, -180);
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||||
|
|
||||||
|
const beforeCursor = await readChartDomain(chart);
|
||||||
|
await cursorButton.click();
|
||||||
|
await expect(
|
||||||
|
chartWindow.getByRole("button", {
|
||||||
|
name: "打开 数值 曲线缩放",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
|
||||||
|
const withCursor = await readChartDomain(chart);
|
||||||
|
expect(withCursor).toEqual(beforeCursor);
|
||||||
|
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.mouse.move(
|
||||||
|
xAxisBounds!.x + xAxisBounds!.width * 0.5,
|
||||||
|
yAxisBounds!.y + yAxisBounds!.height * 0.5,
|
||||||
|
);
|
||||||
|
await page.mouse.wheel(0, 900);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readChartDomain(chart)).xMin)
|
||||||
|
.toBeLessThan(initial.xMin);
|
||||||
|
const expandedDomain = await readChartDomain(chart);
|
||||||
|
expect(expandedDomain.xMax).toBeGreaterThan(initial.xMax);
|
||||||
|
expect(expandedDomain.yMin).toBeLessThan(initial.yMin);
|
||||||
|
expect(expandedDomain.yMax).toBeGreaterThan(initial.yMax);
|
||||||
|
const outline = await chart.evaluate((element) => {
|
||||||
|
element.focus();
|
||||||
|
return getComputedStyle(element).outlineStyle;
|
||||||
|
});
|
||||||
|
expect(outline).toBe("none");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate((snapshotId) => {
|
||||||
|
const raw = sessionStorage.getItem(
|
||||||
|
`system-simulation-flow:result-layout:${snapshotId}`,
|
||||||
|
);
|
||||||
|
const windows = raw ? JSON.parse(raw) : [];
|
||||||
|
const single = windows.find(
|
||||||
|
(item: { kind?: string }) => item.kind === "single",
|
||||||
|
);
|
||||||
|
return single?.viewport?.x?.start ?? 0;
|
||||||
|
}, chartWindowSnapshot.id),
|
||||||
|
)
|
||||||
|
.toBeLessThan(0);
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "false");
|
||||||
|
const restoredDomain = await readChartDomain(chart);
|
||||||
|
expect(restoredDomain).toEqual(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cursor is unavailable when a zoomed viewport contains no curve", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
await page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "数值" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
const chartWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="single"]',
|
||||||
|
);
|
||||||
|
const chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
|
||||||
|
.click();
|
||||||
|
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
|
||||||
|
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
|
||||||
|
const xBounds = await xAxis.boundingBox();
|
||||||
|
const yBounds = await yAxis.boundingBox();
|
||||||
|
expect(xBounds).not.toBeNull();
|
||||||
|
expect(yBounds).not.toBeNull();
|
||||||
|
await page.mouse.move(
|
||||||
|
xBounds!.x + xBounds!.width * 0.08,
|
||||||
|
yBounds!.y + yBounds!.height * 0.08,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
xBounds!.x + xBounds!.width * 0.32,
|
||||||
|
yBounds!.y + yBounds!.height * 0.28,
|
||||||
|
{ steps: 5 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||||
|
const cursorButton = chartWindow.locator("button.cursor");
|
||||||
|
await expect(cursorButton).toBeDisabled();
|
||||||
|
await chartWindow
|
||||||
|
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(cursorButton).toBeEnabled();
|
||||||
|
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
createSimulationActivityWatchdog,
|
||||||
|
isSimulationSolverActivityPhase,
|
||||||
|
observeSimulationActivityWatchdog,
|
||||||
|
} from "../../src/App";
|
||||||
|
|
||||||
|
const TEST_STALL_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
|
test("首个含 activitySequence 的慢步心跳只建立活动基线", () => {
|
||||||
|
const state = createSimulationActivityWatchdog(0);
|
||||||
|
const watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ heartbeat: true, activitySequence: 42 },
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(watched.observation.status).toBe("slow-active");
|
||||||
|
expect(watched.observation.acceptedProgressIdleMs).toBe(TEST_STALL_TIMEOUT_MS);
|
||||||
|
expect(watched.observation.activityIdleMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepted time 停滞但 activitySequence 持续增长时保持慢步运行", () => {
|
||||||
|
let state = createSimulationActivityWatchdog(0);
|
||||||
|
|
||||||
|
({ state } = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 1,
|
||||||
|
acceptedStepSequence: 10,
|
||||||
|
acceptedTime: 0.048,
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
));
|
||||||
|
let watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 2,
|
||||||
|
acceptedStepSequence: 10,
|
||||||
|
acceptedTime: 0.048,
|
||||||
|
},
|
||||||
|
1_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
state = watched.state;
|
||||||
|
expect(watched.observation.status).toBe("slow-active");
|
||||||
|
|
||||||
|
watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 3,
|
||||||
|
acceptedStepSequence: 10,
|
||||||
|
acceptedTime: 0.048,
|
||||||
|
},
|
||||||
|
2_500,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(watched.observation.status).toBe("slow-active");
|
||||||
|
expect(watched.observation.acceptedProgressIdleMs).toBe(2_500);
|
||||||
|
expect(watched.observation.activityIdleMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("旧后端缺少活动字段时只报告遥测不可用且不误判 stalled", () => {
|
||||||
|
const state = createSimulationActivityWatchdog(0);
|
||||||
|
const watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ heartbeat: true },
|
||||||
|
20_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(watched.observation.status).toBe("slow-telemetry-unavailable");
|
||||||
|
expect(watched.observation.activityIdleMs).toBeNull();
|
||||||
|
expect(watched.observation.activityTelemetryAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("旧后端缺少活动字段时使用 15 分钟兼容兜底", () => {
|
||||||
|
const state = createSimulationActivityWatchdog(0);
|
||||||
|
const watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ heartbeat: true },
|
||||||
|
900_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(watched.observation.status).toBe("stalled");
|
||||||
|
expect(watched.observation.activityTelemetryAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("接受步和明确可用的活动序号都长期不变时才报告 stalled", () => {
|
||||||
|
let state = createSimulationActivityWatchdog(0);
|
||||||
|
({ state } = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ heartbeat: true, activitySequence: 7 },
|
||||||
|
0,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
));
|
||||||
|
|
||||||
|
const beforeDeadline = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ heartbeat: true, activitySequence: 7 },
|
||||||
|
999,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(beforeDeadline.observation.status).toBe("progressing");
|
||||||
|
|
||||||
|
const atDeadline = observeSimulationActivityWatchdog(
|
||||||
|
beforeDeadline.state,
|
||||||
|
{ heartbeat: true, activitySequence: 7 },
|
||||||
|
1_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(atDeadline.observation.status).toBe("stalled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heartbeat 报告新的 acceptedStepSequence 时重置接受步计时", () => {
|
||||||
|
let state = createSimulationActivityWatchdog(0);
|
||||||
|
({ state } = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 1,
|
||||||
|
acceptedStepSequence: 10,
|
||||||
|
acceptedTime: 0.048,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
));
|
||||||
|
|
||||||
|
const watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 2,
|
||||||
|
acceptedStepSequence: 11,
|
||||||
|
acceptedTime: 0.049,
|
||||||
|
},
|
||||||
|
5_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(watched.observation.status).toBe("progressing");
|
||||||
|
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
|
||||||
|
expect(watched.state.lastAcceptedProgressAt).toBe(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validation 和 compilation 阶段不参与求解器停滞判定", () => {
|
||||||
|
let state = createSimulationActivityWatchdog(0);
|
||||||
|
|
||||||
|
let watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ phase: "validation", heartbeat: true },
|
||||||
|
90_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
state = watched.state;
|
||||||
|
expect(watched.observation.status).toBe("progressing");
|
||||||
|
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
|
||||||
|
expect(watched.state.integratingPhaseActive).toBe(false);
|
||||||
|
|
||||||
|
watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ phase: "compilation", heartbeat: true, activitySequence: 7 },
|
||||||
|
180_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(watched.observation.status).toBe("progressing");
|
||||||
|
expect(watched.observation.activityIdleMs).toBeNull();
|
||||||
|
expect(isSimulationSolverActivityPhase("compilation")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("进入 integrating 时重新建立计时基线", () => {
|
||||||
|
let state = createSimulationActivityWatchdog(0);
|
||||||
|
({ state } = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ phase: "initialization", heartbeat: true },
|
||||||
|
120_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
));
|
||||||
|
|
||||||
|
let watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
phase: "integrating",
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 11,
|
||||||
|
acceptedStepSequence: 3,
|
||||||
|
acceptedTime: 0.048,
|
||||||
|
},
|
||||||
|
180_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
state = watched.state;
|
||||||
|
expect(watched.observation.status).toBe("progressing");
|
||||||
|
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
|
||||||
|
expect(watched.observation.activityIdleMs).toBe(0);
|
||||||
|
expect(watched.state.integratingPhaseActive).toBe(true);
|
||||||
|
expect(isSimulationSolverActivityPhase("integrating")).toBe(true);
|
||||||
|
|
||||||
|
watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
phase: "integrating",
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 11,
|
||||||
|
acceptedStepSequence: 3,
|
||||||
|
acceptedTime: 0.048,
|
||||||
|
},
|
||||||
|
181_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(watched.observation.status).toBe("stalled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("离开 integrating 后清除旧积分阶段的停滞状态", () => {
|
||||||
|
let state = createSimulationActivityWatchdog(0);
|
||||||
|
({ state } = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
phase: "integrating",
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 5,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
));
|
||||||
|
({ state } = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
phase: "integrating",
|
||||||
|
heartbeat: true,
|
||||||
|
activitySequence: 5,
|
||||||
|
},
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
));
|
||||||
|
|
||||||
|
const watched = observeSimulationActivityWatchdog(
|
||||||
|
state,
|
||||||
|
{ phase: "postprocessing", heartbeat: true },
|
||||||
|
60_000,
|
||||||
|
TEST_STALL_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
expect(watched.observation.status).toBe("progressing");
|
||||||
|
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
|
||||||
|
expect(watched.state.integratingPhaseActive).toBe(false);
|
||||||
|
expect(watched.state.lastActivitySequence).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
type CompactProgressEvent = {
|
||||||
|
receivedAtMs: number;
|
||||||
|
phase?: string;
|
||||||
|
heartbeat: boolean;
|
||||||
|
progress?: number;
|
||||||
|
simulatedTime?: number;
|
||||||
|
totalTime?: number;
|
||||||
|
activitySequence?: number;
|
||||||
|
activityKind?: string;
|
||||||
|
currentTrialTime?: number | null;
|
||||||
|
rhsCallCount?: number;
|
||||||
|
acceptedStepSequence?: number;
|
||||||
|
acceptedTime?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompactResult = {
|
||||||
|
success?: boolean;
|
||||||
|
status?: string;
|
||||||
|
partial?: boolean;
|
||||||
|
simulatedUntil?: number;
|
||||||
|
requestedStopTime?: number;
|
||||||
|
sampleCount?: number;
|
||||||
|
timePointCount?: number;
|
||||||
|
finalTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserStreamEvidence = {
|
||||||
|
lineCount: number;
|
||||||
|
progressEvents: CompactProgressEvent[];
|
||||||
|
result: CompactResult | null;
|
||||||
|
streamErrors: Array<{
|
||||||
|
receivedAtMs: number;
|
||||||
|
status?: number;
|
||||||
|
message?: string;
|
||||||
|
detail?: unknown;
|
||||||
|
}>;
|
||||||
|
parseErrors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_PATH = fileURLToPath(
|
||||||
|
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
|
||||||
|
);
|
||||||
|
const EVIDENCE_PATH = fileURLToPath(
|
||||||
|
new URL(
|
||||||
|
"../../../tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-browser-live-activity-v1-0.2.json",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const EXPECTED_RUNTIME_ENVIRONMENT = {
|
||||||
|
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",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function extractSimulationAttribute(xml: string, name: string) {
|
||||||
|
const match = xml.match(new RegExp(`\\b${name}="([^"]+)"`));
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActivitySummary(events: CompactProgressEvent[]) {
|
||||||
|
const heartbeats = events.filter((event) => event.heartbeat);
|
||||||
|
const activityHeartbeats = heartbeats.filter(
|
||||||
|
(event) => typeof event.activitySequence === "number",
|
||||||
|
);
|
||||||
|
const activitySequences = activityHeartbeats.map(
|
||||||
|
(event) => event.activitySequence as number,
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptedTransitions: CompactProgressEvent[] = [];
|
||||||
|
let lastAcceptedStepSequence: number | undefined;
|
||||||
|
for (const event of events) {
|
||||||
|
if (
|
||||||
|
event.phase !== "integrating" ||
|
||||||
|
typeof event.acceptedStepSequence !== "number"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.acceptedStepSequence === lastAcceptedStepSequence) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastAcceptedStepSequence = event.acceptedStepSequence;
|
||||||
|
acceptedTransitions.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let maximumAcceptedPlateau:
|
||||||
|
| {
|
||||||
|
durationMs: number;
|
||||||
|
fromAcceptedStepSequence?: number;
|
||||||
|
toAcceptedStepSequence?: number;
|
||||||
|
fromAcceptedTime?: number | null;
|
||||||
|
toAcceptedTime?: number | null;
|
||||||
|
heartbeatCount: number;
|
||||||
|
activitySequenceStart: number | null;
|
||||||
|
activitySequenceEnd: number | null;
|
||||||
|
activitySequenceAdvanced: boolean;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
for (let index = 1; index < acceptedTransitions.length; index += 1) {
|
||||||
|
const previous = acceptedTransitions[index - 1];
|
||||||
|
const current = acceptedTransitions[index];
|
||||||
|
const plateauHeartbeats = activityHeartbeats.filter(
|
||||||
|
(event) =>
|
||||||
|
event.receivedAtMs >= previous.receivedAtMs &&
|
||||||
|
event.receivedAtMs <= current.receivedAtMs,
|
||||||
|
);
|
||||||
|
const plateauActivitySequences = plateauHeartbeats.map(
|
||||||
|
(event) => event.activitySequence as number,
|
||||||
|
);
|
||||||
|
const candidate = {
|
||||||
|
durationMs: current.receivedAtMs - previous.receivedAtMs,
|
||||||
|
fromAcceptedStepSequence: previous.acceptedStepSequence,
|
||||||
|
toAcceptedStepSequence: current.acceptedStepSequence,
|
||||||
|
fromAcceptedTime: previous.acceptedTime,
|
||||||
|
toAcceptedTime: current.acceptedTime,
|
||||||
|
heartbeatCount: plateauHeartbeats.length,
|
||||||
|
activitySequenceStart: plateauActivitySequences.at(0) ?? null,
|
||||||
|
activitySequenceEnd: plateauActivitySequences.at(-1) ?? null,
|
||||||
|
activitySequenceAdvanced:
|
||||||
|
plateauActivitySequences.length >= 2 &&
|
||||||
|
(plateauActivitySequences.at(-1) as number) >
|
||||||
|
(plateauActivitySequences.at(0) as number),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!maximumAcceptedPlateau ||
|
||||||
|
candidate.durationMs > maximumAcceptedPlateau.durationMs
|
||||||
|
) {
|
||||||
|
maximumAcceptedPlateau = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slowRegionEvents = events.filter((event) => {
|
||||||
|
const reportedTimes = [
|
||||||
|
event.acceptedTime,
|
||||||
|
event.currentTrialTime,
|
||||||
|
event.simulatedTime,
|
||||||
|
].filter((value): value is number => typeof value === "number");
|
||||||
|
return reportedTimes.some((value) => value >= 0.0475 && value <= 0.0505);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
progressEventCount: events.length,
|
||||||
|
heartbeatCount: heartbeats.length,
|
||||||
|
activityTelemetryHeartbeatCount: activityHeartbeats.length,
|
||||||
|
firstActivitySequence: activitySequences.at(0) ?? null,
|
||||||
|
lastActivitySequence: activitySequences.at(-1) ?? null,
|
||||||
|
activitySequenceAdvanced:
|
||||||
|
activitySequences.length >= 2 &&
|
||||||
|
(activitySequences.at(-1) as number) >
|
||||||
|
(activitySequences.at(0) as number),
|
||||||
|
acceptedTransitionCount: acceptedTransitions.length,
|
||||||
|
maximumAcceptedPlateau: maximumAcceptedPlateau ?? null,
|
||||||
|
slowRegionEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBrowserStreamEvidence(page: Page): Promise<BrowserStreamEvidence> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const state = (
|
||||||
|
window as typeof window & { __mql8LiveStreamEvidence?: BrowserStreamEvidence }
|
||||||
|
).__mql8LiveStreamEvidence;
|
||||||
|
return (
|
||||||
|
state ?? {
|
||||||
|
lineCount: 0,
|
||||||
|
progressEvents: [],
|
||||||
|
result: null,
|
||||||
|
streamErrors: [],
|
||||||
|
parseErrors: ["Browser stream evidence was not initialized."],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBrowserStreamEvidence(): BrowserStreamEvidence {
|
||||||
|
return {
|
||||||
|
lineCount: 0,
|
||||||
|
progressEvents: [],
|
||||||
|
result: null,
|
||||||
|
streamErrors: [],
|
||||||
|
parseErrors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retainMoreCompleteEvidence(
|
||||||
|
retained: BrowserStreamEvidence,
|
||||||
|
candidate: BrowserStreamEvidence,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
candidate.lineCount > retained.lineCount ||
|
||||||
|
(candidate.lineCount === retained.lineCount &&
|
||||||
|
candidate.result !== null &&
|
||||||
|
retained.result === null)
|
||||||
|
) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("test-mql-8 在真实浏览器流式链路完成 0.2 s BDF 仿真", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const startedAtIso = new Date(startedAt).toISOString();
|
||||||
|
const cancellationRequests: string[] = [];
|
||||||
|
const browserConsoleErrors: string[] = [];
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
let simulationXml = "";
|
||||||
|
let simulationId: string | undefined;
|
||||||
|
let workflowCompleted = false;
|
||||||
|
let retainedStreamEvidence = emptyBrowserStreamEvidence();
|
||||||
|
let evidenceRetentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
page.on("request", (request) => {
|
||||||
|
const url = request.url();
|
||||||
|
if (url.includes("/api/system-xml/simulate-stream")) {
|
||||||
|
simulationXml = request.postData() ?? "";
|
||||||
|
simulationId = request.headers()["x-simulation-id"];
|
||||||
|
}
|
||||||
|
if (/\/api\/system-xml\/simulations\/[^/]+\/cancel(?:\?|$)/.test(url)) {
|
||||||
|
cancellationRequests.push(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") {
|
||||||
|
browserConsoleErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
window.location.hash = "#/modeling";
|
||||||
|
|
||||||
|
const evidence: BrowserStreamEvidence = {
|
||||||
|
lineCount: 0,
|
||||||
|
progressEvents: [],
|
||||||
|
result: null,
|
||||||
|
streamErrors: [],
|
||||||
|
parseErrors: [],
|
||||||
|
};
|
||||||
|
(
|
||||||
|
window as typeof window & { __mql8LiveStreamEvidence?: BrowserStreamEvidence }
|
||||||
|
).__mql8LiveStreamEvidence = evidence;
|
||||||
|
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = async (...args: Parameters<typeof window.fetch>) => {
|
||||||
|
const response = await nativeFetch(...args);
|
||||||
|
const input = args[0];
|
||||||
|
const requestUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
if (!requestUrl.includes("/api/system-xml/simulate-stream") || !response.body) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
const recordLine = (line: string) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
evidence.lineCount += 1;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
const receivedAtMs = performance.now();
|
||||||
|
if (parsed.event === "progress") {
|
||||||
|
evidence.progressEvents.push({
|
||||||
|
receivedAtMs,
|
||||||
|
phase: typeof parsed.phase === "string" ? parsed.phase : undefined,
|
||||||
|
heartbeat: parsed.heartbeat === true,
|
||||||
|
progress:
|
||||||
|
typeof parsed.progress === "number" ? parsed.progress : undefined,
|
||||||
|
simulatedTime:
|
||||||
|
typeof parsed.simulatedTime === "number"
|
||||||
|
? parsed.simulatedTime
|
||||||
|
: undefined,
|
||||||
|
totalTime:
|
||||||
|
typeof parsed.totalTime === "number" ? parsed.totalTime : undefined,
|
||||||
|
activitySequence:
|
||||||
|
typeof parsed.activitySequence === "number"
|
||||||
|
? parsed.activitySequence
|
||||||
|
: undefined,
|
||||||
|
activityKind:
|
||||||
|
typeof parsed.activityKind === "string"
|
||||||
|
? parsed.activityKind
|
||||||
|
: undefined,
|
||||||
|
currentTrialTime:
|
||||||
|
typeof parsed.currentTrialTime === "number" ||
|
||||||
|
parsed.currentTrialTime === null
|
||||||
|
? parsed.currentTrialTime
|
||||||
|
: undefined,
|
||||||
|
rhsCallCount:
|
||||||
|
typeof parsed.rhsCallCount === "number"
|
||||||
|
? parsed.rhsCallCount
|
||||||
|
: undefined,
|
||||||
|
acceptedStepSequence:
|
||||||
|
typeof parsed.acceptedStepSequence === "number"
|
||||||
|
? parsed.acceptedStepSequence
|
||||||
|
: undefined,
|
||||||
|
acceptedTime:
|
||||||
|
typeof parsed.acceptedTime === "number" || parsed.acceptedTime === null
|
||||||
|
? parsed.acceptedTime
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} else if (parsed.event === "result") {
|
||||||
|
const result = parsed.result as Record<string, unknown> | undefined;
|
||||||
|
const diagnostics = result?.diagnostics as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const series = result?.series as Record<string, unknown> | undefined;
|
||||||
|
const times = Array.isArray(series?.time)
|
||||||
|
? (series.time as unknown[]).filter(
|
||||||
|
(value): value is number => typeof value === "number",
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
evidence.result = {
|
||||||
|
success:
|
||||||
|
typeof result?.success === "boolean" ? result.success : undefined,
|
||||||
|
status: typeof result?.status === "string" ? result.status : undefined,
|
||||||
|
partial:
|
||||||
|
typeof result?.partial === "boolean" ? result.partial : undefined,
|
||||||
|
simulatedUntil:
|
||||||
|
typeof result?.simulatedUntil === "number"
|
||||||
|
? result.simulatedUntil
|
||||||
|
: undefined,
|
||||||
|
requestedStopTime:
|
||||||
|
typeof result?.requestedStopTime === "number"
|
||||||
|
? result.requestedStopTime
|
||||||
|
: undefined,
|
||||||
|
sampleCount:
|
||||||
|
typeof diagnostics?.sampleCount === "number"
|
||||||
|
? diagnostics.sampleCount
|
||||||
|
: undefined,
|
||||||
|
timePointCount: times.length,
|
||||||
|
finalTime: times.at(-1),
|
||||||
|
};
|
||||||
|
} else if (parsed.event === "error") {
|
||||||
|
evidence.streamErrors.push({
|
||||||
|
receivedAtMs,
|
||||||
|
status: typeof parsed.status === "number" ? parsed.status : undefined,
|
||||||
|
message:
|
||||||
|
typeof parsed.message === "string" ? parsed.message : undefined,
|
||||||
|
detail: parsed.detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
evidence.parseErrors.push(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const flushLines = () => {
|
||||||
|
let newlineIndex = buffer.indexOf("\n");
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
recordLine(buffer.slice(0, newlineIndex));
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
newlineIndex = buffer.indexOf("\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const monitoredBody = response.body.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
flushLines();
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
buffer += decoder.decode();
|
||||||
|
flushLines();
|
||||||
|
recordLine(buffer);
|
||||||
|
buffer = "";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return new Response(monitoredBody, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const retainCurrentStreamEvidence = async () => {
|
||||||
|
const candidate = await readBrowserStreamEvidence(page);
|
||||||
|
retainedStreamEvidence = retainMoreCompleteEvidence(
|
||||||
|
retainedStreamEvidence,
|
||||||
|
candidate,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
evidenceRetentionTimer = setInterval(() => {
|
||||||
|
void retainCurrentStreamEvidence().catch(() => undefined);
|
||||||
|
}, 1_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".catalog-source.ready")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('input[type="file"]').setInputFiles(PROJECT_PATH);
|
||||||
|
const consolePanel = page.getByRole("complementary", {
|
||||||
|
name: "仿真控制台",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const consoleSummary = consolePanel.locator(
|
||||||
|
".simulation-console-dock-summary",
|
||||||
|
);
|
||||||
|
await expect(consoleSummary).toContainText(
|
||||||
|
"已导入工程:test-mql-8.json",
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopTimeInput = page.getByLabel("结束时间", { exact: true });
|
||||||
|
await stopTimeInput.fill("0.2");
|
||||||
|
await stopTimeInput.press("Enter");
|
||||||
|
await expect(stopTimeInput).toHaveValue("0.2");
|
||||||
|
await expect(page.getByLabel("采样步长", { exact: true })).toHaveValue(
|
||||||
|
"0.01",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByLabel("最大积分步长", { exact: true }),
|
||||||
|
).toHaveValue("0.001");
|
||||||
|
await expect(page.getByLabel("求解器", { exact: true })).toHaveValue("BDF");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
|
||||||
|
await expect.poll(() => simulationXml.length, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||||
|
expect(extractSimulationAttribute(simulationXml, "tStop")).toBe("0.2");
|
||||||
|
expect(extractSimulationAttribute(simulationXml, "sampleStep")).toBe("0.01");
|
||||||
|
expect(extractSimulationAttribute(simulationXml, "maxStep")).toBe("0.001");
|
||||||
|
expect(extractSimulationAttribute(simulationXml, "method")).toBe("BDF");
|
||||||
|
|
||||||
|
await expect(consoleSummary).toContainText(
|
||||||
|
"仿真完成,已生成新的结果,共 21 个采样点",
|
||||||
|
{ timeout: 480_000 },
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readBrowserStreamEvidence(page)).result?.status, {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe("completed");
|
||||||
|
|
||||||
|
const streamEvidence = await readBrowserStreamEvidence(page);
|
||||||
|
expect(streamEvidence.parseErrors).toEqual([]);
|
||||||
|
expect(streamEvidence.streamErrors).toEqual([]);
|
||||||
|
expect(streamEvidence.result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
status: "completed",
|
||||||
|
partial: false,
|
||||||
|
simulatedUntil: 0.2,
|
||||||
|
requestedStopTime: 0.2,
|
||||||
|
sampleCount: 21,
|
||||||
|
timePointCount: 21,
|
||||||
|
finalTime: 0.2,
|
||||||
|
});
|
||||||
|
const activitySummary = buildActivitySummary(streamEvidence.progressEvents);
|
||||||
|
expect(activitySummary.heartbeatCount).toBeGreaterThan(0);
|
||||||
|
expect(activitySummary.activityTelemetryHeartbeatCount).toBeGreaterThan(1);
|
||||||
|
expect(activitySummary.activitySequenceAdvanced).toBe(true);
|
||||||
|
expect(cancellationRequests).toEqual([]);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
||||||
|
const consoleLog = consolePanel.getByRole("region", { name: "控制台日志" });
|
||||||
|
await expect(consoleLog).toContainText(
|
||||||
|
"仿真完成,已生成新的结果,共 21 个采样点",
|
||||||
|
);
|
||||||
|
await expect(consoleLog).not.toContainText("仿真失败");
|
||||||
|
await expect(consoleLog).not.toContainText("仿真因进度异常而终止");
|
||||||
|
|
||||||
|
if (
|
||||||
|
activitySummary.maximumAcceptedPlateau &&
|
||||||
|
activitySummary.maximumAcceptedPlateau.durationMs >= 60_000
|
||||||
|
) {
|
||||||
|
expect(
|
||||||
|
activitySummary.maximumAcceptedPlateau.activitySequenceAdvanced,
|
||||||
|
).toBe(true);
|
||||||
|
await expect(consoleLog).toContainText("后端内部活动仍在推进");
|
||||||
|
}
|
||||||
|
workflowCompleted = true;
|
||||||
|
} finally {
|
||||||
|
if (evidenceRetentionTimer !== undefined) {
|
||||||
|
clearInterval(evidenceRetentionTimer);
|
||||||
|
}
|
||||||
|
await retainCurrentStreamEvidence().catch(() => undefined);
|
||||||
|
const streamEvidence = retainedStreamEvidence;
|
||||||
|
const activitySummary = buildActivitySummary(streamEvidence.progressEvents);
|
||||||
|
const finishedAt = Date.now();
|
||||||
|
const projectBytes = readFileSync(PROJECT_PATH);
|
||||||
|
const evidence = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: "live-browser-stream-verification",
|
||||||
|
generatedAt: new Date(finishedAt).toISOString(),
|
||||||
|
startedAt: startedAtIso,
|
||||||
|
wallSeconds: (finishedAt - startedAt) / 1_000,
|
||||||
|
workflowCompleted,
|
||||||
|
topology: {
|
||||||
|
browserOrigin: "http://127.0.0.1:14173",
|
||||||
|
backendOrigin: "http://127.0.0.1:18181",
|
||||||
|
servingMode: "isolated-vite-dev-proxy",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
path: "tests/data/test-mql-8.json",
|
||||||
|
bytes: statSync(PROJECT_PATH).size,
|
||||||
|
sha256: createHash("sha256").update(projectBytes).digest("hex"),
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
tStart: 0,
|
||||||
|
tStop: 0.2,
|
||||||
|
sampleStep: 0.01,
|
||||||
|
maxStep: 0.001,
|
||||||
|
method: "BDF",
|
||||||
|
},
|
||||||
|
runtimeEnvironment: EXPECTED_RUNTIME_ENVIRONMENT,
|
||||||
|
request: {
|
||||||
|
simulationId: simulationId ?? null,
|
||||||
|
xmlBytes: Buffer.byteLength(simulationXml),
|
||||||
|
simulationAttributes: {
|
||||||
|
tStart: extractSimulationAttribute(simulationXml, "tStart"),
|
||||||
|
tStop: extractSimulationAttribute(simulationXml, "tStop"),
|
||||||
|
sampleStep: extractSimulationAttribute(simulationXml, "sampleStep"),
|
||||||
|
maxStep: extractSimulationAttribute(simulationXml, "maxStep"),
|
||||||
|
method: extractSimulationAttribute(simulationXml, "method"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ndjson: {
|
||||||
|
lineCount: streamEvidence.lineCount,
|
||||||
|
parseErrors: streamEvidence.parseErrors,
|
||||||
|
streamErrors: streamEvidence.streamErrors,
|
||||||
|
result: streamEvidence.result,
|
||||||
|
activity: activitySummary,
|
||||||
|
},
|
||||||
|
browser: {
|
||||||
|
cancellationRequests,
|
||||||
|
pageErrors,
|
||||||
|
consoleErrors: browserConsoleErrors,
|
||||||
|
},
|
||||||
|
acceptance: {
|
||||||
|
completedWith21Samples:
|
||||||
|
workflowCompleted &&
|
||||||
|
streamEvidence.result?.status === "completed" &&
|
||||||
|
streamEvidence.result?.sampleCount === 21 &&
|
||||||
|
streamEvidence.result?.timePointCount === 21,
|
||||||
|
noCancellationRequest: cancellationRequests.length === 0,
|
||||||
|
noStreamError: streamEvidence.streamErrors.length === 0,
|
||||||
|
noPageError: pageErrors.length === 0,
|
||||||
|
activityTelemetryAdvanced: activitySummary.activitySequenceAdvanced,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const serializedEvidence = `${JSON.stringify(evidence, null, 2)}\n`;
|
||||||
|
writeFileSync(EVIDENCE_PATH, serializedEvidence, "utf8");
|
||||||
|
await testInfo.attach("mql8-live-browser-evidence", {
|
||||||
|
body: Buffer.from(serializedEvidence),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
const screenshot = await page.screenshot({ fullPage: true }).catch(() => null);
|
||||||
|
if (screenshot) {
|
||||||
|
await testInfo.attach("mql8-live-browser-final-page", {
|
||||||
|
body: screenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS,
|
||||||
|
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
|
||||||
|
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
|
||||||
|
solverActivityStallTimeoutMessage,
|
||||||
|
solverStallTimeoutMessage,
|
||||||
|
solverStallTimeoutReached,
|
||||||
|
} from "../../src/simulationTimeout";
|
||||||
|
|
||||||
|
test("solver stall watchdog allows the validated long-running window", () => {
|
||||||
|
expect(SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES).toBe(15);
|
||||||
|
expect(SIMULATION_SOLVER_STALL_TIMEOUT_MS).toBe(900_000);
|
||||||
|
expect(solverStallTimeoutReached(1_000, 900_999)).toBe(false);
|
||||||
|
expect(solverStallTimeoutReached(1_000, 901_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("solver stall messages use the configured duration", () => {
|
||||||
|
expect(solverStallTimeoutMessage("detected")).toContain("连续 15 分钟");
|
||||||
|
expect(solverStallTimeoutMessage("recovering")).toContain("连续 15 分钟");
|
||||||
|
expect(solverStallTimeoutMessage("recovering")).toContain("恢复部分结果");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity-aware watchdog keeps its shorter true-stall threshold", () => {
|
||||||
|
expect(SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS).toBe(60);
|
||||||
|
expect(SIMULATION_ACTIVITY_STALL_TIMEOUT_MS).toBe(60_000);
|
||||||
|
expect(solverActivityStallTimeoutMessage("detected")).toContain(
|
||||||
|
"接受步和内部活动均连续 60 秒",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
# Supported dependency ranges. Linux CPython 3.12 x86_64 release installs
|
||||||
|
# should use constraints/python312-linux-x86_64.lock directly; see README.md.
|
||||||
fastapi
|
fastapi
|
||||||
lxml>=5,<7
|
lxml>=5,<7
|
||||||
|
numpy>=1.26,<3
|
||||||
|
pydantic>=2,<3
|
||||||
scipy>=1.13,<2
|
scipy>=1.13,<2
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: system-simulation
|
||||||
|
description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,运行并监视仿真、导出结果,以及在用户确认计划后对 JSON v1 执行单目标、有界连续 SI 参数优化。适用于检查模型、修复编码或换行、运行仿真、获取结果和优化结果统计量;不用于旧格式迁移、任意语义修复、离散或拓扑优化、多目标优化或网页自动预装。
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
requires:
|
||||||
|
bins: [python3.12]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 系统仿真
|
||||||
|
|
||||||
|
使用本 Skill 随附的确定性脚本检查模型、调用现有后端并保存结果;不要让语言模型自行重写模型或猜测求解数据。`simulation_skill.py` 处理文件和单次仿真,同一 Skill 内的独立入口 `optimization_skill.py` 处理优化计划与执行。
|
||||||
|
|
||||||
|
## 基本边界
|
||||||
|
|
||||||
|
- 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。
|
||||||
|
- 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。
|
||||||
|
- 工程 JSON 可在连续数值参数中保存受限算术表达式。检查、编译或生成 XML 时由后端安全求值并换算为 SI;不得把计算结果回写到源 JSON。
|
||||||
|
- 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。
|
||||||
|
- 不直接覆盖源文件。普通检查或仿真不自行修改参数、连接、组件类型、模型版本或求解设置;优化也只能在用户确认的派生副本中改变明确选定的参数。
|
||||||
|
- 优化仅面向 ReactFlow 工程 JSON v1,设计变量必须由用户指定,或由用户明确授权 Skill 提议后再纳入计划;它们必须是连续、线性 SI 参数。现有后端不负责证明参数连续性,不能只因字段是数字就自动选作设计变量。带编辑器、离散选项或后端显式否决的参数必须拒绝,整数、条件显示控制量及会改变活动端口、模式或拓扑的参数不得进入连续优化。
|
||||||
|
- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移、离散或拓扑优化以及多目标优化。不要用手工网页操作冒充支持。
|
||||||
|
|
||||||
|
处理文件、解释格式或选择结果变量时,读取 [references/file-contracts.md](references/file-contracts.md)。请求文件修复时,再读取 [references/repair-policy.md](references/repair-policy.md)。需要运行、监视、取消仿真或交付结果时,读取 [references/workflows.md](references/workflows.md)。用户请求按仿真结果优化参数时,必须读取 [references/optimization-workflow.md](references/optimization-workflow.md)。
|
||||||
|
|
||||||
|
## 工作原则
|
||||||
|
|
||||||
|
1. 先用 `inspect` 确认输入格式、版本、结构和诊断,再基于检查结果简要解释组件、连接与仿真设置。
|
||||||
|
2. 如果用户要求修复,只能执行文本规范化。先展示预览和源文件 SHA-256,获得针对该预览的明确确认后,才可写入另一个输出路径;随后重新 `inspect`。
|
||||||
|
3. 单次仿真前必须让用户选择直接曲线查看方式,并解析具体结果变量:
|
||||||
|
- 分别查看所选变量;
|
||||||
|
- 将多个同单位、可比较的变量叠加;
|
||||||
|
- 将不同物理量或单位的变量上下排列。
|
||||||
|
4. 用户用显示名称描述组件或变量时,利用检查结果中的稳定 ID、结果 `key`、物理量和单位消歧。存在重名、多个候选或“参数/结果变量”含义不清时,先询问,不能替用户猜。
|
||||||
|
5. 使用 `simulate` 的事件流持续判断 queued、validating、compiling、integrating 和结束状态。仿真时间暂时不变但内部活动仍增长时,只说明正在处理慢步,不能宣称卡死。
|
||||||
|
6. 成功运行后交付用户选择的 SVG 曲线和完整 `results.csv`,并简要说明完成状态、实际仿真终点和重要诊断。失败或取消时交付能够安全生成的部分结果;若运行前即失败而没有 CSV,要明确说明原因。
|
||||||
|
7. 优化需求优先按自然语言理解:从检查结果补齐稳定结果 `key`、单位和当前参数值,未指定的算法、预算、容差和输出目录采用参考文档中的推荐默认值。不要要求用户填写规格 JSON,也不要追问随机种子、变异因子等已有默认值。`plan --present` 成功后,面向用户展示的设计变量当前值和单位必须直接采用 `presentation.designVariables[].current` 与 `unit`;完整审计计划中的对应字段是 `designVariables[].initial` 与 `unit`。不得根据源 JSON 的 `parameterUnits` 再换算或另行推断。
|
||||||
|
8. 信息足以形成规格后,直接在内部写入规格并执行无候选仿真的 `plan`,无需先征求生成计划的许可。普通流程必须把完整计划以仅当前用户可读的权限保存到输出目录之外的新内部文件,并让 stdout 只返回展示白名单。计划阶段从回复第一个字起使用用户当前语言并直接展示计划,只列目标、可调参数及范围、约束、仿真预算槽位、搜索启动时限、完整输出位置和重要假设。输出位置必须是 `plan` 返回的完整绝对路径,不用 `...` 缩写。采用默认搜索设置且没有需要用户决策的警告时,只说“采用默认搜索设置”及其执行上限,不显示“无警告”或原始 warnings、算法名称或变体、随机种子、种群、变异/交叉参数、搜索/复验预算拆分、边界处理、端点播种或理论完整代数;若展示视图返回 `nonDefaultSettings`,则必须把其中将被确认的非默认值简明列出。参数明显是连续物理标量时,把连续性作为计划假设,一次整体执行确认即可覆盖,不展示用于作出判断的内部合同字段清单;只有语义确有歧义时才自然地追问。内部声明代码、SHA、`planHash` 和 `confirmationToken` 默认不展示。
|
||||||
|
9. 生成计划不等于获准执行。只有用户看过计划摘要并明确表示开始后才能传入 `--confirmed`;用户说只要计划、先看计划且暂时不要运行或其他同等表述时,展示计划后直接停住,不在本轮追问是否开始。计划任一实质内容变化都要重新确认。
|
||||||
|
10. 优化中的失败、取消、停滞或不完整仿真不计算目标分数。最终只对预算内找到的最佳可行候选做一次绕过缓存的完整复验;复验通过前不把候选称为已验证方案。
|
||||||
|
11. 严格区分搜索停止与候选复验:`verified` 只说明最佳搜索候选的新鲜复验通过,不等于搜索收敛、系统达到稳态或全局最优。优化执行完成后的结果报告分别说明搜索候选评估、按阶段拆分的仿真预算槽位占用及完成/失败记录、未形成试验记录的槽位、缓存命中、独立复验、未用预算和真实停止原因;这些审计明细不属于计划摘要,槽位占用也不能说成后端已接收或已完成。只要 `searchConvergenceEstablished` 为 `false`,计划、进度和结果中都不得说搜索“已收敛”“将收敛”或“大概率收敛”;若只想表达重复运行可能得到相同结果,改说“可能再次找到同一候选”。内部防死循环上限及重复停滞后的种群塌缩都不得解释成收敛。
|
||||||
|
12. 目标使用 `final` 时必须报告脚本给出的末段趋势诊断状态;只有完整且覆盖计划终点的新鲜序列才能分析趋势,诊断不可用或样本不足时明确说明且不自行推断。检测到明显变化时,说明终点值只是快照。有限样本只能表述为“在这些已评估点中,参数增大时结果均增大或均减小”,不得称整个范围单调,也不得外推样本之间或未采样位置。新鲜复验已经通过后不再建议重复同一复验;最佳点落在边界时,未经物理、安全和组件合同方面的工程可行性确认,不得建议放宽边界或把它列作默认下一步。
|
||||||
|
|
||||||
|
脚本命令统一从仓库根目录运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py --help
|
||||||
|
py -3.12 skills/system-simulation/scripts/optimization_skill.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
在 OpenClaw 中不要假设当前目录是仓库根目录。使用 `"{baseDir}/scripts/simulation_skill.py"` 或 `"{baseDir}/scripts/optimization_skill.py"`,也可以先进入本 Skill 目录再从 `scripts/` 运行;不要把 `{baseDir}` 当成需要手工猜测的仓库路径。
|
||||||
|
|
||||||
|
Windows 优先使用仓库 `.venv-win\Scripts\python.exe`(若存在),否则使用 `py -3.12`;Linux 优先使用 `.venv/bin/python`,否则使用 `python3.12`。不要调用未经版本确认的 `python`,本项目要求 Python 3.12。
|
||||||
|
|
||||||
|
优先依赖脚本返回的结构化 JSON/JSONL、稳定错误码和退出码做判断,不解析中文提示文本来驱动下一步。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "系统仿真与参数优化助手"
|
||||||
|
short_description: "用自然语言检查、仿真模型,并按安全默认值规划连续参数优化"
|
||||||
|
default_prompt: "使用 $system-simulation 按我描述的目标优化模型参数;请自动补齐安全默认值,先给出易读计划,等我一次确认后执行。"
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 文件合同与解释规则
|
||||||
|
|
||||||
|
## 支持范围
|
||||||
|
|
||||||
|
本 Skill 只接受以下两种当前格式:
|
||||||
|
|
||||||
|
| 格式 | 版本标志 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ReactFlow 工程 JSON | 顶层 `projectSchemaVersion: 1` | 保存组件、画布、端口显示快照、连线和仿真设置,适合继续编辑 |
|
||||||
|
| System XML | 根元素 `System/@schemaVersion="3"` 且 `unitSystem="SI"` | 保存可执行模型,适合校验、编译和求解 |
|
||||||
|
|
||||||
|
默认让 `inspect --format auto` 根据内容和扩展名识别格式。若内容与扩展名不一致、无法唯一识别或用户明确指定格式,则报告实际证据,不悄悄按另一种格式解释。
|
||||||
|
|
||||||
|
System XML v1/v2、缺少 `projectSchemaVersion` 的旧工程、字符串端口和不匹配的组件 `modelVersion` 均不属于本 Skill 的迁移范围。不能只改版本号使其看似当前格式。
|
||||||
|
|
||||||
|
## 工程 JSON v1
|
||||||
|
|
||||||
|
顶层合同为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
projectSchemaVersion = 1
|
||||||
|
name
|
||||||
|
nodes[]
|
||||||
|
edges[]
|
||||||
|
simulation { t_start, t_stop, step, max_step, method }
|
||||||
|
```
|
||||||
|
|
||||||
|
重要规则:
|
||||||
|
|
||||||
|
- 节点的 `id` 是实例稳定标识;显示标签不能替代它。
|
||||||
|
- `data.modelType` 标识注册模型,`data.modelVersion` 必须与当前组件目录精确匹配,执行前不得自动补成当前版本。
|
||||||
|
- `data.parameters` 保存输入值;`parameterUnits`、科学计数法偏好、坐标、旋转和镜像属于编辑显示信息。
|
||||||
|
- 连续数值参数可保存受限算术表达式字符串。支持可选前导 `=`、`+ - * / ^ **`、括号、科学计数法、`pi/e` 和白名单函数 `sqrt/abs/sin/cos/tan/asin/acos/atan/exp/ln/log/log10/min/max/pow`。不支持变量引用、组件间引用、属性访问或任意代码。
|
||||||
|
- 普通数值及可直接解析的数值字符串按已存储的 SI 值处理;只有表达式的计算结果才按 `parameterUnits` 中的显示单位换算为 SI。例如 `area0 = "3.14*10**2/4"` 且单位为 `mm2` 时,XML 值为 `7.85e-05` m²,JSON 仍保留原表达式。
|
||||||
|
- 下拉选项、介质引用等离散参数不允许使用表达式。表达式语法、值域或复杂度不合法时,必须在编译/仿真前明确报错,不得猜测或改写。
|
||||||
|
- 连接必须保留两端组件及 Handle。不能根据节点位置猜测缺失端口。
|
||||||
|
- `simulation.step` 是结果采样间隔;`max_step` 是求解器内部步长上限,两者不能混用。
|
||||||
|
|
||||||
|
工程 JSON 可以导出为 System XML v3,但转换后不会保留全部画布显示信息的对等逆转换合同。
|
||||||
|
导出时表达式仅在内存中求值,System XML 只写入换算后的 SI 数值,不改动输入工程对象或源 JSON 文件。
|
||||||
|
|
||||||
|
## System XML v3
|
||||||
|
|
||||||
|
XML v3 只描述“求解什么”:
|
||||||
|
|
||||||
|
- 每个 `Component` 必须有唯一 `id`、注册 `type`、精确 `modelVersion` 和完整 SI 参数;
|
||||||
|
- 每条连接由两个 `Endpoint(component, port)` 组成;端口类型、方向和物理合同由后端注册表恢复;
|
||||||
|
- `Simulation/@sampleStep` 对应工程 JSON 的 `simulation.step`;
|
||||||
|
- 不保存组件位置、旋转、镜像、显示单位或端口显示快照;
|
||||||
|
- 当前后端固定按 v3 校验,不会根据文件内容选择旧解析器。
|
||||||
|
|
||||||
|
XML 校验依次覆盖安全/语法、XSD 和语义层。通过这些检查后,编译和求解仍可能发现未连接端口、缺少储能锚点、方程结构或数值问题。
|
||||||
|
|
||||||
|
## 简要解释模型
|
||||||
|
|
||||||
|
解释必须依据 `inspect` 的结构化输出以及组件目录,而不是仅凭组件名称推测。优先说明:
|
||||||
|
|
||||||
|
1. 文件格式、版本和项目名;
|
||||||
|
2. 仿真起止时间、采样间隔、最大内部步长和算法;
|
||||||
|
3. 组件数量、稳定 ID、模型类型和主要输入参数;
|
||||||
|
4. 连接数量、连接端点及能够确定的物理域;
|
||||||
|
5. 错误、警告,以及它们属于格式、语义、编译还是运行阶段。
|
||||||
|
|
||||||
|
保持“文件合同正确”和“物理模型合理”两个结论分开。没有组件文档或注册元数据支持时,不声称某个参数具有推测出的物理效果。
|
||||||
|
|
||||||
|
## 参数与结果变量
|
||||||
|
|
||||||
|
必须明确区分:
|
||||||
|
|
||||||
|
- **参数**:仿真开始前设定的固定输入,例如质量、初始压力、摩擦选项;通常没有时间序列。
|
||||||
|
- **结果变量**:仿真返回的时间序列,例如位移、速度、压力或流量;只有这类量可以选作曲线。
|
||||||
|
|
||||||
|
选择曲线时以结果元数据为准,至少核对:
|
||||||
|
|
||||||
|
```text
|
||||||
|
key + componentId + componentType + label/quantity + unit
|
||||||
|
```
|
||||||
|
|
||||||
|
稳定 `key` 是传给 `simulate --variables` 的最终标识。用户只说“质量块的速度”而存在多个质量块,或一个组件存在多个符合描述的速度结果时,列出候选的组件 ID、结果名称和单位,请用户消歧。
|
||||||
|
|
||||||
|
`inspect` 默认对组件摘要、连接和结果变量分页。先读取 `componentTypes` 了解完整模型的组件类型分布,再根据 `componentPage`、`connectionPage` 或 `resultVariablePage` 的 `nextOffset` 翻页。优先使用 `--variable-query` 按组件 ID、标签、物理量或单位缩小范围;只有用户点名组件时才使用 `--component` 读取该组件的完整源数据和可用的编译合同。
|
||||||
|
|
||||||
|
组件摘要中的 `compiledForSimulation` 表示该节点是否进入动态求解网络。介质/物性配置节点仍属于工程,因此会保留在组件总数和列表中,但通常标记为 `false`;这不表示组件丢失或编译失败。
|
||||||
|
|
||||||
|
曲线模式约束:
|
||||||
|
|
||||||
|
- `separate`:每个所选结果变量分别成图;
|
||||||
|
- `overlay`:只叠加单位相同且含义可比较的结果变量;
|
||||||
|
- `stacked`:不同物理量或不同单位上下排列,避免共用一个纵轴造成误读。
|
||||||
|
|
||||||
|
本版运行 `simulate` 时必须指定至少一个 `--variables` 稳定键,避免在大型模型上无意生成成百上千张曲线。完整 CSV 仍包含全部可用结果变量。
|
||||||
|
|
||||||
|
## 权威来源
|
||||||
|
|
||||||
|
- 工程 JSON 请求合同:`app/main.py` 中的 `ReactFlowProjectPayload`
|
||||||
|
- 组件目录:`GET /api/components/catalog`
|
||||||
|
- XML v3:`schemas/system-simulation-v3.xsd`、`docs/standard/system-xml-v3.md`
|
||||||
|
- 接口边界:`docs/standard/backend-interface-version-spec-v1.md`
|
||||||
|
- 结果变量:组件注册合同中的 `RESULT_VARIABLES` 及仿真结果元数据
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# 单目标参数优化工作流
|
||||||
|
|
||||||
|
## 执行入口与范围
|
||||||
|
|
||||||
|
本 Skill 内的 `scripts/optimization_skill.py` 是独立外层优化入口,它复用同目录的 `simulation_skill.py` 访问现有 FastAPI 后端。优化算法、候选生成、目标统计和报告都在 Skill 进程中完成,不要求后端提供原生优化端点或优化参数准入字段。优化入口只接受已通过检查和编译的 ReactFlow 工程 JSON v1,不接受 System XML 作为优化源文件。
|
||||||
|
|
||||||
|
支持范围固定为:
|
||||||
|
|
||||||
|
- 一个结果变量统计量构成的单目标;
|
||||||
|
- 1–16 个有限上下界内的线性尺度连续 SI 参数;
|
||||||
|
- 0–16 个结果响应约束;
|
||||||
|
- 最多占用 200 个仿真预算槽位;
|
||||||
|
- 顺序执行的有界 DE/rand/1/bin 差分进化。
|
||||||
|
|
||||||
|
不支持离散、整数、介质引用、条件显示控制量、活动端口数、组件类型、连接、拓扑、无界、对数尺度或多目标优化。不执行用户文本中的任意 Python 回调或表达式,也不在优化中改变仿真时段、采样间隔、最大内部步长、求解方法或物理拓扑。用户给出多个愿望时,必须选定一个目标,再将可用上下限表达的其余要求定义为响应约束;不自行设计权重合成多目标。
|
||||||
|
|
||||||
|
## 自然语言交互与推荐默认值
|
||||||
|
|
||||||
|
用户不需要了解优化规格 JSON。通常只需说明:想改善哪个结果、让它变大/变小或接近什么值,以及允许调整哪个参数和范围。若用户明确说“你先假定一个”或同等授权,可以提出一个设计变量和工程范围,但必须标成待确认的假设,不能伪装成后端验证结论。
|
||||||
|
|
||||||
|
先运行 `inspect` 和必要的编译检查,再把用户说法解析为严格规格:
|
||||||
|
|
||||||
|
- “结束时”映射为 `final`;“整个仿真中的最大/最小值”映射为 `maximum` / `minimum`;未指定窗口时使用完整结果时间范围。
|
||||||
|
- 从结果和参数合同补齐稳定 ID、`resultKey`、SI 单位和当前值;能唯一解析时不要反问用户这些机器字段。
|
||||||
|
- 用户未提响应约束时使用空列表,不逐项询问“是否需要约束”。
|
||||||
|
- 目标、参数、范围或方向无法唯一确定时必须询问;不要为了可默认的技术字段打断用户。
|
||||||
|
|
||||||
|
用户未指定高级设置时,把下列推荐配置显式写入规格文件,保证计划可复现:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"algorithm": {
|
||||||
|
"name": "differentialEvolution",
|
||||||
|
"seed": 0,
|
||||||
|
"populationSize": 8,
|
||||||
|
"mutationFactor": 0.8,
|
||||||
|
"crossoverProbability": 0.7
|
||||||
|
},
|
||||||
|
"budget": {
|
||||||
|
"maxSimulationRuns": 25,
|
||||||
|
"maxWallSeconds": 3600
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"relativeTolerance": 1e-08,
|
||||||
|
"absoluteTolerance": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用户描述了响应约束但未指定容差时,默认 `tolerance = 0`;未指定 `scale` 时,取该约束所有非空边界绝对值的最大值,若结果为零则取 `1`。目标或约束接近零、后端存在可观察的不确定性,或用户给出安全裕量时,应提出有物理意义的容差建议,不能用一个跨量纲的非零绝对容差替代判断。
|
||||||
|
|
||||||
|
默认输出目录使用项目文件所在目录下尚不存在的 `optimization-runs/<项目名>-<UTC时间戳>`。源目录不可写时,改用当前可写工作区中的同名新目录,并在计划摘要中说明实际位置。不要让用户命名目录,也不要覆盖既有目录。默认计划可对用户概括为“使用默认搜索设置,最多占用 25 个仿真预算槽位;搜索启动时限为 1 小时,并为找到的最佳可行候选预留一次独立复验”。搜索启动时限到达后不会取消正在运行的健康仿真,预留复验也可能在其后执行,所以不能把它称为总耗时硬上限。除非用户询问或计划产生需要用户决策的覆盖不足警告,不主动讲解算法名称或变体、种群、变异因子、交叉概率、随机种子、搜索/复验预算拆分、边界处理、端点播种、理论完整代数或公式。
|
||||||
|
|
||||||
|
生成新计划不以历史运行作为前置检查;除非用户要求复用、比较或解释旧结果,不主动扫描、校验或汇总旧优化目录。若当前对话已经明确存在源文件与实质规格相同的历史运行,为避免混淆最多用一句话注明它只是历史参考、不属于本次计划;用户未追问时不展开旧候选、停止细节、趋势或复验数据,也不用历史样本预测新搜索会收敛或断言连续区间性质。
|
||||||
|
|
||||||
|
## 优化规格 JSON
|
||||||
|
|
||||||
|
规格是独立 JSON 文件,顶层必须且只能包含 `optimizationSchemaVersion`、`objective`、`designVariables`、`constraints`、`algorithm`、`budget` 和 `validation`。完整示例如下;其中组件 ID、参数名、结果 `key` 和单位必须换成实际 `plan` 所检查的合同:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"optimizationSchemaVersion": 1,
|
||||||
|
"objective": {
|
||||||
|
"resultKey": "pressure.chamber_1.absolute",
|
||||||
|
"expectedUnit": "Pa",
|
||||||
|
"statistic": {
|
||||||
|
"kind": "maximum",
|
||||||
|
"window": {"start": 0.0, "end": 1.0}
|
||||||
|
},
|
||||||
|
"goal": {"kind": "minimize"}
|
||||||
|
},
|
||||||
|
"designVariables": [
|
||||||
|
{
|
||||||
|
"id": "orifice_area",
|
||||||
|
"componentId": "valve_1",
|
||||||
|
"parameter": "area0",
|
||||||
|
"unit": "m2",
|
||||||
|
"lower": 1e-06,
|
||||||
|
"upper": 0.0001
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"id": "mass_flow_limit",
|
||||||
|
"resultKey": "massFlow.valve_1.port_2.intoComponent",
|
||||||
|
"expectedUnit": "kg/s",
|
||||||
|
"statistic": {"kind": "peakAbsolute", "window": null},
|
||||||
|
"lower": null,
|
||||||
|
"upper": 0.25,
|
||||||
|
"tolerance": 0.0,
|
||||||
|
"scale": 0.25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"algorithm": {
|
||||||
|
"name": "differentialEvolution",
|
||||||
|
"seed": 0,
|
||||||
|
"populationSize": 8,
|
||||||
|
"mutationFactor": 0.8,
|
||||||
|
"crossoverProbability": 0.7
|
||||||
|
},
|
||||||
|
"budget": {
|
||||||
|
"maxSimulationRuns": 25,
|
||||||
|
"maxWallSeconds": 3600
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"relativeTolerance": 1e-08,
|
||||||
|
"absoluteTolerance": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规格使用严格字段集。未知字段、缺失字段、重复键、布尔型伪装的数字、`NaN` 和无穷值均不能进入优化;`optimizationSchemaVersion` 固定为 `1`。
|
||||||
|
|
||||||
|
`plan` 的 `spec.resolved` 会额外显示只读派生字段,例如统计量 `metricUnit`、算法 `strategy`/`workers`/`updating`、搜索策略、边界处理、一维端点播种策略、停滞代数、预算预留量和固定复验次数;这些字段不是输入规格字段,不能复制回 schema v1 规格文件。输入优化规格仍是 schema v1;最终结果的 `optimizationResultSchemaVersion` 为 `2`,两者不要混淆。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
`objective.resultKey` 必须是编译模型声明的稳定结果键,`expectedUnit` 必须与结果元数据单位完全一致,无量纲时使用空字符串。`goal.kind` 可为:
|
||||||
|
|
||||||
|
- `minimize`:最小化统计值;
|
||||||
|
- `maximize`:最大化统计值;
|
||||||
|
- `target`:最小化 `abs(statistic - value)`,此时必须增加有限数字 `goal.value`。
|
||||||
|
|
||||||
|
`minimize` 和 `maximize` 不接受 `goal.value`。`target` 只定义目标损失,不是提前停止阈值。
|
||||||
|
|
||||||
|
`expectedUnit` 是原始时间序列单位。`final`、`minimum`、`maximum`、`timeMean`、`rms` 和 `peakAbsolute` 的统计量单位与它相同;`integral` 与 `absoluteIntegral` 的统计量单位为原单位乘秒(结构化输出的 `metricUnit` 使用 `(<seriesUnit>)*s`,无量纲序列积分为 `s`)。`target` 的 `value` 使用统计量单位。
|
||||||
|
|
||||||
|
### 设计变量
|
||||||
|
|
||||||
|
`designVariables` 必须包含 1–16 项,ID 和 `(componentId, parameter)` 均不能重复。每项必须:
|
||||||
|
|
||||||
|
- 由用户明确指定,或在用户授权 Skill 提议后纳入计划;执行时它必须是连续、线性 SI 参数,且改变它不会切换模式、改变活动端口或拓扑;
|
||||||
|
- 不带组件合同中的 `editor` 或离散 `options`;若未来合同显式提供 `optimizationEligible: false`,该否决不可由用户确认覆盖;
|
||||||
|
- `unit` 与参数合同的 SI 单位完全一致;
|
||||||
|
- 使用有限数字 `lower < upper`,同时满足目录最小值、最大值和排他下界;
|
||||||
|
- 使源模型编译后的当前 SI 值位于闭区间 `[lower, upper]` 内。
|
||||||
|
|
||||||
|
当前后端目录没有能够单独证明“连续量、整数、模式控制量、活动端口数”的机器字段;字段缺失本身不是连续性证据。不得仅凭“值是数字”自动挑选设计变量。若用户已选定参数,且参数合同、物理量与单位、组件语义和用户给出的连续区间一致表明它是普通物理标量,同时不存在 `editor`、`options` 或显式否决,则可把“按连续线性 SI 参数处理且不改变结构”写成计划假设,不必在生成计划前要求用户复述声明。用户对该计划的一次整体执行确认同时接受这项假设。
|
||||||
|
|
||||||
|
若参数像整数、计数、无量纲模式量、条件控制量,元数据彼此矛盾,或无法判断改变它是否影响端口和拓扑,必须先用自然语言询问,例如:“这个参数可以取任意小数,并且调整时不会切换组件模式或端口吗?”不要向用户显示内部声明代码。`editor`、`options` 和未来可能出现的显式否决只用于拒绝明显不适用的参数,不能证明其余参数连续。
|
||||||
|
|
||||||
|
参数值、初值和边界一律使用线性 SI 合同。ReactFlow JSON 中普通数值参数已经是 SI 值,`parameterUnits` 只是编辑器显示信息,不能据此把普通数值再次换算;表达式所需的显示单位换算由后端完成。如果用户用显示单位给出边界,才把用户输入换算为 SI。普通 `plan --present` 成功后,`presentation.designVariables[].current` 和 `unit` 是计划摘要中当前值与单位的唯一依据;完整审计计划中的对应字段为 `designVariables[].initial` 和 `unit`。不要从源 JSON 重新计算显示值,若其他信息与它矛盾则先排查而不是向用户展示两套数值。用户给出的边界已经使用该 SI 单位且没有矛盾时,不主动解释 `parameterUnits` 或添加显示单位换算旁注。后端在计划阶段将源 JSON 转换为基准 System XML v3;每个候选都从该 XML 重新生成,只替换选中 `Parameter/@value` 的 SI 数字,不在前一个候选上累积修改,源 JSON 永不被覆盖。
|
||||||
|
|
||||||
|
### 统计量与响应约束
|
||||||
|
|
||||||
|
`statistic` 必须同时包含 `kind` 和 `window`。支持的 `kind` 为 `final`、`minimum`、`maximum`、`timeMean`、`rms`、`integral`、`absoluteIntegral` 和 `peakAbsolute`。`window` 可为 `null`,表示使用完整返回时间序列;也可为有限数字的 `{start, end}`,且 `start < end`。
|
||||||
|
|
||||||
|
窗口边界不在采样点时使用线性插值,不为超出结果范围的窗口外推。时间必须严格递增,时间和数值必须有限且等长。`timeMean`、`rms`、`integral` 和 `absoluteIntegral` 使用梯形时间积分。`minimum`、`maximum` 和 `peakAbsolute` 只是样本及插值边界上的统计,不证明连续时间真实峰值;安全关键峰值可能比 `sampleStep` 更窄时,必须报告采样风险。
|
||||||
|
|
||||||
|
`constraints` 只支持响应约束。每项必须包含唯一 `id`、`resultKey`、`expectedUnit`、`statistic`、`lower`、`upper`、`tolerance` 和 `scale`。`lower` 或 `upper` 可为 `null`,但至少一个必须是有限数字;两者都存在时必须 `lower <= upper`。`tolerance >= 0`,`scale > 0`。下界在 `value >= lower - tolerance` 时满足,上界在 `value <= upper + tolerance` 时满足。
|
||||||
|
|
||||||
|
`scale` 只用于将违反量归一化为 `rawViolation / scale`,以便对完整但不可行的候选排序;它不改变可行边界,也不是软约束权重。需要等式时用显式容差带,不使用浮点精确相等。
|
||||||
|
|
||||||
|
约束的 `lower`、`upper`、`tolerance` 和 `scale` 都使用该约束统计量的单位,而不是一律使用原始序列单位。复验中的单个 `absoluteTolerance` 数值分别按每项统计量自己的单位解释;目标与约束量纲差异很大时优先把它设为 `0` 并使用相对容差,或明确接受这一 schema v1 限制。
|
||||||
|
|
||||||
|
## 计划确认
|
||||||
|
|
||||||
|
信息足以形成规格后直接执行 `plan`,不需要用户先批准计划生成。它校验源 JSON、规格、结果键/单位、设计变量合同和边界,并请求后端生成基准 XML,但不开始优化候选仿真。完整审计计划文件包含源 JSON 与规格 JSON 的绝对路径/SHA-256、解析后规格、基准 XML SHA-256、目标/约束元数据、解析后设计变量、执行上限、绝对输出目录、`parameterContinuity`、`requiredAssertions`,以及值相同的 `planHash` 与 `confirmationToken`;普通流程的 stdout 只返回严格白名单的展示视图,不包含这些执行凭据和搜索内部字段。
|
||||||
|
|
||||||
|
计划中的 `OPTIMIZATION_CONTINUITY_USER_ASSERTION` 是给脚本和审计使用的内部标识,不是要求用户照抄的口令。后端不会验证参数的物理/语义连续性,因此面向用户的计划摘要必须用普通语言列出相关假设。若参数语义清楚,用户在看到摘要后明确同意开始运行,即视为同时接受完整计划和这些假设;未得到这次整体确认时不得传入 `--confirmed`。若参数语义不清,则应在执行确认之前先完成自然语言消歧。
|
||||||
|
|
||||||
|
`confirmationToken` 绑定源 JSON SHA-256、规格文件 SHA-256、基准 XML SHA-256、解析后的输出目录、后端 base URL、流读取超时、连续性确认策略和搜索策略。向用户展示计划摘要并获得明确确认后,才能传入 `--confirmed`。`optimize` 还必须提供 `plan` 返回的源 SHA-256、规格 SHA-256 和 token;脚本会重建当前计划并拒绝旧策略或其他过期确认。任一绑定项改变,包括搜索策略升级,都必须重新 `plan` 和确认。输出目录必须是新路径或空目录。
|
||||||
|
|
||||||
|
`requiredAssertions[].code`、各类 SHA、`planHash` 和 `confirmationToken` 是代理执行命令时保存和回传的机器字段。普通对话中应由 Skill 内部保管,不向用户倾倒;只有用户主动要求审计细节,或排查计划过期/文件变化时才展示。
|
||||||
|
|
||||||
|
普通计划摘要只需要回答:
|
||||||
|
|
||||||
|
- 要改善哪个结果,用什么统计口径;
|
||||||
|
- 调整哪些参数,各自在什么范围;
|
||||||
|
- 有哪些响应约束;
|
||||||
|
- 采用默认还是用户指定的搜索配置、最多占用多少仿真预算槽位、停止启动新搜索候选的时限,以及可使总耗时超过该时限的在途仿真和预留复验;
|
||||||
|
- 哪些参数连续性或工程边界属于假设;
|
||||||
|
- 输出写到哪里,并明确源模型不变。
|
||||||
|
|
||||||
|
计划正文只描述即将执行的运行,不把历史结果回顾、搜索审计明细或对本次结果的预测混入计划。默认设置不存在警告时,“采用默认搜索设置”已足够,不再把内部配置、端点播种或合同判定字段展开成技术清单。若安全展示视图包含 `nonDefaultSettings`,只列出其中实际偏离推荐默认值、并会随本计划一起确认的设置;不要反过来读取完整审计计划扩展技术细节。
|
||||||
|
|
||||||
|
用户尚未限制本轮只做计划、且接下来是否执行需要确认时,摘要后只问一次中性的自然问题,例如:“就按这个方案开始吗?”不要主动把换目标、放宽参数边界或其他扩展范围列成备选项。用户明确同意后直接执行,不再追加连续性声明、算法参数或 token 确认。若用户说“只要计划”“暂时不要运行”或同等意思,则交付摘要后直接陈述会停在计划阶段,不在本轮询问是否开始,等用户之后主动要求。用户主动提出调整时再讨论;涉及放宽工程边界时,必须先确认新的范围符合物理、安全和组件合同。
|
||||||
|
|
||||||
|
无警告且使用默认设置时,按下列内容边界组织计划回复;可以顺应用户语言调整措辞,但不要增加其他技术段落:
|
||||||
|
|
||||||
|
```text
|
||||||
|
优化目标:让哪个结果按什么统计口径变大、变小或接近目标值。
|
||||||
|
调整参数:参数名称、脚本 plan 返回的当前 SI 值、用户确认的 SI 范围。
|
||||||
|
响应约束:列出约束;没有就说无。
|
||||||
|
运行上限:采用默认搜索设置,最多占用多少仿真预算槽位;搜索到时后不再启动新候选,但会等在途仿真结束,并为找到的最佳可行候选预留一次独立复验。
|
||||||
|
重要假设:用一句普通语言说明参数按连续物理量处理且不改变模式或结构。
|
||||||
|
输出:plan 返回的新目录完整绝对路径,源模型不变。
|
||||||
|
|
||||||
|
结束语:若本轮可以询问执行,则问“就按这个方案开始吗?”;若用户说暂时不要运行,则说“计划已准备好,我会停在这里,等你之后明确说开始。”
|
||||||
|
```
|
||||||
|
|
||||||
|
正式计划回复从第一个字起使用用户当前语言并直接进入计划内容;不加过程旁白,不显示 `warnings: []` 等内部状态,不复述内部枚举名,也不在计划后追加单位科普、算法原理、历史回顾、结果预测或调整建议。输出目录照抄 `plan` 返回的完整绝对路径,不用省略号或相对路径。只有真实警告、无法消除的单位歧义或其他需要用户决策的问题,才在相应条目中简短说明。
|
||||||
|
|
||||||
|
## DE/rand/1/bin 搜索
|
||||||
|
|
||||||
|
外层优化由 `optimization_skill.py` 使用 Python 标准库自行实现,不调用 SciPy 优化器,也不安装额外优化依赖。`algorithm` 严格包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
name = differentialEvolution
|
||||||
|
seed = 0 .. 2^32-1 的整数
|
||||||
|
populationSize = 4 .. 50
|
||||||
|
mutationFactor = (0, 2]
|
||||||
|
crossoverProbability = [0, 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
`populationSize` 是实际种群个体数,不是乘以设计变量数的倍数。初始候选先在每个线性归一化坐标上做拉丁超立方分层,再用工程当前参数替换第一行,所以替换后的最终种群不承诺保持严格拉丁超立方的每层唯一性。只有一个设计变量时,初始种群还会强制包含归一化坐标 `0` 和 `1`,也就是精确测试用户确认的 SI 下界和上界;若基准点已经等于某个端点,不再为该端点制造重复行。第一次提交仍是基准仿真。基准仿真必须完整成功;基准可以不满足响应约束,此时仍可继续搜索。
|
||||||
|
|
||||||
|
每个完整代开始时冻结当前种群和排名;代内所有目标个体都只从这份冻结种群中选三个不同且不是自己的个体,按 `a + mutationFactor * (b - c)` 生成变异向量,接受结果在完整代结束后统一成为下一代。这是 `updating = deferred`,不会让同一代后面的候选使用刚被接受的新个体。越出归一化区间的坐标通过周期为 `2` 的镜像反射折回 `[0, 1]`,不再硬裁剪到端点;随后做 binomial crossover,并强制至少一个坐标来自变异向量。候选排名顺序是:完整可行点按目标损失;其次是完整但不可行点,按约束归一化违反量总和再按目标损失;失败点最后。平局用最早评估 ID 确定性打破。
|
||||||
|
|
||||||
|
`seed` 由标准库 `random.Random` 使用。固定种子可使同一 Python 实现和同一后端环境中的候选顺序可重放,但不证明跨 Python 版本、后端代码、操作系统或硬件位级一致。
|
||||||
|
|
||||||
|
当前搜索策略标识为 `de-rand-1-bin-deferred-reflection-1d-endpoints-stagnation-v3`。该标识随解析后算法配置进入计划,并绑定到确认 token;不能拿旧搜索策略产生的确认 token 启动新版搜索。
|
||||||
|
|
||||||
|
## 预算、停止、缓存与失败
|
||||||
|
|
||||||
|
`maxSimulationRuns` 必须至少是 `populationSize + 1`,且不得超过 200。它是仿真预算槽位的严格总上限。每次准备进度记录和打开后端流之前先保守占用一个槽位,因此即使本地进度文件创建或连接失败,该槽位也不会重新使用。搜索最多使用 `maxSimulationRuns - 1` 个槽位,始终为最佳可行候选保留一个绕过缓存的新鲜复验槽位。
|
||||||
|
|
||||||
|
计划会给出 `fullGenerationsWithUniqueCandidates = floor((searchRunLimit - populationSize) / populationSize)`。它表示在候选都不重复时,初始种群之外预算还能完整覆盖多少代。值为 `0` 时会返回 `OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY` 警告;此时仍可按用户确认运行,但必须明确说明搜索覆盖很弱。若希望至少完整执行两代,预算至少应为 `3 * populationSize + 1`。
|
||||||
|
|
||||||
|
`maxWallSeconds` 必须在 10 秒至 7 天之间。它只在启动下一个搜索候选前检查:达到后不再启动新搜索,不取消正在处理慢步的健康仿真。已存在最佳可行候选时,即使搜索墙钟已到,预留的一次新鲜复验仍会运行。
|
||||||
|
|
||||||
|
搜索没有目标阈值、目标收敛容差或局部抛光阶段。每个完整 DE 代结束后会统计该代是否产生过新的后端仿真提交;连续 `3` 个完整代没有新提交时提前停止:
|
||||||
|
|
||||||
|
- 若当前种群映射为同一个精确 SI 候选,停止原因为 `populationCollapsedAfterDuplicateStagnation`。这只表示重复停滞发生时种群已经塌缩为一个精确候选,不是数值收敛判定,也不证明全局最优;
|
||||||
|
- 若种群仍映射为多个精确候选,停止原因为 `duplicateProposalStagnation`。这表示候选生成持续重复缓存中的点,不是收敛判定。
|
||||||
|
|
||||||
|
仿真预算用完时为 `simulationBudgetExhausted`,搜索墙钟到达时为 `searchWallTimeReached`。为防止任何未预见的重复循环,候选请求另有 `max(100, searchRunLimit * 20)` 的内部防死循环上限;`optimizerCallLimitReached` 仅表示该安全保护触发,绝不能解释成搜索已经收敛。用户中断或结构化错误也会中止运行。
|
||||||
|
|
||||||
|
结果中的 `generations` 只统计完整完成的 DE 代;如果预算、墙钟或候选请求保护在一代中途阻止下一个候选,该部分代不会增加计数,也不会产生 `optimization-generation-completed` 事件。完整代进一步分成 `generationsWithNewBackendSubmissions` 和 `generationsWithoutNewBackendSubmissions`;初始种群不算一个 DE 代。“候选都唯一时预算可覆盖的完整代数”只是计划容量,不得当作实际完成或有效搜索代数。
|
||||||
|
|
||||||
|
缓存仅在当前 `optimize` 进程内有效。它根据固定参数 ID 顺序和映射后的精确 SI 数值识别重复候选;目标和所有响应约束共享一次仿真。缓存命中不增加后端提交数,最终复验始终绕过缓存。
|
||||||
|
|
||||||
|
最终结果使用 schema v2 分开记录搜索、复验、缓存和预算:
|
||||||
|
|
||||||
|
- 顶层 `search` 给出停止原因及类别、恒为 `false` 的 `searchConvergenceEstablished`、`populationCollapsedToSingleCandidate`、搜索候选评估数、`submissionSlotsConsumed`、缓存命中率、搜索预算的上限/已用/未用/是否耗尽、完整代及有新提交/无新提交代数、停滞连续代数、最终种群精确候选数和防死循环上限;
|
||||||
|
- 顶层 `verification` 给出独立复验是否通过、源文件是否未变、比较容差和 `submissionSlotsConsumed`,并明确其含义是候选可复现且可行,而不是搜索收敛;
|
||||||
|
- `counts.searchProposals`、`searchSubmissionSlotsConsumed`、`verificationSubmissionSlotsConsumed`、`backendSubmissionSlotsConsumed`、分阶段完成/失败记录、`unrecordedSubmissionSlotsConsumed`、`cacheHits` 和 `remainingSearchRunBudget` 提供可直接核对的分项统计。`optimizerCalls` 仅为初版兼容别名,新字段 `candidateRequestsAllStages` 明确包含搜索、缓存命中和复验请求;不带 `SlotsConsumed` 的 `backendSubmissions` 系列也仅是初版兼容别名,不能解释为后端已经接收。`timing` 使用全部已形成的试验记录计算墙钟、仿真耗时总计、最短、最长和平均值。
|
||||||
|
- `bestSearch` 只保存完整可行的最佳搜索点。没有可行点时它必须为 `null`,约束违反最小的完整不可行点只放入 `bestDiagnosticSearch`,报告必须明确称其为诊断点并展示归一化约束违反总量,不能称为方案或最佳可行点。
|
||||||
|
|
||||||
|
面向用户汇报时分别写“搜索停止”和“候选复验状态”,并同时报告搜索候选评估数、按阶段拆分的预算槽位占用与完成/失败记录、没有形成试验记录的槽位、缓存命中、未用搜索额度、完整代中有新仿真的代数与纯重复代数。不得把槽位占用称为后端已接收或已完成的仿真,也不得把 `solutionStatus: verified`、缓存命中次数、纯重复代、种群塌缩或理论预算容量解释为算法收敛证据。
|
||||||
|
|
||||||
|
本版没有持久缓存或 resume 命令。`checkpoint.json` 和 `optimization-events.jsonl` 仅用于审计已完成工作,不承诺中断后恢复同一种群。也没有自动重试:后端、网络或产物写入错误会按结构化错误中止,不悄悄再发起一次仿真。
|
||||||
|
|
||||||
|
只有 `status == completed`、`success == true`、结果变量键与单位仍匹配计划、所需序列存在、时间窗完整被覆盖且时间/数值结构正确时,才计算目标和约束。`failed`、`stalled`、`stopped`、取消或部分结果作为失败候选且不计分,`objectiveValue` 和 `objectiveLoss` 保持为空。若后端把缺失变量、错误单位或畸形序列标成成功完成,则视为结果合同错误并中止本次优化,避免继续解释不可靠数据。完整但不可行的点仍保留统计值,但不能优先于任何完整可行点。
|
||||||
|
|
||||||
|
## 最终复验、产物与措辞
|
||||||
|
|
||||||
|
没有完整可行候选时,`solutionStatus` 为 `noFeasibleCandidate`,`bestSearch` 为 `null`;可在 `bestDiagnosticSearch` 中报告归一化约束违反更小的完整不可行候选作诊断,但不生成 `best-*` 产物。存在最佳可行搜索候选时,脚本重新核对源 JSON SHA-256,从基准 XML 生成候选,使用新 simulation ID 并绕过缓存做一次完整新鲜仿真。
|
||||||
|
|
||||||
|
复验必须仍完整可行,复验结束时源 JSON SHA-256 仍与计划一致,且目标和每个约束原值均满足 `abs(search - verification) <= absoluteTolerance + relativeTolerance * max(abs(search), abs(verification))`。`validation` 只包含非负的 `relativeTolerance` 和 `absoluteTolerance`;复验次数固定为一,不是规格字段。通过时状态为 `verified`,否则为 `verificationFailed`,不用多次平均掩盖差异。`verified` 只表示最佳搜索候选通过了这次绕过缓存的新鲜复验;它不表示搜索收敛,不证明达到稳态,也不是全局最优证明。复验已经通过时,不再默认建议重跑同一项复验。
|
||||||
|
|
||||||
|
若目标统计量是 `final`,schema v2 的 `objectiveEndpointTrend` 会报告一次不影响复验状态的末段趋势诊断。只有新鲜复验本身完整成功,而且目标序列覆盖统计窗口终点或计划仿真终点时才分析;复验未完成、序列无效或未覆盖计划终点时标为 `unavailable` 并给出原因,不能把部分曲线末尾当成计划终点。它优先检查目标统计时段最后 `5%`,为取得至少 `6` 个样本可向前扩展,但最多使用最后 `20%`;仍不足时标为 `insufficientData`。相对量的尺度取“末段最大绝对值、完整时段最大绝对值的 `1e-6` 倍、最小正正规浮点数”三者的最大值。
|
||||||
|
|
||||||
|
末段净相对变化至少 `1%` 且非零相邻变化的方向一致率至少 `80%` 时记录 `directionalChange`;零增量不稀释方向一致率。末段相对峰峰范围至少 `2%` 时记录 `tailVariability`。任一条件成立就标为 `materialChangeDetected`。只有方向变化条件成立时才称为上升或下降;仅由范围条件触发时方向为 `fluctuating`,报告明显波动,并分别展示首尾净变化与峰峰范围,不能把振荡描述成单向趋势。结构化结果同时记录末段起止时刻、样本数、起止值、变化量、平均变化率、相对变化、相对范围、方向和检测原因。
|
||||||
|
|
||||||
|
这项检查只用于提醒“终点快照可能仍处于动态过程”。`steadyStateProven` 始终为 `false`;`noMaterialChangeDetected` 只能表述为“该启发式检查未发现明显末端变化”,不能写成“系统已达到稳态”。检查发现明显变化时,必须指出 `final` 结果只支持所选终点时刻的比较,不能外推成稳态性能更优。
|
||||||
|
|
||||||
|
优化输出包括 `optimization-plan.json`、`optimization-events.jsonl`、`simulation-progress/evaluation-NNNN.jsonl`、`checkpoint.json`、`evaluations.csv`、`optimization-result.json` 和 `report.md`。`evaluations.csv` 对每次形成试验记录的预算请求写一行;若本地准备或连接在形成试验记录前抛错,预算槽位占用可能比 CSV 行数多。`checkpoint.json` 是审计快照而不是 resume 状态。
|
||||||
|
|
||||||
|
只有 `solutionStatus == verified` 时才生成 `best-parameters.json`、`best-system.xml`、`best-project.json`、`result.json`、完整 `results.csv` 和目标/响应约束的独立 SVG 曲线。`best-project.json` 将被优化参数的原表达式替换为普通 SI 数值,源 JSON 不变。`optimize` 仅在状态为 `verified` 时返回退出码 `0`,其他结果返回 `4`。
|
||||||
|
|
||||||
|
只有 `solutionStatus == verified` 时,最终汇报才使用这一口径:
|
||||||
|
|
||||||
|
> 这是实际完成仿真的搜索点中表现最好的可行候选,并已通过一次独立复验;复验不证明搜索收敛、系统达到稳态或全局最优。
|
||||||
|
|
||||||
|
不使用“已找到全局最优”、“必然最优”或其他超出有限搜索证据的措辞。最佳候选位于用户确认的参数边界时,只能报告它是当前边界内实际搜索得到的边界点;在用户确认更宽范围符合物理、安全和组件合同前,不建议直接放宽边界或启动扩边界搜索,也不把有限采样点概括成整个连续区间上的严格单调规律。
|
||||||
|
|
||||||
|
## 内部命令与 OpenClaw 路径
|
||||||
|
|
||||||
|
以下命令供 Skill 实现和故障排查使用;正常交互不得要求用户手工运行命令、创建规格文件或复制确认参数。
|
||||||
|
|
||||||
|
从仓库根目录先预览计划:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/optimization_skill.py plan PROJECT.json `
|
||||||
|
--spec optimization-spec.json `
|
||||||
|
--output-dir OUTPUT_DIR `
|
||||||
|
--plan-file PLAN_FILE `
|
||||||
|
--present
|
||||||
|
```
|
||||||
|
|
||||||
|
普通 Skill 流程必须同时使用 `--plan-file` 和 `--present`:完整审计计划以 `0600` 权限独占写入 `PLAN_FILE`,stdout 只返回用户计划所需的白名单字段。`PLAN_FILE` 必须是位于 `OUTPUT_DIR` 外的新文件,不能覆盖既有文件,也不能与输出目录互为祖先或后代;默认用本次输出目录名加 UTC 时间戳或随机后缀生成同级文件,不复用固定的临时文件名。省略这两个选项的旧式完整 stdout 只用于兼容测试或显式审计排障,不用于普通对话。
|
||||||
|
|
||||||
|
向用户展示计划并获得明确确认后,在内部从 `PLAN_FILE` 读取并原样使用源 SHA、规格 SHA 和 `confirmationToken`,不要向用户展示:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/optimization_skill.py optimize PROJECT.json `
|
||||||
|
--spec optimization-spec.json `
|
||||||
|
--output-dir OUTPUT_DIR `
|
||||||
|
--expected-source-sha256 SOURCE_SHA256 `
|
||||||
|
--expected-spec-sha256 SPEC_SHA256 `
|
||||||
|
--confirmation-token CONFIRMATION_TOKEN `
|
||||||
|
--confirmed
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux 使用已确认的 Python 3.12 解释器和相同参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 skills/system-simulation/scripts/optimization_skill.py plan PROJECT.json \
|
||||||
|
--spec optimization-spec.json \
|
||||||
|
--output-dir OUTPUT_DIR \
|
||||||
|
--plan-file PLAN_FILE \
|
||||||
|
--present
|
||||||
|
|
||||||
|
python3.12 skills/system-simulation/scripts/optimization_skill.py optimize PROJECT.json \
|
||||||
|
--spec optimization-spec.json \
|
||||||
|
--output-dir OUTPUT_DIR \
|
||||||
|
--expected-source-sha256 SOURCE_SHA256 \
|
||||||
|
--expected-spec-sha256 SPEC_SHA256 \
|
||||||
|
--confirmation-token CONFIRMATION_TOKEN \
|
||||||
|
--confirmed
|
||||||
|
```
|
||||||
|
|
||||||
|
需要非默认后端或读取超时时,全局选项必须放在 `plan` / `optimize` 子命令之前,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 skills/system-simulation/scripts/optimization_skill.py \
|
||||||
|
--base-url http://127.0.0.1:18082 --timeout 60 \
|
||||||
|
plan PROJECT.json --spec optimization-spec.json --output-dir OUTPUT_DIR \
|
||||||
|
--plan-file PLAN_FILE --present
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw 中不假设当前目录是仓库根目录,使用 Skill 根目录占位符:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 "{baseDir}/scripts/optimization_skill.py" plan PROJECT.json \
|
||||||
|
--spec optimization-spec.json \
|
||||||
|
--output-dir OUTPUT_DIR \
|
||||||
|
--plan-file PLAN_FILE \
|
||||||
|
--present
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以先进入本 Skill 目录,再使用 `scripts/optimization_skill.py plan ...` 和 `scripts/optimization_skill.py optimize ...`。不根据用户主目录、OpenClaw 数据目录或仓库名称猜测脚本路径。持续消费 JSONL 进展,定期报告已占用/最大仿真预算槽位、已完成和失败记录、当前代数、最佳可行目标、缓存命中和内层仿真阶段;不把槽位占用说成后端已接收或已完成,也不因仿真时间短暂停滞而声称卡死。
|
||||||
|
|
||||||
|
通常省略 `--optimization-id` 让脚本生成唯一 ID。若显式指定,同一后端任务保留窗口内必须使用新的 ID;快速复用旧 ID 会被后端按冲突拒绝。
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 安全文本规范化策略
|
||||||
|
|
||||||
|
## 目的
|
||||||
|
|
||||||
|
`repair-format` 只解决可解析 JSON v1 或 XML v3 的文本层问题,使文件采用稳定的 UTF-8 和跨平台文本格式。它不是模型迁移器,也不是语义修复器。
|
||||||
|
|
||||||
|
## 允许的修改
|
||||||
|
|
||||||
|
仅允许脚本已经证明不会改变解析后数据合同的规范化,例如:
|
||||||
|
|
||||||
|
- 将可安全解码的输入统一写为 UTF-8;
|
||||||
|
- 统一 BOM 和换行表现;
|
||||||
|
- 规范化文件末尾换行;
|
||||||
|
- 对可解析内容采用脚本规定的稳定文本序列化形式。
|
||||||
|
|
||||||
|
以脚本返回的预览、变更摘要和哈希为准;不要在脚本外另写正则替换或自制格式化器。若文件连语法都无法可靠解析,停止并报告诊断,不能尝试猜测闭合括号、XML 标签或截断内容。
|
||||||
|
|
||||||
|
## 禁止的修改
|
||||||
|
|
||||||
|
本版不得自动执行下列动作:
|
||||||
|
|
||||||
|
- 新增、删除、更换或重命名组件和连接;
|
||||||
|
- 修改组件 ID、类型、端口、`modelVersion` 或 Schema 版本;
|
||||||
|
- 填猜缺失参数、改变数值、单位、离散选项或介质引用;
|
||||||
|
- 修改仿真起止时间、采样间隔、最大步长或求解算法;
|
||||||
|
- 把 XML v1/v2 或旧工程升级到当前版本;
|
||||||
|
- 根据报错放宽容差、删除失败组件或改变物理拓扑;
|
||||||
|
- 覆盖源文件,即使用户给出的输出路径通过大小写、相对路径或符号链接指向源文件也不行。
|
||||||
|
|
||||||
|
发现上述问题时,可以解释和给出人工处理建议,但不能借“修复格式”的名义实施。
|
||||||
|
|
||||||
|
## 强制确认流程
|
||||||
|
|
||||||
|
1. 对源文件运行 `inspect`,记录格式、诊断和 SHA-256。
|
||||||
|
2. 生成或读取 `repair-format` 的规范化预览,向用户说明只会改变哪些文本表现,并展示目标输出路径。
|
||||||
|
3. 等待用户针对该预览明确确认。笼统的“帮我看看”或先前对其他版本的确认不能复用。
|
||||||
|
4. 使用同一个源文件 SHA-256、预览返回的 `confirmationToken`、`--confirmed` 和预览中相同的 `--output` 路径执行写入。token 绑定源哈希、规范化输出哈希和目标绝对路径。
|
||||||
|
5. 如果哈希、规范化结果或目标路径已变化,停止并重新预览;不能绕过 `--expected-sha256` 或确认 token。
|
||||||
|
6. 对输出文件重新运行 `inspect`。只有重新校验通过且解析后的模型语义未改变时,才能报告完成。
|
||||||
|
|
||||||
|
示例命令形状见 [workflows.md](workflows.md)。
|
||||||
|
|
||||||
|
## 输出与交付
|
||||||
|
|
||||||
|
- 输出名称建议为原名加 `.normalized`,例如 `plant.normalized.json` 或 `plant.normalized.xml`。
|
||||||
|
- 保留源文件;清楚列出新文件、源 SHA-256、输出 SHA-256 和重新校验结果。
|
||||||
|
- 如果没有文本差异,说明文件无需规范化,不制造副本冒充修复结果。
|
||||||
|
- 如果写入失败或输出校验失败,不能把不完整文件当作成功结果交付。
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 命令与对话工作流
|
||||||
|
|
||||||
|
## CLI 合同
|
||||||
|
|
||||||
|
从仓库根目录调用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py [--base-url URL] [--timeout SECONDS] COMMAND
|
||||||
|
```
|
||||||
|
|
||||||
|
若仓库已有 `.venv-win\Scripts\python.exe`,Windows 应优先用它替换 `py -3.12`;Linux 使用 `.venv/bin/python` 或 `python3.12`。不要使用本机可能指向旧版本的裸 `python`。默认服务地址为 `http://127.0.0.1:8000`,超时参数不得低于 10 秒,以保持在后端 5 秒心跳间隔之上。`inspect`、`repair-format`、`status` 和 `cancel` 在标准输出返回一个结构化 JSON;`simulate` 在标准输出给出节流后的 JSONL 进展和精简完成摘要。输出目录的 `progress.jsonl` 保留完整进展/错误事件,但只保存精简结果摘要;完整数值结果另存为 `result.json`,避免时间序列重复占用空间和智能体上下文。
|
||||||
|
|
||||||
|
退出码:
|
||||||
|
|
||||||
|
| 退出码 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | 命令按合同成功完成 |
|
||||||
|
| `2` | 输入、参数或安全前置条件错误 |
|
||||||
|
| `3` | HTTP、连接或后端结构化错误 |
|
||||||
|
| `4` | 仿真事件流报告失败 |
|
||||||
|
| `5` | 本地结果文件写入失败 |
|
||||||
|
|
||||||
|
不要只看退出码 `0` 就声称仿真数值成功;还要检查最终事件和 `result.json` 中的状态。不要通过匹配本地化消息文本判断状态。
|
||||||
|
|
||||||
|
## 检查与解释
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT --format auto
|
||||||
|
```
|
||||||
|
|
||||||
|
`--format` 可为 `auto`、`json` 或 `xml`。完成后按 [file-contracts.md](file-contracts.md) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。
|
||||||
|
对工程 JSON,`inspect` 的编译检查会安全计算受支持的连续参数表达式;原始组件数据仍显示用户输入的表达式。仿真时生成的临时 XML 只包含换算后的 SI 数值,不会回写 JSON。
|
||||||
|
|
||||||
|
默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT `
|
||||||
|
--component COMPONENT_ID `
|
||||||
|
--component-offset 0 `
|
||||||
|
--component-limit 50 `
|
||||||
|
--connection-offset 0 `
|
||||||
|
--connection-limit 25 `
|
||||||
|
--variable-query QUERY `
|
||||||
|
--variable-offset 0 `
|
||||||
|
--variable-limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
分别依据 `componentPage`、`connectionPage` 和 `resultVariablePage` 的 `hasMore`、`nextOffset` 继续分页,不要为寻找一个组件或变量请求全部详细合同。`componentTypes` 始终汇总完整模型,可先用它判断系统构成。`--component` 返回该 ID 的源文件数据和(若参与求解)编译合同,因此物性介质等配置节点也能查看参数。
|
||||||
|
|
||||||
|
## 文本规范化修复
|
||||||
|
|
||||||
|
先检查并取得源文件 SHA-256。第一次不带 `--confirmed` 调用只返回差异预览、不会写文件:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py repair-format INPUT `
|
||||||
|
--format auto `
|
||||||
|
--output OUTPUT `
|
||||||
|
--expected-sha256 SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
向用户展示预览中的目标路径、源/输出哈希和 `confirmationToken`,取得明确确认后,再用原样 token 执行写入:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py repair-format INPUT `
|
||||||
|
--format auto `
|
||||||
|
--output OUTPUT `
|
||||||
|
--expected-sha256 SHA256 `
|
||||||
|
--confirmation-token PREVIEW_TOKEN `
|
||||||
|
--confirmed
|
||||||
|
```
|
||||||
|
|
||||||
|
token 同时绑定源文件哈希、规范化输出哈希和目标绝对路径;任何一项变化都必须重新预览和确认。脚本拒绝覆盖源文件、哈希/token 不匹配和未确认写入。完整边界见 [repair-policy.md](repair-policy.md)。写入后再次运行 `inspect OUTPUT`。
|
||||||
|
|
||||||
|
## 仿真前对话
|
||||||
|
|
||||||
|
运行前必须完成以下判断:
|
||||||
|
|
||||||
|
1. `inspect` 通过,并取得可用结果变量清单。
|
||||||
|
2. 用户选择 `separate`、`overlay` 或 `stacked`。
|
||||||
|
3. 把自然语言对象解析为稳定结果 `key`;重名、缺单位或把输入参数误称为曲线时先澄清。
|
||||||
|
4. 向用户复述将运行的文件、仿真时段、算法、所选结果变量和曲线方式。
|
||||||
|
|
||||||
|
本版没有网页自动预装能力。用户要求“网页查看”时,说明当前只能直接交付 SVG 曲线与 CSV;不要启动浏览器、生成临时 URL,或声称现有页面会自动载入文件。
|
||||||
|
|
||||||
|
## 启动并监视仿真
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py simulate INPUT `
|
||||||
|
--format auto `
|
||||||
|
--output-dir OUTPUT_DIR `
|
||||||
|
--variables RESULT_KEY_1 RESULT_KEY_2 `
|
||||||
|
--chart-mode overlay `
|
||||||
|
--simulation-id SIMULATION_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
`--variables` 接受结果变量稳定 `key`;不能传组件参数名。`--simulation-id` 可省略并由脚本生成,但应保存最终 ID,供恢复查询或取消使用。
|
||||||
|
|
||||||
|
监视规则:
|
||||||
|
|
||||||
|
- 消费 JSONL,记录最新 `progress`、`phase`、`simulatedTime/totalTime`、心跳和内部活动快照;
|
||||||
|
- 长任务期间定期向用户给出简短进展,避免逐条转发事件;
|
||||||
|
- 仅有仿真时间平台期不能证明卡死。活动序号、RHS、solver step、Jacobian 或闭合计数仍增长时,应报告“正在处理慢步”;
|
||||||
|
- 网络读取中断后,用已知 simulation ID 查询一次任务快照,再决定是否继续说明、恢复结果或报告连接问题;
|
||||||
|
- 不因运行缓慢自动取消。只有用户明确要求取消,或既有系统已经把任务判定为 stalled 时,才使用对应取消原因;
|
||||||
|
- `completed` 才表示完整完成;`stopped`、`stalled`、`failed` 都必须标明是非完整结果。
|
||||||
|
|
||||||
|
当前任务状态保存在后端进程内,终态记录只短期保留,服务重启后也不能恢复。本 Skill 不承诺跨进程或长期断线续传;需要查询时应及时保存 simulation ID、事件日志和已经写出的结果文件。
|
||||||
|
|
||||||
|
`status` 对已完成任务只输出结果摘要,不在终端重复打印整套时间序列;正常 `simulate` 流程会把完整数据保存为 `result.json` 和 `results.csv`。
|
||||||
|
|
||||||
|
恢复查询:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py status SIMULATION_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
用户要求取消时:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 skills/system-simulation/scripts/simulation_skill.py cancel SIMULATION_ID --reason user
|
||||||
|
```
|
||||||
|
|
||||||
|
`--reason stalled` 只用于已有充分停滞证据的内部流程,不用来表达普通的用户取消。
|
||||||
|
|
||||||
|
## 结果文件与交付
|
||||||
|
|
||||||
|
正常运行目录应包含:
|
||||||
|
|
||||||
|
- `progress.jsonl`:原始进度、心跳和结束事件;
|
||||||
|
- `result.json`:最终结构化结果;
|
||||||
|
- `results.csv`:全部可用结果变量的 UTF-8 CSV;
|
||||||
|
- 根据 `separate`、`overlay` 或 `stacked` 生成的 SVG 曲线。
|
||||||
|
|
||||||
|
交付时:
|
||||||
|
|
||||||
|
1. 说明最终状态和实际计算到的仿真时间;
|
||||||
|
2. 返回用户选择的 SVG 曲线;
|
||||||
|
3. 无论用户只选了几条曲线,都同时返回完整 `results.csv`;
|
||||||
|
4. 若失败或取消但存在部分序列,明确标注曲线和 CSV 是部分结果;
|
||||||
|
5. 若没有产生可用时间序列,明确说明没有 CSV,不能创建空文件冒充结果;
|
||||||
|
6. 保留 `result.json` 和 `progress.jsonl` 作为诊断依据,但通常无需把完整事件日志逐行展示给用户。
|
||||||
|
|
||||||
|
本版不会根据结果自动改变模型并重试。诊断后若要改参数、拓扑或算法,先把建议交给用户,等待后续迭代能力或单独授权的人工修改流程。
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,24 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
cd /d "%~dp0"
|
|
||||||
title SystemSimulationApp Launcher
|
|
||||||
|
|
||||||
if not exist "%~dp0start-backend.bat" (
|
|
||||||
echo [ERROR] start-backend.bat was not found.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
if not exist "%~dp0start-reactflow.bat" (
|
|
||||||
echo [ERROR] start-reactflow.bat was not found.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Starting FastAPI and ReactFlow in separate windows...
|
|
||||||
|
|
||||||
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%~dp0start-backend.bat"
|
|
||||||
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%~dp0start-reactflow.bat"
|
|
||||||
|
|
||||||
exit /b 0
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
|
|
||||||
|
|
||||||
set "FRONTEND_DIR=%~dp0frontend"
|
|
||||||
set "START_SCRIPT=%FRONTEND_DIR%\start-dev.bat"
|
|
||||||
|
|
||||||
if not exist "%START_SCRIPT%" (
|
|
||||||
echo [ERROR] ReactFlow start script was not found:
|
|
||||||
echo %START_SCRIPT%
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Starting ReactFlow at http://127.0.0.1:5173
|
|
||||||
echo Press Ctrl+C to stop the service.
|
|
||||||
echo.
|
|
||||||
|
|
||||||
cd /d "%FRONTEND_DIR%"
|
|
||||||
call "%START_SCRIPT%"
|
|
||||||
set "EXIT_CODE=%ERRORLEVEL%"
|
|
||||||
|
|
||||||
if not "%EXIT_CODE%"=="0" (
|
|
||||||
echo.
|
|
||||||
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
|
|
||||||
pause
|
|
||||||
)
|
|
||||||
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
+952
@@ -0,0 +1,952 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_mql_8-production-0.2s-physical-state-v2.1",
|
||||||
|
"contractId": "physical-state-v2.1",
|
||||||
|
"caseId": "0.2s",
|
||||||
|
"lane": "production",
|
||||||
|
"sourceXmlSha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
|
||||||
|
"approval": {
|
||||||
|
"status": "candidate",
|
||||||
|
"generatedAt": "2026-08-18T07:35:03.491746+00:00",
|
||||||
|
"warning": "The correctness gate rejects this file until explicit approval."
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"localSourceReport": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-physical-state-v2.1-0.2.json",
|
||||||
|
"bytes": 380076,
|
||||||
|
"sha256": "c647fc7dab6c5cdaea188789dd8e0192d9caf374873afa1f5f5db0da9d69c49b",
|
||||||
|
"generatedAt": "2026-08-18T07:34:00.055573+00:00"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"archivePath": "AmesimModels/test_mql.ame",
|
||||||
|
"archiveBytes": 21708800,
|
||||||
|
"archiveSha256": "cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb",
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"name": "test_mql_.var",
|
||||||
|
"bytes": 95371,
|
||||||
|
"sha256": "175010af62d254b34b1f876ff652feb903fe2ce88bee80d4dc0ef1d31f04067b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "test_mql_.results",
|
||||||
|
"bytes": 8968576,
|
||||||
|
"sha256": "434a2cdff1322565957934354154425868fa03737558636927cb0f3ec51f1760"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"referencePressurePa": 101300.0,
|
||||||
|
"role": "external calibration reference; not an equality claim"
|
||||||
|
},
|
||||||
|
"amesimStoredMassDataPaths": [
|
||||||
|
"mgas1@pn_c1_8",
|
||||||
|
"mgas1@pn_c1_9",
|
||||||
|
"mgas1@pn_c1_10",
|
||||||
|
"mgas1@pn_c1_11",
|
||||||
|
"mgas1@pn_c1_12",
|
||||||
|
"mgas1@pn_c1_13",
|
||||||
|
"mgas1@pn_c1_14",
|
||||||
|
"mgas1@pn_c1_15",
|
||||||
|
"mgas@pn_general_chamber",
|
||||||
|
"mgas@pn_general_chamber_2",
|
||||||
|
"mgas@pn_general_chamber_4",
|
||||||
|
"mgas@pn_general_chamber_5",
|
||||||
|
"mgas@pneumatic_65",
|
||||||
|
"mgas@pneumatic_66",
|
||||||
|
"mgas@pneumatic_68",
|
||||||
|
"mgas@pneumatic_69",
|
||||||
|
"mgas@pneumatic_70",
|
||||||
|
"mgas@pneumatic_71",
|
||||||
|
"mgas@pneumatic_72",
|
||||||
|
"mgas@pneumatic_73",
|
||||||
|
"mgas@pneumatic_74",
|
||||||
|
"mgas@pneumatic_75",
|
||||||
|
"mgas@pneumatic_76",
|
||||||
|
"mgas@pneumatic_77",
|
||||||
|
"mgas@pneumatic_78",
|
||||||
|
"mgas@pneumatic_79",
|
||||||
|
"mgas@pneumatic_80",
|
||||||
|
"mgas@pneumatic_81",
|
||||||
|
"mgas@pneumatic_82",
|
||||||
|
"mgas@pneumatic_83",
|
||||||
|
"mgas@pneumatic_84",
|
||||||
|
"mgas@pneumatic_85",
|
||||||
|
"mgas@pneumatic_86",
|
||||||
|
"mgas@pneumatic_87",
|
||||||
|
"mgas@pneumatic_88",
|
||||||
|
"mgas@pneumatic_91",
|
||||||
|
"mgas@pneumatic_92",
|
||||||
|
"mgas@pneumatic_93",
|
||||||
|
"mgas@pneumatic_94",
|
||||||
|
"mgas@pneumatic_95",
|
||||||
|
"mgas@pneumatic_96",
|
||||||
|
"mgas@pneumatic_97",
|
||||||
|
"mgas@pneumatic_98",
|
||||||
|
"mgas@pneumatic_99",
|
||||||
|
"mgas@pneumatic_101",
|
||||||
|
"mgas@pneumatic_102",
|
||||||
|
"mgas@pneumatic_103",
|
||||||
|
"mgas@pneumatic_104"
|
||||||
|
],
|
||||||
|
"comparisonPolicy": "Local values are the regression target; transformed AMESim values are retained visibly as an external calibration reference."
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"projectionKeys": [
|
||||||
|
"pressure.pnch012_8.absolute",
|
||||||
|
"pressure.pnl0001_20.absolute",
|
||||||
|
"massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"conservation.p4node2_8.massBalance",
|
||||||
|
"conservation.pnvo001_5.massBalance",
|
||||||
|
"conservation.pnl0001_20_pnch012_8.connectionMassBalance",
|
||||||
|
"conservation.totalStoredGasMass",
|
||||||
|
"discrete.pnvo001_5.openMode",
|
||||||
|
"discrete.mecmas21_9.endstopMode",
|
||||||
|
"discrete.mecmas21_10.endstopMode"
|
||||||
|
],
|
||||||
|
"projections": [
|
||||||
|
{
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"unit": "Pa",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnch012_8.p"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"press@pn_c1_8"
|
||||||
|
],
|
||||||
|
"sourceUnit": "Pa (gauge)",
|
||||||
|
"targetUnit": "Pa (absolute)",
|
||||||
|
"formula": "target = source + 101300",
|
||||||
|
"scale": 1.0,
|
||||||
|
"offset": 101300.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 0.001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"unit": "Pa",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnl0001_20.p"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"p2@pneumatic_69"
|
||||||
|
],
|
||||||
|
"sourceUnit": "Pa (gauge)",
|
||||||
|
"targetUnit": "Pa (absolute)",
|
||||||
|
"formula": "target = source + 101300",
|
||||||
|
"scale": 1.0,
|
||||||
|
"offset": 101300.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 0.001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnl0001_20.port_1.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"dm1@pneumatic_69"
|
||||||
|
],
|
||||||
|
"sourceUnit": "g/s (AMESim component orientation)",
|
||||||
|
"targetUnit": "kg/s (positive into generic component)",
|
||||||
|
"formula": "target = -source * 1e-3",
|
||||||
|
"scale": -0.001,
|
||||||
|
"offset": 0.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnvo001_5.port_2.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"dm2@pn_morifice_1"
|
||||||
|
],
|
||||||
|
"sourceUnit": "g/s (AMESim component orientation)",
|
||||||
|
"targetUnit": "kg/s (positive into generic component)",
|
||||||
|
"formula": "target = -source * 1e-3",
|
||||||
|
"scale": -0.001,
|
||||||
|
"offset": 0.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.p4node2_8.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_p4node2_8.port_1.m_flow",
|
||||||
|
"amesim_p4node2_8.port_2.m_flow",
|
||||||
|
"amesim_p4node2_8.port_3.m_flow",
|
||||||
|
"amesim_p4node2_8.port_4.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [],
|
||||||
|
"note": "AMESim archive has no complete saved four-port tuple."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnvo001_5.port_2.m_flow",
|
||||||
|
"amesim_pnvo001_5.port_3.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"dm2@pn_morifice_1",
|
||||||
|
"dm3@pn_morifice_1"
|
||||||
|
],
|
||||||
|
"sourceUnit": "g/s",
|
||||||
|
"targetUnit": "kg/s",
|
||||||
|
"formula": "target = -(source1 + source2) * 1e-3"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.pnl0001_20_pnch012_8.connectionMassBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnl0001_20.port_1.m_flow",
|
||||||
|
"amesim_pnch012_8.port_1.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [],
|
||||||
|
"note": "PNCH012 port flow is not saved; local connector contract is authoritative."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnch012_10.m",
|
||||||
|
"amesim_pnch012_11.m",
|
||||||
|
"amesim_pnch012_12.m",
|
||||||
|
"amesim_pnch012_13.m",
|
||||||
|
"amesim_pnch012_14.m",
|
||||||
|
"amesim_pnch012_15.m",
|
||||||
|
"amesim_pnch012_8.m",
|
||||||
|
"amesim_pnch012_9.m",
|
||||||
|
"amesim_pnch023_1.m",
|
||||||
|
"amesim_pnch023_2.m",
|
||||||
|
"amesim_pnch023_3.m",
|
||||||
|
"amesim_pnch023_4.m",
|
||||||
|
"amesim_pnl0001_1.m",
|
||||||
|
"amesim_pnl0001_10.m",
|
||||||
|
"amesim_pnl0001_11.m",
|
||||||
|
"amesim_pnl0001_13.m",
|
||||||
|
"amesim_pnl0001_14.m",
|
||||||
|
"amesim_pnl0001_15.m",
|
||||||
|
"amesim_pnl0001_16.m",
|
||||||
|
"amesim_pnl0001_17.m",
|
||||||
|
"amesim_pnl0001_18.m",
|
||||||
|
"amesim_pnl0001_19.m",
|
||||||
|
"amesim_pnl0001_2.m",
|
||||||
|
"amesim_pnl0001_20.m",
|
||||||
|
"amesim_pnl0001_21.m",
|
||||||
|
"amesim_pnl0001_25.m",
|
||||||
|
"amesim_pnl0001_26.m",
|
||||||
|
"amesim_pnl0001_27.m",
|
||||||
|
"amesim_pnl0001_4.m",
|
||||||
|
"amesim_pnl0001_5.m",
|
||||||
|
"amesim_pnl0001_7.m",
|
||||||
|
"amesim_pnl0001_9.m",
|
||||||
|
"amesim_pnl0002_1.m",
|
||||||
|
"amesim_pnl0002_2.m",
|
||||||
|
"amesim_pnl0002_3.m",
|
||||||
|
"amesim_pnl0002_4.m",
|
||||||
|
"amesim_pnl0002_5.m",
|
||||||
|
"amesim_pnl0002_6.m",
|
||||||
|
"amesim_pnl0002_7.m",
|
||||||
|
"amesim_pnl0002_8.m",
|
||||||
|
"amesim_pnl0003_1.m1",
|
||||||
|
"amesim_pnl0003_1.m2",
|
||||||
|
"amesim_pnl0003_2.m1",
|
||||||
|
"amesim_pnl0003_2.m2",
|
||||||
|
"amesim_pnl0003_3.m1",
|
||||||
|
"amesim_pnl0003_3.m2",
|
||||||
|
"amesim_pnl0003_4.m1",
|
||||||
|
"amesim_pnl0003_4.m2",
|
||||||
|
"amesim_pnl0003_5.m1",
|
||||||
|
"amesim_pnl0003_5.m2",
|
||||||
|
"amesim_pnl0003_6.m1",
|
||||||
|
"amesim_pnl0003_6.m2",
|
||||||
|
"amesim_pnl0003_7.m1",
|
||||||
|
"amesim_pnl0003_7.m2",
|
||||||
|
"amesim_pnl0003_8.m1",
|
||||||
|
"amesim_pnl0003_8.m2"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPathSelection": "units == 'g' and signal starts with 'mgas'",
|
||||||
|
"sourceUnit": "g",
|
||||||
|
"targetUnit": "kg",
|
||||||
|
"formula": "target = sum(sources) * 1e-3"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"unit": "1",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnvo001_5.xv"
|
||||||
|
],
|
||||||
|
"formula": "1 if opening >= 0.5 else 0"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"xv@pn_morifice_1"
|
||||||
|
],
|
||||||
|
"sourceUnit": "1",
|
||||||
|
"targetUnit": "mode code {0,1}",
|
||||||
|
"formula": "1 if source >= 0.5 else 0"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"unit": "1",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_mecmas21_9.x",
|
||||||
|
"amesim_mecmas21_9.v",
|
||||||
|
"amesim_mecmas21_9.port_1.f",
|
||||||
|
"amesim_mecmas21_9.port_2.f"
|
||||||
|
],
|
||||||
|
"formula": "-1 lower, 0 free, +1 upper using boundary and net-force direction"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"x1@mass_friction_endstops_19",
|
||||||
|
"v1@mass_friction_endstops_19"
|
||||||
|
],
|
||||||
|
"lowerBound": -0.72,
|
||||||
|
"upperBound": 0.0,
|
||||||
|
"note": "No saved AMESim endstop code; reference is boundary occupancy."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"unit": "1",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_mecmas21_10.x",
|
||||||
|
"amesim_mecmas21_10.v",
|
||||||
|
"amesim_mecmas21_10.port_1.f",
|
||||||
|
"amesim_mecmas21_10.port_2.f"
|
||||||
|
],
|
||||||
|
"formula": "-1 lower, 0 free, +1 upper using boundary and net-force direction"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"x1@mass_friction_endstops_18",
|
||||||
|
"v1@mass_friction_endstops_18"
|
||||||
|
],
|
||||||
|
"lowerBound": 0.0,
|
||||||
|
"upperBound": 0.37,
|
||||||
|
"note": "No saved AMESim endstop code; reference is boundary occupancy."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"projectionLayoutSha256": "55cc8f71ea72fd46f71f1ede2587aeb9f6d590bde8e97e881648f88a4a90dca6"
|
||||||
|
},
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"values": [
|
||||||
|
100000.0,
|
||||||
|
100000.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
5.5668930151375235,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"amesimReferenceValues": [
|
||||||
|
100000.00000000001,
|
||||||
|
100000.00000000001,
|
||||||
|
-0.0,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
5.5668930151375235,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"values": [
|
||||||
|
100153.57210434608,
|
||||||
|
100152.45747547514,
|
||||||
|
6.682314111565264e-06,
|
||||||
|
-0.4978238221497115,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
5.5668930151375235,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"amesimReferenceValues": [
|
||||||
|
100153.61882579065,
|
||||||
|
100152.49205385521,
|
||||||
|
6.672550142634319e-06,
|
||||||
|
-0.4978238226601424,
|
||||||
|
null,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
5.566893015142308,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"values": [
|
||||||
|
3620589.728099082,
|
||||||
|
3756379.1344475006,
|
||||||
|
-0.3723610392591412,
|
||||||
|
-0.3962730176377355,
|
||||||
|
1.8214596497756474e-17,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
5.566893015137554,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"amesimReferenceValues": [
|
||||||
|
3625053.2009413484,
|
||||||
|
3760735.7825351665,
|
||||||
|
-0.3728806429214546,
|
||||||
|
-0.3961933197727504,
|
||||||
|
null,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
5.566892994134813,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"amesimAlignmentAtGeneration": {
|
||||||
|
"passed": true,
|
||||||
|
"issues": [],
|
||||||
|
"comparedValueCount": 25,
|
||||||
|
"excludedValueCount": 2,
|
||||||
|
"maxToleranceRatio": 0.6967416724836332,
|
||||||
|
"maxToleranceRatioUnbounded": false,
|
||||||
|
"worstValue": {
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.3723610392591412,
|
||||||
|
"amesim": -0.3728806429214546,
|
||||||
|
"absoluteError": 0.0005196036623134059,
|
||||||
|
"relativeError": 0.0013934852135053246,
|
||||||
|
"allowedError": 0.0007457622858429093,
|
||||||
|
"toleranceRatio": 0.6967416724836332,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100000.0,
|
||||||
|
"amesim": 100000.00000000001,
|
||||||
|
"absoluteError": 1.4551915228366852e-11,
|
||||||
|
"relativeError": 1.455191522836685e-16,
|
||||||
|
"allowedError": 200.00100000000003,
|
||||||
|
"toleranceRatio": 7.275921234577252e-14,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100000.0,
|
||||||
|
"amesim": 100000.00000000001,
|
||||||
|
"absoluteError": 1.4551915228366852e-11,
|
||||||
|
"relativeError": 1.455191522836685e-16,
|
||||||
|
"allowedError": 200.00100000000003,
|
||||||
|
"toleranceRatio": 7.275921234577252e-14,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 5.5668930151375235,
|
||||||
|
"amesim": 5.5668930151375235,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.011133787030275047,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": 0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": -1.0,
|
||||||
|
"amesim": -1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100153.57210434608,
|
||||||
|
"amesim": 100153.61882579065,
|
||||||
|
"absoluteError": 0.04672144456708338,
|
||||||
|
"relativeError": 4.6649781720170954e-07,
|
||||||
|
"allowedError": 200.3082376515813,
|
||||||
|
"toleranceRatio": 0.00023324774415094828,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100152.45747547514,
|
||||||
|
"amesim": 100152.49205385521,
|
||||||
|
"absoluteError": 0.03457838007307146,
|
||||||
|
"relativeError": 3.4525731076644157e-07,
|
||||||
|
"allowedError": 200.30598410771043,
|
||||||
|
"toleranceRatio": 0.00017262779355846724,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": 6.682314111565264e-06,
|
||||||
|
"amesim": 6.672550142634319e-06,
|
||||||
|
"absoluteError": 9.763968930944316e-09,
|
||||||
|
"relativeError": 0.0014633039426046946,
|
||||||
|
"allowedError": 1.4345100285268639e-08,
|
||||||
|
"toleranceRatio": 0.6806483563569921,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": false,
|
||||||
|
"passed": null,
|
||||||
|
"exclusionReason": "signalDiscontinuityLeftRightLimitSemanticsNotComparableAt0.04s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.4978238221497115,
|
||||||
|
"amesim": -0.4978238226601424,
|
||||||
|
"absoluteError": 5.104309197534462e-10,
|
||||||
|
"relativeError": 1.0253244150228433e-09,
|
||||||
|
"allowedError": 0.0009956486453202849,
|
||||||
|
"toleranceRatio": 5.126616926086898e-07,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": false,
|
||||||
|
"passed": null,
|
||||||
|
"exclusionReason": "signalDiscontinuityLeftRightLimitSemanticsNotComparableAt0.04s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 5.5668930151375235,
|
||||||
|
"amesim": 5.566893015142308,
|
||||||
|
"absoluteError": 4.784617146924575e-12,
|
||||||
|
"relativeError": 8.594771147766101e-13,
|
||||||
|
"allowedError": 0.011133787030284616,
|
||||||
|
"toleranceRatio": 4.2973851879060635e-10,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": -1.0,
|
||||||
|
"amesim": -1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 3620589.728099082,
|
||||||
|
"amesim": 3625053.2009413484,
|
||||||
|
"absoluteError": 4463.4728422663175,
|
||||||
|
"relativeError": 0.0012312847825536048,
|
||||||
|
"allowedError": 7250.107401882698,
|
||||||
|
"toleranceRatio": 0.6156423063618685,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 3756379.1344475006,
|
||||||
|
"amesim": 3760735.7825351665,
|
||||||
|
"absoluteError": 4356.648087665904,
|
||||||
|
"relativeError": 0.0011584563073795695,
|
||||||
|
"allowedError": 7521.472565070333,
|
||||||
|
"toleranceRatio": 0.5792280766798443,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.3723610392591412,
|
||||||
|
"amesim": -0.3728806429214546,
|
||||||
|
"absoluteError": 0.0005196036623134059,
|
||||||
|
"relativeError": 0.0013934852135053246,
|
||||||
|
"allowedError": 0.0007457622858429093,
|
||||||
|
"toleranceRatio": 0.6967416724836332,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.3962730176377355,
|
||||||
|
"amesim": -0.3961933197727504,
|
||||||
|
"absoluteError": 7.969786498507325e-05,
|
||||||
|
"relativeError": 0.00020115903274388008,
|
||||||
|
"allowedError": 0.0007923876395455008,
|
||||||
|
"toleranceRatio": 0.10057938943972738,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 5.566893015137554,
|
||||||
|
"amesim": 5.566892994134813,
|
||||||
|
"absoluteError": 2.1002740879794146e-08,
|
||||||
|
"relativeError": 3.7727940705744284e-09,
|
||||||
|
"allowedError": 0.011133786988269626,
|
||||||
|
"toleranceRatio": 1.8863968658572583e-06,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": -1.0,
|
||||||
|
"amesim": -1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"toleranceProfile": {
|
||||||
|
"id": "amesim-alignment-reviewed-v1",
|
||||||
|
"byCategory": {
|
||||||
|
"pressure": {
|
||||||
|
"relative": 0.002,
|
||||||
|
"absolute": 0.001
|
||||||
|
},
|
||||||
|
"massFlow": {
|
||||||
|
"relative": 0.002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
},
|
||||||
|
"conservation": {
|
||||||
|
"relative": 0.002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
},
|
||||||
|
"discreteMode": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"basis": "0.2% relative envelope for AMESim pressure/flow/inventory alignment; conservation retains a 1e-9 absolute floor and discrete modes are exact.",
|
||||||
|
"exclusions": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"category": "massFlow",
|
||||||
|
"reason": "The Python and AMESim samples at the signal discontinuity may represent different left/right limits. Raw errors remain recorded."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"policy": "diagnostic external-reference comparison; local golden is the regression gate"
|
||||||
|
}
|
||||||
|
}
|
||||||
+955
@@ -0,0 +1,955 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_mql_8-production-0.2s-physical-state-v2.1",
|
||||||
|
"contractId": "physical-state-v2.1",
|
||||||
|
"caseId": "0.2s",
|
||||||
|
"lane": "production",
|
||||||
|
"sourceXmlSha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
|
||||||
|
"approval": {
|
||||||
|
"status": "approved",
|
||||||
|
"approvedAt": "2026-08-18T07:35:25.264794+00:00",
|
||||||
|
"reviewedBy": "Codex OPT-00 physical-state-v2.1 gate",
|
||||||
|
"note": "Reviewed full 0/0.04/0.2 checkpoints: AMESim external alignment passed the declared 0.2% profile; t=0.04 mass-flow values retain errors but are excluded for explicit left/right-limit ambiguity; local p4node, PNVO, and connection mass-balance residuals pass the strict 1e-9 kg/s gate; discrete modes are exact.",
|
||||||
|
"candidatePath": "tests/baselines/simulation/test_mql_8/goldens/candidates/production-0.2s-physical-state-v2.1-candidate.json",
|
||||||
|
"candidateSha256": "699033337c30e6aed97538e886b81ff897fe2c455860b4ad1ed3293f580fb53a"
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"localSourceReport": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-physical-state-v2.1-0.2.json",
|
||||||
|
"bytes": 380076,
|
||||||
|
"sha256": "c647fc7dab6c5cdaea188789dd8e0192d9caf374873afa1f5f5db0da9d69c49b",
|
||||||
|
"generatedAt": "2026-08-18T07:34:00.055573+00:00"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"archivePath": "AmesimModels/test_mql.ame",
|
||||||
|
"archiveBytes": 21708800,
|
||||||
|
"archiveSha256": "cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb",
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"name": "test_mql_.var",
|
||||||
|
"bytes": 95371,
|
||||||
|
"sha256": "175010af62d254b34b1f876ff652feb903fe2ce88bee80d4dc0ef1d31f04067b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "test_mql_.results",
|
||||||
|
"bytes": 8968576,
|
||||||
|
"sha256": "434a2cdff1322565957934354154425868fa03737558636927cb0f3ec51f1760"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"referencePressurePa": 101300.0,
|
||||||
|
"role": "external calibration reference; not an equality claim"
|
||||||
|
},
|
||||||
|
"amesimStoredMassDataPaths": [
|
||||||
|
"mgas1@pn_c1_8",
|
||||||
|
"mgas1@pn_c1_9",
|
||||||
|
"mgas1@pn_c1_10",
|
||||||
|
"mgas1@pn_c1_11",
|
||||||
|
"mgas1@pn_c1_12",
|
||||||
|
"mgas1@pn_c1_13",
|
||||||
|
"mgas1@pn_c1_14",
|
||||||
|
"mgas1@pn_c1_15",
|
||||||
|
"mgas@pn_general_chamber",
|
||||||
|
"mgas@pn_general_chamber_2",
|
||||||
|
"mgas@pn_general_chamber_4",
|
||||||
|
"mgas@pn_general_chamber_5",
|
||||||
|
"mgas@pneumatic_65",
|
||||||
|
"mgas@pneumatic_66",
|
||||||
|
"mgas@pneumatic_68",
|
||||||
|
"mgas@pneumatic_69",
|
||||||
|
"mgas@pneumatic_70",
|
||||||
|
"mgas@pneumatic_71",
|
||||||
|
"mgas@pneumatic_72",
|
||||||
|
"mgas@pneumatic_73",
|
||||||
|
"mgas@pneumatic_74",
|
||||||
|
"mgas@pneumatic_75",
|
||||||
|
"mgas@pneumatic_76",
|
||||||
|
"mgas@pneumatic_77",
|
||||||
|
"mgas@pneumatic_78",
|
||||||
|
"mgas@pneumatic_79",
|
||||||
|
"mgas@pneumatic_80",
|
||||||
|
"mgas@pneumatic_81",
|
||||||
|
"mgas@pneumatic_82",
|
||||||
|
"mgas@pneumatic_83",
|
||||||
|
"mgas@pneumatic_84",
|
||||||
|
"mgas@pneumatic_85",
|
||||||
|
"mgas@pneumatic_86",
|
||||||
|
"mgas@pneumatic_87",
|
||||||
|
"mgas@pneumatic_88",
|
||||||
|
"mgas@pneumatic_91",
|
||||||
|
"mgas@pneumatic_92",
|
||||||
|
"mgas@pneumatic_93",
|
||||||
|
"mgas@pneumatic_94",
|
||||||
|
"mgas@pneumatic_95",
|
||||||
|
"mgas@pneumatic_96",
|
||||||
|
"mgas@pneumatic_97",
|
||||||
|
"mgas@pneumatic_98",
|
||||||
|
"mgas@pneumatic_99",
|
||||||
|
"mgas@pneumatic_101",
|
||||||
|
"mgas@pneumatic_102",
|
||||||
|
"mgas@pneumatic_103",
|
||||||
|
"mgas@pneumatic_104"
|
||||||
|
],
|
||||||
|
"comparisonPolicy": "Local values are the regression target; transformed AMESim values are retained visibly as an external calibration reference."
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"projectionKeys": [
|
||||||
|
"pressure.pnch012_8.absolute",
|
||||||
|
"pressure.pnl0001_20.absolute",
|
||||||
|
"massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"conservation.p4node2_8.massBalance",
|
||||||
|
"conservation.pnvo001_5.massBalance",
|
||||||
|
"conservation.pnl0001_20_pnch012_8.connectionMassBalance",
|
||||||
|
"conservation.totalStoredGasMass",
|
||||||
|
"discrete.pnvo001_5.openMode",
|
||||||
|
"discrete.mecmas21_9.endstopMode",
|
||||||
|
"discrete.mecmas21_10.endstopMode"
|
||||||
|
],
|
||||||
|
"projections": [
|
||||||
|
{
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"unit": "Pa",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnch012_8.p"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"press@pn_c1_8"
|
||||||
|
],
|
||||||
|
"sourceUnit": "Pa (gauge)",
|
||||||
|
"targetUnit": "Pa (absolute)",
|
||||||
|
"formula": "target = source + 101300",
|
||||||
|
"scale": 1.0,
|
||||||
|
"offset": 101300.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 0.001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"unit": "Pa",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnl0001_20.p"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"p2@pneumatic_69"
|
||||||
|
],
|
||||||
|
"sourceUnit": "Pa (gauge)",
|
||||||
|
"targetUnit": "Pa (absolute)",
|
||||||
|
"formula": "target = source + 101300",
|
||||||
|
"scale": 1.0,
|
||||||
|
"offset": 101300.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 0.001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnl0001_20.port_1.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"dm1@pneumatic_69"
|
||||||
|
],
|
||||||
|
"sourceUnit": "g/s (AMESim component orientation)",
|
||||||
|
"targetUnit": "kg/s (positive into generic component)",
|
||||||
|
"formula": "target = -source * 1e-3",
|
||||||
|
"scale": -0.001,
|
||||||
|
"offset": 0.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnvo001_5.port_2.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "identity"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"dm2@pn_morifice_1"
|
||||||
|
],
|
||||||
|
"sourceUnit": "g/s (AMESim component orientation)",
|
||||||
|
"targetUnit": "kg/s (positive into generic component)",
|
||||||
|
"formula": "target = -source * 1e-3",
|
||||||
|
"scale": -0.001,
|
||||||
|
"offset": 0.0
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.p4node2_8.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_p4node2_8.port_1.m_flow",
|
||||||
|
"amesim_p4node2_8.port_2.m_flow",
|
||||||
|
"amesim_p4node2_8.port_3.m_flow",
|
||||||
|
"amesim_p4node2_8.port_4.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [],
|
||||||
|
"note": "AMESim archive has no complete saved four-port tuple."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnvo001_5.port_2.m_flow",
|
||||||
|
"amesim_pnvo001_5.port_3.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"dm2@pn_morifice_1",
|
||||||
|
"dm3@pn_morifice_1"
|
||||||
|
],
|
||||||
|
"sourceUnit": "g/s",
|
||||||
|
"targetUnit": "kg/s",
|
||||||
|
"formula": "target = -(source1 + source2) * 1e-3"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.pnl0001_20_pnch012_8.connectionMassBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnl0001_20.port_1.m_flow",
|
||||||
|
"amesim_pnch012_8.port_1.m_flow"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [],
|
||||||
|
"note": "PNCH012 port flow is not saved; local connector contract is authoritative."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"unit": "kg",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnch012_10.m",
|
||||||
|
"amesim_pnch012_11.m",
|
||||||
|
"amesim_pnch012_12.m",
|
||||||
|
"amesim_pnch012_13.m",
|
||||||
|
"amesim_pnch012_14.m",
|
||||||
|
"amesim_pnch012_15.m",
|
||||||
|
"amesim_pnch012_8.m",
|
||||||
|
"amesim_pnch012_9.m",
|
||||||
|
"amesim_pnch023_1.m",
|
||||||
|
"amesim_pnch023_2.m",
|
||||||
|
"amesim_pnch023_3.m",
|
||||||
|
"amesim_pnch023_4.m",
|
||||||
|
"amesim_pnl0001_1.m",
|
||||||
|
"amesim_pnl0001_10.m",
|
||||||
|
"amesim_pnl0001_11.m",
|
||||||
|
"amesim_pnl0001_13.m",
|
||||||
|
"amesim_pnl0001_14.m",
|
||||||
|
"amesim_pnl0001_15.m",
|
||||||
|
"amesim_pnl0001_16.m",
|
||||||
|
"amesim_pnl0001_17.m",
|
||||||
|
"amesim_pnl0001_18.m",
|
||||||
|
"amesim_pnl0001_19.m",
|
||||||
|
"amesim_pnl0001_2.m",
|
||||||
|
"amesim_pnl0001_20.m",
|
||||||
|
"amesim_pnl0001_21.m",
|
||||||
|
"amesim_pnl0001_25.m",
|
||||||
|
"amesim_pnl0001_26.m",
|
||||||
|
"amesim_pnl0001_27.m",
|
||||||
|
"amesim_pnl0001_4.m",
|
||||||
|
"amesim_pnl0001_5.m",
|
||||||
|
"amesim_pnl0001_7.m",
|
||||||
|
"amesim_pnl0001_9.m",
|
||||||
|
"amesim_pnl0002_1.m",
|
||||||
|
"amesim_pnl0002_2.m",
|
||||||
|
"amesim_pnl0002_3.m",
|
||||||
|
"amesim_pnl0002_4.m",
|
||||||
|
"amesim_pnl0002_5.m",
|
||||||
|
"amesim_pnl0002_6.m",
|
||||||
|
"amesim_pnl0002_7.m",
|
||||||
|
"amesim_pnl0002_8.m",
|
||||||
|
"amesim_pnl0003_1.m1",
|
||||||
|
"amesim_pnl0003_1.m2",
|
||||||
|
"amesim_pnl0003_2.m1",
|
||||||
|
"amesim_pnl0003_2.m2",
|
||||||
|
"amesim_pnl0003_3.m1",
|
||||||
|
"amesim_pnl0003_3.m2",
|
||||||
|
"amesim_pnl0003_4.m1",
|
||||||
|
"amesim_pnl0003_4.m2",
|
||||||
|
"amesim_pnl0003_5.m1",
|
||||||
|
"amesim_pnl0003_5.m2",
|
||||||
|
"amesim_pnl0003_6.m1",
|
||||||
|
"amesim_pnl0003_6.m2",
|
||||||
|
"amesim_pnl0003_7.m1",
|
||||||
|
"amesim_pnl0003_7.m2",
|
||||||
|
"amesim_pnl0003_8.m1",
|
||||||
|
"amesim_pnl0003_8.m2"
|
||||||
|
],
|
||||||
|
"formula": "sum"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPathSelection": "units == 'g' and signal starts with 'mgas'",
|
||||||
|
"sourceUnit": "g",
|
||||||
|
"targetUnit": "kg",
|
||||||
|
"formula": "target = sum(sources) * 1e-3"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"unit": "1",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_pnvo001_5.xv"
|
||||||
|
],
|
||||||
|
"formula": "1 if opening >= 0.5 else 0"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"xv@pn_morifice_1"
|
||||||
|
],
|
||||||
|
"sourceUnit": "1",
|
||||||
|
"targetUnit": "mode code {0,1}",
|
||||||
|
"formula": "1 if source >= 0.5 else 0"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"unit": "1",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_mecmas21_9.x",
|
||||||
|
"amesim_mecmas21_9.v",
|
||||||
|
"amesim_mecmas21_9.port_1.f",
|
||||||
|
"amesim_mecmas21_9.port_2.f"
|
||||||
|
],
|
||||||
|
"formula": "-1 lower, 0 free, +1 upper using boundary and net-force direction"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"x1@mass_friction_endstops_19",
|
||||||
|
"v1@mass_friction_endstops_19"
|
||||||
|
],
|
||||||
|
"lowerBound": -0.72,
|
||||||
|
"upperBound": 0.0,
|
||||||
|
"note": "No saved AMESim endstop code; reference is boundary occupancy."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"unit": "1",
|
||||||
|
"python": {
|
||||||
|
"sourceKeys": [
|
||||||
|
"amesim_mecmas21_10.x",
|
||||||
|
"amesim_mecmas21_10.v",
|
||||||
|
"amesim_mecmas21_10.port_1.f",
|
||||||
|
"amesim_mecmas21_10.port_2.f"
|
||||||
|
],
|
||||||
|
"formula": "-1 lower, 0 free, +1 upper using boundary and net-force direction"
|
||||||
|
},
|
||||||
|
"amesim": {
|
||||||
|
"dataPaths": [
|
||||||
|
"x1@mass_friction_endstops_18",
|
||||||
|
"v1@mass_friction_endstops_18"
|
||||||
|
],
|
||||||
|
"lowerBound": 0.0,
|
||||||
|
"upperBound": 0.37,
|
||||||
|
"note": "No saved AMESim endstop code; reference is boundary occupancy."
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"projectionLayoutSha256": "55cc8f71ea72fd46f71f1ede2587aeb9f6d590bde8e97e881648f88a4a90dca6"
|
||||||
|
},
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"values": [
|
||||||
|
100000.0,
|
||||||
|
100000.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
5.5668930151375235,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"amesimReferenceValues": [
|
||||||
|
100000.00000000001,
|
||||||
|
100000.00000000001,
|
||||||
|
-0.0,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
5.5668930151375235,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"values": [
|
||||||
|
100153.57210434608,
|
||||||
|
100152.45747547514,
|
||||||
|
6.682314111565264e-06,
|
||||||
|
-0.4978238221497115,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
5.5668930151375235,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"amesimReferenceValues": [
|
||||||
|
100153.61882579065,
|
||||||
|
100152.49205385521,
|
||||||
|
6.672550142634319e-06,
|
||||||
|
-0.4978238226601424,
|
||||||
|
null,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
5.566893015142308,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"values": [
|
||||||
|
3620589.728099082,
|
||||||
|
3756379.1344475006,
|
||||||
|
-0.3723610392591412,
|
||||||
|
-0.3962730176377355,
|
||||||
|
1.8214596497756474e-17,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
5.566893015137554,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
],
|
||||||
|
"amesimReferenceValues": [
|
||||||
|
3625053.2009413484,
|
||||||
|
3760735.7825351665,
|
||||||
|
-0.3728806429214546,
|
||||||
|
-0.3961933197727504,
|
||||||
|
null,
|
||||||
|
-0.0,
|
||||||
|
null,
|
||||||
|
5.566892994134813,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"amesimAlignmentAtGeneration": {
|
||||||
|
"passed": true,
|
||||||
|
"issues": [],
|
||||||
|
"comparedValueCount": 25,
|
||||||
|
"excludedValueCount": 2,
|
||||||
|
"maxToleranceRatio": 0.6967416724836332,
|
||||||
|
"maxToleranceRatioUnbounded": false,
|
||||||
|
"worstValue": {
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.3723610392591412,
|
||||||
|
"amesim": -0.3728806429214546,
|
||||||
|
"absoluteError": 0.0005196036623134059,
|
||||||
|
"relativeError": 0.0013934852135053246,
|
||||||
|
"allowedError": 0.0007457622858429093,
|
||||||
|
"toleranceRatio": 0.6967416724836332,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100000.0,
|
||||||
|
"amesim": 100000.00000000001,
|
||||||
|
"absoluteError": 1.4551915228366852e-11,
|
||||||
|
"relativeError": 1.455191522836685e-16,
|
||||||
|
"allowedError": 200.00100000000003,
|
||||||
|
"toleranceRatio": 7.275921234577252e-14,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100000.0,
|
||||||
|
"amesim": 100000.00000000001,
|
||||||
|
"absoluteError": 1.4551915228366852e-11,
|
||||||
|
"relativeError": 1.455191522836685e-16,
|
||||||
|
"allowedError": 200.00100000000003,
|
||||||
|
"toleranceRatio": 7.275921234577252e-14,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 5.5668930151375235,
|
||||||
|
"amesim": 5.5668930151375235,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.011133787030275047,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": 0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": -1.0,
|
||||||
|
"amesim": -1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100153.57210434608,
|
||||||
|
"amesim": 100153.61882579065,
|
||||||
|
"absoluteError": 0.04672144456708338,
|
||||||
|
"relativeError": 4.6649781720170954e-07,
|
||||||
|
"allowedError": 200.3082376515813,
|
||||||
|
"toleranceRatio": 0.00023324774415094828,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 100152.45747547514,
|
||||||
|
"amesim": 100152.49205385521,
|
||||||
|
"absoluteError": 0.03457838007307146,
|
||||||
|
"relativeError": 3.4525731076644157e-07,
|
||||||
|
"allowedError": 200.30598410771043,
|
||||||
|
"toleranceRatio": 0.00017262779355846724,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": 6.682314111565264e-06,
|
||||||
|
"amesim": 6.672550142634319e-06,
|
||||||
|
"absoluteError": 9.763968930944316e-09,
|
||||||
|
"relativeError": 0.0014633039426046946,
|
||||||
|
"allowedError": 1.4345100285268639e-08,
|
||||||
|
"toleranceRatio": 0.6806483563569921,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": false,
|
||||||
|
"passed": null,
|
||||||
|
"exclusionReason": "signalDiscontinuityLeftRightLimitSemanticsNotComparableAt0.04s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.4978238221497115,
|
||||||
|
"amesim": -0.4978238226601424,
|
||||||
|
"absoluteError": 5.104309197534462e-10,
|
||||||
|
"relativeError": 1.0253244150228433e-09,
|
||||||
|
"allowedError": 0.0009956486453202849,
|
||||||
|
"toleranceRatio": 5.126616926086898e-07,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": false,
|
||||||
|
"passed": null,
|
||||||
|
"exclusionReason": "signalDiscontinuityLeftRightLimitSemanticsNotComparableAt0.04s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 5.5668930151375235,
|
||||||
|
"amesim": 5.566893015142308,
|
||||||
|
"absoluteError": 4.784617146924575e-12,
|
||||||
|
"relativeError": 8.594771147766101e-13,
|
||||||
|
"allowedError": 0.011133787030284616,
|
||||||
|
"toleranceRatio": 4.2973851879060635e-10,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": -1.0,
|
||||||
|
"amesim": -1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "pressure.pnch012_8.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 3620589.728099082,
|
||||||
|
"amesim": 3625053.2009413484,
|
||||||
|
"absoluteError": 4463.4728422663175,
|
||||||
|
"relativeError": 0.0012312847825536048,
|
||||||
|
"allowedError": 7250.107401882698,
|
||||||
|
"toleranceRatio": 0.6156423063618685,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "pressure.pnl0001_20.absolute",
|
||||||
|
"category": "pressure",
|
||||||
|
"python": 3756379.1344475006,
|
||||||
|
"amesim": 3760735.7825351665,
|
||||||
|
"absoluteError": 4356.648087665904,
|
||||||
|
"relativeError": 0.0011584563073795695,
|
||||||
|
"allowedError": 7521.472565070333,
|
||||||
|
"toleranceRatio": 0.5792280766798443,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "massFlow.pnl0001_20.port_1.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.3723610392591412,
|
||||||
|
"amesim": -0.3728806429214546,
|
||||||
|
"absoluteError": 0.0005196036623134059,
|
||||||
|
"relativeError": 0.0013934852135053246,
|
||||||
|
"allowedError": 0.0007457622858429093,
|
||||||
|
"toleranceRatio": 0.6967416724836332,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "massFlow.pnvo001_5.port_2.intoComponent",
|
||||||
|
"category": "massFlow",
|
||||||
|
"python": -0.3962730176377355,
|
||||||
|
"amesim": -0.3961933197727504,
|
||||||
|
"absoluteError": 7.969786498507325e-05,
|
||||||
|
"relativeError": 0.00020115903274388008,
|
||||||
|
"allowedError": 0.0007923876395455008,
|
||||||
|
"toleranceRatio": 0.10057938943972738,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "conservation.pnvo001_5.massBalance",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 0.0,
|
||||||
|
"amesim": -0.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 1e-09,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "conservation.totalStoredGasMass",
|
||||||
|
"category": "conservation",
|
||||||
|
"python": 5.566893015137554,
|
||||||
|
"amesim": 5.566892994134813,
|
||||||
|
"absoluteError": 2.1002740879794146e-08,
|
||||||
|
"relativeError": 3.7727940705744284e-09,
|
||||||
|
"allowedError": 0.011133786988269626,
|
||||||
|
"toleranceRatio": 1.8863968658572583e-06,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "discrete.pnvo001_5.openMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "discrete.mecmas21_9.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": 1.0,
|
||||||
|
"amesim": 1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"key": "discrete.mecmas21_10.endstopMode",
|
||||||
|
"category": "discreteMode",
|
||||||
|
"python": -1.0,
|
||||||
|
"amesim": -1.0,
|
||||||
|
"absoluteError": 0.0,
|
||||||
|
"relativeError": 0.0,
|
||||||
|
"allowedError": 0.0,
|
||||||
|
"toleranceRatio": 0.0,
|
||||||
|
"unboundedToleranceRatio": false,
|
||||||
|
"evaluated": true,
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"toleranceProfile": {
|
||||||
|
"id": "amesim-alignment-reviewed-v1",
|
||||||
|
"byCategory": {
|
||||||
|
"pressure": {
|
||||||
|
"relative": 0.002,
|
||||||
|
"absolute": 0.001
|
||||||
|
},
|
||||||
|
"massFlow": {
|
||||||
|
"relative": 0.002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
},
|
||||||
|
"conservation": {
|
||||||
|
"relative": 0.002,
|
||||||
|
"absolute": 1e-09
|
||||||
|
},
|
||||||
|
"discreteMode": {
|
||||||
|
"relative": 0.0,
|
||||||
|
"absolute": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"basis": "0.2% relative envelope for AMESim pressure/flow/inventory alignment; conservation retains a 1e-9 absolute floor and discrete modes are exact.",
|
||||||
|
"exclusions": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"category": "massFlow",
|
||||||
|
"reason": "The Python and AMESim samples at the signal discontinuity may represent different left/right limits. Raw errors remain recorded."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"policy": "diagnostic external-reference comparison; local golden is the regression gate"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_mql_8-production-0.2s-physical-state-v2",
|
||||||
|
"caseId": "0.2s",
|
||||||
|
"lane": "production",
|
||||||
|
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||||
|
"approval": {
|
||||||
|
"status": "approved",
|
||||||
|
"approvedAt": "2026-08-17T14:11:10.904892+00:00",
|
||||||
|
"basis": "Initial P0 bounded production baseline passed finite-series, checkpoint, signal-segment, mechanical-event, residual, audit, and fallback gates."
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"sourceReport": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json",
|
||||||
|
"sha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5",
|
||||||
|
"generatedAt": "2026-08-17T14:11:10.904892+00:00",
|
||||||
|
"metadataCompatibility": {
|
||||||
|
"status": "current",
|
||||||
|
"differences": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"physicalLayout": {
|
||||||
|
"projectionCategories": [
|
||||||
|
"state"
|
||||||
|
],
|
||||||
|
"stateKeys": [
|
||||||
|
"amesim_mecmas21_1.a",
|
||||||
|
"amesim_mecmas21_1.v",
|
||||||
|
"amesim_mecmas21_1.x",
|
||||||
|
"amesim_mecmas21_10.a",
|
||||||
|
"amesim_mecmas21_10.v",
|
||||||
|
"amesim_mecmas21_10.x",
|
||||||
|
"amesim_mecmas21_2.a",
|
||||||
|
"amesim_mecmas21_2.v",
|
||||||
|
"amesim_mecmas21_2.x",
|
||||||
|
"amesim_mecmas21_3.a",
|
||||||
|
"amesim_mecmas21_3.v",
|
||||||
|
"amesim_mecmas21_3.x",
|
||||||
|
"amesim_mecmas21_4.a",
|
||||||
|
"amesim_mecmas21_4.v",
|
||||||
|
"amesim_mecmas21_4.x",
|
||||||
|
"amesim_mecmas21_5.a",
|
||||||
|
"amesim_mecmas21_5.v",
|
||||||
|
"amesim_mecmas21_5.x",
|
||||||
|
"amesim_mecmas21_6.a",
|
||||||
|
"amesim_mecmas21_6.v",
|
||||||
|
"amesim_mecmas21_6.x",
|
||||||
|
"amesim_mecmas21_7.a",
|
||||||
|
"amesim_mecmas21_7.v",
|
||||||
|
"amesim_mecmas21_7.x",
|
||||||
|
"amesim_mecmas21_8.a",
|
||||||
|
"amesim_mecmas21_8.v",
|
||||||
|
"amesim_mecmas21_8.x",
|
||||||
|
"amesim_mecmas21_9.a",
|
||||||
|
"amesim_mecmas21_9.v",
|
||||||
|
"amesim_mecmas21_9.x",
|
||||||
|
"amesim_pnch012_10.U",
|
||||||
|
"amesim_pnch012_10.m",
|
||||||
|
"amesim_pnch012_11.U",
|
||||||
|
"amesim_pnch012_11.m",
|
||||||
|
"amesim_pnch012_12.U",
|
||||||
|
"amesim_pnch012_12.m",
|
||||||
|
"amesim_pnch012_13.U",
|
||||||
|
"amesim_pnch012_13.m",
|
||||||
|
"amesim_pnch012_14.U",
|
||||||
|
"amesim_pnch012_14.m",
|
||||||
|
"amesim_pnch012_15.U",
|
||||||
|
"amesim_pnch012_15.m",
|
||||||
|
"amesim_pnch012_8.U",
|
||||||
|
"amesim_pnch012_8.m",
|
||||||
|
"amesim_pnch012_9.U",
|
||||||
|
"amesim_pnch012_9.m",
|
||||||
|
"amesim_pnch023_1.U",
|
||||||
|
"amesim_pnch023_1.m",
|
||||||
|
"amesim_pnch023_2.U",
|
||||||
|
"amesim_pnch023_2.m",
|
||||||
|
"amesim_pnch023_3.U",
|
||||||
|
"amesim_pnch023_3.m",
|
||||||
|
"amesim_pnch023_4.U",
|
||||||
|
"amesim_pnch023_4.m",
|
||||||
|
"amesim_pnl0001_1.U",
|
||||||
|
"amesim_pnl0001_1.m",
|
||||||
|
"amesim_pnl0001_10.U",
|
||||||
|
"amesim_pnl0001_10.m",
|
||||||
|
"amesim_pnl0001_11.U",
|
||||||
|
"amesim_pnl0001_11.m",
|
||||||
|
"amesim_pnl0001_13.U",
|
||||||
|
"amesim_pnl0001_13.m",
|
||||||
|
"amesim_pnl0001_14.U",
|
||||||
|
"amesim_pnl0001_14.m",
|
||||||
|
"amesim_pnl0001_15.U",
|
||||||
|
"amesim_pnl0001_15.m",
|
||||||
|
"amesim_pnl0001_16.U",
|
||||||
|
"amesim_pnl0001_16.m",
|
||||||
|
"amesim_pnl0001_17.U",
|
||||||
|
"amesim_pnl0001_17.m",
|
||||||
|
"amesim_pnl0001_18.U",
|
||||||
|
"amesim_pnl0001_18.m",
|
||||||
|
"amesim_pnl0001_19.U",
|
||||||
|
"amesim_pnl0001_19.m",
|
||||||
|
"amesim_pnl0001_2.U",
|
||||||
|
"amesim_pnl0001_2.m",
|
||||||
|
"amesim_pnl0001_20.U",
|
||||||
|
"amesim_pnl0001_20.m",
|
||||||
|
"amesim_pnl0001_4.U",
|
||||||
|
"amesim_pnl0001_4.m",
|
||||||
|
"amesim_pnl0001_5.U",
|
||||||
|
"amesim_pnl0001_5.m",
|
||||||
|
"amesim_pnl0001_7.U",
|
||||||
|
"amesim_pnl0001_7.m",
|
||||||
|
"amesim_pnl0001_9.U",
|
||||||
|
"amesim_pnl0001_9.m",
|
||||||
|
"amesim_pnl0002_1.U",
|
||||||
|
"amesim_pnl0002_1.m",
|
||||||
|
"amesim_pnl0002_2.U",
|
||||||
|
"amesim_pnl0002_2.m",
|
||||||
|
"amesim_pnl0002_3.U",
|
||||||
|
"amesim_pnl0002_3.m",
|
||||||
|
"amesim_pnl0002_4.U",
|
||||||
|
"amesim_pnl0002_4.m",
|
||||||
|
"amesim_pnl0002_5.U",
|
||||||
|
"amesim_pnl0002_5.m",
|
||||||
|
"amesim_pnl0002_6.U",
|
||||||
|
"amesim_pnl0002_6.m",
|
||||||
|
"amesim_pnl0002_7.U",
|
||||||
|
"amesim_pnl0002_7.m",
|
||||||
|
"amesim_pnl0002_8.U",
|
||||||
|
"amesim_pnl0002_8.m",
|
||||||
|
"amesim_pnl0003_1.U1",
|
||||||
|
"amesim_pnl0003_1.U2",
|
||||||
|
"amesim_pnl0003_1.m1",
|
||||||
|
"amesim_pnl0003_1.m2",
|
||||||
|
"amesim_pnl0003_2.U1",
|
||||||
|
"amesim_pnl0003_2.U2",
|
||||||
|
"amesim_pnl0003_2.m1",
|
||||||
|
"amesim_pnl0003_2.m2",
|
||||||
|
"amesim_pnl0003_3.U1",
|
||||||
|
"amesim_pnl0003_3.U2",
|
||||||
|
"amesim_pnl0003_3.m1",
|
||||||
|
"amesim_pnl0003_3.m2",
|
||||||
|
"amesim_pnl0003_4.U1",
|
||||||
|
"amesim_pnl0003_4.U2",
|
||||||
|
"amesim_pnl0003_4.m1",
|
||||||
|
"amesim_pnl0003_4.m2",
|
||||||
|
"amesim_pnl0003_5.U1",
|
||||||
|
"amesim_pnl0003_5.U2",
|
||||||
|
"amesim_pnl0003_5.m1",
|
||||||
|
"amesim_pnl0003_5.m2",
|
||||||
|
"amesim_pnl0003_6.U1",
|
||||||
|
"amesim_pnl0003_6.U2",
|
||||||
|
"amesim_pnl0003_6.m1",
|
||||||
|
"amesim_pnl0003_6.m2",
|
||||||
|
"amesim_pnl0003_7.U1",
|
||||||
|
"amesim_pnl0003_7.U2",
|
||||||
|
"amesim_pnl0003_7.m1",
|
||||||
|
"amesim_pnl0003_7.m2",
|
||||||
|
"amesim_pnl0003_8.U1",
|
||||||
|
"amesim_pnl0003_8.U2",
|
||||||
|
"amesim_pnl0003_8.m1",
|
||||||
|
"amesim_pnl0003_8.m2"
|
||||||
|
],
|
||||||
|
"stateKeyLayoutSha256": "12e216303d33639a5f53ef83b6b9fc5851beffb1222a0ed4bb5e1a2432b3d739"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09,
|
||||||
|
"checkpointTimeAbsoluteSeconds": 1e-12
|
||||||
|
},
|
||||||
|
"physicalCheckpoints": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"values": [
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"values": [
|
||||||
|
-0.7201778435512728,
|
||||||
|
-0.03131260841232088,
|
||||||
|
-0.0006393186923261844,
|
||||||
|
0.0,
|
||||||
|
6.421450189245049e-22,
|
||||||
|
0.0,
|
||||||
|
-0.7201733833792814,
|
||||||
|
-0.031312355811718325,
|
||||||
|
-0.0006393136520185212,
|
||||||
|
-0.7201718196391171,
|
||||||
|
-0.03131233882672457,
|
||||||
|
-0.0006393140197282858,
|
||||||
|
-0.7201722508603697,
|
||||||
|
-0.031312278047383596,
|
||||||
|
-0.0006393124896720936,
|
||||||
|
-0.7201744791266093,
|
||||||
|
-0.03131218496519997,
|
||||||
|
-0.0006393087827851101,
|
||||||
|
-0.7201783488656359,
|
||||||
|
-0.031314094334835896,
|
||||||
|
-0.0006393469119830168,
|
||||||
|
-0.7201775030345068,
|
||||||
|
-0.031312389100822,
|
||||||
|
-0.0006393118191591414,
|
||||||
|
-0.7201771803079525,
|
||||||
|
-0.031312911030261764,
|
||||||
|
-0.0006393266351878673,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
1.120876769981429e-26,
|
||||||
|
-1563.233367712832,
|
||||||
|
0.0024627146762544114,
|
||||||
|
-1563.2343003288415,
|
||||||
|
0.0024627154840700283,
|
||||||
|
-1563.2343310045646,
|
||||||
|
0.002462715544028645,
|
||||||
|
-1563.234317887853,
|
||||||
|
0.002462715540851028,
|
||||||
|
-1563.2342832229026,
|
||||||
|
0.0024627154834822402,
|
||||||
|
-1563.234128076657,
|
||||||
|
0.0024627152649548067,
|
||||||
|
-1563.2340241256454,
|
||||||
|
0.0024627151889310217,
|
||||||
|
-1563.234154194445,
|
||||||
|
0.0024627153106296135,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.755346551358684,
|
||||||
|
5.162917551035796e-05,
|
||||||
|
-32.7553713095164,
|
||||||
|
5.162919361109966e-05,
|
||||||
|
-32.755371520495,
|
||||||
|
5.1629194498694165e-05,
|
||||||
|
-32.7553740517406,
|
||||||
|
5.162919592464222e-05,
|
||||||
|
-32.75549815317228,
|
||||||
|
5.16292749500248e-05,
|
||||||
|
-32.7557671078149,
|
||||||
|
5.1629447239129814e-05,
|
||||||
|
-32.755537600705786,
|
||||||
|
5.162929920389579e-05,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.7554490658762,
|
||||||
|
5.1629242163634275e-05,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-65.50992369305769,
|
||||||
|
0.0001032573549102297,
|
||||||
|
-65.50924422087368,
|
||||||
|
0.00010325691856038897,
|
||||||
|
-65.50924420634506,
|
||||||
|
0.00010325691908177892,
|
||||||
|
-65.50924789343988,
|
||||||
|
0.00010325692011611614,
|
||||||
|
-65.50952699017678,
|
||||||
|
0.00010325709663260638,
|
||||||
|
-65.50953538930851,
|
||||||
|
0.00010325710204152489,
|
||||||
|
-65.50932045418728,
|
||||||
|
0.00010325696357174907,
|
||||||
|
-65.50995769649337,
|
||||||
|
0.00010325737519785258,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"values": [
|
||||||
|
2.6370963314548135e-06,
|
||||||
|
1.1907436243229137e-06,
|
||||||
|
1.5914303294371521e-07,
|
||||||
|
0.0,
|
||||||
|
6.530827087152731e-22,
|
||||||
|
6.105198566652894e-27,
|
||||||
|
2.6364088989794256e-06,
|
||||||
|
1.1908086349821896e-06,
|
||||||
|
1.5912507766913015e-07,
|
||||||
|
2.636304998304695e-06,
|
||||||
|
1.1908227907789107e-06,
|
||||||
|
1.5912158717247314e-07,
|
||||||
|
2.6363827055320143e-06,
|
||||||
|
1.1908285194387939e-06,
|
||||||
|
1.5912015990557016e-07,
|
||||||
|
2.6363664073869586e-06,
|
||||||
|
1.1908301556228963e-06,
|
||||||
|
1.5911975015798518e-07,
|
||||||
|
2.6362371863797307e-06,
|
||||||
|
1.1908285541103296e-06,
|
||||||
|
1.5912015181363026e-07,
|
||||||
|
2.6363282813690604e-06,
|
||||||
|
1.190822777157153e-06,
|
||||||
|
1.5912159035091038e-07,
|
||||||
|
2.636463905218989e-06,
|
||||||
|
1.1908086605158301e-06,
|
||||||
|
1.5912507172531535e-07,
|
||||||
|
0.0,
|
||||||
|
1.409251022153514e-21,
|
||||||
|
2.583112155689631e-23,
|
||||||
|
-21824.884876930104,
|
||||||
|
0.06638877583716687,
|
||||||
|
-21824.8394513789,
|
||||||
|
0.06638874206868575,
|
||||||
|
-21824.88503639051,
|
||||||
|
0.06638877594459791,
|
||||||
|
-21825.04659229254,
|
||||||
|
0.06638889465525798,
|
||||||
|
-21825.449220760413,
|
||||||
|
0.06638918449710254,
|
||||||
|
-21860.3893940624,
|
||||||
|
0.06641207557540514,
|
||||||
|
-21825.44912779144,
|
||||||
|
0.06638918443617388,
|
||||||
|
-21825.046674584086,
|
||||||
|
0.06638889470996256,
|
||||||
|
-877968.2311707166,
|
||||||
|
1.2399623843859275,
|
||||||
|
-877968.2314488764,
|
||||||
|
1.239962386668905,
|
||||||
|
-877968.231641228,
|
||||||
|
1.2399623882475983,
|
||||||
|
-877968.2313630553,
|
||||||
|
1.23996238596452,
|
||||||
|
-2232.5529371119105,
|
||||||
|
0.0031580856642891347,
|
||||||
|
-2232.552938579765,
|
||||||
|
0.003158085669643602,
|
||||||
|
-2232.552934122746,
|
||||||
|
0.0031580856635922043,
|
||||||
|
-1392.944729430734,
|
||||||
|
0.002032422883230558,
|
||||||
|
-1391.7199124494437,
|
||||||
|
0.0020316357664157303,
|
||||||
|
-1391.7199702457754,
|
||||||
|
0.002031635646355635,
|
||||||
|
-1391.7199593465837,
|
||||||
|
0.00203163554240735,
|
||||||
|
-1391.7199471015297,
|
||||||
|
0.0020316355018333084,
|
||||||
|
-1391.719956464844,
|
||||||
|
0.0020316355405555213,
|
||||||
|
-1391.719971723481,
|
||||||
|
0.0020316356473075348,
|
||||||
|
-2232.5529379989352,
|
||||||
|
0.0031580856654872167,
|
||||||
|
-1391.7199093676563,
|
||||||
|
0.0020316357644361133,
|
||||||
|
-2232.552933678534,
|
||||||
|
0.003158085664480916,
|
||||||
|
-2232.552938069336,
|
||||||
|
0.0031580856704486304,
|
||||||
|
-2232.5529342591235,
|
||||||
|
0.003158085668637221,
|
||||||
|
-2232.5529350799816,
|
||||||
|
0.003158085669751649,
|
||||||
|
-145.14613577820415,
|
||||||
|
0.0023684760609916228,
|
||||||
|
-234.44464213523725,
|
||||||
|
0.002425822913152506,
|
||||||
|
-234.44208866732288,
|
||||||
|
0.0024258210179891326,
|
||||||
|
-234.43920235483773,
|
||||||
|
0.0024258190341378137,
|
||||||
|
-234.43948538748822,
|
||||||
|
0.0024258192159024635,
|
||||||
|
-234.4423567512829,
|
||||||
|
0.0024258211901560865,
|
||||||
|
-234.44466632040084,
|
||||||
|
0.002425822928687371,
|
||||||
|
-145.14616131190638,
|
||||||
|
0.0023684760773897006,
|
||||||
|
-665.721080447237,
|
||||||
|
-664.9052921895398,
|
||||||
|
0.0009423231603427254,
|
||||||
|
0.0009411988264117098,
|
||||||
|
-665.721080812701,
|
||||||
|
-664.9052927495628,
|
||||||
|
0.0009423231608531282,
|
||||||
|
0.0009411988270450505,
|
||||||
|
-665.7210807465625,
|
||||||
|
-664.9052926367955,
|
||||||
|
0.0009423231622138283,
|
||||||
|
0.0009411988283754829,
|
||||||
|
-665.7210793863333,
|
||||||
|
-664.9052912142575,
|
||||||
|
0.0009423231613560865,
|
||||||
|
0.0009411988274784932,
|
||||||
|
-665.7210790083244,
|
||||||
|
-664.9052908966471,
|
||||||
|
0.0009423231608560905,
|
||||||
|
0.0009411988270168083,
|
||||||
|
-665.721081019834,
|
||||||
|
-664.9052928469027,
|
||||||
|
0.0009423231621245239,
|
||||||
|
0.0009411988282463315,
|
||||||
|
-665.7210790496954,
|
||||||
|
-664.9052910102263,
|
||||||
|
0.0009423231594614548,
|
||||||
|
0.0009411988256686023,
|
||||||
|
-665.7210788464794,
|
||||||
|
-664.9052906188476,
|
||||||
|
0.0009423231596132578,
|
||||||
|
0.0009411988257017965
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputContract": {
|
||||||
|
"sha256": "3a5cdcf25a6b841f7963044c5ecd1e8a3744fac98000eda53df2956ad0ac6506"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_mql_8-production-0.2s-physical-state-v2-amesim-aligned",
|
||||||
|
"caseId": "0.2s",
|
||||||
|
"lane": "production",
|
||||||
|
"sourceXmlSha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
|
||||||
|
"approval": {
|
||||||
|
"status": "approved",
|
||||||
|
"approvedAt": "2026-08-18T07:35:01+00:00",
|
||||||
|
"basis": "AME-aligned source, full physical-state-v2.1 external-reference assessment, finite-series, checkpoint, signal-segment, mechanical-event, residual, audit, no-fallback, and output-shape gates passed in the locked Python 3.12 production environment."
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"sourceReport": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-physical-state-v2.1-0.2.json",
|
||||||
|
"sha256": "c647fc7dab6c5cdaea188789dd8e0192d9caf374873afa1f5f5db0da9d69c49b",
|
||||||
|
"generatedAt": "2026-08-18T07:34:00.055573+00:00",
|
||||||
|
"metadataCompatibility": {
|
||||||
|
"status": "current",
|
||||||
|
"differences": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"amesimReference": {
|
||||||
|
"path": "AmesimModels/test_mql.ame",
|
||||||
|
"sha256": "cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb",
|
||||||
|
"role": "external physical-state-v2.1 calibration reference"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"physicalLayout": {
|
||||||
|
"projectionCategories": [
|
||||||
|
"state"
|
||||||
|
],
|
||||||
|
"stateKeys": [
|
||||||
|
"amesim_mecmas21_1.a",
|
||||||
|
"amesim_mecmas21_1.v",
|
||||||
|
"amesim_mecmas21_1.x",
|
||||||
|
"amesim_mecmas21_10.a",
|
||||||
|
"amesim_mecmas21_10.v",
|
||||||
|
"amesim_mecmas21_10.x",
|
||||||
|
"amesim_mecmas21_2.a",
|
||||||
|
"amesim_mecmas21_2.v",
|
||||||
|
"amesim_mecmas21_2.x",
|
||||||
|
"amesim_mecmas21_3.a",
|
||||||
|
"amesim_mecmas21_3.v",
|
||||||
|
"amesim_mecmas21_3.x",
|
||||||
|
"amesim_mecmas21_4.a",
|
||||||
|
"amesim_mecmas21_4.v",
|
||||||
|
"amesim_mecmas21_4.x",
|
||||||
|
"amesim_mecmas21_5.a",
|
||||||
|
"amesim_mecmas21_5.v",
|
||||||
|
"amesim_mecmas21_5.x",
|
||||||
|
"amesim_mecmas21_6.a",
|
||||||
|
"amesim_mecmas21_6.v",
|
||||||
|
"amesim_mecmas21_6.x",
|
||||||
|
"amesim_mecmas21_7.a",
|
||||||
|
"amesim_mecmas21_7.v",
|
||||||
|
"amesim_mecmas21_7.x",
|
||||||
|
"amesim_mecmas21_8.a",
|
||||||
|
"amesim_mecmas21_8.v",
|
||||||
|
"amesim_mecmas21_8.x",
|
||||||
|
"amesim_mecmas21_9.a",
|
||||||
|
"amesim_mecmas21_9.v",
|
||||||
|
"amesim_mecmas21_9.x",
|
||||||
|
"amesim_pnch012_10.U",
|
||||||
|
"amesim_pnch012_10.m",
|
||||||
|
"amesim_pnch012_11.U",
|
||||||
|
"amesim_pnch012_11.m",
|
||||||
|
"amesim_pnch012_12.U",
|
||||||
|
"amesim_pnch012_12.m",
|
||||||
|
"amesim_pnch012_13.U",
|
||||||
|
"amesim_pnch012_13.m",
|
||||||
|
"amesim_pnch012_14.U",
|
||||||
|
"amesim_pnch012_14.m",
|
||||||
|
"amesim_pnch012_15.U",
|
||||||
|
"amesim_pnch012_15.m",
|
||||||
|
"amesim_pnch012_8.U",
|
||||||
|
"amesim_pnch012_8.m",
|
||||||
|
"amesim_pnch012_9.U",
|
||||||
|
"amesim_pnch012_9.m",
|
||||||
|
"amesim_pnch023_1.U",
|
||||||
|
"amesim_pnch023_1.m",
|
||||||
|
"amesim_pnch023_2.U",
|
||||||
|
"amesim_pnch023_2.m",
|
||||||
|
"amesim_pnch023_3.U",
|
||||||
|
"amesim_pnch023_3.m",
|
||||||
|
"amesim_pnch023_4.U",
|
||||||
|
"amesim_pnch023_4.m",
|
||||||
|
"amesim_pnl0001_1.U",
|
||||||
|
"amesim_pnl0001_1.m",
|
||||||
|
"amesim_pnl0001_10.U",
|
||||||
|
"amesim_pnl0001_10.m",
|
||||||
|
"amesim_pnl0001_11.U",
|
||||||
|
"amesim_pnl0001_11.m",
|
||||||
|
"amesim_pnl0001_13.U",
|
||||||
|
"amesim_pnl0001_13.m",
|
||||||
|
"amesim_pnl0001_14.U",
|
||||||
|
"amesim_pnl0001_14.m",
|
||||||
|
"amesim_pnl0001_15.U",
|
||||||
|
"amesim_pnl0001_15.m",
|
||||||
|
"amesim_pnl0001_16.U",
|
||||||
|
"amesim_pnl0001_16.m",
|
||||||
|
"amesim_pnl0001_17.U",
|
||||||
|
"amesim_pnl0001_17.m",
|
||||||
|
"amesim_pnl0001_18.U",
|
||||||
|
"amesim_pnl0001_18.m",
|
||||||
|
"amesim_pnl0001_19.U",
|
||||||
|
"amesim_pnl0001_19.m",
|
||||||
|
"amesim_pnl0001_2.U",
|
||||||
|
"amesim_pnl0001_2.m",
|
||||||
|
"amesim_pnl0001_20.U",
|
||||||
|
"amesim_pnl0001_20.m",
|
||||||
|
"amesim_pnl0001_21.U",
|
||||||
|
"amesim_pnl0001_21.m",
|
||||||
|
"amesim_pnl0001_25.U",
|
||||||
|
"amesim_pnl0001_25.m",
|
||||||
|
"amesim_pnl0001_26.U",
|
||||||
|
"amesim_pnl0001_26.m",
|
||||||
|
"amesim_pnl0001_27.U",
|
||||||
|
"amesim_pnl0001_27.m",
|
||||||
|
"amesim_pnl0001_4.U",
|
||||||
|
"amesim_pnl0001_4.m",
|
||||||
|
"amesim_pnl0001_5.U",
|
||||||
|
"amesim_pnl0001_5.m",
|
||||||
|
"amesim_pnl0001_7.U",
|
||||||
|
"amesim_pnl0001_7.m",
|
||||||
|
"amesim_pnl0001_9.U",
|
||||||
|
"amesim_pnl0001_9.m",
|
||||||
|
"amesim_pnl0002_1.U",
|
||||||
|
"amesim_pnl0002_1.m",
|
||||||
|
"amesim_pnl0002_2.U",
|
||||||
|
"amesim_pnl0002_2.m",
|
||||||
|
"amesim_pnl0002_3.U",
|
||||||
|
"amesim_pnl0002_3.m",
|
||||||
|
"amesim_pnl0002_4.U",
|
||||||
|
"amesim_pnl0002_4.m",
|
||||||
|
"amesim_pnl0002_5.U",
|
||||||
|
"amesim_pnl0002_5.m",
|
||||||
|
"amesim_pnl0002_6.U",
|
||||||
|
"amesim_pnl0002_6.m",
|
||||||
|
"amesim_pnl0002_7.U",
|
||||||
|
"amesim_pnl0002_7.m",
|
||||||
|
"amesim_pnl0002_8.U",
|
||||||
|
"amesim_pnl0002_8.m",
|
||||||
|
"amesim_pnl0003_1.U1",
|
||||||
|
"amesim_pnl0003_1.U2",
|
||||||
|
"amesim_pnl0003_1.m1",
|
||||||
|
"amesim_pnl0003_1.m2",
|
||||||
|
"amesim_pnl0003_2.U1",
|
||||||
|
"amesim_pnl0003_2.U2",
|
||||||
|
"amesim_pnl0003_2.m1",
|
||||||
|
"amesim_pnl0003_2.m2",
|
||||||
|
"amesim_pnl0003_3.U1",
|
||||||
|
"amesim_pnl0003_3.U2",
|
||||||
|
"amesim_pnl0003_3.m1",
|
||||||
|
"amesim_pnl0003_3.m2",
|
||||||
|
"amesim_pnl0003_4.U1",
|
||||||
|
"amesim_pnl0003_4.U2",
|
||||||
|
"amesim_pnl0003_4.m1",
|
||||||
|
"amesim_pnl0003_4.m2",
|
||||||
|
"amesim_pnl0003_5.U1",
|
||||||
|
"amesim_pnl0003_5.U2",
|
||||||
|
"amesim_pnl0003_5.m1",
|
||||||
|
"amesim_pnl0003_5.m2",
|
||||||
|
"amesim_pnl0003_6.U1",
|
||||||
|
"amesim_pnl0003_6.U2",
|
||||||
|
"amesim_pnl0003_6.m1",
|
||||||
|
"amesim_pnl0003_6.m2",
|
||||||
|
"amesim_pnl0003_7.U1",
|
||||||
|
"amesim_pnl0003_7.U2",
|
||||||
|
"amesim_pnl0003_7.m1",
|
||||||
|
"amesim_pnl0003_7.m2",
|
||||||
|
"amesim_pnl0003_8.U1",
|
||||||
|
"amesim_pnl0003_8.U2",
|
||||||
|
"amesim_pnl0003_8.m1",
|
||||||
|
"amesim_pnl0003_8.m2"
|
||||||
|
],
|
||||||
|
"stateKeyLayoutSha256": "9c5276b19aebf5648aa0175a8b30eeb090ae48a645e00af5efe400679c2a19c0"
|
||||||
|
},
|
||||||
|
"tolerance": {
|
||||||
|
"relative": 0.0002,
|
||||||
|
"absolute": 1e-09,
|
||||||
|
"checkpointTimeAbsoluteSeconds": 1e-12
|
||||||
|
},
|
||||||
|
"physicalCheckpoints": [
|
||||||
|
{
|
||||||
|
"requestedTime": 0.0,
|
||||||
|
"values": [
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.8167936695810981,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-1563.893548405841,
|
||||||
|
0.002462855848963059,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.754109884455104,
|
||||||
|
5.158193227968668e-05,
|
||||||
|
-4.814854153014898,
|
||||||
|
7.582544045113939e-06,
|
||||||
|
-4.814854153014898,
|
||||||
|
7.582544045113939e-06,
|
||||||
|
-4.814854153014898,
|
||||||
|
7.582544045113939e-06,
|
||||||
|
-4.814854153014898,
|
||||||
|
7.582544045113939e-06,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-65.50821976891021,
|
||||||
|
0.00010316386455937335,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.04,
|
||||||
|
"values": [
|
||||||
|
-0.7202663767630412,
|
||||||
|
-0.031313670277005726,
|
||||||
|
-0.0006393262654652846,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
-0.7202445746758257,
|
||||||
|
-0.03131326532214226,
|
||||||
|
-0.0006393222149083683,
|
||||||
|
-0.7202431079507902,
|
||||||
|
-0.031313245044468446,
|
||||||
|
-0.000639322126344006,
|
||||||
|
-0.7202608891564758,
|
||||||
|
-0.03131357572935321,
|
||||||
|
-0.000639325500334864,
|
||||||
|
-0.7203004770720824,
|
||||||
|
-0.03131467563203143,
|
||||||
|
-0.0006393436082476693,
|
||||||
|
-0.7203200385813013,
|
||||||
|
-0.031314937758515383,
|
||||||
|
-0.0006393445984135971,
|
||||||
|
-0.7203210417978083,
|
||||||
|
-0.03131494127751647,
|
||||||
|
-0.0006393443430309219,
|
||||||
|
-0.7203038829240781,
|
||||||
|
-0.031314872463294875,
|
||||||
|
-0.0006393483841598907,
|
||||||
|
0.0,
|
||||||
|
-1.3196376986328492e-22,
|
||||||
|
-2.1367171061617978e-27,
|
||||||
|
-1563.2317229265764,
|
||||||
|
0.0024627103498254554,
|
||||||
|
-1563.2320575702738,
|
||||||
|
0.002462711020582906,
|
||||||
|
-1563.2329311978795,
|
||||||
|
0.002462712553941299,
|
||||||
|
-1563.233271708201,
|
||||||
|
0.0024627131946489966,
|
||||||
|
-1563.2332458437982,
|
||||||
|
0.0024627131438128085,
|
||||||
|
-1563.2328271988004,
|
||||||
|
0.0024627123577517533,
|
||||||
|
-1563.2319625907526,
|
||||||
|
0.0024627108660579895,
|
||||||
|
-1563.2317089857306,
|
||||||
|
0.0024627103184389166,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-886868.3937230138,
|
||||||
|
1.374510850335198,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.75534627762145,
|
||||||
|
5.162913025809795e-05,
|
||||||
|
-32.755348137443136,
|
||||||
|
5.1629142243738343e-05,
|
||||||
|
-32.75534804136382,
|
||||||
|
5.162914291472205e-05,
|
||||||
|
-32.7553506164913,
|
||||||
|
5.162913577247215e-05,
|
||||||
|
-32.755345646102356,
|
||||||
|
5.1629112834267025e-05,
|
||||||
|
-32.75534577906393,
|
||||||
|
5.162910342320198e-05,
|
||||||
|
-32.75534577952895,
|
||||||
|
5.162910292904694e-05,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-32.75534579846172,
|
||||||
|
5.1629111455848846e-05,
|
||||||
|
-4.815030419476938,
|
||||||
|
7.589448594417277e-06,
|
||||||
|
-4.815030011609514,
|
||||||
|
7.589450747154388e-06,
|
||||||
|
-4.815030010504332,
|
||||||
|
7.589450673612998e-06,
|
||||||
|
-4.815030207302603,
|
||||||
|
7.589452052046662e-06,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-2395.136531414716,
|
||||||
|
0.0037120966016654976,
|
||||||
|
-65.50987529417476,
|
||||||
|
0.00010325724257177476,
|
||||||
|
-65.50922049387084,
|
||||||
|
0.00010325683053111428,
|
||||||
|
-65.50922023152388,
|
||||||
|
0.00010325682249857915,
|
||||||
|
-65.50983596710446,
|
||||||
|
0.00010325719283273935,
|
||||||
|
-65.51060953242626,
|
||||||
|
0.00010325766285951179,
|
||||||
|
-65.51060943575153,
|
||||||
|
0.00010325765252819858,
|
||||||
|
-65.51060962617947,
|
||||||
|
0.0001032576608609912,
|
||||||
|
-65.51061020790279,
|
||||||
|
0.00010325768895577629,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627,
|
||||||
|
-733.2050606371581,
|
||||||
|
-733.2050606371581,
|
||||||
|
0.0011363561025506627,
|
||||||
|
0.0011363561025506627
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"requestedTime": 0.2,
|
||||||
|
"values": [
|
||||||
|
2.633213298395276e-06,
|
||||||
|
1.1892894705560516e-06,
|
||||||
|
1.5894798508064198e-07,
|
||||||
|
0.0,
|
||||||
|
-2.578751264156614e-22,
|
||||||
|
-4.4071381894537746e-27,
|
||||||
|
2.632795658428222e-06,
|
||||||
|
1.189314089655013e-06,
|
||||||
|
1.589407569517069e-07,
|
||||||
|
2.632610849104822e-06,
|
||||||
|
1.1893270837855721e-06,
|
||||||
|
1.5893755046259315e-07,
|
||||||
|
2.6318631717003883e-06,
|
||||||
|
1.1893767434265974e-06,
|
||||||
|
1.5892477796136446e-07,
|
||||||
|
2.627345675136894e-06,
|
||||||
|
1.1896818589834777e-06,
|
||||||
|
1.588580216987135e-07,
|
||||||
|
2.626597124617547e-06,
|
||||||
|
1.189729910245627e-06,
|
||||||
|
1.5884573170762932e-07,
|
||||||
|
2.626716741360724e-06,
|
||||||
|
1.1897230638966853e-06,
|
||||||
|
1.5884742627218636e-07,
|
||||||
|
2.627662615850568e-06,
|
||||||
|
1.189655709206818e-06,
|
||||||
|
1.588644534572078e-07,
|
||||||
|
0.0,
|
||||||
|
-1.3196897803884347e-20,
|
||||||
|
2.911421882340363e-22,
|
||||||
|
-21722.04652916195,
|
||||||
|
0.06623994849325848,
|
||||||
|
-21723.45572154048,
|
||||||
|
0.06624103226621368,
|
||||||
|
-21805.8029650575,
|
||||||
|
0.06629330102524067,
|
||||||
|
-21807.348632461802,
|
||||||
|
0.06629448766538328,
|
||||||
|
-21807.718245902277,
|
||||||
|
0.06629475455162867,
|
||||||
|
-21841.35926610171,
|
||||||
|
0.06631660356825392,
|
||||||
|
-21724.166913573034,
|
||||||
|
0.06624154605298845,
|
||||||
|
-21722.236505250527,
|
||||||
|
0.06624008714645983,
|
||||||
|
-877966.3231213323,
|
||||||
|
1.2399467552157493,
|
||||||
|
-877966.3231959188,
|
||||||
|
1.2399467558278492,
|
||||||
|
-877963.9668549704,
|
||||||
|
1.2399274199318358,
|
||||||
|
-877963.9667803708,
|
||||||
|
1.2399274193197396,
|
||||||
|
-2232.5019275800073,
|
||||||
|
0.00315798322918964,
|
||||||
|
-2232.4899974231744,
|
||||||
|
0.003157926865311079,
|
||||||
|
-2232.5374050849664,
|
||||||
|
0.003157991047678687,
|
||||||
|
-1390.8326096413955,
|
||||||
|
0.0020292934150847593,
|
||||||
|
-1389.5677102640286,
|
||||||
|
0.0020284826306108832,
|
||||||
|
-1389.567755519397,
|
||||||
|
0.0020284825046839408,
|
||||||
|
-1389.5640026929677,
|
||||||
|
0.002028477899231957,
|
||||||
|
-1384.2773409257754,
|
||||||
|
0.0020250769587299407,
|
||||||
|
-1384.2734539740877,
|
||||||
|
0.002025072398131466,
|
||||||
|
-1384.273453362348,
|
||||||
|
0.002025072497347585,
|
||||||
|
-2232.5493408893735,
|
||||||
|
0.0031580474195833626,
|
||||||
|
-1384.2772469562626,
|
||||||
|
0.0020250771935230094,
|
||||||
|
-225.385588248691,
|
||||||
|
0.00032428657025173376,
|
||||||
|
-225.38393721998736,
|
||||||
|
0.0003242844761714517,
|
||||||
|
-225.38395476780747,
|
||||||
|
0.00032428450098976854,
|
||||||
|
-225.3856398271819,
|
||||||
|
0.0003242866434989134,
|
||||||
|
-2232.549339734738,
|
||||||
|
0.0031580474193159055,
|
||||||
|
-2232.501928009357,
|
||||||
|
0.003157983231060593,
|
||||||
|
-2232.489996268724,
|
||||||
|
0.0031579268650437325,
|
||||||
|
-2232.537405514378,
|
||||||
|
0.0031579910495496645,
|
||||||
|
-152.21172950802372,
|
||||||
|
0.002369452972089371,
|
||||||
|
-233.94138935309604,
|
||||||
|
0.0024219418441874473,
|
||||||
|
-234.03246203501217,
|
||||||
|
0.0024219979480173534,
|
||||||
|
-213.0564611194868,
|
||||||
|
0.002408520518742427,
|
||||||
|
-185.7911471147455,
|
||||||
|
0.0023910044024748737,
|
||||||
|
-185.83507440684312,
|
||||||
|
0.0023910306600708267,
|
||||||
|
-185.52866330836179,
|
||||||
|
0.0023908362272859114,
|
||||||
|
-113.23743631814425,
|
||||||
|
0.0023444176118442837,
|
||||||
|
-665.6994611241944,
|
||||||
|
-664.8834960853846,
|
||||||
|
0.0009422838620001763,
|
||||||
|
0.0009411594503803834,
|
||||||
|
-665.7201688784651,
|
||||||
|
-664.9043863133073,
|
||||||
|
0.0009423119505012294,
|
||||||
|
0.0009411876374833056,
|
||||||
|
-665.6994612846539,
|
||||||
|
-664.8834962525066,
|
||||||
|
0.0009422838626008165,
|
||||||
|
0.0009411594509858708,
|
||||||
|
-665.715814148825,
|
||||||
|
-664.8994727705187,
|
||||||
|
0.0009422941728241457,
|
||||||
|
0.000941169278566084,
|
||||||
|
-665.6951112104504,
|
||||||
|
-664.8785874929312,
|
||||||
|
0.0009422660888104697,
|
||||||
|
0.0009411410960587667,
|
||||||
|
-665.695111741859,
|
||||||
|
-664.8785880272087,
|
||||||
|
0.0009422660891455687,
|
||||||
|
0.0009411410963954202,
|
||||||
|
-665.7158139885557,
|
||||||
|
-664.8994726048427,
|
||||||
|
0.0009422941722236292,
|
||||||
|
0.0009411692779619336,
|
||||||
|
-665.7201683466378,
|
||||||
|
-664.9043857816836,
|
||||||
|
0.0009423119501658692,
|
||||||
|
0.0009411876371485153
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputContract": {
|
||||||
|
"sha256": "ae78c3bc5c60768e1cfc9542267d89922ef10673c5da216f257323c4d94b7499"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "test_mql_8-progressive-v3",
|
||||||
|
"description": "Primary generic-solver regression target for the restored eight-branch inputs aligned to the test_mql AMESim archive, with a short smoke gate before staged extension.",
|
||||||
|
"source": {
|
||||||
|
"path": "tests/data/test-mql-8.xml",
|
||||||
|
"sha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
|
||||||
|
"bytes": 104110,
|
||||||
|
"xmlIsAuthoritative": true,
|
||||||
|
"referenceArchive": {
|
||||||
|
"path": "AmesimModels/test_mql.ame",
|
||||||
|
"sha256": "cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb",
|
||||||
|
"bytes": 21708800,
|
||||||
|
"role": "authoritativePhysicalBaseline",
|
||||||
|
"resultStartTime": 0.0,
|
||||||
|
"resultStopTime": 10.0,
|
||||||
|
"resultSampleStep": 0.01,
|
||||||
|
"resultPointCount": 1002
|
||||||
|
},
|
||||||
|
"companionProject": {
|
||||||
|
"path": "tests/data/test-mql-8.json",
|
||||||
|
"sha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18",
|
||||||
|
"bytes": 272592,
|
||||||
|
"executionInput": false
|
||||||
|
},
|
||||||
|
"simulation": {
|
||||||
|
"tStart": 0.0,
|
||||||
|
"tStop": 10.0,
|
||||||
|
"sampleStep": 0.01,
|
||||||
|
"maxStep": 0.001,
|
||||||
|
"method": "BDF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"acceptanceEvidence": {
|
||||||
|
"production0.2ApprovedReplay": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-opt00-approved-replay-0.2.json",
|
||||||
|
"sha256": "2e27cd54026a2612df88d1903431f3968e03599b2ca6eb83d9b98638cc051502",
|
||||||
|
"bytes": 398198,
|
||||||
|
"workerWallSeconds": 159.60707960650325,
|
||||||
|
"orchestrationWallSeconds": 160.47319143638015,
|
||||||
|
"acceptancePassed": true,
|
||||||
|
"regressionGoldenPassed": true,
|
||||||
|
"physicalStateV21GoldenPassed": true
|
||||||
|
},
|
||||||
|
"extensionDecision": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-opt00-extension-decision.json",
|
||||||
|
"sha256": "7d34aea736863788d9efa72c46910afffd543ddc46eacdf1c6aa9caeab202022",
|
||||||
|
"bytes": 1313,
|
||||||
|
"oneSecondOutcome": "eligible",
|
||||||
|
"extensionWasNotStarted": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"historicalReports": [
|
||||||
|
{
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-solver-only-v1.json",
|
||||||
|
"sha256": "ed0e8514c5fc083af6d403270eb0617fc20672ebb96460c355757d28d7059d0f",
|
||||||
|
"sourceXmlSha256": "42e2d6277d39bd5990cb322243bec8d71c40ff0696b440db3c8623f2ab15c2ee",
|
||||||
|
"status": "historicalOnly",
|
||||||
|
"compatibleWithCurrentSource": false,
|
||||||
|
"reason": "This report used the former spaced filename and 0.002 s sampleStep/maxStep. It is retained only as historical performance evidence and must never seed a v2 golden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-extension-decision.json",
|
||||||
|
"sha256": "d067bab69418ec21a11481f4a2c1fcb1ba1fc9528faeeb163ea5f466c2e2987f",
|
||||||
|
"sourceXmlSha256": "42e2d6277d39bd5990cb322243bec8d71c40ff0696b440db3c8623f2ab15c2ee",
|
||||||
|
"status": "historicalOnly",
|
||||||
|
"compatibleWithCurrentSource": false,
|
||||||
|
"reason": "This deferred-stage decision was calculated from the former 0.002 s source report and is not a runtime prediction for the renamed v2 source."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json",
|
||||||
|
"sha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5",
|
||||||
|
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||||
|
"status": "historicalOnly",
|
||||||
|
"compatibleWithCurrentSource": false,
|
||||||
|
"reason": "This accepted 0.2 s report predates the four restored PNL0001 branches and the AMESim-aligned 10 s simulation settings; it remains historical evidence only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-extension-decision.json",
|
||||||
|
"sha256": "c2dd8acfaae71e9c5f2eaef35d529dd1ce1d420b48703faff3d111678496b671",
|
||||||
|
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||||
|
"status": "historicalOnly",
|
||||||
|
"compatibleWithCurrentSource": false,
|
||||||
|
"reason": "This runtime extension decision was derived from the superseded v2 source and must not control the AMESim-aligned v3 source."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"historicalGoldens": [
|
||||||
|
{
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json",
|
||||||
|
"sha256": "99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72",
|
||||||
|
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
|
||||||
|
"status": "historicalOnly",
|
||||||
|
"compatibleWithCurrentSource": false,
|
||||||
|
"reason": "This Python-state golden predates the four restored PNL0001 branches and the AMESim-aligned simulation settings; it must not approve the current v3 source."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"structure": {
|
||||||
|
"componentCount": 156,
|
||||||
|
"connectionCount": 178,
|
||||||
|
"dynamicComponentCount": 58,
|
||||||
|
"stateCount": 132,
|
||||||
|
"resultVariableCount": 1784,
|
||||||
|
"pressureFlowUnknownCount": 776,
|
||||||
|
"pressureFlowEquationCount": 776,
|
||||||
|
"pressureFlowIsSquare": true,
|
||||||
|
"logicalEffortCoordinateCount": 116,
|
||||||
|
"eliminatedEffortAliasCount": 324,
|
||||||
|
"canonicalCoordinateCount": 452,
|
||||||
|
"compatibilityScatterCount": 776,
|
||||||
|
"jacobianNonzeroCount": 3280,
|
||||||
|
"jacobianColorGroupCount": 52,
|
||||||
|
"hasMechanicalStateEvents": true
|
||||||
|
},
|
||||||
|
"sequence": [
|
||||||
|
"0.01s-smoke",
|
||||||
|
"0.2s",
|
||||||
|
"1s",
|
||||||
|
"5s",
|
||||||
|
"10s"
|
||||||
|
],
|
||||||
|
"lanes": {
|
||||||
|
"solver-only": {
|
||||||
|
"description": "Algorithm iteration and staged-extension lane: same XML and method, with an in-memory 0.02 s output grid and 0.05 s maximum step; signal breakpoints remain explicit solver segments.",
|
||||||
|
"samplingMode": "fixed",
|
||||||
|
"sampleStep": 0.02,
|
||||||
|
"maxStepMode": "fixed",
|
||||||
|
"maxStep": 0.05,
|
||||||
|
"instrumentationMode": "standard",
|
||||||
|
"productionEquivalentOutput": false
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"description": "Acceptance lane: changes only tStop and preserves the AMESim-aligned source 0.01 s output grid and 0.001 s internal-step cap.",
|
||||||
|
"samplingMode": "source",
|
||||||
|
"maxStepMode": "source",
|
||||||
|
"instrumentationMode": "standard",
|
||||||
|
"productionEquivalentOutput": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variants": {
|
||||||
|
"0.01s-smoke": {
|
||||||
|
"stopTime": 0.01,
|
||||||
|
"softTimeoutSeconds": 60.0,
|
||||||
|
"hardTimeoutSeconds": 90.0,
|
||||||
|
"useForRuntimePrediction": false,
|
||||||
|
"checkpointTimes": [
|
||||||
|
0.0,
|
||||||
|
0.01
|
||||||
|
],
|
||||||
|
"expectedSignalEventTimes": []
|
||||||
|
},
|
||||||
|
"0.2s": {
|
||||||
|
"stopTime": 0.2,
|
||||||
|
"softTimeoutSeconds": 300.0,
|
||||||
|
"hardTimeoutSeconds": 360.0,
|
||||||
|
"checkpointTimes": [
|
||||||
|
0.0,
|
||||||
|
0.04,
|
||||||
|
0.2
|
||||||
|
],
|
||||||
|
"expectedSignalEventTimes": [
|
||||||
|
0.04
|
||||||
|
],
|
||||||
|
"expectedMechanicalTransitionTimes": [],
|
||||||
|
"goldens": {
|
||||||
|
"production": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v2.json",
|
||||||
|
"sha256": "7ca8e859d7d2889d6608726eba0faf3441f0f2ad579799c2abdfe571fd49fd0e",
|
||||||
|
"role": "pythonDeterminismRegression",
|
||||||
|
"affectsPhysicalCorrectness": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"physicalStateV21Goldens": {
|
||||||
|
"production": {
|
||||||
|
"path": "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-physical-state-v2.1.json",
|
||||||
|
"sha256": "6f0752afe8c1f7690599c2709a76d9a1342f99f726c6c8593321dc6267e175b2",
|
||||||
|
"role": "amesimPhysicalBaseline",
|
||||||
|
"compareOnEveryRun": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1s": {
|
||||||
|
"stopTime": 1.0,
|
||||||
|
"softTimeoutSeconds": 1200.0,
|
||||||
|
"hardTimeoutSeconds": 1260.0,
|
||||||
|
"checkpointTimes": [
|
||||||
|
0.0,
|
||||||
|
0.04,
|
||||||
|
0.8,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"expectedSignalEventTimes": [
|
||||||
|
0.04,
|
||||||
|
0.8
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"5s": {
|
||||||
|
"stopTime": 5.0,
|
||||||
|
"softTimeoutSeconds": 2700.0,
|
||||||
|
"hardTimeoutSeconds": 2760.0,
|
||||||
|
"checkpointTimes": [
|
||||||
|
0.0,
|
||||||
|
0.04,
|
||||||
|
0.8,
|
||||||
|
1.0,
|
||||||
|
2.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"expectedSignalEventTimes": [
|
||||||
|
0.04,
|
||||||
|
0.8
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"10s": {
|
||||||
|
"stopTime": 10.0,
|
||||||
|
"softTimeoutSeconds": 5400.0,
|
||||||
|
"hardTimeoutSeconds": 5460.0,
|
||||||
|
"checkpointTimes": [
|
||||||
|
0.0,
|
||||||
|
0.04,
|
||||||
|
0.8,
|
||||||
|
1.0,
|
||||||
|
2.0,
|
||||||
|
5.0,
|
||||||
|
10.0
|
||||||
|
],
|
||||||
|
"expectedSignalEventTimes": [
|
||||||
|
0.04,
|
||||||
|
0.8
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"status": "complete",
|
||||||
|
"maximumScaledResidual": 1e-7,
|
||||||
|
"requireFiniteSeries": true,
|
||||||
|
"requireStrictlyIncreasingTimes": true,
|
||||||
|
"physicalProjectionCategories": [
|
||||||
|
"state",
|
||||||
|
"pressure",
|
||||||
|
"massFlow",
|
||||||
|
"conservation",
|
||||||
|
"discreteMode"
|
||||||
|
],
|
||||||
|
"stateRelativeTolerance": 0.0002,
|
||||||
|
"stateAbsoluteTolerance": 1e-9,
|
||||||
|
"checkpointTimeAbsoluteToleranceSeconds": 1e-12,
|
||||||
|
"eventTimeAbsoluteToleranceSeconds": 0.00002,
|
||||||
|
"signalEventTimeAbsoluteToleranceSeconds": 1e-12,
|
||||||
|
"mechanicalTransitionTimesAvailable": true,
|
||||||
|
"physicalBaselineAuthority": "amesim",
|
||||||
|
"compareAmesimOnEveryRun": true,
|
||||||
|
"pythonGoldenRole": "determinismDiagnosticOnly",
|
||||||
|
"note": "AMESim simulation results are the sole physical baseline and every production run reports current-to-AMESim relative errors. The exact Python-state golden detects determinism and implementation drift only; it cannot approve physical correctness. Longer progressive horizons remain independently staged work."
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"causalExecutorV2Default": true,
|
||||||
|
"environment": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"predictionSafetyFactor": 1.5,
|
||||||
|
"terminationGraceSeconds": 10.0,
|
||||||
|
"deferAfterFailureOrTimeout": true,
|
||||||
|
"deferWhenPredictedWallExceedsSoftTimeout": true,
|
||||||
|
"longTestEnvironmentVariable": "RUN_TEST_MQL_8_LONG_REGRESSION"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "progressive-extension-decision",
|
||||||
|
"sourceReport": "2026-08-17-solver-only-v1.json",
|
||||||
|
"sourceReportSha256": "ed0e8514c5fc083af6d403270eb0617fc20672ebb96460c355757d28d7059d0f",
|
||||||
|
"lane": "solver-only",
|
||||||
|
"observedCase": {
|
||||||
|
"caseId": "0.2s",
|
||||||
|
"outcome": "completed",
|
||||||
|
"wallSeconds": 132.30459557846189,
|
||||||
|
"acceptancePassed": true
|
||||||
|
},
|
||||||
|
"predictionSafetyFactor": 1.5,
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"caseId": "1s",
|
||||||
|
"outcome": "deferred",
|
||||||
|
"predictedWallSeconds": 992.2844668384642,
|
||||||
|
"softTimeoutSeconds": 900.0,
|
||||||
|
"reason": "predictedWallExceedsSoftBudget"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caseId": "5s",
|
||||||
|
"outcome": "deferred",
|
||||||
|
"reason": "predecessorDeferred"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caseId": "10s",
|
||||||
|
"outcome": "deferred",
|
||||||
|
"reason": "predecessorDeferred"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"simulationWasNotStartedForDeferredCases": true
|
||||||
|
}
|
||||||
+6623
File diff suppressed because it is too large.
Load diff
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user