Compare commits
20
Commits
4e0b9fd8cc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408b4ecb22 | ||
|
|
e18399c022 | ||
|
|
c19cf77aee | ||
|
|
eb6ea70e19 | ||
|
|
27f9f4add8 | ||
|
|
60b743dd81 | ||
|
|
a8c733883c | ||
|
|
53f8601fec | ||
|
|
f725f038b6 | ||
|
|
684d28752a | ||
|
|
b435daecf2 | ||
|
|
143e8dd309 | ||
|
|
0fa166c8e5 | ||
|
|
16a7eb2d6c | ||
|
|
6bb0591d32 | ||
|
|
cca9d1e883 | ||
|
|
22b35b2945 | ||
|
|
f09dfcf542 | ||
|
|
5332a788f3 | ||
|
|
57b459bc72 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,180 @@
|
||||
name: Solver regression
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "app/simulation/**"
|
||||
- "tests/**"
|
||||
- "requirements.txt"
|
||||
- "constraints/**"
|
||||
- ".python-version"
|
||||
- "README.md"
|
||||
- ".github/workflows/solver-regression.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "app/simulation/**"
|
||||
- "tests/**"
|
||||
- "requirements.txt"
|
||||
- "constraints/**"
|
||||
- ".python-version"
|
||||
- "README.md"
|
||||
- ".github/workflows/solver-regression.yml"
|
||||
schedule:
|
||||
- cron: "17 3 * * 1-6"
|
||||
- cron: "17 3 * * 0"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: Regression tier
|
||||
required: true
|
||||
default: quick
|
||||
type: choice
|
||||
options:
|
||||
- quick
|
||||
- historical
|
||||
- main-long
|
||||
case:
|
||||
description: Longest main-model horizon (predecessors run first)
|
||||
required: true
|
||||
default: 0.2s
|
||||
type: choice
|
||||
options:
|
||||
- 0.2s
|
||||
- 1s
|
||||
- 5s
|
||||
- 10s
|
||||
lane:
|
||||
description: Output sampling lane
|
||||
required: true
|
||||
default: production
|
||||
type: choice
|
||||
options:
|
||||
- solver-only
|
||||
- production
|
||||
|
||||
concurrency:
|
||||
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quick:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'pull_request' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: .python-version
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
constraints/python312-direct.txt
|
||||
constraints/python312-linux-x86_64.lock
|
||||
- name: Install hashed Linux release lock
|
||||
run: |
|
||||
python -m pip install \
|
||||
--force-reinstall \
|
||||
-r constraints/python312-linux-x86_64.lock
|
||||
python -m pip check
|
||||
- name: Run solver foundation tests
|
||||
env:
|
||||
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
|
||||
run: |
|
||||
python -W error::ResourceWarning -m unittest \
|
||||
tests.test_dependency_constraints \
|
||||
tests.test_benchmark_regression \
|
||||
tests.test_physical_state_v21 \
|
||||
tests.test_test_mql_ame_contract \
|
||||
tests.test_test_mql_8_regression \
|
||||
tests.test_mql_full_branches_regression \
|
||||
tests.test_pressure_flow_causal_execution \
|
||||
tests.test_stream_pressure_block_solver \
|
||||
tests.test_core_solver \
|
||||
tests.test_supported_piston_tangent \
|
||||
tests.test_three_piston_tangent \
|
||||
tests.test_sparse_secant_jacobian \
|
||||
tests.test_generic_jacobian_sparsity \
|
||||
tests.test_generic_system_xml_simulation
|
||||
|
||||
historical-nightly:
|
||||
if: >-
|
||||
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: .python-version
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
constraints/python312-direct.txt
|
||||
constraints/python312-linux-x86_64.lock
|
||||
- name: Install hashed Linux release lock
|
||||
run: |
|
||||
python -m pip install \
|
||||
--force-reinstall \
|
||||
-r constraints/python312-linux-x86_64.lock
|
||||
python -m pip check
|
||||
- name: Run 0.81 and 2.10 second historical regression
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
python -m app.simulation.benchmark_regression \
|
||||
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
|
||||
--lane production \
|
||||
--output artifacts/test-mql-full-branches.json
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: historical-solver-regression
|
||||
path: artifacts/*.json
|
||||
if-no-files-found: warn
|
||||
|
||||
main-periodic:
|
||||
if: >-
|
||||
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: .python-version
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
constraints/python312-direct.txt
|
||||
constraints/python312-linux-x86_64.lock
|
||||
- name: Install hashed Linux release lock
|
||||
run: |
|
||||
python -m pip install \
|
||||
--force-reinstall \
|
||||
-r constraints/python312-linux-x86_64.lock
|
||||
python -m pip check
|
||||
- name: Run bounded progressive main-model regression
|
||||
env:
|
||||
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
|
||||
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
python -m app.simulation.benchmark_regression \
|
||||
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
|
||||
--lane "$REQUESTED_LANE" \
|
||||
--case "$REQUESTED_CASE" \
|
||||
--output artifacts/test-mql-8-progressive.json
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: main-model-progressive-regression
|
||||
path: artifacts/*.json
|
||||
if-no-files-found: warn
|
||||
@@ -12,6 +12,10 @@ htmlcov/
|
||||
# Local virtual environments
|
||||
.venv/
|
||||
.venv-win/
|
||||
|
||||
# Local Linux toolchain (downloaded for the startup scripts)
|
||||
.tools/node-*-linux-x64/
|
||||
|
||||
app/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12.3
|
||||
@@ -2,6 +2,88 @@
|
||||
|
||||
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 启动时自动加载。
|
||||
@@ -19,19 +101,14 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
||||
|
||||
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||
|
||||
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
|
||||
|
||||
```powershell
|
||||
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
|
||||
```
|
||||
XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
|
||||
|
||||
## 文档
|
||||
|
||||
- [开发文档索引](docs/README.md)
|
||||
- [后端接口版本与定义规范 v1](docs/backend-interface-version-spec-v1.md)
|
||||
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
|
||||
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
|
||||
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
|
||||
- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
|
||||
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
|
||||
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||
- [System XML 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)
|
||||
|
||||
+115
-30
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
import json
|
||||
@@ -21,6 +22,9 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from app.simulation.performance import performance_span, profile_phase, profile_run
|
||||
from app.simulation.property_cache import property_cache_run
|
||||
from app.simulation.solvers.solver import SolverActivityTracker
|
||||
from app.system_xml import (
|
||||
SystemXmlDocument,
|
||||
SystemXmlValidationReport,
|
||||
@@ -34,7 +38,19 @@ if TYPE_CHECKING:
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
app = FastAPI(title="System Simulation ReactFlow App")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||
from app.simulation.warmup import warm_up_simulation_runtime
|
||||
|
||||
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="System Simulation ReactFlow App",
|
||||
lifespan=_app_lifespan,
|
||||
)
|
||||
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
||||
SYSTEM_XML_SCHEMA_VERSION = "3"
|
||||
@@ -71,6 +87,9 @@ SimulationTaskStatus = Literal[
|
||||
class SimulationTaskRecord:
|
||||
simulation_id: str
|
||||
cancel_event: threading.Event
|
||||
activity_tracker: SolverActivityTracker = field(
|
||||
default_factory=SolverActivityTracker
|
||||
)
|
||||
status: SimulationTaskStatus = "queued"
|
||||
cancel_reason: SimulationCancelReason | None = None
|
||||
result: dict[str, object] | None = None
|
||||
@@ -559,6 +578,7 @@ def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]:
|
||||
"cancelReason": task.cancel_reason,
|
||||
"result": task.result,
|
||||
"error": task.error,
|
||||
**task.activity_tracker.snapshot().as_dict(),
|
||||
}
|
||||
|
||||
|
||||
@@ -666,6 +686,40 @@ def run_system_xml_simulation(
|
||||
xml_bytes: bytes,
|
||||
progress_callback: SimulationProgressEmitter | None = None,
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
activity_tracker: SolverActivityTracker | None = None,
|
||||
) -> dict[str, object]:
|
||||
with property_cache_run() as property_cache:
|
||||
with profile_run() as trace:
|
||||
result = _run_system_xml_simulation_profiled(
|
||||
xml_bytes,
|
||||
progress_callback,
|
||||
cancel_check,
|
||||
activity_tracker,
|
||||
)
|
||||
|
||||
performance = trace.snapshot()
|
||||
if performance.get("mode") == "audit" and property_cache is not None:
|
||||
cache_info = property_cache.info()
|
||||
performance["propertyCache"] = {
|
||||
"hits": cache_info.hits,
|
||||
"misses": cache_info.misses,
|
||||
"maxEntriesPerCache": cache_info.max_entries_per_cache,
|
||||
"cacheCount": cache_info.cache_count,
|
||||
"currentEntries": cache_info.current_entries,
|
||||
"evictions": cache_info.evictions,
|
||||
}
|
||||
if performance.get("mode") != "off":
|
||||
diagnostics = dict(result.get("diagnostics", {}))
|
||||
diagnostics["performance"] = performance
|
||||
result["diagnostics"] = diagnostics
|
||||
return result
|
||||
|
||||
|
||||
def _run_system_xml_simulation_profiled(
|
||||
xml_bytes: bytes,
|
||||
progress_callback: SimulationProgressEmitter | None = None,
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
activity_tracker: SolverActivityTracker | None = None,
|
||||
) -> dict[str, object]:
|
||||
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
@@ -728,6 +782,7 @@ def run_system_xml_simulation(
|
||||
sample_step=document.simulation.sample_step,
|
||||
progress_callback=report_system_progress,
|
||||
cancel_check=cancel_check,
|
||||
activity_tracker=activity_tracker,
|
||||
)
|
||||
except SimulationPreparationError as exc:
|
||||
raise HTTPException(
|
||||
@@ -745,6 +800,9 @@ def run_system_xml_simulation(
|
||||
},
|
||||
) from exc
|
||||
except AlgebraicSolveError as exc:
|
||||
algebraic_diagnostics = exc.diagnostics.as_dict()
|
||||
algebraic_diagnostics["scopeKind"] = exc.scope_kind
|
||||
algebraic_diagnostics["scopeComponents"] = list(exc.scope_components)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
@@ -757,7 +815,7 @@ def run_system_xml_simulation(
|
||||
"message": str(exc),
|
||||
}
|
||||
],
|
||||
"diagnostics": exc.diagnostics.as_dict(),
|
||||
"diagnostics": algebraic_diagnostics,
|
||||
},
|
||||
) from exc
|
||||
except StreamSolveError as exc:
|
||||
@@ -792,12 +850,13 @@ def run_system_xml_simulation(
|
||||
},
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"validation": report.as_dict(),
|
||||
"simulation": document.as_model_data()["simulation"],
|
||||
"model": network.as_interface_dict(),
|
||||
**result.as_dict(),
|
||||
}
|
||||
with performance_span("simulation.response_assembly"):
|
||||
return {
|
||||
"validation": report.as_dict(),
|
||||
"simulation": document.as_model_data()["simulation"],
|
||||
"model": network.as_interface_dict(),
|
||||
**result.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def simulation_event_stream(
|
||||
@@ -812,6 +871,9 @@ def simulation_event_stream(
|
||||
latest_message = "正在等待仿真任务启动"
|
||||
latest_simulated_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(
|
||||
progress: int,
|
||||
@@ -839,6 +901,7 @@ def simulation_event_stream(
|
||||
event["simulatedTime"] = latest_simulated_time
|
||||
if latest_total_time is not None:
|
||||
event["totalTime"] = latest_total_time
|
||||
event.update(activity_tracker.snapshot().as_dict())
|
||||
events.put(event)
|
||||
|
||||
def worker() -> None:
|
||||
@@ -849,28 +912,44 @@ def simulation_event_stream(
|
||||
xml_bytes,
|
||||
emit_progress,
|
||||
task.cancel_event.is_set if task is not None else None,
|
||||
activity_tracker,
|
||||
)
|
||||
if task is not None:
|
||||
result = _mark_simulation_task_result(task, result)
|
||||
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 = {
|
||||
"completed": "仿真完成",
|
||||
"stopped": "仿真已由用户终止,已保留部分结果",
|
||||
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
|
||||
"failed": "仿真异常终止,已保留可用的部分结果",
|
||||
}
|
||||
events.put(
|
||||
{
|
||||
"event": "result",
|
||||
"progress": 100 if result_status == "completed" else latest_progress,
|
||||
"phase": result_status,
|
||||
"message": result_messages.get(result_status, "仿真任务结束"),
|
||||
"simulatedTime": result.get("simulatedUntil"),
|
||||
"totalTime": result.get("requestedStopTime"),
|
||||
"result": result,
|
||||
}
|
||||
)
|
||||
result_event = {
|
||||
"event": "result",
|
||||
"progress": 100 if result_status == "completed" else latest_progress,
|
||||
"phase": result_status,
|
||||
"message": result_messages.get(result_status, "仿真任务结束"),
|
||||
"simulatedTime": result.get("simulatedUntil"),
|
||||
"totalTime": result.get("requestedStopTime"),
|
||||
"result": result,
|
||||
}
|
||||
result_event.update(activity_tracker.snapshot().as_dict())
|
||||
events.put(result_event)
|
||||
except HTTPException as exc:
|
||||
activity_tracker.record_phase("failed")
|
||||
detail = exc.detail
|
||||
message = (
|
||||
str(detail.get("message", "仿真失败"))
|
||||
@@ -885,10 +964,12 @@ def simulation_event_stream(
|
||||
"message": message,
|
||||
"detail": detail,
|
||||
}
|
||||
error_event.update(activity_tracker.snapshot().as_dict())
|
||||
if task is not None:
|
||||
_mark_simulation_task_error(task, error_event)
|
||||
events.put(error_event)
|
||||
except Exception as exc: # pragma: no cover - last-resort stream guard
|
||||
activity_tracker.record_phase("failed")
|
||||
error_event = {
|
||||
"event": "error",
|
||||
"progress": latest_progress,
|
||||
@@ -897,6 +978,7 @@ def simulation_event_stream(
|
||||
"message": "仿真服务发生未预期错误。",
|
||||
"detail": str(exc),
|
||||
}
|
||||
error_event.update(activity_tracker.snapshot().as_dict())
|
||||
if task is not None:
|
||||
_mark_simulation_task_error(task, error_event)
|
||||
events.put(error_event)
|
||||
@@ -914,16 +996,18 @@ def simulation_event_stream(
|
||||
try:
|
||||
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
|
||||
except queue.Empty:
|
||||
heartbeat_event = {
|
||||
"event": "progress",
|
||||
"progress": latest_progress,
|
||||
"phase": latest_phase,
|
||||
"message": latest_message,
|
||||
"heartbeat": True,
|
||||
"simulatedTime": latest_simulated_time,
|
||||
"totalTime": latest_total_time,
|
||||
}
|
||||
heartbeat_event.update(activity_tracker.snapshot().as_dict())
|
||||
yield json.dumps(
|
||||
{
|
||||
"event": "progress",
|
||||
"progress": latest_progress,
|
||||
"phase": latest_phase,
|
||||
"message": latest_message,
|
||||
"heartbeat": True,
|
||||
"simulatedTime": latest_simulated_time,
|
||||
"totalTime": latest_total_time,
|
||||
},
|
||||
heartbeat_event,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
) + "\n"
|
||||
@@ -1200,6 +1284,7 @@ def compile_reactflow_network(
|
||||
)
|
||||
|
||||
|
||||
@profile_phase("simulation.network_compilation")
|
||||
def compile_system_xml_network(
|
||||
document: SystemXmlDocument,
|
||||
*,
|
||||
|
||||
@@ -33,8 +33,8 @@ FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件
|
||||
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
|
||||
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
||||
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
|
||||
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
|
||||
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
|
||||
|
||||
当前关键文件:
|
||||
|
||||
@@ -42,6 +42,8 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
||||
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
|
||||
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
|
||||
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
|
||||
- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点
|
||||
- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具
|
||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
@@ -58,6 +60,36 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
||||
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
||||
|
||||
## 可选性能诊断
|
||||
|
||||
`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard`
|
||||
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
|
||||
明显更高。最终优化收益必须在 `off` 下复测。
|
||||
|
||||
Peng–Robinson 氦气的高开销物性默认使用一次仿真内独立的精确 LRU 缓存;不同
|
||||
仿真任务不会共享条目,仿真结束后自动释放。可在启动进程前设置
|
||||
`SIMULATIONAPP_PROPERTY_CACHE=off` 做数值和性能 A/B,正常运行保持默认 `on`。
|
||||
缓存只复用完全相同的输入,不做四舍五入或容差匹配。
|
||||
|
||||
FastAPI worker 默认在 lifespan 启动阶段预热 SciPy 积分、非线性求解、稀疏
|
||||
Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行业务模型,也不
|
||||
写入文件;如需诊断冷启动,可设置 `SIMULATIONAPP_WARMUP=off`。每个 worker 都会
|
||||
独立暖机一次。
|
||||
|
||||
```powershell
|
||||
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
|
||||
--mode audit --warmups 1 --runs 3 `
|
||||
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
|
||||
--output app/data/performance-evaluations/helium-step.json
|
||||
```
|
||||
|
||||
缓存关闭对照可在同一命令中增加 `--disable-property-cache`。缓存容量、命中、
|
||||
未命中和驱逐数会在 audit 响应的
|
||||
`diagnostics.performance.propertyCache` 中返回。
|
||||
|
||||
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
|
||||
[`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。
|
||||
|
||||
## 当前阶段进度
|
||||
|
||||
这一阶段原先有 4 件重点工作,现在的状态如下:
|
||||
@@ -279,7 +311,7 @@ print(result.used_modelica_reference)
|
||||
## 基线结果
|
||||
|
||||
当前基线对比摘要来自:
|
||||
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||
[`testmodel_modelica_comparison_summary.txt`](../../tests/data/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||
|
||||
当前四个主变量的最大误差为:
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import statistics
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from time import perf_counter_ns, process_time_ns
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _named_value(value: str, *, option: str) -> tuple[str, str]:
|
||||
name, separator, target = value.partition("=")
|
||||
if not separator or not name.strip() or not target.strip():
|
||||
raise ValueError(
|
||||
f"{option} must use NAME=VALUE syntax, received {value!r}."
|
||||
)
|
||||
return name.strip(), target.strip()
|
||||
|
||||
|
||||
def _percentile(values: list[float], percentile: float) -> float:
|
||||
ordered = sorted(values)
|
||||
index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1))
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def _duration_summary(values: list[float]) -> dict[str, object]:
|
||||
return {
|
||||
"samplesMs": values,
|
||||
"minimumMs": min(values),
|
||||
"medianMs": statistics.median(values),
|
||||
"p95Ms": _percentile(values, 0.95),
|
||||
"maximumMs": max(values),
|
||||
}
|
||||
|
||||
|
||||
def _load_factory_xml(specification: str) -> bytes:
|
||||
module_name, separator, member_name = specification.partition(":")
|
||||
if not separator or not module_name or not member_name:
|
||||
raise ValueError(
|
||||
"Factory specifications must use module.path:callable syntax."
|
||||
)
|
||||
factory = getattr(importlib.import_module(module_name), member_name)
|
||||
value = factory()
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.encode("utf-8")
|
||||
|
||||
from app.main import build_reactflow_system_xml
|
||||
|
||||
return build_reactflow_system_xml(value)
|
||||
|
||||
|
||||
def _serialize_result_event(result: dict[str, object]) -> bytes:
|
||||
"""Render the final NDJSON payload shape used by the streaming endpoint."""
|
||||
|
||||
status = str(result.get("status", "completed"))
|
||||
event = {
|
||||
"event": "result",
|
||||
"progress": 100 if status == "completed" else 0,
|
||||
"phase": status,
|
||||
"message": "仿真完成" if status == "completed" else "仿真任务结束",
|
||||
"simulatedTime": result.get("simulatedUntil"),
|
||||
"totalTime": result.get("requestedStopTime"),
|
||||
"result": result,
|
||||
}
|
||||
return (
|
||||
json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _run_case(
|
||||
name: str,
|
||||
xml_bytes: bytes,
|
||||
*,
|
||||
warmups: int,
|
||||
runs: int,
|
||||
cancellable_path: bool,
|
||||
allow_failures: bool,
|
||||
) -> dict[str, object]:
|
||||
from app.main import run_system_xml_simulation
|
||||
|
||||
cancel_check = (lambda: False) if cancellable_path else None
|
||||
for _ in range(warmups):
|
||||
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
|
||||
if not bool(result.get("success")) and not allow_failures:
|
||||
raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}")
|
||||
|
||||
wall_samples_ms: list[float] = []
|
||||
cpu_samples_ms: list[float] = []
|
||||
serialization_samples_ms: list[float] = []
|
||||
serialized_sizes: list[int] = []
|
||||
profiles: list[dict[str, object]] = []
|
||||
final_result: dict[str, object] | None = None
|
||||
for _ in range(runs):
|
||||
wall_start = perf_counter_ns()
|
||||
cpu_start = process_time_ns()
|
||||
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
|
||||
cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0)
|
||||
wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0)
|
||||
if not bool(result.get("success")) and not allow_failures:
|
||||
raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}")
|
||||
diagnostics = result.get("diagnostics")
|
||||
if isinstance(diagnostics, dict):
|
||||
performance = diagnostics.get("performance")
|
||||
if isinstance(performance, dict):
|
||||
profiles.append(performance)
|
||||
serialization_start = perf_counter_ns()
|
||||
serialized_event = _serialize_result_event(result)
|
||||
serialization_samples_ms.append(
|
||||
(perf_counter_ns() - serialization_start) / 1_000_000.0
|
||||
)
|
||||
serialized_sizes.append(len(serialized_event))
|
||||
final_result = result
|
||||
|
||||
assert final_result is not None
|
||||
return {
|
||||
"name": name,
|
||||
"success": bool(final_result.get("success")),
|
||||
"message": final_result.get("message"),
|
||||
"inputBytes": len(xml_bytes),
|
||||
"inputSha256": hashlib.sha256(xml_bytes).hexdigest(),
|
||||
"status": final_result.get("status"),
|
||||
"simulatedUntil": final_result.get("simulatedUntil"),
|
||||
"requestedStopTime": final_result.get("requestedStopTime"),
|
||||
"wall": _duration_summary(wall_samples_ms),
|
||||
"cpu": _duration_summary(cpu_samples_ms),
|
||||
"resultSerialization": _duration_summary(serialization_samples_ms),
|
||||
"resultEventBytes": serialized_sizes,
|
||||
"performanceRuns": profiles,
|
||||
}
|
||||
|
||||
|
||||
def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark the real System XML simulation path with optional profiling."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("off", "standard", "audit"),
|
||||
default="audit",
|
||||
help="Instrumentation depth selected before importing the simulation modules.",
|
||||
)
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--runs", type=int, default=5)
|
||||
parser.add_argument(
|
||||
"--xml",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=PATH",
|
||||
help="Add an XML file benchmark case.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--factory",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=MODULE:CALLABLE",
|
||||
help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--direct-path",
|
||||
action="store_true",
|
||||
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-property-cache",
|
||||
action="store_true",
|
||||
help="Disable the run-local exact property cache for an A/B comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-failures",
|
||||
action="store_true",
|
||||
help="Record failed simulation runs instead of aborting the benchmark.",
|
||||
)
|
||||
parser.add_argument("--output", type=Path)
|
||||
arguments = parser.parse_args(argv)
|
||||
if arguments.warmups < 0:
|
||||
parser.error("--warmups must not be negative.")
|
||||
if arguments.runs <= 0:
|
||||
parser.error("--runs must be positive.")
|
||||
if not arguments.xml and not arguments.factory:
|
||||
parser.error("At least one --xml or --factory case is required.")
|
||||
return arguments
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = _parse_arguments(argv)
|
||||
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
|
||||
os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = (
|
||||
"off" if arguments.disable_property_cache else "on"
|
||||
)
|
||||
|
||||
cases: list[tuple[str, bytes]] = []
|
||||
for raw_case in arguments.xml:
|
||||
name, raw_path = _named_value(raw_case, option="--xml")
|
||||
cases.append((name, Path(raw_path).read_bytes()))
|
||||
for raw_case in arguments.factory:
|
||||
name, specification = _named_value(raw_case, option="--factory")
|
||||
cases.append((name, _load_factory_xml(specification)))
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
"profileMode": arguments.mode,
|
||||
"cancellableSolverPath": not arguments.direct_path,
|
||||
"propertyCacheEnabled": not arguments.disable_property_cache,
|
||||
"allowFailures": bool(arguments.allow_failures),
|
||||
"warmups": arguments.warmups,
|
||||
"runs": arguments.runs,
|
||||
"runtime": {
|
||||
"python": sys.version,
|
||||
"platform": platform.platform(),
|
||||
"processor": platform.processor(),
|
||||
},
|
||||
"cases": [
|
||||
_run_case(
|
||||
name,
|
||||
xml_bytes,
|
||||
warmups=arguments.warmups,
|
||||
runs=arguments.runs,
|
||||
cancellable_path=not arguments.direct_path,
|
||||
allow_failures=arguments.allow_failures,
|
||||
)
|
||||
for name, xml_bytes in cases
|
||||
],
|
||||
}
|
||||
text = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
if arguments.output is not None:
|
||||
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
arguments.output.write_text(text + "\n", encoding="utf-8")
|
||||
print(f"Performance report written to {arguments.output.resolve()}")
|
||||
else:
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@ class AmesimPnpl01(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "amesim_pnpl01"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
||||
PARAMETERS = ()
|
||||
RESULT_VARIABLES = ()
|
||||
|
||||
@@ -35,6 +35,7 @@ _FLOWSET_USES_CV = (ParameterCondition("flowset", (2.0,)),)
|
||||
_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),)
|
||||
_PN_PRESSURE_RATIO_ACCURACY = 0.9999
|
||||
_PN_LAMINAR_SMOOTHING_GAIN = 12.0
|
||||
_PNVO001_CLOSED_OPENING_ABS_TOL = 1.0e-12
|
||||
_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(
|
||||
id="flow_coefficient",
|
||||
label="流量系数",
|
||||
@@ -58,6 +59,10 @@ class AmesimPnor001(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "amesim_pnor001"
|
||||
MODEL_VERSION = "0.3.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
@@ -462,6 +467,10 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "amesim_pnvo001_fixed"
|
||||
MODEL_VERSION = "0.2.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||
@@ -565,8 +574,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
category_id="flow",
|
||||
symbol="amesim_pnvo001_fixed",
|
||||
ports=(
|
||||
PortDisplaySpec("port_2", "left", order=10),
|
||||
PortDisplaySpec("port_3", "right", order=20),
|
||||
# AMESim default geometry places port 2 right and port 3 left.
|
||||
PortDisplaySpec("port_2", "right", order=10),
|
||||
PortDisplaySpec("port_3", "left", order=20),
|
||||
),
|
||||
order=30,
|
||||
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
||||
@@ -698,7 +708,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
|
||||
def mass_flow(self, p_2: float, p_3: float) -> float:
|
||||
if (
|
||||
isclose(p_2, p_3, rel_tol=1.0e-7, abs_tol=1.0e-9)
|
||||
isclose(p_2, p_3, rel_tol=0.0, abs_tol=1.0e-8)
|
||||
or self.effective_area == 0.0
|
||||
):
|
||||
return 0.0
|
||||
@@ -714,6 +724,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
upstream_temperature=self._upstream_temperature("port_3"),
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=32768)
|
||||
def _one_way_flow_characteristics(
|
||||
self,
|
||||
*,
|
||||
@@ -832,6 +843,16 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
||||
downstream_pressure=downstream_pressure,
|
||||
upstream_temperature=upstream_temperature,
|
||||
)
|
||||
# AMESim reports no vena-contracta velocity while the valve is closed.
|
||||
# Signal propagation around a step can leave a round-off-sized opening,
|
||||
# so apply the same numerical-zero convention to this diagnostic only.
|
||||
if isclose(
|
||||
self.opening,
|
||||
0.0,
|
||||
rel_tol=0.0,
|
||||
abs_tol=_PNVO001_CLOSED_OPENING_ABS_TOL,
|
||||
):
|
||||
gas_velocity = 0.0
|
||||
return {
|
||||
"xv": self.opening,
|
||||
"cm": mass_flow_parameter,
|
||||
@@ -885,6 +906,11 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
|
||||
MODEL_TYPE = "amesim_pnvo001"
|
||||
MODEL_VERSION = "0.2.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||
# Repeat the exact-sum promise on this concrete subclass deliberately.
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.signal("res", nominal_role="input"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
@@ -965,8 +991,9 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
||||
symbol="amesim_pnvo001",
|
||||
ports=(
|
||||
PortDisplaySpec("res", "left", order=5),
|
||||
PortDisplaySpec("port_2", "left", order=10),
|
||||
PortDisplaySpec("port_3", "right", order=20),
|
||||
# AMESim default geometry places port 2 right and port 3 left.
|
||||
PortDisplaySpec("port_2", "right", order=10),
|
||||
PortDisplaySpec("port_3", "left", order=20),
|
||||
),
|
||||
order=35,
|
||||
parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 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 (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
@@ -22,11 +23,124 @@ from app.simulation.core.metadata import (
|
||||
ResultVariableDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.medium import (
|
||||
GasMedium,
|
||||
ThermodynamicProperties,
|
||||
ThermodynamicPropertiesLinearization,
|
||||
)
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class 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_HEAT_EXCHANGE_MODE = ParameterCondition("mode", (2.0,))
|
||||
_DYNAMIC_PIPE_PARAMETER_GROUPS = (
|
||||
@@ -43,12 +157,16 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
"""AMESim PNL00R pneumatic pipe friction resistance.
|
||||
|
||||
The public model exposes the AMESim PNL00R catalog/XML contract and uses
|
||||
an auditable Darcy-Weisbach resistance with Reynolds/roughness-dependent
|
||||
friction. Exact `pn2pipefr_` parity is left for the later model tuning pass.
|
||||
the `pn2pipefr` compressible gas relation with a Reynolds/roughness-
|
||||
dependent equivalent flow coefficient.
|
||||
"""
|
||||
|
||||
MODEL_TYPE = "amesim_pnl00r"
|
||||
MODEL_VERSION = "0.3.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
@@ -156,6 +274,15 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
self.port_1.h_outflow = initial_h
|
||||
self.port_2 = self.register_declared_port("port_2")
|
||||
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
|
||||
def _integer_parameter(name: str, value: float) -> int:
|
||||
@@ -186,7 +313,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
return max(
|
||||
self.medium.temperature_from_pressure_enthalpy(
|
||||
max(port.p, 1.0),
|
||||
port.h_outflow,
|
||||
self._connected_h[port_name],
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
@@ -202,7 +329,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
if reynolds_number <= 0.0:
|
||||
return 64_000_000.0
|
||||
laminar = 64.0 / reynolds_number
|
||||
if reynolds_number <= 2300.0:
|
||||
if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS:
|
||||
return laminar
|
||||
|
||||
# pn2pipefr does not apply the fully rough correction at every
|
||||
@@ -232,10 +359,13 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
turbulent = smooth_turbulent + roughness_weight * (
|
||||
fully_rough - smooth_turbulent
|
||||
)
|
||||
if reynolds_number >= 4000.0:
|
||||
return turbulent
|
||||
fraction = (reynolds_number - 2300.0) / 1700.0
|
||||
return laminar + fraction**0.58 * (turbulent - laminar)
|
||||
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 darcy_pressure_drop(
|
||||
self,
|
||||
@@ -259,6 +389,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
)
|
||||
return magnitude if mass_flow > 0.0 else -magnitude
|
||||
|
||||
@lru_cache(maxsize=32768)
|
||||
def _mass_flow_for_pressure_drop(
|
||||
self,
|
||||
pressure_drop: float,
|
||||
@@ -283,16 +414,20 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
return 0.5 * (lower + upper)
|
||||
|
||||
def mass_flow(self, p_1: float, p_2: float) -> float:
|
||||
if isclose(p_1, p_2, rel_tol=1.0e-7, abs_tol=1.0e-9):
|
||||
if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8):
|
||||
return 0.0
|
||||
pressure_difference = p_1 - p_2
|
||||
upstream_pressure = max(p_1, p_2, 1.0)
|
||||
upstream_temperature = self._port_temperature("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(
|
||||
abs(pressure_difference),
|
||||
density=density,
|
||||
temperature=upstream_temperature,
|
||||
upstream_temperature = self._port_temperature(
|
||||
"port_1" if pressure_difference > 0.0 else "port_2"
|
||||
)
|
||||
magnitude = AmesimPnl0001._one_way_pn2pipefr_mass_flow(
|
||||
self,
|
||||
upstream_pressure=max(p_1, p_2),
|
||||
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
|
||||
|
||||
@@ -304,17 +439,19 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
)
|
||||
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
|
||||
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)
|
||||
cm = (
|
||||
abs(m_flow)
|
||||
* sqrt(upstream_temperature)
|
||||
/ max(self.area * upstream_pressure, 1.0e-18)
|
||||
/ max(flow_coefficient * self.area * upstream_pressure, 1.0e-18)
|
||||
)
|
||||
return {
|
||||
"re": reynolds,
|
||||
"cm": cm,
|
||||
"v": velocity,
|
||||
"ff": self.friction_factor(reynolds),
|
||||
"ff": _reported_friction_factor(friction),
|
||||
}
|
||||
|
||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||
@@ -354,9 +491,16 @@ class AmesimPnl00r(AlgebraicComponent):
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self._connected_h = dict(connected_h)
|
||||
self.port_1.h_outflow = connected_h["port_2"]
|
||||
self.port_2.h_outflow = connected_h["port_1"]
|
||||
|
||||
def update_flow_temperature_references(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> None:
|
||||
self._connected_h = dict(connected_h)
|
||||
|
||||
|
||||
class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
|
||||
@@ -704,6 +848,8 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
downstream_pressure: float,
|
||||
upstream_temperature: float,
|
||||
resistance_length: float,
|
||||
max_iterations: int = 16,
|
||||
analytic_laminar: bool = False,
|
||||
) -> float:
|
||||
"""AMESim pn2pipefr-style compressible friction flow."""
|
||||
|
||||
@@ -774,29 +920,72 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
value *= tanh(max(smoothing_argument, 0.0))
|
||||
return value
|
||||
|
||||
pressure_ratio_flow_parameter = mass_flow_parameter(pressure_ratio)
|
||||
if analytic_laminar:
|
||||
viscosity = self._dynamic_viscosity(T_up)
|
||||
laminar_mass_flow = (
|
||||
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
|
||||
)
|
||||
reynolds_denominator = (
|
||||
pi * self.diam * self._dynamic_viscosity(T_up)
|
||||
if max_iterations > 0 and compiled_reynolds
|
||||
else None
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
def target_flow(mass_flow: float) -> float:
|
||||
reynolds = self.reynolds_number(mass_flow, T_up)
|
||||
friction = self.friction_factor(reynolds)
|
||||
flow_coefficient = sqrt(
|
||||
self.diam / (resistance_length * friction)
|
||||
reynolds = (
|
||||
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 (
|
||||
flow_coefficient
|
||||
sqrt(self.diam / (resistance_length * friction))
|
||||
* self.area
|
||||
* p_up
|
||||
* mass_flow_parameter(pressure_ratio)
|
||||
/ sqrt(T_up)
|
||||
* pressure_ratio_flow_parameter
|
||||
/ sqrt_temperature
|
||||
)
|
||||
|
||||
flow_coefficient = sqrt(self.diam / (resistance_length * 0.02))
|
||||
magnitude = (
|
||||
flow_coefficient
|
||||
sqrt(self.diam / (resistance_length * 0.02))
|
||||
* self.area
|
||||
* p_up
|
||||
* mass_flow_parameter(pressure_ratio)
|
||||
/ sqrt(T_up)
|
||||
* pressure_ratio_flow_parameter
|
||||
/ sqrt_temperature
|
||||
)
|
||||
for _iteration in range(16):
|
||||
for _iteration in range(max_iterations):
|
||||
next_magnitude = target_flow(magnitude)
|
||||
if abs(next_magnitude - magnitude) <= max(
|
||||
1.0e-12,
|
||||
@@ -820,12 +1009,112 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
)
|
||||
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]:
|
||||
props = self.properties()
|
||||
flow = self.mass_flow(self.port_1.p, props.p, props.T)
|
||||
upstream_pressure = max(self.port_1.p, props.p, 1.0)
|
||||
density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12)
|
||||
reynolds = self.reynolds_number(flow, props.T)
|
||||
friction = self.friction_factor(reynolds)
|
||||
return {
|
||||
"m": self.state.m,
|
||||
"U": self.state.U,
|
||||
@@ -835,13 +1124,17 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
"u": props.u,
|
||||
"h": props.h,
|
||||
"re": reynolds,
|
||||
"cm": (
|
||||
abs(flow)
|
||||
* sqrt(props.T)
|
||||
/ max(self.area * upstream_pressure, 1.0e-18)
|
||||
"cm": _bare_pipe_mass_flow_parameter(
|
||||
flow,
|
||||
area=self.area,
|
||||
diameter=self.diam,
|
||||
upstream_pressure=upstream_pressure,
|
||||
upstream_temperature=props.T,
|
||||
resistance_length=self.le,
|
||||
friction_factor=friction,
|
||||
),
|
||||
"v": flow / (density * self.area),
|
||||
"ff": self.friction_factor(reynolds),
|
||||
"ff": _reported_friction_factor(friction),
|
||||
}
|
||||
|
||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||
@@ -851,6 +1144,23 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
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, ...]:
|
||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume)
|
||||
return (
|
||||
@@ -904,12 +1214,105 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
||||
)
|
||||
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):
|
||||
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
|
||||
|
||||
MODEL_TYPE = "amesim_pnl0002"
|
||||
MODEL_VERSION = "0.6.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
@@ -1063,16 +1466,21 @@ class AmesimPnl0002(AmesimPnl0001):
|
||||
1.0e-12,
|
||||
)
|
||||
reynolds = self.reynolds_number(flow, upstream_temperature)
|
||||
friction = self.friction_factor(reynolds)
|
||||
resistance_diagnostics.append(
|
||||
(
|
||||
reynolds,
|
||||
(
|
||||
abs(flow)
|
||||
* sqrt(upstream_temperature)
|
||||
/ max(self.area * upstream_pressure, 1.0e-18)
|
||||
_bare_pipe_mass_flow_parameter(
|
||||
flow,
|
||||
area=self.area,
|
||||
diameter=self.diam,
|
||||
upstream_pressure=upstream_pressure,
|
||||
upstream_temperature=upstream_temperature,
|
||||
resistance_length=self.resistance_length,
|
||||
friction_factor=friction,
|
||||
),
|
||||
abs(flow) / (density * self.area),
|
||||
self.friction_factor(reynolds),
|
||||
friction,
|
||||
)
|
||||
)
|
||||
reynolds, cm, velocity, friction = (
|
||||
@@ -1090,7 +1498,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
||||
"re": reynolds,
|
||||
"cm": cm,
|
||||
"v": velocity,
|
||||
"ff": friction,
|
||||
"ff": _reported_friction_factor(friction),
|
||||
}
|
||||
|
||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||
@@ -1351,6 +1759,16 @@ class AmesimPnl0003(DynamicComponent):
|
||||
viscosity = self._dynamic_viscosity(temperature)
|
||||
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:
|
||||
return AmesimPnl00r.friction_factor(self, reynolds_number)
|
||||
|
||||
@@ -1369,6 +1787,7 @@ class AmesimPnl0003(DynamicComponent):
|
||||
magnitude = friction * (self.le / self.diam) * density * velocity * velocity / 2.0
|
||||
return magnitude if mass_flow > 0.0 else -magnitude
|
||||
|
||||
@lru_cache(maxsize=32768)
|
||||
def _mass_flow_for_pressure_drop(
|
||||
self,
|
||||
pressure_drop: float,
|
||||
@@ -1396,7 +1815,7 @@ class AmesimPnl0003(DynamicComponent):
|
||||
port_1 = self._properties(self.state_1)
|
||||
port_2 = self._properties(self.state_2)
|
||||
pressure_difference = port_1.p - port_2.p
|
||||
if isclose(port_1.p, port_2.p, rel_tol=1.0e-7, abs_tol=1.0e-9):
|
||||
if isclose(port_1.p, port_2.p, rel_tol=0.0, abs_tol=1.0e-8):
|
||||
return 0.0
|
||||
upstream = port_1 if pressure_difference > 0.0 else port_2
|
||||
magnitude = self._mass_flow_for_pressure_drop(
|
||||
@@ -1417,6 +1836,8 @@ class AmesimPnl0003(DynamicComponent):
|
||||
center_flow = self.resistance_mass_flow()
|
||||
upstream = port_1 if center_flow >= 0.0 else port_2
|
||||
reynolds = self.reynolds_number(center_flow, upstream.T)
|
||||
reported_reynolds = self.reported_reynolds_number(center_flow, upstream.T)
|
||||
friction = self.friction_factor(reynolds)
|
||||
return {
|
||||
"m1": self.state_1.m,
|
||||
"U1": self.state_1.U,
|
||||
@@ -1433,14 +1854,18 @@ class AmesimPnl0003(DynamicComponent):
|
||||
"u2": port_2.u,
|
||||
"h2": port_2.h,
|
||||
"dmctr": center_flow,
|
||||
"re": reynolds,
|
||||
"cm": (
|
||||
abs(center_flow)
|
||||
* sqrt(upstream.T)
|
||||
/ max(self.area * max(port_1.p, port_2.p, 1.0), 1.0e-18)
|
||||
"re": reported_reynolds,
|
||||
"cm": _bare_pipe_mass_flow_parameter(
|
||||
center_flow,
|
||||
area=self.area,
|
||||
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),
|
||||
"ff": self.friction_factor(reynolds),
|
||||
"ff": _reported_friction_factor(friction),
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO = 0.05
|
||||
|
||||
|
||||
def _regularized_inverse_outflow(flow: float, transition_flow: float) -> float:
|
||||
"""Return a C1 inverse that tends to zero as a negative flow vanishes."""
|
||||
|
||||
if flow >= 0.0:
|
||||
return 0.0
|
||||
transition_flow = max(float(transition_flow), 1.0e-12)
|
||||
if -flow >= transition_flow:
|
||||
return 1.0 / flow
|
||||
return (
|
||||
flow
|
||||
* (2.0 * transition_flow * transition_flow - flow * flow)
|
||||
/ transition_flow**4
|
||||
)
|
||||
|
||||
|
||||
class _AmesimPneumaticNode(AlgebraicComponent):
|
||||
"""Shared implementation for AMESim pneumatic junction submodels.
|
||||
|
||||
@@ -18,6 +36,7 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
||||
balance, matching the AMESim dh2 causality.
|
||||
"""
|
||||
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
REFERENCE_PORT = "port_2"
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
@@ -101,7 +120,7 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
||||
else self.temperature_reference_h
|
||||
)
|
||||
|
||||
if reference_port.m_flow < -1e-12:
|
||||
if reference_port.m_flow < 0.0:
|
||||
energy_without_reference = sum(
|
||||
port.m_flow
|
||||
* (
|
||||
@@ -112,8 +131,29 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
||||
for name, port in self.ports.items()
|
||||
if name != self.REFERENCE_PORT
|
||||
)
|
||||
non_reference_flow_scale = sum(
|
||||
abs(port.m_flow)
|
||||
for name, port in self.ports.items()
|
||||
if name != self.REFERENCE_PORT
|
||||
)
|
||||
transition_flow = (
|
||||
_REFERENCE_OUTFLOW_REGULARIZATION_RATIO
|
||||
* non_reference_flow_scale
|
||||
)
|
||||
# Port 2 carries AMESim's residual-energy causality. Exact
|
||||
# division is singular when its outflow reverses through zero, so
|
||||
# use a C1 band that matches the exact balance at its boundary and
|
||||
# tends to the mixed enthalpy at zero flow.
|
||||
inverse_flow = _regularized_inverse_outflow(
|
||||
reference_port.m_flow,
|
||||
transition_flow,
|
||||
)
|
||||
energy_residual_at_mixed_h = (
|
||||
energy_without_reference
|
||||
+ reference_port.m_flow * mixed_h
|
||||
)
|
||||
reference_port.h_outflow = (
|
||||
-energy_without_reference / reference_port.m_flow
|
||||
mixed_h - energy_residual_at_mixed_h * inverse_flow
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +162,10 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
||||
|
||||
MODEL_TYPE = "amesim_pn3node2"
|
||||
MODEL_VERSION = "0.3.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
@@ -158,6 +202,10 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
||||
|
||||
MODEL_TYPE = "amesim_p4node2"
|
||||
MODEL_VERSION = "0.3.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import pi
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite, pi
|
||||
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
@@ -18,6 +19,18 @@ from app.simulation.core.ports import PortDefinition
|
||||
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pnrp17Linearization:
|
||||
volume: float
|
||||
volume_flow: float
|
||||
pressure_force: float
|
||||
volume_tangent: tuple[float, ...]
|
||||
volume_flow_tangent: tuple[float, ...]
|
||||
pressure_force_tangent: tuple[float, ...]
|
||||
valid: bool = True
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class AmesimPnrp17(AlgebraicComponent):
|
||||
"""AMESim PNRP17 pneumatic piston with two mechanical faces.
|
||||
|
||||
@@ -28,6 +41,7 @@ class AmesimPnrp17(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "amesim_pnrp17"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||
PortDefinition.mechanical_translational("port_2"),
|
||||
@@ -229,6 +243,53 @@ class AmesimPnrp17(AlgebraicComponent):
|
||||
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
|
||||
return {"port_1": (self.chamber_volume, self.chamber_volume_flow)}
|
||||
|
||||
def linearize_geometry_and_force(
|
||||
self,
|
||||
port_4_x_tangent: Sequence[float],
|
||||
port_5_x_tangent: Sequence[float],
|
||||
port_4_v_tangent: Sequence[float],
|
||||
port_5_v_tangent: Sequence[float],
|
||||
port_1_pressure_tangent: Sequence[float],
|
||||
) -> Pnrp17Linearization:
|
||||
"""Return exact piston geometry and pressure-force tangents."""
|
||||
|
||||
vectors = tuple(
|
||||
tuple(float(value) for value in values)
|
||||
for values in (
|
||||
port_4_x_tangent,
|
||||
port_5_x_tangent,
|
||||
port_4_v_tangent,
|
||||
port_5_v_tangent,
|
||||
port_1_pressure_tangent,
|
||||
)
|
||||
)
|
||||
widths = {len(values) for values in vectors}
|
||||
if len(widths) != 1:
|
||||
raise ValueError("PNRP17 tangent vectors must have equal lengths.")
|
||||
valid = all(isfinite(value) for values in vectors for value in values)
|
||||
area = self.effective_area
|
||||
volume_tangent = tuple(
|
||||
area * (right - left)
|
||||
for left, right in zip(vectors[0], vectors[1], strict=True)
|
||||
)
|
||||
volume_flow_tangent = tuple(
|
||||
area * (right - left)
|
||||
for left, right in zip(vectors[2], vectors[3], strict=True)
|
||||
)
|
||||
pressure_force_tangent = tuple(
|
||||
area * value for value in vectors[4]
|
||||
)
|
||||
return Pnrp17Linearization(
|
||||
volume=self.chamber_volume,
|
||||
volume_flow=self.chamber_volume_flow,
|
||||
pressure_force=self.pressure_force,
|
||||
volume_tangent=volume_tangent,
|
||||
volume_flow_tangent=volume_flow_tangent,
|
||||
pressure_force_tangent=pressure_force_tangent,
|
||||
valid=valid,
|
||||
reason=None if valid else "non_finite_tangent_input",
|
||||
)
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self.port_1.h_outflow = connected_h.get(
|
||||
"port_1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from math import expm1
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import expm1, isfinite
|
||||
|
||||
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||
from app.simulation.core.catalog import (
|
||||
@@ -20,6 +21,24 @@ from app.simulation.core.medium import IdealGasMedium
|
||||
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_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0))
|
||||
_MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0))
|
||||
@@ -723,6 +742,204 @@ class AmesimMecmas21(DynamicComponent):
|
||||
)
|
||||
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]:
|
||||
return {
|
||||
"a": self.acceleration(),
|
||||
@@ -979,6 +1196,112 @@ class AmesimLstp00a(AlgebraicComponent):
|
||||
)
|
||||
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:
|
||||
self._causal_penetration = None
|
||||
self._causal_contact_force = None
|
||||
@@ -1063,6 +1386,9 @@ class AmesimLmechn1(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "amesim_lmechn1"
|
||||
MODEL_VERSION = "0.2.0"
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("force_balance",)
|
||||
)
|
||||
PORTS = tuple(
|
||||
PortDefinition.mechanical_translational(f"port_{index}")
|
||||
for index in range(1, 22)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from math import exp, isfinite, log
|
||||
from typing import ClassVar
|
||||
|
||||
from app.simulation.core.errors import RecoverableTrialStateError
|
||||
@@ -10,8 +10,12 @@ from app.simulation.core.medium import (
|
||||
GasMedium,
|
||||
IdealGasMedium,
|
||||
ThermodynamicProperties,
|
||||
ThermodynamicPropertiesLinearization,
|
||||
ThermodynamicPropertyTangents,
|
||||
)
|
||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from app.simulation.performance import profile_property, record_property_iterations
|
||||
from app.simulation.property_cache import cache_property_calculation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,6 +55,12 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
|
||||
nasa_cp_over_R: ClassVar[float] = 2.5
|
||||
nasa_enthalpy_constant_K: ClassVar[float] = -745.375
|
||||
nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (
|
||||
0.7501594,
|
||||
35.76324,
|
||||
-2212.129,
|
||||
0.9212635,
|
||||
)
|
||||
|
||||
name: str = "AMESimHeliumPengRobinson"
|
||||
R_gas: float = HELIUM_PR.specific_gas_constant
|
||||
@@ -69,6 +79,21 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
del T
|
||||
return self.cv
|
||||
|
||||
def diagnostic_dynamic_viscosity(self, T: float) -> float:
|
||||
"""Return the AMESim NASA-table viscosity used by pipe diagnostics.
|
||||
|
||||
pn2pipefr reports Reynolds number with sagum viscosity. Keep this
|
||||
separate from dynamic_viscosity so matching that diagnostic cannot
|
||||
alter the already-validated pipe flow or friction dynamics.
|
||||
"""
|
||||
|
||||
if T <= 0.0:
|
||||
raise ValueError("Temperature must be positive.")
|
||||
a, b, c, d = self.nasa_viscosity_coefficients
|
||||
return 1.0e-7 * exp(a * log(T) + b / T + c / (T * T) + d)
|
||||
|
||||
@profile_property("density")
|
||||
@cache_property_calculation("density")
|
||||
def density(self, p: float, T: float) -> float:
|
||||
return self.fluid.density(p, T)
|
||||
|
||||
@@ -134,6 +159,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
)
|
||||
return factor, exponent
|
||||
|
||||
@profile_property("isentropic_density_pressure_factor")
|
||||
@cache_property_calculation("isentropic_density_pressure_factor")
|
||||
def isentropic_density_pressure_factor(
|
||||
self,
|
||||
p: float,
|
||||
@@ -166,12 +193,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
raise ValueError("Volume must stay positive.")
|
||||
return self.fluid.pressure_from_density(T, m / V)
|
||||
|
||||
@profile_property("specific_internal_energy")
|
||||
def specific_internal_energy(self, T: float) -> float:
|
||||
return self.R_gas * (
|
||||
(self.nasa_cp_over_R - 1.0) * T
|
||||
+ self.nasa_enthalpy_constant_K
|
||||
)
|
||||
|
||||
@profile_property("specific_internal_energy_at_pressure")
|
||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
||||
density = self.density(p, T)
|
||||
return (
|
||||
@@ -179,12 +208,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
|
||||
)
|
||||
|
||||
@profile_property("specific_enthalpy")
|
||||
def specific_enthalpy(self, T: float) -> float:
|
||||
return self.R_gas * (
|
||||
self.nasa_cp_over_R * T
|
||||
+ self.nasa_enthalpy_constant_K
|
||||
)
|
||||
|
||||
@profile_property("specific_enthalpy_at_pressure")
|
||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
||||
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
|
||||
|
||||
@@ -198,7 +229,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
h / self.R_gas - self.nasa_enthalpy_constant_K
|
||||
) / self.nasa_cp_over_R
|
||||
|
||||
@lru_cache(maxsize=8192)
|
||||
@profile_property("temperature_from_pressure_enthalpy")
|
||||
@cache_property_calculation("temperature_from_pressure_enthalpy")
|
||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
||||
for _iteration in range(16):
|
||||
@@ -211,8 +243,18 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
temperature,
|
||||
1.0,
|
||||
):
|
||||
record_property_iterations(
|
||||
"temperature_from_pressure_enthalpy",
|
||||
_iteration + 1,
|
||||
True,
|
||||
)
|
||||
return next_temperature
|
||||
temperature = next_temperature
|
||||
record_property_iterations(
|
||||
"temperature_from_pressure_enthalpy",
|
||||
16,
|
||||
False,
|
||||
)
|
||||
return temperature
|
||||
|
||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
||||
@@ -222,7 +264,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
)
|
||||
return self.temperature_from_internal_energy(U / m)
|
||||
|
||||
@lru_cache(maxsize=8192)
|
||||
@profile_property("properties_from_mU")
|
||||
@cache_property_calculation("properties_from_mU")
|
||||
def properties_from_mU(
|
||||
self,
|
||||
m: float,
|
||||
@@ -249,6 +292,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
self.temperature_from_internal_energy(target_internal_energy),
|
||||
2.2,
|
||||
)
|
||||
converged = False
|
||||
for _iteration in range(16):
|
||||
residual_internal_energy = (
|
||||
self.fluid.residual_specific_internal_energy_at_density(
|
||||
@@ -267,8 +311,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
1.0,
|
||||
):
|
||||
temperature = next_temperature
|
||||
converged = True
|
||||
break
|
||||
temperature = next_temperature
|
||||
record_property_iterations(
|
||||
"properties_from_mU",
|
||||
_iteration + 1,
|
||||
converged,
|
||||
)
|
||||
pressure = self.fluid.pressure_from_density(temperature, density)
|
||||
return ThermodynamicProperties(
|
||||
p=pressure,
|
||||
@@ -281,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)
|
||||
class AmesimGasPropertyModelSpec:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
from app.simulation.components.amesim.gases import (
|
||||
AMESIM_GAS_INDEX_PARAMETER,
|
||||
@@ -14,11 +16,24 @@ from app.simulation.core.metadata import (
|
||||
ResultVariableDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.medium import (
|
||||
GasMedium,
|
||||
ThermodynamicProperties,
|
||||
ThermodynamicPropertiesLinearization,
|
||||
)
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pnch012DerivativeLinearization:
|
||||
derivative: tuple[float, float]
|
||||
tangents: tuple[tuple[float, ...], tuple[float, ...]]
|
||||
properties: ThermodynamicPropertiesLinearization
|
||||
valid: bool = True
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class AmesimPnch023(ThermodynamicVolumeComponent):
|
||||
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
|
||||
|
||||
@@ -518,6 +533,132 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
||||
energy_derivative -= props.p * self.total_volume_rate()
|
||||
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
|
||||
|
||||
def linearize_state_derivative(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
*,
|
||||
state_mass_tangent: Sequence[float],
|
||||
state_energy_tangent: Sequence[float],
|
||||
external_volume_tangent: Sequence[float],
|
||||
external_volume_rate_tangent: Sequence[float],
|
||||
port_mass_flow_tangents: Mapping[str, Sequence[float]],
|
||||
connected_h_tangents: Mapping[str, Sequence[float]],
|
||||
property_linearization: ThermodynamicPropertiesLinearization | None = None,
|
||||
flow_boundary_tolerance: float = 1.0e-12,
|
||||
) -> Pnch012DerivativeLinearization:
|
||||
"""Linearize the chamber balance while keeping stream modes fixed."""
|
||||
|
||||
port_names = ("port_1", "port_2", "port_3", "port_4")
|
||||
vectors = {
|
||||
"state_mass": tuple(float(value) for value in state_mass_tangent),
|
||||
"state_energy": tuple(float(value) for value in state_energy_tangent),
|
||||
"volume": tuple(float(value) for value in external_volume_tangent),
|
||||
"volume_rate": tuple(
|
||||
float(value) for value in external_volume_rate_tangent
|
||||
),
|
||||
}
|
||||
for port_name in port_names:
|
||||
vectors[f"flow:{port_name}"] = tuple(
|
||||
float(value) for value in port_mass_flow_tangents[port_name]
|
||||
)
|
||||
vectors[f"enthalpy:{port_name}"] = tuple(
|
||||
float(value) for value in connected_h_tangents[port_name]
|
||||
)
|
||||
widths = {len(values) for values in vectors.values()}
|
||||
if len(widths) != 1:
|
||||
raise ValueError("PNCH012 tangent vectors must have equal lengths.")
|
||||
width = len(vectors["state_mass"])
|
||||
invalid_reason: str | None = None
|
||||
if not all(isfinite(value) for values in vectors.values() for value in values):
|
||||
invalid_reason = "non_finite_tangent_input"
|
||||
|
||||
raw_volume = (
|
||||
self.cvol0
|
||||
+ sum(self.external_volumes.values())
|
||||
+ self.connected_external_volume()
|
||||
)
|
||||
minimum_volume = self.cvol0 / 100.0
|
||||
volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18)
|
||||
on_volume_boundary = (
|
||||
abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale
|
||||
)
|
||||
supplied_volume_tangent = vectors["volume"]
|
||||
if raw_volume < minimum_volume or on_volume_boundary:
|
||||
used_volume_tangent = (0.0,) * width
|
||||
used_volume_rate_tangent = (0.0,) * width
|
||||
if on_volume_boundary and any(
|
||||
value != 0.0
|
||||
for value in (
|
||||
*supplied_volume_tangent,
|
||||
*vectors["volume_rate"],
|
||||
)
|
||||
):
|
||||
invalid_reason = invalid_reason or "volume_floor_boundary"
|
||||
else:
|
||||
used_volume_tangent = supplied_volume_tangent
|
||||
used_volume_rate_tangent = vectors["volume_rate"]
|
||||
|
||||
properties = property_linearization or self.medium.linearize_properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.total_volume(),
|
||||
vectors["state_mass"],
|
||||
vectors["state_energy"],
|
||||
used_volume_tangent,
|
||||
)
|
||||
if properties.tangents.width != width:
|
||||
raise ValueError(
|
||||
"PNCH012 property tangent width must match balance tangents."
|
||||
)
|
||||
props = properties.properties
|
||||
if not properties.valid:
|
||||
invalid_reason = invalid_reason or properties.reason
|
||||
|
||||
mass_derivative = sum(
|
||||
self.get_port(port_name).m_flow for port_name in port_names
|
||||
)
|
||||
volume_rate = self.total_volume_rate()
|
||||
energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate
|
||||
mass_tangent = [0.0] * width
|
||||
energy_tangent = [
|
||||
-self.kth * self.sth * properties.tangents.T[index]
|
||||
- volume_rate * properties.tangents.p[index]
|
||||
- props.p * used_volume_rate_tangent[index]
|
||||
for index in range(width)
|
||||
]
|
||||
|
||||
for port_name in port_names:
|
||||
port = self.get_port(port_name)
|
||||
flow_tangent = vectors[f"flow:{port_name}"]
|
||||
if (
|
||||
abs(port.m_flow) <= flow_boundary_tolerance
|
||||
and any(value != 0.0 for value in flow_tangent)
|
||||
):
|
||||
invalid_reason = invalid_reason or (
|
||||
f"flow_direction_boundary:{port_name}"
|
||||
)
|
||||
if port.m_flow > 0.0:
|
||||
inlet_h = connected_h[port_name]
|
||||
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
|
||||
else:
|
||||
inlet_h = props.h
|
||||
inlet_h_tangent = properties.tangents.h
|
||||
energy_derivative += port.m_flow * inlet_h
|
||||
for index in range(width):
|
||||
mass_tangent[index] += flow_tangent[index]
|
||||
energy_tangent[index] += (
|
||||
inlet_h * flow_tangent[index]
|
||||
+ port.m_flow * inlet_h_tangent[index]
|
||||
)
|
||||
|
||||
return Pnch012DerivativeLinearization(
|
||||
derivative=(mass_derivative, energy_derivative),
|
||||
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
|
||||
properties=properties,
|
||||
valid=invalid_reason is None,
|
||||
reason=invalid_reason,
|
||||
)
|
||||
|
||||
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||
pressure = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 元件建模规范与示例
|
||||
|
||||
规范的权威版本位于
|
||||
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
|
||||
[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
|
||||
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
|
||||
修改中同步,不能让示例形成另一套规则。
|
||||
|
||||
@@ -279,4 +279,4 @@ models=(
|
||||
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
||||
|
||||
组件库、分类和自动发现的完整规则参见
|
||||
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
|
||||
[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。
|
||||
@@ -16,6 +16,10 @@ class Orifice(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "orifice"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
@@ -115,4 +119,3 @@ class Orifice(AlgebraicComponent):
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
self.port_a.h_outflow = connected_h["port_b"]
|
||||
self.port_b.h_outflow = connected_h["port_a"]
|
||||
|
||||
@@ -16,6 +16,10 @@ class ResistivePipe(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "pipe"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
|
||||
@@ -14,6 +14,10 @@ class Tee(AlgebraicComponent):
|
||||
|
||||
MODEL_TYPE = "tee"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||
("mass_flow_balance",)
|
||||
)
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||
@@ -21,6 +21,20 @@ if TYPE_CHECKING:
|
||||
class Component(ABC):
|
||||
MODEL_TYPE: ClassVar[str | None] = None
|
||||
MODEL_VERSION: ClassVar[str | None] = None
|
||||
# ``True`` means that pressure/flow residuals read values written by
|
||||
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
|
||||
# ``False`` is an explicit promise that those residuals are independent of
|
||||
# stream propagation. ``None`` keeps custom components conservative: when
|
||||
# they override either stream hook, the closure planner retains the legacy
|
||||
# full-network thermofluid fixed point.
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
|
||||
# Exact residual suffixes whose declared variables are summed, in order,
|
||||
# to form a ``sumToZero`` flow equation. The causal solver deliberately
|
||||
# reads this capability from the concrete class ``__dict__``: subclasses
|
||||
# must repeat the promise after changing any equation semantics.
|
||||
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
|
||||
frozenset[str]
|
||||
] = frozenset()
|
||||
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||
@@ -238,6 +252,18 @@ class Component(ABC):
|
||||
for equation in self.pressure_flow_equation_residuals()
|
||||
)
|
||||
|
||||
def pressure_flow_equation_value_readers(
|
||||
self,
|
||||
) -> Mapping[str, Callable[[], float]]:
|
||||
"""Return explicitly separable scalar residual readers.
|
||||
|
||||
The solver consumes this optional capability only when the concrete
|
||||
component class declares the method itself. Subclasses therefore
|
||||
cannot accidentally inherit an equation-purity promise.
|
||||
"""
|
||||
|
||||
return {}
|
||||
|
||||
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||
"""Update connector outflow properties from current flow directions."""
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from math import isfinite
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from app.simulation.core.errors import RecoverableTrialStateError
|
||||
from app.simulation.performance import profile_property
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -15,6 +17,36 @@ class ThermodynamicProperties:
|
||||
h: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThermodynamicPropertyTangents:
|
||||
"""Directional derivatives of a recovered thermodynamic state."""
|
||||
|
||||
p: tuple[float, ...]
|
||||
T: tuple[float, ...]
|
||||
rho: tuple[float, ...]
|
||||
u: tuple[float, ...]
|
||||
h: tuple[float, ...]
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return len(self.p)
|
||||
|
||||
@classmethod
|
||||
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
|
||||
values = (0.0,) * width
|
||||
return cls(p=values, T=values, rho=values, u=values, h=values)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThermodynamicPropertiesLinearization:
|
||||
"""Primal properties and a validity-checked directional linearization."""
|
||||
|
||||
properties: ThermodynamicProperties
|
||||
tangents: ThermodynamicPropertyTangents
|
||||
valid: bool = True
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class GasMedium(Protocol):
|
||||
"""Thermodynamic contract required by pneumatic components.
|
||||
|
||||
@@ -49,6 +81,8 @@ class GasMedium(Protocol):
|
||||
|
||||
def dynamic_viscosity(self, T: float) -> float: ...
|
||||
|
||||
def diagnostic_dynamic_viscosity(self, T: float) -> float: ...
|
||||
|
||||
def specific_internal_energy(self, T: float) -> float: ...
|
||||
|
||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ...
|
||||
@@ -74,6 +108,18 @@ class GasMedium(Protocol):
|
||||
V: float,
|
||||
) -> ThermodynamicProperties: ...
|
||||
|
||||
def linearize_properties_from_mU(
|
||||
self,
|
||||
m: float,
|
||||
U: float,
|
||||
V: float,
|
||||
dm: Sequence[float],
|
||||
dU: Sequence[float],
|
||||
dV: Sequence[float],
|
||||
*,
|
||||
properties: ThermodynamicProperties | None = None,
|
||||
) -> ThermodynamicPropertiesLinearization: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdealGasMedium:
|
||||
@@ -108,9 +154,11 @@ class IdealGasMedium:
|
||||
def cv_at_temperature(self, T: float) -> float:
|
||||
return self.cp_at_temperature(T) - self.R_gas
|
||||
|
||||
@profile_property("density")
|
||||
def density(self, p: float, T: float) -> float:
|
||||
return p / (self.R_gas * T)
|
||||
|
||||
@profile_property("isentropic_density_pressure_factor")
|
||||
def isentropic_density_pressure_factor(
|
||||
self,
|
||||
p: float,
|
||||
@@ -123,6 +171,7 @@ class IdealGasMedium:
|
||||
cv = self.cv_at_temperature(T)
|
||||
return cv / cp
|
||||
|
||||
@profile_property("dynamic_viscosity")
|
||||
def dynamic_viscosity(self, T: float) -> float:
|
||||
"""Return dynamic viscosity using the default air Sutherland law."""
|
||||
|
||||
@@ -135,6 +184,17 @@ class IdealGasMedium:
|
||||
/ (T + self.sutherland_constant)
|
||||
)
|
||||
|
||||
def diagnostic_dynamic_viscosity(self, T: float) -> float:
|
||||
"""Return the viscosity convention used by derived diagnostics.
|
||||
|
||||
Most media use the same transport property for dynamics and reported
|
||||
diagnostics. Reference-library media may override this without
|
||||
changing a calibrated constitutive flow relation.
|
||||
"""
|
||||
|
||||
return self.dynamic_viscosity(T)
|
||||
|
||||
@profile_property("specific_internal_energy")
|
||||
def specific_internal_energy(self, T: float) -> float:
|
||||
delta_T = T - self.T_ref
|
||||
return (
|
||||
@@ -143,10 +203,12 @@ class IdealGasMedium:
|
||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
||||
)
|
||||
|
||||
@profile_property("specific_internal_energy_at_pressure")
|
||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
||||
del p
|
||||
return self.specific_internal_energy(T)
|
||||
|
||||
@profile_property("specific_enthalpy")
|
||||
def specific_enthalpy(self, T: float) -> float:
|
||||
delta_T = T - self.T_ref
|
||||
return (
|
||||
@@ -155,6 +217,7 @@ class IdealGasMedium:
|
||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
||||
)
|
||||
|
||||
@profile_property("specific_enthalpy_at_pressure")
|
||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
||||
del p
|
||||
return self.specific_enthalpy(T)
|
||||
@@ -191,6 +254,7 @@ class IdealGasMedium:
|
||||
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
||||
return self.T_ref + delta_T
|
||||
|
||||
@profile_property("temperature_from_pressure_enthalpy")
|
||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||
del p
|
||||
return self.temperature_from_enthalpy(h)
|
||||
@@ -207,6 +271,7 @@ class IdealGasMedium:
|
||||
raise ValueError("Volume must stay positive.")
|
||||
return m * self.R_gas * T / V
|
||||
|
||||
@profile_property("properties_from_mU")
|
||||
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
||||
T = self.temperature_from_mass_internal_energy(m, U)
|
||||
p = self.pressure(m, T, V)
|
||||
@@ -214,3 +279,100 @@ class IdealGasMedium:
|
||||
u = U / m
|
||||
h = self.specific_enthalpy(T)
|
||||
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
|
||||
|
||||
def linearize_properties_from_mU(
|
||||
self,
|
||||
m: float,
|
||||
U: float,
|
||||
V: float,
|
||||
dm: Sequence[float],
|
||||
dU: Sequence[float],
|
||||
dV: Sequence[float],
|
||||
*,
|
||||
properties: ThermodynamicProperties | None = None,
|
||||
) -> ThermodynamicPropertiesLinearization:
|
||||
"""Linearize properties_from_mU for several seed directions."""
|
||||
|
||||
dm_values = tuple(float(value) for value in dm)
|
||||
dU_values = tuple(float(value) for value in dU)
|
||||
dV_values = tuple(float(value) for value in dV)
|
||||
if not (len(dm_values) == len(dU_values) == len(dV_values)):
|
||||
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
|
||||
props = properties or self.properties_from_mU(m, U, V)
|
||||
width = len(dm_values)
|
||||
expected_density = m / V
|
||||
expected_internal_energy = U / m
|
||||
if (
|
||||
abs(props.rho - expected_density)
|
||||
> 1.0e-12 * max(abs(expected_density), 1.0)
|
||||
or abs(props.u - expected_internal_energy)
|
||||
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
|
||||
):
|
||||
return ThermodynamicPropertiesLinearization(
|
||||
properties=props,
|
||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||
valid=False,
|
||||
reason="properties_primal_mismatch",
|
||||
)
|
||||
if not all(
|
||||
isfinite(value)
|
||||
for values in (dm_values, dU_values, dV_values)
|
||||
for value in values
|
||||
):
|
||||
return ThermodynamicPropertiesLinearization(
|
||||
properties=props,
|
||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||
valid=False,
|
||||
reason="non_finite_tangent_input",
|
||||
)
|
||||
|
||||
cv = self.cv_at_temperature(props.T)
|
||||
cp = self.cp_at_temperature(props.T)
|
||||
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
|
||||
return ThermodynamicPropertiesLinearization(
|
||||
properties=props,
|
||||
tangents=ThermodynamicPropertyTangents.zeros(width),
|
||||
valid=False,
|
||||
reason="non_positive_heat_capacity",
|
||||
)
|
||||
|
||||
drho: list[float] = []
|
||||
du: list[float] = []
|
||||
dT: list[float] = []
|
||||
dp: list[float] = []
|
||||
dh: list[float] = []
|
||||
for mass_tangent, energy_tangent, volume_tangent in zip(
|
||||
dm_values,
|
||||
dU_values,
|
||||
dV_values,
|
||||
strict=True,
|
||||
):
|
||||
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
|
||||
internal_energy_tangent = (
|
||||
energy_tangent / m - U * mass_tangent / (m * m)
|
||||
)
|
||||
temperature_tangent = internal_energy_tangent / cv
|
||||
pressure_tangent = self.R_gas * (
|
||||
props.T * density_tangent + props.rho * temperature_tangent
|
||||
)
|
||||
enthalpy_tangent = cp * temperature_tangent
|
||||
drho.append(density_tangent)
|
||||
du.append(internal_energy_tangent)
|
||||
dT.append(temperature_tangent)
|
||||
dp.append(pressure_tangent)
|
||||
dh.append(enthalpy_tangent)
|
||||
|
||||
tangent_values = (*drho, *du, *dT, *dp, *dh)
|
||||
valid = all(isfinite(value) for value in tangent_values)
|
||||
return ThermodynamicPropertiesLinearization(
|
||||
properties=props,
|
||||
tangents=ThermodynamicPropertyTangents(
|
||||
p=tuple(dp),
|
||||
T=tuple(dT),
|
||||
rho=tuple(drho),
|
||||
u=tuple(du),
|
||||
h=tuple(dh),
|
||||
),
|
||||
valid=valid,
|
||||
reason=None if valid else "non_finite_property_tangent",
|
||||
)
|
||||
@@ -5,6 +5,8 @@ from app.simulation.core.errors import RecoverableTrialStateError
|
||||
from dataclasses import dataclass
|
||||
from math import acos, cos, isfinite, log, pi, sqrt
|
||||
|
||||
from app.simulation.performance import profile_property
|
||||
|
||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
||||
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
|
||||
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
|
||||
@@ -97,6 +99,11 @@ class PengRobinsonFluid:
|
||||
) -> float:
|
||||
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
|
||||
|
||||
@profile_property(
|
||||
"pressure_from_molar_volume",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
if molar_volume <= self.b_parameter:
|
||||
@@ -107,11 +114,21 @@ class PengRobinsonFluid:
|
||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
||||
return repulsive - attractive
|
||||
|
||||
@profile_property(
|
||||
"pressure_from_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
||||
if density <= 0.0:
|
||||
raise ValueError("Density must be positive.")
|
||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
||||
|
||||
@profile_property(
|
||||
"pressure_temperature_derivative_at_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_temperature_derivative_at_density(
|
||||
self,
|
||||
temperature: float,
|
||||
@@ -132,6 +149,11 @@ class PengRobinsonFluid:
|
||||
- self.attractive_parameter_temperature_derivative(temperature) / denominator
|
||||
)
|
||||
|
||||
@profile_property(
|
||||
"pressure_density_derivative_at_temperature",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_density_derivative_at_temperature(
|
||||
self,
|
||||
temperature: float,
|
||||
@@ -165,6 +187,11 @@ class PengRobinsonFluid:
|
||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
||||
return A, B
|
||||
|
||||
@profile_property(
|
||||
"compressibility_roots",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
||||
A, B = self.reduced_parameters(pressure, temperature)
|
||||
coefficients = (
|
||||
@@ -178,6 +205,11 @@ class PengRobinsonFluid:
|
||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
||||
return physical_roots
|
||||
|
||||
@profile_property(
|
||||
"compressibility_factor",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def compressibility_factor(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -193,6 +225,11 @@ class PengRobinsonFluid:
|
||||
return roots[-1]
|
||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
||||
|
||||
@profile_property(
|
||||
"molar_volume",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def molar_volume(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -202,6 +239,7 @@ class PengRobinsonFluid:
|
||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
||||
|
||||
@profile_property("density", layer="kernel", minimum_mode="audit")
|
||||
def density(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -210,6 +248,11 @@ class PengRobinsonFluid:
|
||||
) -> float:
|
||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
||||
|
||||
@profile_property(
|
||||
"residual_specific_enthalpy",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def residual_specific_enthalpy(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -239,6 +282,11 @@ class PengRobinsonFluid:
|
||||
)
|
||||
return residual_molar_enthalpy / self.molar_mass
|
||||
|
||||
@profile_property(
|
||||
"residual_specific_internal_energy_at_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def residual_specific_internal_energy_at_density(
|
||||
self,
|
||||
temperature: float,
|
||||
@@ -268,6 +316,11 @@ class PengRobinsonFluid:
|
||||
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
|
||||
return residual_molar_internal_energy / self.molar_mass
|
||||
|
||||
@profile_property(
|
||||
"residual_isochoric_heat_capacity_at_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def residual_isochoric_heat_capacity_at_density(
|
||||
self,
|
||||
temperature: float,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,7 +15,7 @@ DATA_DIR = (
|
||||
)
|
||||
|
||||
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
|
||||
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "data"
|
||||
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
|
||||
MODELICA_TESTMODEL_RESULT_PATH = (
|
||||
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
"""Low-overhead, run-local performance instrumentation for simulations.
|
||||
|
||||
The profiling mode is intentionally read once when this module is imported.
|
||||
``standard`` records low-frequency pipeline stages, while ``audit`` also wraps
|
||||
hot RHS/property operations and computes exact-input reuse metrics. With
|
||||
profiling disabled, decorators return the original callable while classes are
|
||||
being defined, so ordinary simulation calls do not pass through a wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
import inspect
|
||||
import os
|
||||
import struct
|
||||
from time import perf_counter_ns
|
||||
from typing import Any, Literal, ParamSpec, TypeVar, cast
|
||||
|
||||
|
||||
ProfileMode = Literal["off", "standard", "audit"]
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2}
|
||||
|
||||
|
||||
def _read_startup_mode() -> ProfileMode:
|
||||
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
|
||||
aliases: dict[str, ProfileMode] = {
|
||||
"": "off",
|
||||
"0": "off",
|
||||
"false": "off",
|
||||
"no": "off",
|
||||
"off": "off",
|
||||
"1": "standard",
|
||||
"true": "standard",
|
||||
"yes": "standard",
|
||||
"on": "standard",
|
||||
"standard": "standard",
|
||||
"audit": "audit",
|
||||
}
|
||||
try:
|
||||
return aliases[raw_mode]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
|
||||
) from exc
|
||||
|
||||
|
||||
PROFILE_MODE: ProfileMode = _read_startup_mode()
|
||||
|
||||
|
||||
def _minimum_mode(value: str) -> ProfileMode:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in _MODE_RANK:
|
||||
raise ValueError("minimum_mode must be one of: off, standard, audit.")
|
||||
return cast(ProfileMode, normalized)
|
||||
|
||||
|
||||
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
||||
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
|
||||
# ``minimum_mode="off"`` by mistake.
|
||||
return (
|
||||
PROFILE_MODE != "off"
|
||||
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TimingStats:
|
||||
calls: int = 0
|
||||
inclusive_ns: int = 0
|
||||
self_ns: int = 0
|
||||
max_ns: int = 0
|
||||
errors: int = 0
|
||||
|
||||
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
|
||||
self.calls += 1
|
||||
self.inclusive_ns += inclusive_ns
|
||||
self.self_ns += self_ns
|
||||
self.max_ns = max(self.max_ns, inclusive_ns)
|
||||
if error:
|
||||
self.errors += 1
|
||||
|
||||
def snapshot(self) -> dict[str, int]:
|
||||
return {
|
||||
"calls": self.calls,
|
||||
"inclusiveNs": self.inclusive_ns,
|
||||
"selfNs": self.self_ns,
|
||||
"maxNs": self.max_ns,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PropertyStats(_TimingStats):
|
||||
operation: str = ""
|
||||
layer: str = "semantic"
|
||||
medium: str = "unknown"
|
||||
exact_input_unique: int = 0
|
||||
exact_input_repeats: int = 0
|
||||
iteration_calls: int = 0
|
||||
iteration_total: int = 0
|
||||
iteration_max: int = 0
|
||||
iteration_converged: int = 0
|
||||
iteration_nonconverged: int = 0
|
||||
cache_lookups: int = 0
|
||||
cache_hits: int = 0
|
||||
cache_misses: int = 0
|
||||
|
||||
def snapshot(self, *, audit: bool) -> dict[str, object]:
|
||||
result: dict[str, object] = super().snapshot()
|
||||
result.update(
|
||||
{
|
||||
"operation": self.operation,
|
||||
"layer": self.layer,
|
||||
"medium": self.medium,
|
||||
"cacheLookups": self.cache_lookups,
|
||||
"cacheHits": self.cache_hits,
|
||||
"cacheMisses": self.cache_misses,
|
||||
}
|
||||
)
|
||||
if audit:
|
||||
result.update(
|
||||
{
|
||||
"exactInputUnique": self.exact_input_unique,
|
||||
"exactInputRepeats": self.exact_input_repeats,
|
||||
"iterationCalls": self.iteration_calls,
|
||||
"iterationTotal": self.iteration_total,
|
||||
"iterationMax": self.iteration_max,
|
||||
"iterationConverged": self.iteration_converged,
|
||||
"iterationNonconverged": self.iteration_nonconverged,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ActiveSpan:
|
||||
trace: PerformanceTrace
|
||||
name: str
|
||||
started_ns: int
|
||||
property_key: str | None = None
|
||||
property_operation: str | None = None
|
||||
property_outermost: bool = False
|
||||
child_ns: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceTrace:
|
||||
"""Mutable counters owned by exactly one :func:`profile_run` context."""
|
||||
|
||||
mode: ProfileMode
|
||||
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
|
||||
_properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False)
|
||||
_property_outermost_ns: int = field(default=0, repr=False)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.mode != "off"
|
||||
|
||||
def _property_stats(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
operation: str,
|
||||
layer: str,
|
||||
medium: str,
|
||||
) -> _PropertyStats:
|
||||
stats = self._properties.get(key)
|
||||
if stats is None:
|
||||
stats = _PropertyStats(
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
)
|
||||
self._properties[key] = stats
|
||||
return stats
|
||||
|
||||
def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None:
|
||||
self_ns = max(0, elapsed_ns - frame.child_ns)
|
||||
if frame.property_key is None:
|
||||
stats = self._phases.setdefault(frame.name, _TimingStats())
|
||||
else:
|
||||
layer, medium, operation = frame.property_key.split("|", 2)
|
||||
stats = self._property_stats(
|
||||
frame.property_key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
)
|
||||
if frame.property_outermost:
|
||||
self._property_outermost_ns += elapsed_ns
|
||||
stats.record(elapsed_ns, self_ns, error)
|
||||
|
||||
def _record_exact_input(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
operation: str,
|
||||
layer: str,
|
||||
medium: str,
|
||||
fingerprint: object,
|
||||
) -> None:
|
||||
stats = self._property_stats(
|
||||
key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
)
|
||||
shadow_key = (key, fingerprint)
|
||||
shadow = _PROPERTY_SHADOW.get()
|
||||
if shadow is None:
|
||||
# A trace normally installs its own set in ``profile_run``. Keep
|
||||
# the ContextVar default immutable so no task can accidentally
|
||||
# share a process-global shadow set.
|
||||
shadow = set()
|
||||
_PROPERTY_SHADOW.set(shadow)
|
||||
if shadow_key in shadow:
|
||||
stats.exact_input_repeats += 1
|
||||
else:
|
||||
shadow.add(shadow_key)
|
||||
stats.exact_input_unique += 1
|
||||
|
||||
def _record_iterations(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
operation: str,
|
||||
layer: str,
|
||||
medium: str,
|
||||
iterations: int,
|
||||
converged: bool,
|
||||
) -> None:
|
||||
stats = self._property_stats(
|
||||
key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
)
|
||||
iteration_count = max(0, int(iterations))
|
||||
stats.iteration_calls += 1
|
||||
stats.iteration_total += iteration_count
|
||||
stats.iteration_max = max(stats.iteration_max, iteration_count)
|
||||
if converged:
|
||||
stats.iteration_converged += 1
|
||||
else:
|
||||
stats.iteration_nonconverged += 1
|
||||
|
||||
def _record_cache(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
operation: str,
|
||||
layer: str,
|
||||
medium: str,
|
||||
hits: int,
|
||||
misses: int,
|
||||
) -> None:
|
||||
stats = self._property_stats(
|
||||
key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
)
|
||||
hit_delta = max(0, hits)
|
||||
miss_delta = max(0, misses)
|
||||
stats.cache_hits += hit_delta
|
||||
stats.cache_misses += miss_delta
|
||||
stats.cache_lookups += hit_delta + miss_delta
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
"""Return a detached, JSON-serializable copy of all counters."""
|
||||
|
||||
audit = self.mode == "audit"
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"phases": {
|
||||
name: self._phases[name].snapshot()
|
||||
for name in sorted(self._phases)
|
||||
},
|
||||
"properties": {
|
||||
_public_property_key(key): self._properties[key].snapshot(audit=audit)
|
||||
for key in sorted(self._properties)
|
||||
},
|
||||
"propertyOutermostNs": self._property_outermost_ns,
|
||||
}
|
||||
|
||||
|
||||
_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar(
|
||||
"simulation_performance_trace",
|
||||
default=None,
|
||||
)
|
||||
_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar(
|
||||
"simulation_performance_spans",
|
||||
default=(),
|
||||
)
|
||||
_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar(
|
||||
"simulation_property_shadow",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def _public_property_key(key: str) -> str:
|
||||
layer, medium, operation = key.split("|", 2)
|
||||
return f"{layer}.{medium}.{operation}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _tracked_span(
|
||||
trace: PerformanceTrace,
|
||||
name: str,
|
||||
*,
|
||||
property_key: str | None = None,
|
||||
property_operation: str | None = None,
|
||||
reset_property_shadow: bool = False,
|
||||
) -> Generator[None, None, None]:
|
||||
stack = _ACTIVE_SPANS.get()
|
||||
property_outermost = property_key is not None and not any(
|
||||
item.property_key is not None for item in stack
|
||||
)
|
||||
frame = _ActiveSpan(
|
||||
trace=trace,
|
||||
name=name,
|
||||
started_ns=perf_counter_ns(),
|
||||
property_key=property_key,
|
||||
property_operation=property_operation,
|
||||
property_outermost=property_outermost,
|
||||
)
|
||||
stack_token = _ACTIVE_SPANS.set((*stack, frame))
|
||||
shadow_token: Token[set[object] | None] | None = None
|
||||
if reset_property_shadow and trace.mode == "audit":
|
||||
shadow_token = _PROPERTY_SHADOW.set(set())
|
||||
error = False
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
error = True
|
||||
raise
|
||||
finally:
|
||||
elapsed_ns = max(0, perf_counter_ns() - frame.started_ns)
|
||||
_ACTIVE_SPANS.reset(stack_token)
|
||||
if shadow_token is not None:
|
||||
_PROPERTY_SHADOW.reset(shadow_token)
|
||||
if stack:
|
||||
stack[-1].child_ns += elapsed_ns
|
||||
trace._record_span(frame, elapsed_ns, error)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def profile_run() -> Generator[PerformanceTrace, None, None]:
|
||||
"""Create and bind an isolated trace for one simulation run.
|
||||
|
||||
The yielded trace remains usable after the context exits, which lets the
|
||||
caller attach ``trace.snapshot()`` to a result without exposing live state.
|
||||
"""
|
||||
|
||||
trace = PerformanceTrace(mode=PROFILE_MODE)
|
||||
trace_token = _CURRENT_TRACE.set(trace)
|
||||
spans_token = _ACTIVE_SPANS.set(())
|
||||
shadow_token = _PROPERTY_SHADOW.set(set())
|
||||
try:
|
||||
if trace.enabled:
|
||||
with _tracked_span(trace, "simulation.total"):
|
||||
yield trace
|
||||
else:
|
||||
yield trace
|
||||
finally:
|
||||
_PROPERTY_SHADOW.reset(shadow_token)
|
||||
_ACTIVE_SPANS.reset(spans_token)
|
||||
_CURRENT_TRACE.reset(trace_token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def performance_span(
|
||||
name: str,
|
||||
minimum_mode: str = "standard",
|
||||
reset_property_shadow: bool = False,
|
||||
) -> Generator[None, None, None]:
|
||||
"""Time a block in the current run, or act as a no-op outside one."""
|
||||
|
||||
minimum = _minimum_mode(minimum_mode)
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None or not _mode_enabled(minimum):
|
||||
yield
|
||||
return
|
||||
with _tracked_span(
|
||||
trace,
|
||||
name,
|
||||
reset_property_shadow=reset_property_shadow,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def profile_phase(
|
||||
name: str,
|
||||
minimum_mode: str = "standard",
|
||||
reset_property_shadow: bool = False,
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||
"""Decorate a simulation phase while preserving the off-mode callable."""
|
||||
|
||||
minimum = _minimum_mode(minimum_mode)
|
||||
|
||||
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
if not _mode_enabled(minimum):
|
||||
return function
|
||||
|
||||
if inspect.iscoroutinefunction(function):
|
||||
|
||||
@wraps(function)
|
||||
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None:
|
||||
return await function(*args, **kwargs)
|
||||
with _tracked_span(
|
||||
trace,
|
||||
name,
|
||||
reset_property_shadow=reset_property_shadow,
|
||||
):
|
||||
return await function(*args, **kwargs)
|
||||
|
||||
return cast(Callable[_P, _R], async_wrapper)
|
||||
|
||||
@wraps(function)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None:
|
||||
return function(*args, **kwargs)
|
||||
with _tracked_span(
|
||||
trace,
|
||||
name,
|
||||
reset_property_shadow=reset_property_shadow,
|
||||
):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def _medium_name(args: tuple[object, ...]) -> str:
|
||||
if not args:
|
||||
return "unknown"
|
||||
owner = args[0]
|
||||
configured_name = getattr(owner, "name", None)
|
||||
if isinstance(configured_name, str) and configured_name:
|
||||
return configured_name
|
||||
return type(owner).__name__
|
||||
|
||||
|
||||
def _fingerprint(value: object) -> object:
|
||||
"""Build a hashable, bit-exact token without retaining arbitrary objects."""
|
||||
|
||||
if value is None or isinstance(value, (bool, int, str, bytes)):
|
||||
return (type(value).__name__, value)
|
||||
if isinstance(value, float):
|
||||
return ("float64", struct.pack("!d", value))
|
||||
if isinstance(value, tuple):
|
||||
return ("tuple", tuple(_fingerprint(item) for item in value))
|
||||
if isinstance(value, list):
|
||||
return ("list", tuple(_fingerprint(item) for item in value))
|
||||
if isinstance(value, Mapping):
|
||||
items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()]
|
||||
items.sort(key=repr)
|
||||
return ("mapping", tuple(items))
|
||||
return (
|
||||
"object",
|
||||
type(value).__module__,
|
||||
type(value).__qualname__,
|
||||
id(value),
|
||||
)
|
||||
|
||||
|
||||
def _input_fingerprint(
|
||||
signature: inspect.Signature | None,
|
||||
args: tuple[object, ...],
|
||||
kwargs: dict[str, object],
|
||||
) -> object:
|
||||
if signature is not None:
|
||||
try:
|
||||
bound = signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
return tuple(
|
||||
(name, _fingerprint(value))
|
||||
for name, value in bound.arguments.items()
|
||||
)
|
||||
except TypeError:
|
||||
pass
|
||||
return (
|
||||
_fingerprint(args),
|
||||
tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())),
|
||||
)
|
||||
|
||||
|
||||
def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None:
|
||||
cache_info = getattr(function, "cache_info", None)
|
||||
if not callable(cache_info):
|
||||
return None
|
||||
try:
|
||||
info = cache_info()
|
||||
return int(info.hits), int(info.misses)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None:
|
||||
for attribute in ("cache_clear", "cache_info", "cache_parameters"):
|
||||
value = getattr(source, attribute, None)
|
||||
if value is not None:
|
||||
setattr(target, attribute, value)
|
||||
|
||||
|
||||
def profile_property(
|
||||
operation: str,
|
||||
layer: str = "semantic",
|
||||
minimum_mode: str = "audit",
|
||||
capture_inputs: bool = True,
|
||||
track_cache: bool = False,
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||
"""Decorate one thermodynamic property operation."""
|
||||
|
||||
minimum = _minimum_mode(minimum_mode)
|
||||
|
||||
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
if not _mode_enabled(minimum):
|
||||
return function
|
||||
try:
|
||||
signature: inspect.Signature | None = inspect.signature(function)
|
||||
except (TypeError, ValueError):
|
||||
signature = None
|
||||
|
||||
def prepare(
|
||||
args: tuple[object, ...],
|
||||
kwargs: dict[str, object],
|
||||
) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]:
|
||||
trace = _CURRENT_TRACE.get()
|
||||
medium = _medium_name(args)
|
||||
key = f"{layer}|{medium}|{operation}"
|
||||
if trace is not None and trace.mode == "audit" and capture_inputs:
|
||||
trace._record_exact_input(
|
||||
key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
fingerprint=_input_fingerprint(signature, args, kwargs),
|
||||
)
|
||||
before = _cache_counts(function) if trace is not None and track_cache else None
|
||||
return trace, medium, key, before
|
||||
|
||||
def finish_cache(
|
||||
trace: PerformanceTrace | None,
|
||||
medium: str,
|
||||
key: str,
|
||||
before: tuple[int, int] | None,
|
||||
) -> None:
|
||||
if trace is None or before is None:
|
||||
return
|
||||
after = _cache_counts(function)
|
||||
if after is None:
|
||||
return
|
||||
trace._record_cache(
|
||||
key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
hits=after[0] - before[0],
|
||||
misses=after[1] - before[1],
|
||||
)
|
||||
|
||||
if inspect.iscoroutinefunction(function):
|
||||
|
||||
@wraps(function)
|
||||
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||
object_args = cast(tuple[object, ...], args)
|
||||
object_kwargs = cast(dict[str, object], kwargs)
|
||||
trace, medium, key, before = prepare(object_args, object_kwargs)
|
||||
try:
|
||||
if trace is None:
|
||||
return await function(*args, **kwargs)
|
||||
with _tracked_span(
|
||||
trace,
|
||||
f"property.{_public_property_key(key)}",
|
||||
property_key=key,
|
||||
property_operation=operation,
|
||||
):
|
||||
return await function(*args, **kwargs)
|
||||
finally:
|
||||
finish_cache(trace, medium, key, before)
|
||||
|
||||
_copy_cache_api(function, async_wrapper)
|
||||
return cast(Callable[_P, _R], async_wrapper)
|
||||
|
||||
@wraps(function)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
object_args = cast(tuple[object, ...], args)
|
||||
object_kwargs = cast(dict[str, object], kwargs)
|
||||
trace, medium, key, before = prepare(object_args, object_kwargs)
|
||||
try:
|
||||
if trace is None:
|
||||
return function(*args, **kwargs)
|
||||
with _tracked_span(
|
||||
trace,
|
||||
f"property.{_public_property_key(key)}",
|
||||
property_key=key,
|
||||
property_operation=operation,
|
||||
):
|
||||
return function(*args, **kwargs)
|
||||
finally:
|
||||
finish_cache(trace, medium, key, before)
|
||||
|
||||
_copy_cache_api(function, wrapper)
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def record_property_iterations(
|
||||
operation: str,
|
||||
iterations: int,
|
||||
converged: bool,
|
||||
) -> None:
|
||||
"""Record inverse-property solver iterations in audit mode."""
|
||||
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None or trace.mode != "audit":
|
||||
return
|
||||
for frame in reversed(_ACTIVE_SPANS.get()):
|
||||
if (
|
||||
frame.property_key is not None
|
||||
and frame.property_operation == operation
|
||||
):
|
||||
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
||||
trace._record_iterations(
|
||||
frame.property_key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
iterations=iterations,
|
||||
converged=converged,
|
||||
)
|
||||
return
|
||||
key = f"semantic|unknown|{operation}"
|
||||
trace._record_iterations(
|
||||
key,
|
||||
operation=operation,
|
||||
layer="semantic",
|
||||
medium="unknown",
|
||||
iterations=iterations,
|
||||
converged=converged,
|
||||
)
|
||||
|
||||
|
||||
def record_property_cache(operation: str, *, hit: bool) -> None:
|
||||
"""Record one run-local property-cache lookup in audit mode."""
|
||||
|
||||
trace = _CURRENT_TRACE.get()
|
||||
if trace is None or trace.mode != "audit":
|
||||
return
|
||||
for frame in reversed(_ACTIVE_SPANS.get()):
|
||||
if (
|
||||
frame.property_key is not None
|
||||
and frame.property_operation == operation
|
||||
):
|
||||
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
||||
trace._record_cache(
|
||||
frame.property_key,
|
||||
operation=operation,
|
||||
layer=layer,
|
||||
medium=medium,
|
||||
hits=1 if hit else 0,
|
||||
misses=0 if hit else 1,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROFILE_MODE",
|
||||
"PerformanceTrace",
|
||||
"performance_span",
|
||||
"profile_phase",
|
||||
"profile_property",
|
||||
"profile_run",
|
||||
"record_property_cache",
|
||||
"record_property_iterations",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,234 @@
|
||||
"""Run-local, exact-key cache for expensive thermodynamic calculations.
|
||||
|
||||
The cache is deliberately bound to one simulation through ``ContextVar``.
|
||||
That keeps concurrent runs isolated and releases all cached states when the
|
||||
run finishes. Keys use the original Python values with no rounding or
|
||||
tolerance-based reuse that could flatten numerical residuals seen by ODE and
|
||||
nonlinear solvers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache, wraps
|
||||
import os
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
from app.simulation.performance import PROFILE_MODE, record_property_cache
|
||||
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192
|
||||
|
||||
|
||||
def _read_cache_enabled() -> bool:
|
||||
raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower()
|
||||
if raw_value in {"", "1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw_value in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ValueError(
|
||||
"SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0."
|
||||
)
|
||||
|
||||
|
||||
PROPERTY_CACHE_ENABLED = _read_cache_enabled()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PropertyCacheInfo:
|
||||
hits: int
|
||||
misses: int
|
||||
max_entries_per_cache: int
|
||||
cache_count: int
|
||||
current_entries: int
|
||||
evictions: int
|
||||
|
||||
|
||||
class SimulationPropertyCache:
|
||||
"""Bounded C-level LRUs owned by one simulation run."""
|
||||
|
||||
def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None:
|
||||
if max_entries <= 0:
|
||||
raise ValueError("Property cache max_entries must be positive.")
|
||||
self.max_entries_per_cache = int(max_entries)
|
||||
self._functions: dict[
|
||||
tuple[str, int, Callable[..., object]],
|
||||
Callable[..., object],
|
||||
] = {}
|
||||
self._failed_misses: dict[
|
||||
tuple[str, int, Callable[..., object]],
|
||||
int,
|
||||
] = {}
|
||||
self._owners: dict[int, tuple[object, int]] = {}
|
||||
self._next_owner_token = 0
|
||||
|
||||
def owner_token(self, owner: object) -> int:
|
||||
"""Return a stable identity token and retain its owner for this run."""
|
||||
|
||||
identity = id(owner)
|
||||
existing = self._owners.get(identity)
|
||||
if existing is not None and existing[0] is owner:
|
||||
return existing[1]
|
||||
self._next_owner_token += 1
|
||||
self._owners[identity] = (owner, self._next_owner_token)
|
||||
return self._next_owner_token
|
||||
|
||||
def get_or_compute(
|
||||
self,
|
||||
operation: str,
|
||||
owner: object,
|
||||
function: Callable[..., _R],
|
||||
args: tuple[object, ...],
|
||||
kwargs: dict[str, object],
|
||||
) -> _R:
|
||||
cache_key = (operation, self.owner_token(owner), function)
|
||||
cached_function = self._functions.get(cache_key)
|
||||
if cached_function is None:
|
||||
|
||||
@lru_cache(maxsize=self.max_entries_per_cache, typed=True)
|
||||
def invoke(*cached_args: object, **cached_kwargs: object) -> _R:
|
||||
return function(owner, *cached_args, **cached_kwargs)
|
||||
|
||||
cached_function = invoke
|
||||
self._functions[cache_key] = cached_function
|
||||
|
||||
if PROFILE_MODE != "audit":
|
||||
try:
|
||||
return cached_function(*args, **kwargs)
|
||||
except Exception:
|
||||
self._failed_misses[cache_key] = (
|
||||
self._failed_misses.get(cache_key, 0) + 1
|
||||
)
|
||||
raise
|
||||
|
||||
before = cached_function.cache_info() # type: ignore[attr-defined]
|
||||
try:
|
||||
value = cached_function(*args, **kwargs)
|
||||
except Exception:
|
||||
self._failed_misses[cache_key] = (
|
||||
self._failed_misses.get(cache_key, 0) + 1
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
after = cached_function.cache_info() # type: ignore[attr-defined]
|
||||
hit = after.hits > before.hits
|
||||
record_property_cache(operation, hit=hit)
|
||||
return value
|
||||
|
||||
def info(self) -> PropertyCacheInfo:
|
||||
cache_infos = {
|
||||
key: cached.cache_info() # type: ignore[attr-defined]
|
||||
for key, cached in self._functions.items()
|
||||
}
|
||||
return PropertyCacheInfo(
|
||||
hits=sum(info.hits for info in cache_infos.values()),
|
||||
misses=sum(info.misses for info in cache_infos.values()),
|
||||
max_entries_per_cache=self.max_entries_per_cache,
|
||||
cache_count=len(self._functions),
|
||||
current_entries=sum(info.currsize for info in cache_infos.values()),
|
||||
evictions=sum(
|
||||
max(
|
||||
0,
|
||||
info.misses
|
||||
- self._failed_misses.get(key, 0)
|
||||
- info.currsize,
|
||||
)
|
||||
for key, info in cache_infos.items()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar(
|
||||
"simulation_property_cache",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def current_property_cache() -> SimulationPropertyCache | None:
|
||||
return _CURRENT_PROPERTY_CACHE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def property_cache_run(
|
||||
*,
|
||||
max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES,
|
||||
) -> Generator[SimulationPropertyCache | None, None, None]:
|
||||
"""Bind a fresh cache to one top-level simulation run.
|
||||
|
||||
Nested uses reuse the existing cache so lower-level simulation helpers can
|
||||
safely opt in without replacing the cache created by the API entry point.
|
||||
"""
|
||||
|
||||
existing = _CURRENT_PROPERTY_CACHE.get()
|
||||
if existing is not None:
|
||||
yield existing
|
||||
return
|
||||
if not PROPERTY_CACHE_ENABLED:
|
||||
yield None
|
||||
return
|
||||
|
||||
cache = SimulationPropertyCache(max_entries=max_entries)
|
||||
token = _CURRENT_PROPERTY_CACHE.set(cache)
|
||||
try:
|
||||
yield cache
|
||||
finally:
|
||||
_CURRENT_PROPERTY_CACHE.reset(token)
|
||||
|
||||
|
||||
def cache_property_calculation(
|
||||
operation: str,
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||
"""Cache one pure property calculation with hashable arguments per run."""
|
||||
|
||||
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
if not PROPERTY_CACHE_ENABLED:
|
||||
return function
|
||||
|
||||
@wraps(function)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
cache = _CURRENT_PROPERTY_CACHE.get()
|
||||
if cache is None:
|
||||
return function(*args, **kwargs)
|
||||
owner = args[0] if args else function
|
||||
return cache.get_or_compute(
|
||||
operation,
|
||||
owner,
|
||||
function,
|
||||
tuple(args[1:] if args else ()),
|
||||
dict(kwargs),
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
"""Ensure a simulation entry point has a run-local cache."""
|
||||
|
||||
if not PROPERTY_CACHE_ENABLED:
|
||||
return function
|
||||
|
||||
@wraps(function)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
with property_cache_run():
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROPERTY_CACHE_MAX_ENTRIES",
|
||||
"PROPERTY_CACHE_ENABLED",
|
||||
"PropertyCacheInfo",
|
||||
"SimulationPropertyCache",
|
||||
"cache_property_calculation",
|
||||
"current_property_cache",
|
||||
"property_cache_run",
|
||||
"with_property_cache",
|
||||
]
|
||||
@@ -23,6 +23,8 @@ class TestMqlComparisonMetric:
|
||||
max_abs_error: float
|
||||
mean_abs_error: float
|
||||
max_rel_error: float
|
||||
undefined_rel_error_count: int
|
||||
near_zero_baseline_count: int
|
||||
final_abs_error: float
|
||||
|
||||
|
||||
@@ -44,6 +46,10 @@ class TestMqlComparisonResult:
|
||||
def max_rel_error(self) -> float:
|
||||
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
|
||||
|
||||
@property
|
||||
def undefined_rel_error_count(self) -> int:
|
||||
return sum(metric.undefined_rel_error_count for metric in self.metrics)
|
||||
|
||||
|
||||
class TestMqlComparisonError(ValueError):
|
||||
"""Raised when Python and AMESim series cannot be aligned."""
|
||||
@@ -57,6 +63,16 @@ def compare_test_mql_series(
|
||||
data_paths: tuple[str, ...] | list[str] | None = None,
|
||||
relative_floor: float = 1.0e-12,
|
||||
) -> TestMqlComparisonResult:
|
||||
"""Compare current values directly with AMESim simulation values.
|
||||
|
||||
``relative_floor`` only identifies near-zero baselines for reporting. It is
|
||||
never substituted into the relative-error denominator. An exact zero
|
||||
AMESim baseline has undefined relative error and is counted separately;
|
||||
absolute error remains available for judgement.
|
||||
"""
|
||||
|
||||
if relative_floor < 0.0:
|
||||
raise TestMqlComparisonError("relative_floor cannot be negative.")
|
||||
_validate_time_axis(python_times)
|
||||
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
|
||||
metrics = []
|
||||
@@ -70,11 +86,18 @@ def compare_test_mql_series(
|
||||
amesim_values = amesim_results.series(data_path)
|
||||
abs_errors = []
|
||||
rel_errors = []
|
||||
undefined_rel_error_count = 0
|
||||
near_zero_baseline_count = 0
|
||||
for time_value, python_value in zip(python_times, python_values):
|
||||
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
|
||||
abs_error = abs(python_value - amesim_value)
|
||||
abs_errors.append(abs_error)
|
||||
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
|
||||
if abs(amesim_value) <= relative_floor:
|
||||
near_zero_baseline_count += 1
|
||||
if amesim_value == 0.0:
|
||||
undefined_rel_error_count += 1
|
||||
else:
|
||||
rel_errors.append(abs_error / abs(amesim_value))
|
||||
final_amesim_value = interpolate_series_value(
|
||||
amesim_results.times,
|
||||
amesim_values,
|
||||
@@ -87,6 +110,8 @@ def compare_test_mql_series(
|
||||
max_abs_error=max(abs_errors, default=0.0),
|
||||
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
|
||||
max_rel_error=max(rel_errors, default=0.0),
|
||||
undefined_rel_error_count=undefined_rel_error_count,
|
||||
near_zero_baseline_count=near_zero_baseline_count,
|
||||
final_abs_error=abs(python_values[-1] - final_amesim_value),
|
||||
)
|
||||
)
|
||||
@@ -153,8 +178,19 @@ def write_test_mql_comparison_csv(
|
||||
float(time_value),
|
||||
)
|
||||
abs_error = abs(python_value - amesim_value)
|
||||
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
|
||||
row.extend([python_value, amesim_value, abs_error, rel_error])
|
||||
rel_error = (
|
||||
None
|
||||
if amesim_value == 0.0
|
||||
else abs_error / abs(amesim_value)
|
||||
)
|
||||
row.extend(
|
||||
[
|
||||
python_value,
|
||||
amesim_value,
|
||||
abs_error,
|
||||
"" if rel_error is None else rel_error,
|
||||
]
|
||||
)
|
||||
writer.writerow(row)
|
||||
|
||||
summary_lines = [
|
||||
@@ -163,6 +199,7 @@ def write_test_mql_comparison_csv(
|
||||
f"max_abs_error={metric.max_abs_error:.12g}, "
|
||||
f"mean_abs_error={metric.mean_abs_error:.12g}, "
|
||||
f"max_rel_error={metric.max_rel_error:.12%}, "
|
||||
f"undefined_rel_error_count={metric.undefined_rel_error_count}, "
|
||||
f"final_abs_error={metric.final_abs_error:.12g}"
|
||||
)
|
||||
for metric in comparison.metrics
|
||||
|
||||
+1934
-94
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 dataclasses import dataclass
|
||||
from math import isfinite
|
||||
import os
|
||||
from typing import Callable, Literal, Mapping, Sequence
|
||||
|
||||
from app.simulation.components.amesim.mechanical.translational import (
|
||||
AmesimLstp00a,
|
||||
AmesimMecmas21,
|
||||
)
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
@@ -12,15 +15,114 @@ from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
|
||||
MechanicalAbsoluteToleranceMode = Literal["legacy", "contact-aware-v1"]
|
||||
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
|
||||
class MechanicalConstraintGroup:
|
||||
"""MECMAS21 inertias that share one rigid translational coordinate."""
|
||||
|
||||
components: tuple[AmesimMecmas21, ...]
|
||||
mode: ConstraintMode = "uninitialized"
|
||||
contact_components: tuple[AmesimLstp00a, ...] = ()
|
||||
|
||||
@property
|
||||
def representative(self) -> AmesimMecmas21:
|
||||
@@ -361,9 +463,52 @@ class MechanicalStateReducer:
|
||||
roots = (find("x", first_port), find("v", first_port))
|
||||
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(
|
||||
MechanicalConstraintGroup(tuple(components))
|
||||
for components in masses_by_roots.values()
|
||||
MechanicalConstraintGroup(
|
||||
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, ...]:
|
||||
@@ -391,26 +536,253 @@ class MechanicalStateReducer:
|
||||
def has_state_events(self) -> bool:
|
||||
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(
|
||||
self,
|
||||
default: float,
|
||||
*,
|
||||
mechanical: float = 1.0e-12,
|
||||
mode: MechanicalAbsoluteToleranceMode | None = None,
|
||||
) -> 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
|
||||
endstop position across the much smaller unilateral boundary band while
|
||||
constructing finite-difference Jacobians. Mechanical coordinates need
|
||||
a tighter floor; thermodynamic states retain the caller's tolerance.
|
||||
"""
|
||||
values: list[float] = []
|
||||
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
|
||||
return list(
|
||||
self.absolute_tolerance_plan(
|
||||
default,
|
||||
mechanical=mechanical,
|
||||
mode=mode,
|
||||
).values
|
||||
)
|
||||
|
||||
def reset_constraint_modes(self) -> None:
|
||||
for group in self.groups:
|
||||
|
||||
@@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
from app.simulation.core.base import Component
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
@@ -18,34 +21,62 @@ class PneumaticVolumeDiagnostics:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PneumaticVolumeConnectionBinding:
|
||||
connected_endpoint: Endpoint
|
||||
connected_port: PortState
|
||||
|
||||
|
||||
class PneumaticVolumeResolver:
|
||||
"""Propagate AMESim pneumatic external-volume connector variables."""
|
||||
|
||||
def __init__(self, network: SimulationNetwork) -> None:
|
||||
self.network = network
|
||||
self._pneumatic_ports = tuple(
|
||||
component.get_port(definition.name)
|
||||
for component in network.components.values()
|
||||
for definition in component.active_port_definitions
|
||||
if definition.kind == "physical" and definition.domain == "pneumatic"
|
||||
)
|
||||
self._output_components = tuple(
|
||||
component
|
||||
for component in network.components.values()
|
||||
if type(component).pneumatic_volume_outputs
|
||||
is not Component.pneumatic_volume_outputs
|
||||
)
|
||||
self._connected_endpoint = self._build_connection_map()
|
||||
self.last_diagnostics: PneumaticVolumeDiagnostics | None = None
|
||||
|
||||
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
||||
result: dict[Endpoint, Endpoint] = {}
|
||||
def _build_connection_map(
|
||||
self,
|
||||
) -> dict[Endpoint, _PneumaticVolumeConnectionBinding]:
|
||||
result: dict[Endpoint, _PneumaticVolumeConnectionBinding] = {}
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical" or connection.domain != "pneumatic":
|
||||
continue
|
||||
first, second = connection.endpoints
|
||||
result[first] = second
|
||||
result[second] = first
|
||||
result[first] = _PneumaticVolumeConnectionBinding(
|
||||
connected_endpoint=second,
|
||||
connected_port=self.network.components[second.component].get_port(
|
||||
second.port
|
||||
),
|
||||
)
|
||||
result[second] = _PneumaticVolumeConnectionBinding(
|
||||
connected_endpoint=first,
|
||||
connected_port=self.network.components[first.component].get_port(
|
||||
first.port
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
@profile_phase("simulation.pneumatic_volume", minimum_mode="audit")
|
||||
def solve(self) -> PneumaticVolumeDiagnostics:
|
||||
for component in self.network.components.values():
|
||||
for definition in component.active_port_definitions:
|
||||
if definition.kind == "physical" and definition.domain == "pneumatic":
|
||||
port = component.get_port(definition.name)
|
||||
port.volume = 0.0
|
||||
port.volume_flow = 0.0
|
||||
for port in self._pneumatic_ports:
|
||||
port.volume = 0.0
|
||||
port.volume_flow = 0.0
|
||||
|
||||
outputs: dict[Endpoint, tuple[float, float]] = {}
|
||||
for component in self.network.components.values():
|
||||
for component in self._output_components:
|
||||
for port_name, raw_values in component.pneumatic_volume_outputs().items():
|
||||
port = component.get_port(port_name)
|
||||
definition = port.definition
|
||||
@@ -71,18 +102,15 @@ class PneumaticVolumeResolver:
|
||||
|
||||
propagated = 0
|
||||
for endpoint, values in outputs.items():
|
||||
connected = self._connected_endpoint.get(endpoint)
|
||||
if connected is None:
|
||||
binding = self._connected_endpoint.get(endpoint)
|
||||
if binding is None:
|
||||
continue
|
||||
if connected in outputs:
|
||||
if binding.connected_endpoint in outputs:
|
||||
raise ValueError(
|
||||
"A pneumatic connection cannot contain two external-volume "
|
||||
f"sources: {endpoint} and {connected}."
|
||||
f"sources: {endpoint} and {binding.connected_endpoint}."
|
||||
)
|
||||
connected_port = self.network.components[connected.component].get_port(
|
||||
connected.port
|
||||
)
|
||||
connected_port.volume, connected_port.volume_flow = values
|
||||
binding.connected_port.volume, binding.connected_port.volume_flow = values
|
||||
propagated += 1
|
||||
|
||||
diagnostics = PneumaticVolumeDiagnostics(
|
||||
|
||||
@@ -2,8 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.simulation.core.base import Component
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
@@ -37,30 +40,56 @@ class SignalSolveDiagnostics:
|
||||
return {"propagated": self.propagated}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SignalOutputBinding:
|
||||
component: Component
|
||||
evaluate: Callable[[float], dict[str, float]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SignalConnectionBinding:
|
||||
source: PortState
|
||||
target: PortState
|
||||
|
||||
|
||||
class SignalResolver:
|
||||
"""Propagate scalar signal connections from output ports to input ports."""
|
||||
|
||||
def __init__(self, network: SimulationNetwork) -> None:
|
||||
self.network = network
|
||||
self._connections = [
|
||||
connection for connection in network.connections if connection.kind == "signal"
|
||||
]
|
||||
self._output_bindings = tuple(
|
||||
_SignalOutputBinding(component=component, evaluate=evaluate)
|
||||
for component in network.components.values()
|
||||
if (evaluate := getattr(component, "signal_output_values", None)) is not None
|
||||
)
|
||||
self._event_sources = tuple(
|
||||
(component.name, source_event_times)
|
||||
for component in network.components.values()
|
||||
if (
|
||||
source_event_times := getattr(
|
||||
component,
|
||||
"signal_event_times",
|
||||
None,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
self._connections = tuple(
|
||||
self._connection_binding(connection.endpoints)
|
||||
for connection in network.connections
|
||||
if connection.kind == "signal"
|
||||
)
|
||||
self.last_diagnostics: SignalSolveDiagnostics | None = None
|
||||
|
||||
@profile_phase("simulation.signal", minimum_mode="audit")
|
||||
def solve(self, time: float) -> SignalSolveDiagnostics:
|
||||
for component in self.network.components.values():
|
||||
signal_output_values = getattr(component, "signal_output_values", None)
|
||||
if signal_output_values is None:
|
||||
continue
|
||||
for port_name, value in signal_output_values(time).items():
|
||||
component.get_port(port_name).signal = float(value)
|
||||
for binding in self._output_bindings:
|
||||
for port_name, value in binding.evaluate(time).items():
|
||||
binding.component.get_port(port_name).signal = float(value)
|
||||
|
||||
propagated = 0
|
||||
for connection in self._connections:
|
||||
source, target = self._source_target(connection.endpoints)
|
||||
source_port = self.network.components[source.component].get_port(source.port)
|
||||
target_port = self.network.components[target.component].get_port(target.port)
|
||||
target_port.signal = source_port.signal
|
||||
for binding in self._connections:
|
||||
binding.target.signal = binding.source.signal
|
||||
propagated += 1
|
||||
|
||||
diagnostics = SignalSolveDiagnostics(propagated=propagated)
|
||||
@@ -84,15 +113,12 @@ class SignalResolver:
|
||||
return ()
|
||||
|
||||
events: set[float] = set()
|
||||
for component in self.network.components.values():
|
||||
source_event_times = getattr(component, "signal_event_times", None)
|
||||
if source_event_times is None:
|
||||
continue
|
||||
for component_name, source_event_times in self._event_sources:
|
||||
for raw_time in source_event_times(start, stop):
|
||||
event_time = float(raw_time)
|
||||
if not isfinite(event_time):
|
||||
raise ValueError(
|
||||
f"Signal event time from component '{component.name}' must be finite."
|
||||
f"Signal event time from component '{component_name}' must be finite."
|
||||
)
|
||||
if start < event_time < stop:
|
||||
events.add(event_time)
|
||||
@@ -107,3 +133,13 @@ class SignalResolver:
|
||||
if second_port.definition is not None and second_port.definition.nominal_role == "output":
|
||||
return second, first
|
||||
raise ValueError("Signal connection must contain one output endpoint.")
|
||||
|
||||
def _connection_binding(
|
||||
self,
|
||||
endpoints: tuple[Endpoint, Endpoint],
|
||||
) -> _SignalConnectionBinding:
|
||||
source, target = self._source_target(endpoints)
|
||||
return _SignalConnectionBinding(
|
||||
source=self.network.components[source.component].get_port(source.port),
|
||||
target=self.network.components[target.component].get_port(target.port),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.simulation.core.errors import RecoverableTrialStateError
|
||||
from app.simulation.performance import profile_phase
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
@@ -11,6 +12,136 @@ CancellationCheck = Callable[[], bool]
|
||||
AcceptedStepCallback = Callable[[float], None]
|
||||
IntegrationStatus = Literal["completed", "cancelled", "failed"]
|
||||
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)
|
||||
@@ -27,9 +158,13 @@ StateTransitionHandler = Callable[
|
||||
]
|
||||
|
||||
_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
|
||||
|
||||
|
||||
@@ -44,6 +179,29 @@ class SolveIVPConfig:
|
||||
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)
|
||||
class SolverSegmentDiagnostics:
|
||||
"""Work performed by implicit solver instances inside one event segment."""
|
||||
@@ -57,10 +215,22 @@ class SolverSegmentDiagnostics:
|
||||
accepted_step_count: int = 0
|
||||
solver_start_count: int = 0
|
||||
state_transition_count: int = 0
|
||||
state_transition_times: tuple[float, ...] = ()
|
||||
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]:
|
||||
return {
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"startTime": self.start_time,
|
||||
"requestedStopTime": self.requested_stop_time,
|
||||
"simulatedUntil": self.simulated_until,
|
||||
@@ -72,6 +242,143 @@ class SolverSegmentDiagnostics:
|
||||
"stateTransitionCount": self.state_transition_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)
|
||||
@@ -308,7 +615,7 @@ def _runge_kutta_4(
|
||||
for target_time in t_eval[1:]:
|
||||
while current_time < target_time:
|
||||
if cancel_check is not None and cancel_check():
|
||||
raise _IntegrationCancelled
|
||||
raise IntegrationCancelled
|
||||
dt = min(config.max_step, target_time - current_time)
|
||||
k1 = rhs(current_time, state)
|
||||
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
||||
@@ -373,7 +680,7 @@ def _runge_kutta_4(
|
||||
report_step(current_time)
|
||||
|
||||
_append_solution_sample(times, states, target_time, state)
|
||||
except _IntegrationCancelled:
|
||||
except IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = "Simulation was stopped before reaching the requested end time."
|
||||
_append_solution_sample(times, states, current_time, state)
|
||||
@@ -450,7 +757,7 @@ def _runge_kutta_4_segmented(
|
||||
nonlocal current_time, last_transition, same_time_transition_count, state
|
||||
while current_time < target_time:
|
||||
if cancel_check is not None and cancel_check():
|
||||
raise _IntegrationCancelled
|
||||
raise IntegrationCancelled
|
||||
dt = min(config.max_step, target_time - current_time)
|
||||
k1 = rhs(current_time, state)
|
||||
k2 = rhs(
|
||||
@@ -564,7 +871,7 @@ def _runge_kutta_4_segmented(
|
||||
sample_time = float(sample_times[sample_index])
|
||||
_append_solution_sample(times, states, sample_time, state)
|
||||
sample_index += 1
|
||||
except _IntegrationCancelled:
|
||||
except IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = "Simulation was stopped before reaching the requested end time."
|
||||
_append_solution_sample(times, states, current_time, state)
|
||||
@@ -594,6 +901,8 @@ def _integrate_scipy_stepwise(
|
||||
breakpoints: Sequence[float] = (),
|
||||
state_transition_handler: StateTransitionHandler | None = None,
|
||||
jac_sparsity=None,
|
||||
jac: JacobianCallable | None = None,
|
||||
activity_tracker: SolverActivityTracker | None = None,
|
||||
) -> ODESolution:
|
||||
"""Initial stepwise integration path for breakpoints and state resets.
|
||||
|
||||
@@ -616,6 +925,19 @@ def _integrate_scipy_stepwise(
|
||||
solver_type = solver_types.get(config.method)
|
||||
if solver_type is None:
|
||||
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)]
|
||||
states = [[float(value)] for value in initial_state]
|
||||
@@ -631,8 +953,13 @@ def _integrate_scipy_stepwise(
|
||||
|
||||
def cancellable_rhs(time, state):
|
||||
if cancel_check():
|
||||
raise _IntegrationCancelled
|
||||
return rhs(float(time), [float(value) for value in state])
|
||||
raise IntegrationCancelled
|
||||
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"
|
||||
message = "The solver successfully reached the end of the integration interval."
|
||||
@@ -675,13 +1002,19 @@ def _integrate_scipy_stepwise(
|
||||
segment_max_step = float(config.max_step)
|
||||
recoverable_retry_count = 0
|
||||
last_recoverable_error: RecoverableTrialStateError | None = None
|
||||
retry_first_step: float | None = None
|
||||
segment_nfev = 0
|
||||
segment_njev = 0
|
||||
segment_nlu = 0
|
||||
segment_accepted_steps = 0
|
||||
segment_solver_starts = 0
|
||||
segment_state_transitions = 0
|
||||
segment_state_transition_times: list[float] = []
|
||||
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:
|
||||
if cancel_check():
|
||||
@@ -694,11 +1027,14 @@ def _integrate_scipy_stepwise(
|
||||
"atol": config.atol,
|
||||
"max_step": segment_max_step,
|
||||
}
|
||||
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
||||
solver_options["jac_sparsity"] = jac_sparsity
|
||||
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
|
||||
requested_first_step = (
|
||||
0.1 * segment_max_step
|
||||
if last_recoverable_error is not None
|
||||
retry_first_step
|
||||
if retry_first_step is not None
|
||||
else config.first_step
|
||||
)
|
||||
if requested_first_step is not None:
|
||||
@@ -706,8 +1042,20 @@ def _integrate_scipy_stepwise(
|
||||
requested_first_step,
|
||||
integration_end - last_accepted_time,
|
||||
)
|
||||
|
||||
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(
|
||||
cancellable_rhs,
|
||||
last_accepted_time,
|
||||
@@ -715,7 +1063,7 @@ def _integrate_scipy_stepwise(
|
||||
integration_end,
|
||||
**solver_options,
|
||||
)
|
||||
except _IntegrationCancelled:
|
||||
except IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = cancellation_message()
|
||||
break
|
||||
@@ -723,14 +1071,33 @@ def _integrate_scipy_stepwise(
|
||||
recoverable_retry_count += 1
|
||||
segment_recoverable_retries += 1
|
||||
last_recoverable_error = exc
|
||||
next_step = 0.5 * segment_max_step
|
||||
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
|
||||
if recoverable_retry_count > 16 or next_step <= minimum_step:
|
||||
retry_steps = (
|
||||
_recoverable_retry_steps(
|
||||
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"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
break
|
||||
segment_max_step = next_step
|
||||
segment_max_step, retry_first_step = retry_steps
|
||||
continue
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
@@ -753,8 +1120,19 @@ def _integrate_scipy_stepwise(
|
||||
step_start_time = last_accepted_time
|
||||
step_start_state = list(last_accepted_state)
|
||||
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()
|
||||
except _IntegrationCancelled:
|
||||
except IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
message = (
|
||||
"Simulation was stopped before reaching the requested end time."
|
||||
@@ -764,17 +1142,38 @@ def _integrate_scipy_stepwise(
|
||||
recoverable_retry_count += 1
|
||||
segment_recoverable_retries += 1
|
||||
last_recoverable_error = exc
|
||||
attempted_step = segment_max_step
|
||||
next_step = 0.5 * attempted_step
|
||||
minimum_step = 64.0 * math.ulp(
|
||||
max(abs(last_accepted_time), 1.0)
|
||||
retry_steps = (
|
||||
_recoverable_retry_steps(
|
||||
attempted_step,
|
||||
last_accepted_time=last_accepted_time,
|
||||
)
|
||||
if recoverable_retry_count
|
||||
<= _MAX_RECOVERABLE_RETRIES
|
||||
else None
|
||||
)
|
||||
if recoverable_retry_count > 16 or next_step <= minimum_step:
|
||||
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"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
break
|
||||
segment_max_step = next_step
|
||||
segment_max_step, retry_first_step = retry_steps
|
||||
restart_after_recoverable = True
|
||||
break
|
||||
except Exception as exc:
|
||||
@@ -788,27 +1187,72 @@ def _integrate_scipy_stepwise(
|
||||
if last_recoverable_error is not None:
|
||||
recoverable_retry_count += 1
|
||||
segment_recoverable_retries += 1
|
||||
next_step = 0.5 * segment_max_step
|
||||
minimum_step = 64.0 * math.ulp(
|
||||
max(abs(last_accepted_time), 1.0)
|
||||
retry_steps = (
|
||||
_recoverable_retry_steps(
|
||||
attempted_step,
|
||||
last_accepted_time=last_accepted_time,
|
||||
)
|
||||
if recoverable_retry_count
|
||||
<= _MAX_RECOVERABLE_RETRIES
|
||||
else None
|
||||
)
|
||||
if (
|
||||
recoverable_retry_count <= 16
|
||||
and next_step > minimum_step
|
||||
):
|
||||
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
|
||||
break
|
||||
status = "failed"
|
||||
message = str(step_message or "Integration step failed.")
|
||||
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
|
||||
step_end_time = float(solver.t)
|
||||
step_end_state = [float(value) for value in solver.y]
|
||||
crosses_sample = (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= step_end_time
|
||||
)
|
||||
dense_output = (
|
||||
solver.dense_output()
|
||||
if sample_times or state_transition_handler is not None
|
||||
if crosses_sample or state_transition_handler is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -853,6 +1297,9 @@ def _integrate_scipy_stepwise(
|
||||
|
||||
if transition is not None:
|
||||
segment_state_transitions += 1
|
||||
segment_state_transition_times.append(
|
||||
float(transition.time)
|
||||
)
|
||||
try:
|
||||
same_time_transition_count = (
|
||||
_next_same_time_transition_count(
|
||||
@@ -910,18 +1357,17 @@ def _integrate_scipy_stepwise(
|
||||
|
||||
last_accepted_time = step_end_time
|
||||
last_accepted_state = step_end_state
|
||||
recoverable_retry_count = 0
|
||||
reported_time = (
|
||||
float(segment_end)
|
||||
if is_breakpoint and solver.status == "finished"
|
||||
else last_accepted_time
|
||||
)
|
||||
if sample_times:
|
||||
assert dense_output is not None
|
||||
while (
|
||||
sample_index < len(sample_times)
|
||||
and sample_times[sample_index] <= last_accepted_time
|
||||
):
|
||||
assert dense_output is not None
|
||||
sample_time = float(sample_times[sample_index])
|
||||
sample_state = [
|
||||
float(value) for value in dense_output(sample_time)
|
||||
@@ -952,6 +1398,11 @@ def _integrate_scipy_stepwise(
|
||||
if not restart_at_transition:
|
||||
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(
|
||||
SolverSegmentDiagnostics(
|
||||
start_time=float(segment_start_time),
|
||||
@@ -965,7 +1416,41 @@ def _integrate_scipy_stepwise(
|
||||
accepted_step_count=segment_accepted_steps,
|
||||
solver_start_count=segment_solver_starts,
|
||||
state_transition_count=segment_state_transitions,
|
||||
state_transition_times=tuple(
|
||||
segment_state_transition_times
|
||||
),
|
||||
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":
|
||||
@@ -1018,6 +1503,7 @@ def _integrate_scipy_stepwise(
|
||||
)
|
||||
|
||||
|
||||
@profile_phase("simulation.integration")
|
||||
def integrate_ode(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
@@ -1028,6 +1514,9 @@ def integrate_ode(
|
||||
breakpoints: Sequence[float] | None = None,
|
||||
state_transition_handler: StateTransitionHandler | None = 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.
|
||||
|
||||
@@ -1039,8 +1528,39 @@ def integrate_ode(
|
||||
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
|
||||
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 (
|
||||
state_transition_handler is not None
|
||||
and config.t_stop < config.t_start
|
||||
@@ -1064,22 +1584,22 @@ def integrate_ode(
|
||||
except ImportError:
|
||||
if normalized_breakpoints:
|
||||
return _runge_kutta_4_segmented(
|
||||
rhs,
|
||||
integration_rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
normalized_breakpoints,
|
||||
cancel_check,
|
||||
accepted_step_callback,
|
||||
integration_accepted_step_callback,
|
||||
state_transition_handler,
|
||||
)
|
||||
return _runge_kutta_4(
|
||||
rhs,
|
||||
integration_rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
cancel_check,
|
||||
accepted_step_callback,
|
||||
integration_accepted_step_callback,
|
||||
state_transition_handler,
|
||||
)
|
||||
|
||||
@@ -1087,21 +1607,52 @@ def integrate_ode(
|
||||
cancel_check is not None
|
||||
or normalized_breakpoints
|
||||
or state_transition_handler is not None
|
||||
or recoverable_trial_retries
|
||||
):
|
||||
return _integrate_scipy_stepwise(
|
||||
rhs,
|
||||
integration_rhs,
|
||||
initial_state,
|
||||
config,
|
||||
t_eval,
|
||||
cancel_check or (lambda: False),
|
||||
accepted_step_callback,
|
||||
integration_accepted_step_callback,
|
||||
normalized_breakpoints,
|
||||
state_transition_handler,
|
||||
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 = {
|
||||
"fun": rhs,
|
||||
"fun": solve_rhs,
|
||||
"t_span": (config.t_start, config.t_stop),
|
||||
"y0": initial_state,
|
||||
"method": config.method,
|
||||
@@ -1112,6 +1663,11 @@ def integrate_ode(
|
||||
}
|
||||
if config.first_step is not None:
|
||||
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
|
||||
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
|
||||
@@ -2,8 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
from app.simulation.core.base import Component, DynamicComponent
|
||||
from app.simulation.core.ports import PortState
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
class StreamSolveError(RuntimeError):
|
||||
@@ -26,6 +28,14 @@ class StreamSolveDiagnostics:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StreamConnectionBinding:
|
||||
component_name: str
|
||||
port_name: str
|
||||
connected_component: Component
|
||||
connected_port: PortState
|
||||
|
||||
|
||||
class StreamResolver:
|
||||
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
||||
|
||||
@@ -39,28 +49,69 @@ class StreamResolver:
|
||||
self.network = network
|
||||
self.relative_tolerance = relative_tolerance
|
||||
self.max_iterations = max_iterations
|
||||
self._connected_endpoint = self._build_connection_map()
|
||||
self._components = tuple(network.components.values())
|
||||
self._dynamic_components = tuple(
|
||||
component
|
||||
for component in self._components
|
||||
if isinstance(component, DynamicComponent)
|
||||
)
|
||||
self._non_dynamic_components = tuple(
|
||||
component
|
||||
for component in self._components
|
||||
if not isinstance(component, DynamicComponent)
|
||||
)
|
||||
# State ownership and pressure-flow stream sensitivity are independent
|
||||
# classifications. Compile this hook by behavior so algebraic
|
||||
# components such as PNL00R receive their upstream-temperature
|
||||
# references without dispatching a no-op to every component at runtime.
|
||||
self._flow_temperature_reference_components = tuple(
|
||||
component
|
||||
for component in self._components
|
||||
if type(component).update_flow_temperature_references
|
||||
is not Component.update_flow_temperature_references
|
||||
)
|
||||
self._ports = tuple(
|
||||
(component.name, port_name, port)
|
||||
for component in self._components
|
||||
for port_name, port in component.ports.items()
|
||||
)
|
||||
self._connection_bindings = self._build_connection_bindings()
|
||||
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
||||
|
||||
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
||||
result: dict[Endpoint, Endpoint] = {}
|
||||
def _build_connection_bindings(self) -> tuple[_StreamConnectionBinding, ...]:
|
||||
result: list[_StreamConnectionBinding] = []
|
||||
for connection in self.network.connections:
|
||||
if connection.kind != "physical":
|
||||
continue
|
||||
first, second = connection.endpoints
|
||||
result[first] = second
|
||||
result[second] = first
|
||||
return result
|
||||
first_component = self.network.components[first.component]
|
||||
second_component = self.network.components[second.component]
|
||||
result.append(
|
||||
_StreamConnectionBinding(
|
||||
component_name=first.component,
|
||||
port_name=first.port,
|
||||
connected_component=second_component,
|
||||
connected_port=second_component.get_port(second.port),
|
||||
)
|
||||
)
|
||||
result.append(
|
||||
_StreamConnectionBinding(
|
||||
component_name=second.component,
|
||||
port_name=second.port,
|
||||
connected_component=first_component,
|
||||
connected_port=first_component.get_port(first.port),
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
||||
values: dict[str, dict[str, float]] = {
|
||||
component.name: {} for component in self.network.components.values()
|
||||
component.name: {} for component in self._components
|
||||
}
|
||||
for endpoint, connected in self._connected_endpoint.items():
|
||||
connected_port = self.network.components[connected.component].get_port(
|
||||
connected.port
|
||||
for binding in self._connection_bindings:
|
||||
values[binding.component_name][binding.port_name] = (
|
||||
binding.connected_port.h_outflow
|
||||
)
|
||||
values[endpoint.component][endpoint.port] = connected_port.h_outflow
|
||||
return values
|
||||
|
||||
def connected_temperature_reference_enthalpies(
|
||||
@@ -69,52 +120,66 @@ class StreamResolver:
|
||||
"""Return connector references used for upstream temperature only."""
|
||||
|
||||
values: dict[str, dict[str, float]] = {
|
||||
component.name: {} for component in self.network.components.values()
|
||||
component.name: {} for component in self._components
|
||||
}
|
||||
for endpoint, connected in self._connected_endpoint.items():
|
||||
connected_component = self.network.components[connected.component]
|
||||
connected_port = connected_component.get_port(connected.port)
|
||||
values[endpoint.component][endpoint.port] = float(
|
||||
for binding in self._connection_bindings:
|
||||
values[binding.component_name][binding.port_name] = float(
|
||||
getattr(
|
||||
connected_component,
|
||||
binding.connected_component,
|
||||
"temperature_reference_h",
|
||||
connected_port.h_outflow,
|
||||
binding.connected_port.h_outflow,
|
||||
)
|
||||
)
|
||||
return values
|
||||
|
||||
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||
dynamic_components = [
|
||||
component
|
||||
for component in self.network.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
]
|
||||
for component in dynamic_components:
|
||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||
def refresh_flow_temperature_references(self) -> None:
|
||||
"""Refresh pressure-flow property inputs without changing stream outflows."""
|
||||
|
||||
connected = self.connected_temperature_reference_enthalpies()
|
||||
for component in self._flow_temperature_reference_components:
|
||||
component.update_flow_temperature_references(
|
||||
connected[component.name]
|
||||
)
|
||||
|
||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||
def _refresh_dynamic_components(self) -> None:
|
||||
for component in self._dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
|
||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||
def _refresh_stream_components(
|
||||
self,
|
||||
connected: dict[str, dict[str, float]],
|
||||
) -> None:
|
||||
for component in self._non_dynamic_components:
|
||||
component.update_stream_outflows(connected[component.name])
|
||||
|
||||
@profile_phase("simulation.stream", minimum_mode="audit")
|
||||
def solve(
|
||||
self,
|
||||
*,
|
||||
dynamic_ports_are_current: bool = False,
|
||||
) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||
if not dynamic_ports_are_current:
|
||||
self._refresh_dynamic_components()
|
||||
|
||||
max_delta = 0.0
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
previous = {
|
||||
(component.name, port_name): port.h_outflow
|
||||
for component in self.network.components.values()
|
||||
for port_name, port in component.ports.items()
|
||||
(component_name, port_name): port.h_outflow
|
||||
for component_name, port_name, port in self._ports
|
||||
}
|
||||
connected = self.connected_enthalpies()
|
||||
for component in self.network.components.values():
|
||||
if isinstance(component, DynamicComponent):
|
||||
component.refresh_thermodynamic_ports()
|
||||
else:
|
||||
component.update_stream_outflows(connected[component.name])
|
||||
self._refresh_stream_components(connected)
|
||||
|
||||
deltas = [
|
||||
abs(port.h_outflow - previous[(component.name, port_name)])
|
||||
for component in self.network.components.values()
|
||||
for port_name, port in component.ports.items()
|
||||
abs(port.h_outflow - previous[(component_name, port_name)])
|
||||
for component_name, port_name, port in self._ports
|
||||
]
|
||||
magnitudes = [
|
||||
abs(port.h_outflow)
|
||||
for component in self.network.components.values()
|
||||
for port in component.ports.values()
|
||||
for _component_name, _port_name, port in self._ports
|
||||
]
|
||||
max_delta = max(deltas, default=0.0)
|
||||
scale = max(magnitudes + [1.0])
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
}
|
||||
+1208
-214
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
"""Process-local warm-up for the numerical simulation runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import logging
|
||||
import os
|
||||
from threading import Lock
|
||||
from time import perf_counter
|
||||
from typing import Literal
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
WarmupStatus = Literal["completed", "failed", "disabled"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationWarmupReport:
|
||||
status: WarmupStatus
|
||||
duration_ms: float
|
||||
error: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
_WARMUP_LOCK = Lock()
|
||||
_WARMUP_REPORT: SimulationWarmupReport | None = None
|
||||
|
||||
|
||||
def simulation_warmup_enabled() -> bool:
|
||||
raw_value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower()
|
||||
if raw_value in {"", "1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw_value in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ValueError(
|
||||
"SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0."
|
||||
)
|
||||
|
||||
|
||||
def _run_numerical_warmup() -> None:
|
||||
"""Exercise only in-memory SciPy paths used by real simulations."""
|
||||
|
||||
import numpy as np
|
||||
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau, solve_ivp
|
||||
from scipy.optimize import brentq, least_squares
|
||||
from scipy.optimize._numdiff import group_columns
|
||||
from scipy.sparse import csc_matrix, csr_matrix
|
||||
|
||||
# Importing these classes is intentional even though the micro solve below
|
||||
# uses BDF: the stepwise solver selects them dynamically at runtime.
|
||||
solver_types = (BDF, DOP853, LSODA, RK23, RK45, Radau)
|
||||
if len(solver_types) != 6:
|
||||
raise RuntimeError("SciPy solver warm-up did not load every supported method.")
|
||||
|
||||
sparsity = csc_matrix(np.array([[1.0]], dtype=float))
|
||||
groups = group_columns(sparsity)
|
||||
if groups.shape != (1,):
|
||||
raise RuntimeError("SciPy Jacobian grouping warm-up returned an invalid shape.")
|
||||
|
||||
integration = solve_ivp(
|
||||
lambda _time, state: -state,
|
||||
(0.0, 1.0e-4),
|
||||
np.array([1.0], dtype=float),
|
||||
method="BDF",
|
||||
t_eval=np.array([0.0, 1.0e-4], dtype=float),
|
||||
jac_sparsity=sparsity,
|
||||
rtol=1.0e-6,
|
||||
atol=1.0e-9,
|
||||
)
|
||||
if not integration.success or not np.isfinite(integration.y).all():
|
||||
raise RuntimeError("SciPy integration warm-up did not complete successfully.")
|
||||
|
||||
algebraic_sparsity = csr_matrix(np.eye(2, dtype=bool))
|
||||
algebraic = least_squares(
|
||||
lambda state: np.array(
|
||||
[state[0] - 1.0, state[1] - 2.0],
|
||||
dtype=float,
|
||||
),
|
||||
np.array([0.5, 0.5], dtype=float),
|
||||
bounds=(
|
||||
np.array([0.0, 0.0], dtype=float),
|
||||
np.array([3.0, 3.0], dtype=float),
|
||||
),
|
||||
jac_sparsity=algebraic_sparsity,
|
||||
tr_solver="lsmr",
|
||||
)
|
||||
if (
|
||||
not algebraic.success
|
||||
or not np.isfinite(algebraic.x).all()
|
||||
or not np.allclose(algebraic.x, np.array([1.0, 2.0]), atol=1.0e-8)
|
||||
):
|
||||
raise RuntimeError("SciPy algebraic warm-up did not complete successfully.")
|
||||
|
||||
root = brentq(lambda value: value - 0.5, 0.0, 1.0)
|
||||
if abs(root - 0.5) > 1.0e-12:
|
||||
raise RuntimeError("SciPy scalar root warm-up returned an invalid result.")
|
||||
|
||||
# Compile the cached v3 XSD through the same public validation path. The
|
||||
# intentionally incomplete document is never accepted or persisted.
|
||||
from app.system_xml import validate_system_xml_document
|
||||
|
||||
validate_system_xml_document(b"<System/>")
|
||||
|
||||
|
||||
def warm_up_simulation_runtime() -> SimulationWarmupReport:
|
||||
"""Warm one worker exactly once, returning a startup diagnostic report.
|
||||
|
||||
Ordinary warm-up failures are reported but do not prevent the editor and
|
||||
non-simulation APIs from starting. ``MemoryError`` remains fatal because
|
||||
continuing a worker under memory exhaustion is unsafe.
|
||||
"""
|
||||
|
||||
global _WARMUP_REPORT
|
||||
|
||||
with _WARMUP_LOCK:
|
||||
if _WARMUP_REPORT is not None:
|
||||
return _WARMUP_REPORT
|
||||
if not simulation_warmup_enabled():
|
||||
_WARMUP_REPORT = SimulationWarmupReport(
|
||||
status="disabled",
|
||||
duration_ms=0.0,
|
||||
)
|
||||
return _WARMUP_REPORT
|
||||
|
||||
started = perf_counter()
|
||||
try:
|
||||
_run_numerical_warmup()
|
||||
except MemoryError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_WARMUP_REPORT = SimulationWarmupReport(
|
||||
status="failed",
|
||||
duration_ms=(perf_counter() - started) * 1000.0,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
LOGGER.exception("Simulation runtime warm-up failed; startup will continue.")
|
||||
else:
|
||||
_WARMUP_REPORT = SimulationWarmupReport(
|
||||
status="completed",
|
||||
duration_ms=(perf_counter() - started) * 1000.0,
|
||||
)
|
||||
LOGGER.info(
|
||||
"Simulation runtime warm-up completed in %.1f ms.",
|
||||
_WARMUP_REPORT.duration_ms,
|
||||
)
|
||||
return _WARMUP_REPORT
|
||||
|
||||
|
||||
def _reset_simulation_warmup_for_tests() -> None:
|
||||
global _WARMUP_REPORT
|
||||
with _WARMUP_LOCK:
|
||||
_WARMUP_REPORT = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SimulationWarmupReport",
|
||||
"simulation_warmup_enabled",
|
||||
"warm_up_simulation_runtime",
|
||||
]
|
||||
@@ -10,6 +10,7 @@ from typing import Literal
|
||||
from lxml import etree
|
||||
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
from app.simulation.systems.generic import (
|
||||
@@ -191,6 +192,7 @@ class SystemXmlValidationReport:
|
||||
return result
|
||||
|
||||
|
||||
@profile_phase("simulation.xml_validation")
|
||||
def validate_system_xml_document(source: bytes | str) -> SystemXmlValidationReport:
|
||||
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
||||
if not xml_bytes.strip():
|
||||
|
||||
@@ -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
|
||||
setlocal
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
|
||||
title SystemSimulationApp FastAPI - 127.0.0.1:8000
|
||||
|
||||
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe"
|
||||
set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe"
|
||||
|
||||
if not exist "%PYTHON_EXE%" (
|
||||
echo [ERROR] Python virtual environment was not found:
|
||||
echo %PYTHON_EXE%
|
||||
echo.
|
||||
echo Create it and install the backend dependencies first:
|
||||
echo py -3 -m venv "%REPO_ROOT%\.venv-win"
|
||||
echo "%PYTHON_EXE%" -m pip install -r "%REPO_ROOT%\requirements.txt"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
pushd "%REPO_ROOT%" >nul
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Unable to enter the repository directory:
|
||||
echo %REPO_ROOT%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
@@ -27,4 +40,5 @@ if not "%EXIT_CODE%"=="0" (
|
||||
pause
|
||||
)
|
||||
|
||||
popd
|
||||
exit /b %EXIT_CODE%
|
||||
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
|
||||
+24
-47
@@ -1,60 +1,37 @@
|
||||
# 开发文档索引
|
||||
# 文档目录说明
|
||||
|
||||
本目录是 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)
|
||||
新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
|
||||
|
||||
## 模型开发
|
||||
## `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)
|
||||
- [System XML v3 XSD(当前 Schema)](../schemas/system-simulation-v3.xsd)
|
||||
```markdown
|
||||
# 更新日志 YYYY-MM-DD
|
||||
|
||||
新增模型时,模型类和组件库清单是后端事实来源;System XML v3 只保存求解所需的
|
||||
组件实例、模型版本、参数、连接和仿真设置。端口契约由注册模型恢复,画布位置、图标
|
||||
方向等编辑信息只属于工程 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) |
|
||||
## HH:mm
|
||||
|
||||
## AI 使用原则
|
||||
|
||||
- 先读规范和相邻模型,再改代码。
|
||||
- 只从 `library.py` 受控登记公开模型。
|
||||
- 不在前端复制后端端口、参数或默认值作为正式来源。
|
||||
- 不覆盖用户已有改动。
|
||||
- 不自行猜测缺失的物理方程。
|
||||
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
|
||||
- 完成的修改、结果及影响范围。
|
||||
```
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system`
|
||||
|
||||
配套规范:[`component-model-authoring-spec-v1.md`](component-model-authoring-spec-v1.md)
|
||||
配套规范:[`component-model-authoring-spec-v1.md`](../standard/component-model-authoring-spec-v1.md)
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
| `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入名为 `res` 的标量信号输入端口,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 |
|
||||
| `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_pn3node2`,`junctions` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 |
|
||||
| `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_p4node2`,`junctions` | 已接入四端等压、流量守恒基础版;仍需复核 port 2 参考温压和多支路混合。 |
|
||||
| `PNL00R` | 4 | 管路纯阻性摩擦段 | 第一版公开 | `amesim_pnl00r`,`flow` | 已接入准稳态阻性管公开契约;仍需按 AMESim `PNL00R` 参数和摩擦公式复核。 |
|
||||
| `PNL00R` | 4 | 管路纯阻性摩擦段 | 第一版公开 | `amesim_pnl00r`,`flow` | 已按 AMESim `pn2pipefr` 接入可压缩 `Cm/Cq` 关系、同侧上游温度和裸 `Cm` 诊断;使用未修改 AMESim 结果包直接保存的 `Re/ff` 标定 Re≈1400–3400 的 shifted-Hill C2 过渡曲线,并通过固定点、单调性和第八路 `0–0.81 s` 回归。 |
|
||||
| `PNL0001` | 20 | C-R 动态管路 | 第一版公开 | `amesim_pnl0001`,`flow` | 已按公开契约接入两状态管内容积 + port 1 摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 |
|
||||
| `PNL0002` | 8 | R-C-R 动态管路 | 第一版公开 | `amesim_pnl0002`,`flow` | 已按公开契约接入中心两状态容积 + 两端半长摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 |
|
||||
| `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 |
|
||||
@@ -89,6 +89,6 @@
|
||||
## 下一步执行建议
|
||||
|
||||
1. 以 `app.simulation.components.amesim.library` 的 22 个模型为公开清单唯一来源。
|
||||
2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2 / PNL00R` 与动态管路的 AMESim baseline 误差。
|
||||
2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2` 与动态管路的 AMESim baseline 误差;`PNL00R` 保持现有层流、过渡区固定点和单调性回归。
|
||||
3. 每次调整模型都同步补充目录校验、参数边界、System XML 编译和最小仿真测试。
|
||||
4. 用完整或代表性的 `test_mql` 画布校准 `PNRP17 + PNCH012` 的压力、力、位移和容积轨迹,并补齐事件边界语义。
|
||||
File renamed without changes.
@@ -0,0 +1,285 @@
|
||||
# SystemSimulationApp 仿真性能评估(2026-08-15)
|
||||
|
||||
> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。
|
||||
> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。
|
||||
> 2026-08-16 已按本报告建议实现“仿真内独立物性缓存”“worker 启动暖机”、高刚度试探压力边界修复、方程关联块闭合、代数稀疏回退、外部 volume 跨域 ODE Jacobian 修正和 dense output 惰性构造;原始基线数据保留用于对照,当前大型 XML 复验见第 11 节。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
1. **压力—流量闭合是原始基线的首要热点。** 2026-08-15 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加;2026-08-16 已完成方程块与稀疏首轮,当前现状见第 11 节。
|
||||
2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。
|
||||
3. **评估基线已有的两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些数据描述 2026-08-15 的原始基线,后续实现见第 9 节。
|
||||
4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。
|
||||
5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。
|
||||
6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。
|
||||
7. **用户提供的高刚度 XML 已能完成 10 s 仿真。** 原始基线在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;原因是压力优化下界为 1 Pa,排除了 RK45 合法产生的、仍严格大于 0 Pa 的亚帕试探值。2026-08-16 将优化器压力下界放宽到 0 Pa 后,构成方程仍要求压力严格为正,完整 RK45 仿真通过且没有触发可恢复重试。
|
||||
8. **闭合不再固定执行第二次全网压力求解,也不再把一个大物理岛等同于一个求解块。** 每次闭合仍先保证全网成立;stream 更新后,只重算声明为 stream-sensitive 的方程—未知量关联块。物理连通岛只是安全范围,当前 `secondaryBlockCount` 是真实方程块数;无法安全分类的自定义模型会保守回退原全网路径。
|
||||
9. **历史物理岛版收益取决于模型拓扑。** `off` 模式配对测试中,空气链、空气分支、氦气阶跃、机械接触和高刚度短算例分别改善 7.9%、6.5%、0.6%、21.4% 和 14.6%。这些数据保留作纵向基线,但该版已由方程关联块实现取代。
|
||||
10. **大型分支 XML 的 `0.69 s` 现象已定位并完整跑通。** 输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量、74 个 ODE 状态。根因是外部 volume 跨域耦合在 ODE Jacobian 依赖图中漏 12 个实测显著项,而不是线程死锁;修正后结构由 1092 非零/27 色变为 1284 非零/31 色。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009。
|
||||
11. **代数非线性回退已有可信声明图上的稀疏保护链。** 先求本轮未闭合方程块的 union sparse;失败恢复原始 `x0` 后做 global sparse,再失败才做 dense。受控扰动微基准在相同 `max_nfev=20` 下把真实残差回调由 3796 降至 164、墙钟约 7.357 s 降至 0.634 s(约 11.6 倍);活动接触或不可信声明仍走兼容 dense 路径。
|
||||
12. **首轮其他优化均按适用范围解释。** worker 暖机已覆盖 sparse `least_squares` 的 LSMR 路径;dense output 仅在跨采样点或需要状态事件时构造,但本次大型 XML 含状态事件,因此没有本案收益。机械 `atol` 的 `1e-12→1e-10` A/B 约快 16%,但会改变机械误差合同,未采用;外层 thermofluid 流量固定点相对容差的 `1e-12→1e-9` A/B 反而增加 BDF 步数并改变轨迹,也未采用。
|
||||
|
||||
## 2. 埋点实现与污染控制
|
||||
|
||||
性能开关由进程启动环境变量 `SIMULATIONAPP_PROFILE` 决定:
|
||||
|
||||
| 模式 | 用途 | 记录内容 | 适合场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| `off` | 正常运行,默认值 | 不在响应中加入性能数据;装饰器在模块加载时直接返回原函数 | 正式仿真和最终性能对比 |
|
||||
| `standard` | 低开销阶段统计 | XML 校验、网络编译、系统构造、初始化、积分、后处理、结果组装 | 日常定位“大阶段” |
|
||||
| `audit` | 深度审计 | 再展开 RHS、代数闭合、压力流量、stream、刷新、导数和物性内核 | 短算例诊断、调用频率与缓存评估 |
|
||||
|
||||
一次运行使用一个 `ContextVar` 隔离的 `PerformanceTrace`,不会把不同仿真任务的阶段计数混在一起。成功或失败的求解结果在 profiling 模式下都会把快照放入 `diagnostics.performance`。主要字段为:
|
||||
|
||||
- 阶段:`calls`、`inclusiveNs`、`selfNs`、`maxNs`、`errors`;
|
||||
- 物性:上述时间字段,以及介质、操作、缓存查询/命中/未命中;
|
||||
- audit 专有:闭合内精确输入唯一数/重复数、逆解迭代总数/最大值/收敛与未收敛次数;
|
||||
- `propertyOutermostNs`:只累计最外层物性调用,避免把嵌套 PR 内核时间重复相加。
|
||||
|
||||
标准模式只保留低频的大阶段计时。21 次氦气阶跃配对运行中,标准模式相对关闭模式的中位开销为 1.9%;四个短算例分开校准为 0.5%~2.9%。audit 会逐次生成精确指纹并计时,短算例可慢到约 2.5~5 倍,因此 audit 数据用于定位和计数,最终优化收益必须回到 `off` 模式复测。
|
||||
|
||||
将当前代码的 `off` 模式与备份提交 `6a06489` 同时运行 21 次氦气阶跃,墙钟中位数差为约 0.3%,处于本机噪声范围。也就是说,默认关闭时没有观察到稳定的热路径退化。
|
||||
|
||||
## 3. 测试方法
|
||||
|
||||
环境:Windows 11、Python 3.12.3、SciPy 1.18.0、64 位 Intel 处理器。仓库没有 PyInstaller/Nuitka 等可执行文件构建链,本次直接使用项目实际启动后端的 `.venv-win` 解释器。把同一 Python 代码再包成单文件只会混入解包和启动成本,不会使这里的求解内核更接近生产路径。
|
||||
|
||||
基准工具入口:
|
||||
|
||||
```powershell
|
||||
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
|
||||
--mode audit --warmups 1 --runs 3 `
|
||||
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
|
||||
--output app/data/performance-evaluations/helium-step.json
|
||||
```
|
||||
|
||||
工具默认传入取消检查回调,从而走与前端流式仿真相同的低层逐步积分路径。它记录墙钟、进程 CPU、最终 NDJSON 编码、输入 SHA-256 和完整性能快照。原始 JSON 写入被 Git 忽略的 `app/data/performance-evaluations/`,避免把机器相关的大量样本提交到仓库。
|
||||
|
||||
本次代表算例:
|
||||
|
||||
| 算例 | 内容 | 暖机后 `off` 墙钟中位数 | 重复次数 |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `air_chain` | 空气气缸—节流孔—管路—储罐 | 62.4 ms | 9 |
|
||||
| `air_branched` | 空气分支网络 | 130.3 ms | 9 |
|
||||
| `helium_step` | 高压 PR 氦气、信号阶跃阀 | 65.2 ms | 9 |
|
||||
| `mechanical_contact` | MECMAS21/LSTP00A 弹性接触 | 33.0 ms | 9 |
|
||||
| `helium_long` | 10 s PR 氦气均压、501 个输出点 | 10.63 s | 1 |
|
||||
|
||||
短算例先暖机 2 次再测 9 次;缓存 A/B 使用两个同时启动的独立进程各暖机 5 次、测量 21 次,以尽量抵消瞬时系统负载。长算例只测 1 次,因此它只用于判断数量级与阶段占比。
|
||||
|
||||
## 4. 深度阶段结果
|
||||
|
||||
下表时间是 audit 中位数,会包含审计自身开销;调用数和相对热点比绝对时间更可靠。
|
||||
|
||||
| 算例 | RHS | 完整闭合 | 压力流量求解 | 压力流量包含时间占 audit 总时间 | 物性调用 | 闭合内精确重复率 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `air_chain` | 33 | 37 | 74 | 77.0% | 4,265 | 91.2% |
|
||||
| `air_branched` | 23 | 26 | 56 | 84.6% | 7,378 | 96.5% |
|
||||
| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% |
|
||||
| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 |
|
||||
|
||||
这是 2026-08-15 原始基线的闭合数据:当时每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点,理论上最多 26 次;四个算例平均为 2.00、2.15、2.30 和 2.00 次/闭合。随后 2026-08-16 的第一版先把后续重算缩到 stream-sensitive 物理连通岛;该历史版本又被当前方程关联块版取代。当前做法是在安全物理范围内只重算敏感方程实际关联的块,没有敏感块时不强制第二次压力求解。这样裁剪的是无效重算,不是删除真实耦合。
|
||||
|
||||
## 5. 物性调用与缓存结果
|
||||
|
||||
氦气阶跃的冷缓存 audit 代表运行:
|
||||
|
||||
| 操作 | 调用 | 命中/未命中 | 命中率 | 真实逆解次数 | 平均迭代 | 最大迭代 | 未收敛 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 |
|
||||
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 |
|
||||
|
||||
在原始基线中,暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当时的进程级精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
|
||||
|
||||
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,712 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
|
||||
|
||||
空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。
|
||||
|
||||
## 6. 输出与失败样本
|
||||
|
||||
| 算例 | 最终结果大小 | NDJSON 编码中位数 |
|
||||
| --- | ---: | ---: |
|
||||
| `air_chain` | 29.2 KiB | 0.39 ms |
|
||||
| `air_branched` | 59.1 KiB | 1.21 ms |
|
||||
| `helium_step` | 55.5 KiB | 1.10 ms |
|
||||
| `mechanical_contact` | 51.4 KiB | 0.62 ms |
|
||||
| `helium_long` | 507.4 KiB | 18.48 ms |
|
||||
|
||||
用户高刚度 XML 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。原始 audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;其 1.08 s 只代表历史失败路径,不能当作完整模型性能。允许严格正的亚帕试探压力后,同一模型已完成 10 s,当前完整性能结果见第 10 节。
|
||||
|
||||
## 7. 后续优化顺序
|
||||
|
||||
1. **[2026-08-16 已落实方程块与稀疏首轮] 优化压力流量执行计划。** 压力/流量方程、显式赋值、热流依赖和方程—未知量关联图已预编译;每轮 stream 更新后只重算敏感方程块。非线性时先做可信未闭合块的 union sparse,失败从原 `x0` 做 global sparse→dense;自定义、活动接触或结构不安全的网络保守回退兼容路径。后续仍可评估 equality group 真正消元和解析 Jacobian。
|
||||
2. **[2026-08-16 已落实] 减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,并缓存相同输入的密度和等熵因子;沿用精确键、有界容量和按仿真隔离原则。
|
||||
3. **[2026-08-16 已落实] 处理冷启动。** worker 在 FastAPI lifespan 中完成无业务副作用的 SciPy/XSD 微型暖机后再接收请求;不要把约 0.65 s 冷启动归因到每次仿真。
|
||||
4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。
|
||||
5. **[2026-08-16 已落实] 修复高刚度 XML 的压力试探边界。** 优化器允许严格正的亚帕试探值,物理构成方程仍拒绝零压和负压;该模型已用 10 s RK45 回归验证,不需要把这类合法试探误报为可恢复拒步。
|
||||
6. **[2026-08-16 已落实] 补全外部 volume 的跨域 ODE Jacobian 依赖。** 机械位移写入气室容积后,气动储能与机械力平衡必须在状态稀疏图中双向关联;大型分支 XML 据此完整跑通。机械 `atol` 放宽虽有约 16% 的单次改善但改变精度合同;flow 固定点容差放宽反而增加步数,均未采用。
|
||||
7. **[2026-08-16 已落实] dense output 惰性构造。** 仅当已接受步跨越下一样本或需要状态事件时构造插值;含状态事件的模型每步仍需要,不将其宣传为大型分支 XML 的收益来源。
|
||||
|
||||
## 8. 本次评估边界
|
||||
|
||||
- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。
|
||||
- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。
|
||||
- 原始基线的 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告当时均为单任务运行。该并发统计限制已由第 9 节的仿真内独立缓存消除。
|
||||
- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。
|
||||
- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。
|
||||
- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。
|
||||
|
||||
## 9. 2026-08-16 缓存与启动暖机复验
|
||||
|
||||
原先两个函数级 LRU 会在 Python 进程内跨仿真共享条目。现已改为每次仿真通过
|
||||
`ContextVar` 创建独立缓存,并在运行结束后整体释放;并发任务不会共享缓存或
|
||||
命中统计。每个“物性操作 + 介质实例”使用独立的 C 层有界 LRU,默认上限为
|
||||
8192 项。当前只缓存四条有明确重复收益的氦气路径:密度、等熵密度—压力因子、
|
||||
`properties_from_mU` 和 `temperature_from_pressure_enthalpy`。
|
||||
|
||||
同一个高压氦气阶跃算例的冷缓存 audit 结果为:
|
||||
|
||||
| 操作 | 调用 | 命中 / 未命中 | 命中率 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `density` | 578 | 403 / 175 | 69.7% |
|
||||
| `isentropic_density_pressure_factor` | 398 | 310 / 88 | 77.9% |
|
||||
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% |
|
||||
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% |
|
||||
|
||||
四项合计 2,869 次命中、435 次未命中、435 个最终条目,未发生驱逐。PR 三次根
|
||||
计算从原审计的 1,712 次降到 689 次。关闭埋点、各自预热 5 次并测量 21 次时,
|
||||
缓存开启/完全关闭的墙钟中位数在本机分别为 71.6 ms 和 154.0 ms。这个对比表示
|
||||
“四项缓存整体”相对“完全不缓存”的收益,不能误解为相对旧版两个 LRU 又提升
|
||||
53.5%。缓存开关两次运行的完整 `series` 和 `final` SHA-256 一致。
|
||||
|
||||
10 s、501 个输出点的氦气均压算例单次复验中,缓存开启和关闭分别用时
|
||||
14.18 s 与 26.64 s;开启时命中 400,571 次、未命中 96,427 次。四个缓存均达到
|
||||
各自 8192 项上限,共发生 63,659 次 LRU 驱逐,但仍完成到 10 s。该长算例每种
|
||||
配置只测了一次,只能说明容量上限确实生效且仍有收益,不能作为稳定百分比承诺。
|
||||
|
||||
worker 暖机只执行内存中的一维 BDF、`least_squares`、`brentq`、稀疏 Jacobian
|
||||
分组和 XSD 编译,不运行用户模型、不写文件、不填充氦气业务缓存。当前暖机还显式
|
||||
覆盖携带 `jac_sparsity`、使用 sparse LSMR trust-region 子问题的代数路径,避免真实
|
||||
用户任务第一次触发该 SciPy 分支时再承担惰性初始化。三个新进程的
|
||||
中位数为:不暖机首个仿真 709.1 ms;启动暖机本身 637.1 ms;暖机后的首个仿真
|
||||
69.8 ms,同进程第二次约 68~74 ms。也就是说总初始化成本没有消失,而是被
|
||||
移到服务宣告就绪之前。
|
||||
|
||||
## 10. 2026-08-16 历史物理岛版闭合复验
|
||||
|
||||
> 本节保留方程关联块实现之前的物理岛版数据,用于纵向对照。它已不是当前执行计划;当前结果见第 11 节。
|
||||
|
||||
该轮把 signal 源/连接、stream 组件/端口/连接以及气动外部 volume 组件/连接的
|
||||
静态查找移到系统构造阶段;运行时仍按每个状态执行实际传播和热力刷新。压力流量
|
||||
闭合先进行一次全网求解,再根据预编译的依赖声明,只对 stream-sensitive 的物理
|
||||
连通块做后续固定点重算。对未声明依赖的自定义 stream 组件、跨组件方程或非方阵
|
||||
物理岛,执行计划保守回退到原全网求解,不以性能换取模型兼容性。
|
||||
|
||||
该历史版本的 `off` 模式配对结果如下。表中百分比是同一模型、同一数值配置下的墙钟改善,适合
|
||||
判断优化方向,不是跨机器速度承诺:
|
||||
|
||||
| 算例 | 历史物理岛版相对原全网闭合的改善 |
|
||||
| --- | ---: |
|
||||
| `air_chain` | 7.9% |
|
||||
| `air_branched` | 6.5% |
|
||||
| `helium_step` | 0.6% |
|
||||
| `mechanical_contact` | 21.4% |
|
||||
| high-stiffness short | 14.6% |
|
||||
|
||||
`helium_step` 在该历史版本里只有一个需要在 stream 后继续求解的敏感物理岛,因此 0.6% 的改善处于
|
||||
小幅范围;不能用其他无敏感岛模型的收益夸大氦气模型的效果。积分完成后的后处理
|
||||
也没有跳过闭合:每个输出采样点仍重新应用状态、执行完整 `_close_current_state()`
|
||||
并提取结果,只是闭合内部使用同一安全执行计划。
|
||||
|
||||
完整 high-stiffness 10 s 算例的历史 `off` 基线约为 28.126 s;本轮全部改动后的
|
||||
多次运行中位数为 13.694 s。这个跨版本对比同时包含本轮多项改动,不能把差额全部
|
||||
归因于物理岛裁剪。为了单独核对当时的闭合计划,在同版代码上做 optimized/forced-global
|
||||
对照,墙钟分别为 14.676 s 和 16.929 s,完整 `series` 完全一致;这组受控对比才
|
||||
直接反映该模型的执行计划收益。
|
||||
|
||||
当前诊断已经进一步消除旧命名歧义:`secondaryPhysicalIslandCount` 表示安全分类的
|
||||
物理范围,`secondaryBlockCount` 表示方程—未知量关联图中的真实块数,
|
||||
`secondaryUnknownCount` 表示这些方程块合计未知量。`solveCount` 统计实际求解器调用,
|
||||
`closurePassCount` 单列发生过压力求解的闭合 pass;`last` 中还包含
|
||||
`residualEvaluations`、`jacobianMode`、dense/方程块回退状态。不能再把
|
||||
`secondaryBlockCount` 解释为物理岛数。
|
||||
|
||||
## 11. 2026-08-16 大型分支 XML 与方程块首轮复验
|
||||
|
||||
验证输入 `test_mql-full-branches-01-04.xml` 的 SHA-256 为
|
||||
`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`。模型规模如下:
|
||||
|
||||
| 项目 | 数量 |
|
||||
| --- | ---: |
|
||||
| 组件 | 98 |
|
||||
| ODE 状态 | 74 |
|
||||
| 压力/流量/机械代数未知量与方程 | 472 / 472 |
|
||||
| 代数声明图结构非零 | 919(约 0.413%) |
|
||||
| 全部独立代数方程块 | 58 |
|
||||
| stream 后续敏感方程块 | 9,合计 192 个未知量 |
|
||||
|
||||
### 11.1 `0.69 s` 慢区的根因与完整结果
|
||||
|
||||
旧代码在 `0.69 s` 左右不是线程死锁:它仍会缓慢前进,但 BDF 在刚性变化区大量
|
||||
缩步、重建有限差分 Jacobian 和执行 LU。定位出的结构错误是气动外部 volume
|
||||
跨域耦合没有完整进入 ODE 状态依赖图。机械位置先写入气室容积,气室压力又反馈到
|
||||
机械力平衡;旧图只沿普通物理端口追踪,漏掉这条闭环中的 12 个实测显著导数项。
|
||||
|
||||
修正后,状态稀疏图由 1092 个非零项、27 个颜色组变为 1284 个非零项、31 个颜色组。
|
||||
颜色数增加是因为补上了真实依赖,并非回退到更差算法;完整 Jacobian 让 BDF 少走
|
||||
错误 Newton 方向和重复试步。功能收口过程中的较早阶段测量为 92.187 s;最终稳定
|
||||
代码连续三次完整 `0~0.81 s` 分别用时 79.049 s、74.658 s 和 85.103 s,数值工作量一致:
|
||||
|
||||
| 指标 | 结果 |
|
||||
| --- | ---: |
|
||||
| `nfev` | 3393 |
|
||||
| `njev` | 226 |
|
||||
| `nlu` | 667 |
|
||||
| 接受步 | 1009 |
|
||||
| 求解器启动次数 | 3 |
|
||||
| 压力流量求解 | 28008,全部 seeded |
|
||||
| 方程块/dense 非线性回退 | 0 / 0 |
|
||||
| 三次 full-response 规范 JSON SHA-256 | `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f` |
|
||||
| `physical-solution-v1` SHA-256 | `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7` |
|
||||
|
||||
这里的 full-response 哈希覆盖完整响应,所以诊断字段增删也会改变它。为稳定比较物理
|
||||
结果,`physical-solution-v1` 只把 `{status, simulatedUntil, requestedStopTime, series,
|
||||
final}` 投影为待哈希对象;schema 名只是外部标签,不进入对象。两种口径都使用
|
||||
`json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 后计算 SHA-256。
|
||||
旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,响应结构不同,
|
||||
不作为最终结果,也不能与当前口径直接比较。
|
||||
|
||||
容差 A/B 必须分开解释:机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次测试约快
|
||||
16%,但会改变机械状态与事件的误差合同,当前未采用;外层 thermofluid 流量固定点
|
||||
相对容差从 `1e-12` 放宽到 `1e-9` 后,BDF 内部步数反而增加并改变积分轨迹,也未
|
||||
采用。完整成功来自依赖图修正和闭合优化,不是牺牲积分精度或闭合精度。
|
||||
|
||||
### 11.2 stream 方程块与受控 A/B
|
||||
|
||||
第一次压力流量求解仍承担“全网必须成立”的语义;但可信声明图允许它在非线性时只把
|
||||
本轮未闭合的独立方程块合并成一个 union sparse 问题,而不是固定构造 472 变量的
|
||||
dense 问题。stream 更新后的固定点进一步只处理 9 个敏感方程块、合计 192 个未知量,
|
||||
不再因为机械总线把拓扑连成一个大物理岛,就重复求解全部 472 个未知量。
|
||||
|
||||
在相同当前代码、相同 `0~0.01 s` 区间做 optimized/forced-global 配对,墙钟分别为
|
||||
16.200 s 和 18.584 s,物理解与 `series` 逐值一致。这组对照隔离的是后续 stream
|
||||
闭合作用域;它不包含完整 `0.81 s` 慢区的全部收益,不能与最终完整运行直接换算百分比。
|
||||
|
||||
### 11.3 非线性稀疏回退与其他首轮项
|
||||
|
||||
全局非线性回退当前采用兼容保护链:可信声明图先求未闭合方程块的 union sparse;
|
||||
若块解失败,先把所有共享端口未知量恢复到原始 `x0`,再做 global sparse;若 sparse
|
||||
仍未达到既有残差合同,再次从原 `x0` 做 global dense。活动接触会改变坐标/活动集,
|
||||
不可信自定义声明也可能漏依赖,这两类不冒险使用静态稀疏图,继续走 dense 兼容路径。
|
||||
|
||||
受控扰动微基准在相同 `max_nfev=20` 下得到:
|
||||
|
||||
| 路径 | 真实残差回调 | 墙钟 |
|
||||
| --- | ---: | ---: |
|
||||
| dense | 3796 | 7.357 s |
|
||||
| sparse | 164 | 0.634 s |
|
||||
|
||||
同一评估预算下约为 11.6 倍的回退成本改善;两条路径在 20 次优化器评估内都没有收敛,
|
||||
所以这是“数值 Jacobian 试算成本”微基准,不是整体仿真加速承诺。诊断用
|
||||
`residualEvaluations` 记录真实残差回调,避免仅看 SciPy `nfev` 漏掉内部差分调用。
|
||||
|
||||
worker 暖机现已覆盖带 `jac_sparsity` 的 sparse LSMR `least_squares` 路径。逐步积分的
|
||||
dense output 也改为只在当前步跨越下一采样点或需要状态事件定位时构造;本 XML 含
|
||||
状态事件,所以每步仍需要插值,这项优化对最终 79.049 s/74.658 s/85.103 s 复验没有收益。
|
||||
@@ -1,7 +1,7 @@
|
||||
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
|
||||
|
||||
> 调研基线:2026-08-12(System XML v3 迁移后),依据当前仓库代码、配置、说明文档与测试。
|
||||
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。文中没有把静态代码分析冒充 CPU、内存实测。
|
||||
> 调研基线:2026-08-15(System XML v3 迁移后);2026-08-16 已补充压力边界、预编译闭合执行计划、方程关联图分块、代数稀疏回退、ODE Jacobian 修正和实测复验的当前状态。
|
||||
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。机器相关的实测结果单独见[仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)。
|
||||
|
||||
## 0. 三分钟读懂
|
||||
|
||||
@@ -74,12 +74,15 @@
|
||||
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
|
||||
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
|
||||
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
|
||||
4. **[已实现] 每次 RHS 的完整闭合至少调用 2 次、存在外部容积传播时最多调用 3 次压力流量求解。** 积分结束后,每个输出采样点又执行一次完整闭合并提取全部公开结果。这是当前最明确的单任务重复工作来源。
|
||||
5. **[已实现] 代数求解已有因果化快路径。** 压力流量求解器预编译相等组与显式流量计划,种子残差足够小时不调用非线性优化;否则对全局未知向量调用 SciPy `least_squares`,当前未提供解析 Jacobian 或 `jac_sparsity`。
|
||||
4. **[已优化] 每次完整闭合仍先做 1 次语义上的全网压力流量求解,再传播 stream;后续固定点只重算 stream-sensitive 方程实际关联的方程块。** 物理连通岛现在只是安全分类的第一层,真正执行的 `secondaryBlockCount` 是方程—未知量关联图中的块数;无法可信分类的自定义或异常结构仍保守回退原全网路径。积分结束后,每个输出采样点仍执行一次完整闭合并提取结果。
|
||||
5. **[已实现] 代数求解已有因果化快路径、方程块和稀疏 `least_squares` 回退。** 对可信内置声明,求解器从方程—未知量关联图编译 `jac_sparsity`,先把本轮未闭合的独立方程块合并成一个 union sparse 问题;若失败,恢复到同一个原始 `x0`,再执行全局 sparse,仍失败才执行兼容的 dense 路径。活动接触或不可信自定义声明不会冒险稀疏化,保留 dense 兼容路径。当前没有解析 Jacobian,但不再是“完全未提供 `jac_sparsity`”。
|
||||
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
|
||||
7. **[推断] 优化应分两条线:**
|
||||
- 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算;
|
||||
- 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。
|
||||
8. **[已修复] 高刚度 XML 不再因合法亚帕试探压力失败。** 压力优化下界由 1 Pa 放宽到 0 Pa,允许严格正的亚帕试探值,同时构成方程仍拒绝零压和负压;同一 10 s RK45 算例已完成且没有可恢复重试。
|
||||
9. **[历史实测] 闭合裁剪的收益随拓扑明显变化。** 已被方程块版取代的物理岛版短算例 `off` 配对改善为 0.6%~21.4%;其 high-stiffness 10 s optimized/forced-global 对照为 14.676 s/16.929 s,完整 `series` 一致。数据保留作纵向基线,不能当作当前方程块版结果,也不能把其他拓扑的收益外推给单一氦气敏感岛。
|
||||
10. **[实测] 98 组件的大型分支 XML 在 `0.69 s` 附近的长时间停留不是死锁。** 根因是气动外部 volume 跨域耦合在 ODE 有限差分 Jacobian 依赖图中漏了 12 个实测显著项,BDF 因此反复做小步和 Jacobian/LU 工作;修正后结构非零数由 1092 增至 1284、颜色组由 27 增至 31。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009;stream 后续闭合为 9 个方程块、合计 192 个未知量。
|
||||
|
||||
## 2. 证据标签与范围
|
||||
|
||||
@@ -182,18 +185,21 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
||||
|
||||
### 4.3 压力流量因果化与非线性回退
|
||||
|
||||
`PressureFlowSolver` 初始化时预编译 effort 等值组、显式流/力赋值计划和未知量布局(`app/simulation/solvers/algebraic.py:86-106, 637-729`)。每次 `solve()`:
|
||||
`PressureFlowSolver` 初始化时预编译方程模板、effort 等值组、显式流/力赋值计划、未知量布局,以及方程声明到未知量的静态关联图。热流闭合先按物理连通关系划定安全范围,再在可信内置模型中按这张关联图拆成更细的方程块。每次 `solve()`:
|
||||
|
||||
1. 从上一解与当前动态状态播种端口未知量;
|
||||
2. 传播状态拥有的压力、位移和速度;
|
||||
3. 执行可显式求值的构成关系与守恒关系;
|
||||
4. 处理单边接触约束;
|
||||
5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`;
|
||||
6. 否则调用 `scipy.optimize.least_squares`。
|
||||
6. 否则找出残差未闭合或种子不可行的方程块,把这些互相独立的块合并成一次 sparse `least_squares`;
|
||||
7. 方程块失败时恢复原始 `x0`,执行全局 sparse `least_squares`;全局 sparse 仍失败时再次恢复同一个 `x0`,最后执行 dense 兼容回退。
|
||||
|
||||
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`,没有传入解析 Jacobian 或 `jac_sparsity`(`app/simulation/solvers/algebraic.py:771-968`)。
|
||||
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`。可信结构会传入由声明图编译的 `jac_sparsity`;活动单边接触会改变坐标和活动集,自定义组件也可能没有完整依赖声明,因此这些情况继续使用 dense 路径。稀疏/分块是加速层,不会把失败候选带入兼容回退。诊断中的 `evaluations` 是 SciPy 报告的优化器 `nfev`,`residualEvaluations` 才包含数值 Jacobian 在内的真实残差回调数,并通过 `jacobianMode`、`denseFallbackUsed` 和块回退字段说明实际路径。
|
||||
|
||||
**[推断]** 即使快速路径经常命中,固定的多次全网扫描、残差重建和热物性刷新仍会发生;一旦回退到有限差分 `least_squares`,成本会随全局未知量数量快速上升。
|
||||
**[当前边界]** 第一次压力流量求解仍要保证全网所有方程成立,但“全网语义”不再等于“总是把全部未知量交给一个 dense 优化器”:可信声明图可只求本轮未闭合方程块的 union。后续 stream 固定点则进一步只重算敏感方程块。保守条件会恢复全网 sparse→dense 行为;没有可信依赖声明或活动接触时,仍可能直接恢复 dense,成本会随未知量数快速上升。
|
||||
|
||||
在用户提供的 `test_mql-full-branches-01-04.xml`(SHA-256 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`)中,472×472 代数关联矩阵只有 919 个结构非零项,密度约 0.413%,可拆为 58 个独立方程块。一个受控扰动实验在相同 `max_nfev=20` 下,dense 与 sparse 分别触发 3796 和 164 次真实残差回调,墙钟约为 7.357 s 和 0.634 s,即同一预算下约 11.6 倍的单次回退成本改善;这项实验只衡量回退开销,不代表两条路径在 20 次评估内已经收敛。
|
||||
|
||||
## 5. 每次导数计算的代数闭合
|
||||
|
||||
@@ -205,30 +211,25 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
||||
- 气体携带的能量按实际流向交给下游;
|
||||
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。
|
||||
|
||||
代码目前不是一次对完,而是按固定顺序做若干轮专项检查。因此一个“计算变化率”的请求会调用 **2 次压力/流量闭合**;涉及外部容积传播时会调用 **3 次**。这也是后文首要优化方向。
|
||||
代码先建立全网初始压力解,再传播 stream 焓;只有组件方程明确依赖 stream 的范围,才让 stream 焓和其方程—未知量关联块继续迭代到同一个固定点。这样既让当前 RHS 不依赖上一次调用留下的焓/流量历史、保持有限差分 Jacobian 可重复,又避免无关方程重复对账。物理岛仍用于确认范围不会跨越未声明边界,但不再等同于实际求解块。
|
||||
|
||||
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py:258-290`:
|
||||
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py`:
|
||||
|
||||
| 顺序 | 操作 | 目的 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | `SignalResolver.solve(time)` | 更新时间信号源并从 output 传播到 input |
|
||||
| 2 | 刷新动态组件热力端口 | 由当前 `m/U/V` 恢复压力、温度、焓等 |
|
||||
| 3 | 第一次 `PressureFlowSolver.solve()` | 闭合当前压力、流量、机械端口代数关系 |
|
||||
| 4 | `PneumaticVolumeResolver.solve()` | 沿气动连接传播外部 `volume/volume_flow` |
|
||||
| 5 | 若发生容积传播,再刷新热力状态并第二次求压力/流量 | 让变容边界进入气室状态关系 |
|
||||
| 6 | `StreamResolver.solve()` | 按实际流向迭代传播/混合 `h_outflow` |
|
||||
| 7 | 无条件再次求压力/流量 | 让依赖 stream/温度的构成关系重新闭合 |
|
||||
| 8 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
|
||||
| 1 | `SignalResolver.solve(time)` | 用构造时预绑定的信号源和连接更新时间信号 |
|
||||
| 2 | 传播机械 `x/v` 等值关系 | 把当前机械状态同步到刚性连接端口 |
|
||||
| 3 | `PneumaticVolumeResolver.solve()` | 用预绑定的气动端口、输出组件和连接传播外部 `volume/volume_flow` |
|
||||
| 4 | 刷新动态组件热力端口 | 由当前 `m/U/V` 和最新体积恢复压力、温度、焓 |
|
||||
| 5 | 第一次全网 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 |
|
||||
| 6 | `StreamResolver.solve()`,必要时最多 25 轮重算敏感方程块 | 用预绑定的组件、端口和连接传播焓;让焓、温度引用和敏感构成流量同时收敛 |
|
||||
| 7 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
|
||||
|
||||
随后 `rhs()` 才收集各动态组件的导数(`app/simulation/systems/generic.py:298-301`)。
|
||||
随后 `rhs()` 才收集各动态组件的导数。
|
||||
|
||||
因此:
|
||||
因此每次闭合固定有 **1 次全网语义**的压力流量求解;没有敏感方程块时,stream 传播一次后结束,不再有第二次压力求解。若有 `B` 个敏感方程块,每一轮外层热流固定点会把相应块作为一个 union 快路径求解,最多 25 轮;保守回退时恢复原全网求解器。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代,外层热流固定点最多 25 轮,两层上限不能混为一个数。上述大型 XML 的敏感 union 是 9 个方程块、合计 192 个未知量,而不是其单一巨大物理连通岛中的全部 472 个未知量。
|
||||
|
||||
- 无外部容积传播时,每次闭合固定有 **2 次**压力流量求解;
|
||||
- 有外部容积传播时固定有 **3 次**;
|
||||
- stream 默认相对容差 `1e-9`、最多 100 次迭代,每轮复制端口焓并扫描组件/端口(`app/simulation/solvers/stream.py:29-119`)。
|
||||
|
||||
**[发现]** 这套固定顺序没有按模型实际能力裁剪。例如没有信号、没有外部容积源或 stream 不反向影响构成关系的网络,仍经过对应全网 pass。是否能安全删去某一 pass 必须由依赖关系和回归测试决定,不能只凭某个算例结果不变。
|
||||
signal、stream 和外部 volume resolver 已把静态组件列表、端口引用和连接绑定预编译到系统构造阶段;运行时仍执行真实的信号赋值、焓迭代、volume 传播和动态热力刷新。闭合计划只在组件以布尔能力声明明确说明压力流量方程是否依赖 stream、且方程变量声明可信时裁剪;未分类自定义 stream 组件、非法声明、跨组件残差或局部非方阵都会保守回退全网。这个边界避免把“少扫描”误做成“少算物理关系”。
|
||||
|
||||
## 6. 初始化、积分参数与推进方式
|
||||
|
||||
@@ -256,17 +257,17 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
||||
| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` |
|
||||
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
|
||||
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
|
||||
| `rtol` | 通用 XML 路径硬编码 `1e-5` | 外层 ODE 相对误差;用户不可配置 |
|
||||
| `atol` | `SolveIVPConfig` 标量默认 `1e-8` | 同时用于不同量纲的全部状态;用户不可配置 |
|
||||
| `rtol` | 通用 XML 路径硬编码 `1e-6` | 外层 ODE 相对误差;用户不可配置 |
|
||||
| `atol` | `SolveIVPConfig` 标量默认 `1e-8`;机械状态收紧到 `min(default, 1e-12)` | 热力状态使用默认值,机械速度/位置使用更紧的分量容差;用户不可配置 |
|
||||
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
|
||||
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
|
||||
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
|
||||
| stream 容差/迭代 | `1e-9 / 100` | 焓传播固定点 |
|
||||
| 采样数上限 | `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`。
|
||||
|
||||
**[已实现]** `sampleStep` 生成的采样网格会确保包含 `t_stop`,并在超过 10001 点时拒绝;它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。
|
||||
**[已实现]** `sampleStep` 生成的采样网格会确保包含 `t_stop`,不再设置固定点数上限;仅在数值非有限、当前运行时无法表示点数或时间无法严格递增时预先拒绝。它不会把 BDF 变成固定步算法。实际 RHS 次数由自适应误差控制、Jacobian 估计、拒绝步、事件重启和 `max_step` 共同决定。
|
||||
|
||||
### 6.3 逐步推进分派
|
||||
|
||||
@@ -281,13 +282,29 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
||||
|
||||
1. 检查取消;
|
||||
2. 推进一步;
|
||||
3. 构造 dense output,并插入跨越到的 `t_eval` 样本;
|
||||
3. 仅当本步跨越下一个 `t_eval` 样本或需要机械状态事件检测时构造 dense output,并插入样本;
|
||||
4. 检测状态事件;
|
||||
5. 报告进度;
|
||||
6. 必要时重建求解器。
|
||||
|
||||
**[已优化]** 没有跨采样点、也没有状态事件时,不再为每个已接受步无条件构造插值对象。该优化对稀疏采样、无机械事件的模型有收益;用户大型分支 XML 含机械状态事件,所以仍必须在每步保留用于事件定位的 dense output,本轮实测中这项优化对该 XML 没有收益。
|
||||
|
||||
Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。
|
||||
|
||||
高刚度 XML 的历史失败不是“所有亚帕压力都不物理”,而是优化器原 1 Pa 下界过严:RK45 的内部自适应试探会短暂给出仍严格大于 0 Pa、但小于 1 Pa 的压力种子。当前 `PRESSURE_LOWER_BOUND_PA=0.0`,合法正压试探交回 RK45 自身判断,零压/负压仍由构成方程拒绝。该模型 10 s RK45 已通过且可恢复重试数为 0,因此这里没有用异常重启掩盖真实模型错误。
|
||||
|
||||
### 6.4 大型分支 XML 的 `0.69 s` 慢区
|
||||
|
||||
用户 XML 包含 98 个组件、472 个代数未知量和 74 个 ODE 状态。旧代码不是在 `0.69 s` 死锁,而是 BDF 到达这一刚性变化区后开始大量缩步、重建有限差分 Jacobian 并做 LU 分解。定位发现,气动外部 volume resolver 把机械位移写入气室容积,形成“机械位置 → 气室热力/压力 → 气动力”的跨域闭环;旧 ODE 稀疏依赖图只沿普通物理端口追踪,漏掉了这条外部 volume 边的 12 个实测显著导数项。
|
||||
|
||||
修正采用保守的双向跨域依赖:接收外部容积的气动储能状态依赖相关机械状态,机械力平衡也依赖被耦合的气动状态。结构非零数因此由 1092 增至 1284,有限差分颜色组由 27 增至 31。颜色组稍多不是退化:旧图更小是因为漏项,给 BDF 的 Jacobian 数值不完整,导致后续重复试步的总成本更高。
|
||||
|
||||
功能收口过程中的较早阶段测量为 92.187 s;最终稳定代码连续三次完整运行到 `0.81 s`,分别用时 79.049 s、74.658 s 和 85.103 s。三次积分统计均为 `nfev=3393`、`njev=226`、`nlu=667`、接受步 1009、求解器启动 3 次;压力流量求解 28008 次,全部由 seeded 快路径满足残差合同,没有触发方程块或 dense 非线性回退。
|
||||
|
||||
三次完整响应按规范 JSON 序列化后的 SHA-256 均为 `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f`。为避免诊断字段增删造成“物理结果没变但响应哈希变化”,另定义外部标签 `physical-solution-v1`:待哈希对象只投影 `{status, simulatedUntil, requestedStopTime, series, final}`,标签本身不放入对象;用 `json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 规范化后 SHA-256 为 `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7`。旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,受响应结构影响,不能与当前哈希直接比较。
|
||||
|
||||
本轮还区分验证了两种容易混淆的“容差”。机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次 A/B 约快 16%,但这会改变机械状态与事件的误差合同,当前证据不足,未采用。另一项是外层 thermofluid **流量固定点**相对容差从 `1e-12` 放宽到 `1e-9`;它反而增加 BDF 内部步数并改变积分轨迹,也未采用。这里的正式修复是补全依赖图,不是通过放宽精度或闭合容差掩盖问题。
|
||||
|
||||
仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。
|
||||
|
||||
## 7. 事件、取消与停止
|
||||
@@ -322,18 +339,20 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
|
||||
|
||||
**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。
|
||||
|
||||
**[当前边界]** 本轮优化没有复用积分期间的闭合快照,也没有跳过后处理对账。每个输出点仍执行完整 `_close_current_state()`;变化仅在于该闭合内部使用同一套预编译绑定和敏感方程块执行计划。
|
||||
|
||||
### 8.2 当前返回的诊断
|
||||
|
||||
**[已实现]** 结果包含:
|
||||
|
||||
- 状态数、采样数;
|
||||
- 压力流量 `solveCount`、最大残差、最大单次评估数;
|
||||
- 压力流量 `solveCount`、`closurePassCount`、`secondaryPhysicalIslandCount`、真实方程 `secondaryBlockCount`、`secondaryUnknownCount`、保守回退原因、最大残差、最大单次评估数和最后求解作用域;
|
||||
- stream 最大迭代数;
|
||||
- 停止状态及部分错误上下文。
|
||||
|
||||
**[发现]** `_close_current_state()` 中局部变量 `algebraic` 会被后续 pass 覆盖;最大残差/评估统计只采集每次闭合最后一次压力求解,而 `solveCount` 才累计了全部调用。现有响应还没有外层积分 `nfev/njev/nlu`、接受/拒绝步、重启次数、分阶段墙钟时间、热物性调用数、峰值 RSS、队列深度和结果字节数。
|
||||
**[已实现]** 积分诊断已经包含分段及汇总的 `nfev/njev/nlu`、已接受步、求解器启动、状态迁移和可恢复重试数。设置 `SIMULATIONAPP_PROFILE=standard|audit` 后,响应还会加入分阶段墙钟时间;audit 进一步记录物性调用、精确重复、缓存命中和逆解迭代。
|
||||
|
||||
因此本文可静态定位重复工作,但不能用现有诊断精确量化每个热点的时间占比。
|
||||
**[已修复]** 闭合现在聚合所有实际压力求解的最大残差和最大单次评估数,`last` 与 `lastScope` 指向最后一个真实求解作用域,不再被一个未执行或较早 pass 的局部变量覆盖。`solveCount` 统计求解器调用次数,`closurePassCount` 单列发生过压力求解的固定点 pass;`secondaryPhysicalIslandCount` 只表示安全分类得到的物理范围,`secondaryBlockCount` 明确表示方程关联块数,不能再把两者混称为“块”。代数诊断还返回真实 `residualEvaluations`、`jacobianMode`、dense/方程块回退状态。子作用域失败时,API 返回 `scopeKind` 和 `scopeComponents`。仍缺少峰值 RSS、任务队列深度等服务级指标;结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。
|
||||
|
||||
## 9. 求解时前后端交流
|
||||
|
||||
@@ -365,10 +384,10 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
|
||||
前端规则:
|
||||
|
||||
- 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 开发和部署连接数
|
||||
|
||||
@@ -387,8 +406,8 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
|
||||
|
||||
### 10.1 进程与线程
|
||||
|
||||
- `start-all.bat` 分别启动 Vite 与 FastAPI(`start-all.bat:19-22`)。
|
||||
- `start-backend.bat` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker(`start-backend.bat:17-21`)。
|
||||
- `bat/start-all.bat` 与 `bat/start-all.sh` 分别启动 Vite 与 FastAPI。
|
||||
- `bat/start-backend.bat` 与 `bat/start-backend.sh` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker。
|
||||
- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。
|
||||
- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。
|
||||
- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。
|
||||
@@ -405,7 +424,7 @@ O(组件 + 连接 + 代数结构)
|
||||
+ O(采样数 × 公开结果变量数)
|
||||
```
|
||||
|
||||
当前采样上限是 10001。放大因素包括:
|
||||
当前不设置固定采样点数上限,调用方必须根据模型输出变量数和可用内存选择 `sampleStep`。放大因素包括:
|
||||
|
||||
- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在;
|
||||
- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行;
|
||||
@@ -440,19 +459,38 @@ O(组件 + 连接 + 代数结构)
|
||||
|
||||
| 热点 | 代码证据 | 影响范围 | 判断 |
|
||||
| --- | --- | --- | --- |
|
||||
| 每次闭合固定 2~3 次压力流量求解 | `generic.py:258-290` | 每个 RHS、初始化、每个结果采样点 | [已实现] 重复 pass;真实耗时待测 |
|
||||
| stream 每轮复制/扫描并重复刷新 | `stream.py:29-119` | 每个闭合,最多 100 轮 | [已实现];网络越大越明显 |
|
||||
| 非线性回退用全局有限差分 least-squares | `algebraic.py:927-947` | 快速路径失效时 | [已实现];大非线性网络潜在陡增 |
|
||||
| 外层刚性积分器看不到显式稀疏 Jacobian | `solver.py:528-1009` | BDF/Radau 的每步/Newton | [已实现] |
|
||||
| 热物性重复反算 | `mediums.py:199-266` 及各动态组件 refresh | 每个 RHS/闭合 pass | [推断] 需调用计数确认 |
|
||||
| 每次闭合的压力流量重算 | `generic.py` 的预编译热流闭合计划 | 每个 RHS、初始化、每个结果采样点 | [已优化] 固定 1 次全网语义初解;后续只重算 stream-sensitive 方程关联块,不再把整个敏感物理岛重复求解 |
|
||||
| stream 每轮复制/比较焓并刷新 | `stream.py` | 每个闭合,最多 100 轮 | [静态预编译已完成] 组件、端口和连接已预绑定;每轮必要的数值复制、比较和刷新仍保留 |
|
||||
| 非线性回退使用有限差分 least-squares | `algebraic.py` | 快速路径失效时 | [首轮已优化] 可信声明图先做未闭合方程块 union sparse;失败恢复 `x0` 后做 global sparse→dense;接触/不可信结构保留 dense |
|
||||
| 外层 ODE Jacobian 稀疏依赖图 | `generic.py`、`solver.py` | BDF/Radau 的每步/Newton | [已修复] 已传 `jac_sparsity`;补上外部 volume 的跨域双向依赖,用户 XML 为 1284 非零/31 色 |
|
||||
| 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [已优化] 2026-08-16 已加入仿真隔离的四项氦气精确 LRU;代表算例 2,869/435 次命中/未命中,PR 三次根调用由 1,712 降至 689 |
|
||||
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
|
||||
| 每个已接受步构造 dense output | `solver.py:528-914` | 流式逐步路径 | [已实现];无跨样本/事件时可能浪费 |
|
||||
| dense output 插值对象 | `solver.py` | 流式逐步路径 | [已优化] 仅跨样本或需要状态事件时构造;含状态事件的用户 XML 每步仍需要,因此无本案收益 |
|
||||
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
|
||||
| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 |
|
||||
|
||||
下面是 2026-08-16 **物理岛版首轮实现的历史 `off` 基线**。这些数据仍可说明旧执行计划相对更早“每轮全网”的收益,但该实现已由方程关联块版取代,不能把表中的“块”解释为当前 `secondaryBlockCount`:
|
||||
|
||||
| 算例 | 墙钟改善 |
|
||||
| --- | ---: |
|
||||
| `air_chain` | 7.9% |
|
||||
| `air_branched` | 6.5% |
|
||||
| `helium_step` | 0.6% |
|
||||
| `mechanical_contact` | 21.4% |
|
||||
| high-stiffness short | 14.6% |
|
||||
|
||||
完整 high-stiffness 10 s 的历史基线约 28.126 s,本轮全部改动后的中位数为
|
||||
13.694 s;这个跨版本差额不能归到某一项优化。当前代码上单独强制恢复全网后续
|
||||
闭合的受控对照为 optimized 14.676 s、forced-global 16.929 s,完整 `series`
|
||||
一致。`helium_step` 只有一个仍需重算的敏感岛,改善仅 0.6%;这说明收益取决于
|
||||
可跳过多少无关网络,不能宣称单一氦岛也有两位数提升。后处理的逐采样点完整闭合
|
||||
仍然保留。
|
||||
|
||||
当前方程关联块版另用用户大型分支 XML 做了受控短区间 A/B:输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量和 74 个 ODE 状态。stream 后续求解的 9 个方程块合计 192 个未知量;`0~0.01 s` optimized 与 forced-global 分别为 16.200 s 和 18.584 s,物理解与 `series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`。短区间 A/B 只归因于后续 stream 闭合作用域,完整运行同时包含 Jacobian 修正和最终执行路径收口,二者不能混算成一个百分比。
|
||||
|
||||
## 13. 优化建议排序
|
||||
|
||||
以下按**预期综合收益**排序;同档位优先低风险、低难度项。收益是基于调用频率与复杂度的代码推断,不是基准测试结果。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
|
||||
以下按**预期综合收益**排序;同档位优先低风险、低难度项。排序同时参考代码结构和 2026-08-15 的阶段/物性实测,但尚未覆盖大规模拓扑与多任务吞吐。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
|
||||
|
||||
### 13.1 先看人话版
|
||||
|
||||
@@ -460,8 +498,8 @@ O(组件 + 连接 + 代数结构)
|
||||
|
||||
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
|
||||
| ---: | --- | --- | --- |
|
||||
| 1 | 少做重复“瞬时对账” | 当前每次变化率计算固定做 2~3 次压力/流量闭合,调用频率最高 | 少做一轮可能漏掉真实耦合,必须按组件依赖裁剪 |
|
||||
| 2 | 先整理方程,再求解 | 合并重复未知量,把关联较弱的方程分组;大模型回退迭代时收益很高 | 连接、接触和跨域活塞会让分组出错 |
|
||||
| 1 | 少做重复“瞬时对账” | [方程块首轮已完成] 初解保持全网语义,后续只重算 stream-sensitive 的关联方程块 | 自定义/异常结构必须继续保守回退,不能漏掉真实耦合 |
|
||||
| 2 | 先整理方程,再求解 | [稀疏首轮已完成] 可信声明图将未闭合块合并求解;大模型回退时减少数值 Jacobian 试算 | 接触活动集和不可信自定义声明必须走兼容回退 |
|
||||
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
|
||||
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
|
||||
| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 |
|
||||
@@ -471,30 +509,30 @@ O(组件 + 连接 + 代数结构)
|
||||
|
||||
| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| 1 | 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:无信号不扫信号、无外部容积源不传播;仅在 stream 或体积确实使构成关系变脏时追加压力求解。保留可收敛的耦合迭代上限。 | 单算例 | 高;覆盖每个 RHS 和每个后处理点 | 中:错误裁剪会破坏耦合一致性 | 中 |
|
||||
| 2 | 强化代数结构消元:合并 equality group 中重复未知量,按方程关联图分块,预编译残差/尺度;为非线性回退提供解析或稀疏 Jacobian/`jac_sparsity`。跨域活塞应按方程关联而非仅按物理域分块。 | 单算例、大网络 | 很高,尤其 least-squares 回退时 | 高:影响收敛与接触约束 | 高 |
|
||||
| 3 | 为 BDF/Radau 提供状态缩放、分量级 `atol` 和 Jacobian 稀疏结构;允许有边界地配置 `rtol/atol`,依据物理时标选择 `max_step`,不要简单全局放宽容差。 | 单算例、刚性网络 | 中到高 | 中高:会改变误差轨迹/事件时刻 | 中高 |
|
||||
| 4 | 在单次闭包内缓存热物性结果,并预计算 dynamic components、signal sources、stream components、端口引用和结果访问器;状态或体积变化时严格失效。 | 单算例 | 中到高,热力网络可能高 | 中:缓存失效错误会污染物理结果 | 中 |
|
||||
| 1 | [方程块首轮已完成] 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:signal/stream/volume 预绑定;首次压力求解保留全网语义,仅在 stream 确实使构成关系变脏时重算相关方程关联块,并保留全网回退和耦合迭代上限。 | 单算例 | 历史物理岛版实测 0.6%~21.4%;大型 XML 方程块版短区间 16.200 s 对 18.584 s | 中:错误裁剪会破坏耦合一致性,需持续回归自定义模型 | 已完成首轮 |
|
||||
| 2 | [稀疏首轮已完成] 可信方程声明图已用于 union block sparse 和 global sparse→dense 回退;后续继续评估 equality group 真正消元、解析 Jacobian,以及活动接触的安全分块。 | 单算例、大网络 | 扰动实验同预算残差回调 3796→164,约 11.6 倍;实际收益取决于是否触发非线性回退 | 高:接触活动集与错误声明会影响收敛 | 首轮已完成,继续深化 |
|
||||
| 3 | [依赖图已修复] BDF/Radau 已使用状态 `jac_sparsity`,外部 volume 跨域漏边已补;后续再评估状态缩放和可配置分量级 `rtol/atol`,不要简单全局放宽容差。 | 单算例、刚性网络 | 大型 XML 已从 `0.69 s` 慢区定位并完整跑通;机械 `atol` A/B 虽约快 16%但改变精度合同,flow 固定点放宽则增加步数,均未采用 | 中高:会改变误差轨迹/事件时刻 | 稀疏图首轮完成,缩放待评估 |
|
||||
| 4 | [已完成] 氦气高成本物性已按单次仿真做精确、有界缓存;dynamic components 及 signal/stream/volume 的静态组件、端口和连接也已预绑定。 | 单算例 | 已取得可见收益,且减少固定拓扑的重复查找 | 中:缓存失效错误会污染物理结果 | 已完成 |
|
||||
| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 |
|
||||
| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 |
|
||||
| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 |
|
||||
| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 |
|
||||
| 9 | 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;记录并优化事件重启。大量周期 UD00 事件采用惰性调度。 | 单算例、事件密集模型 | 中 | 低到中 | 低到中 |
|
||||
| 9 | [首轮已完成] 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;后续记录并优化事件重启,大量周期 UD00 事件再评估惰性调度。 | 单算例、事件密集模型 | 无事件且采样稀疏时可减少插值对象;本次含状态事件 XML 无收益 | 低到中 | 首轮已完成 |
|
||||
| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 |
|
||||
| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 |
|
||||
| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 |
|
||||
|
||||
结果访问器和每轮剩余临时容器不属于第 4 项已经完成的 signal/stream/volume 静态预绑定;前者应结合第 5 项后处理改造单独基准,不把尚未实测的小项混入已完成收益。
|
||||
|
||||
### 13.2 推荐落地顺序
|
||||
|
||||
在改算法前先增加低侵入观测,但不把“加指标”误列为直接加速:
|
||||
低侵入阶段/物性观测、积分计数和代表算例首轮基准已经落地,但不把“加指标”误列为直接加速。下一步建议:
|
||||
|
||||
1. 记录每个闭合 pass 的调用数、墙钟时间、代数 `nfev` 与是否命中快路径;
|
||||
2. 记录热物性调用数/迭代数、stream 迭代数;
|
||||
3. 导出外层 `nfev/njev/nlu`、接受/拒绝步、事件与重启次数;
|
||||
4. 记录状态/结果数组字节数、最终 JSON 字节、任务队列深度和进程 RSS;
|
||||
5. 用小、中、大三类基准网络定位排名 1~5 的真实占比;
|
||||
6. 先实施第 1、4、8、9 项的可回滚改造,再决定第 2、3、5 项的深度;
|
||||
7. 服务并发需求明确后并行推进第 6、7 项。
|
||||
1. 继续汇总压力流量快路径命中、非线性 `nfev`、真实 `residualEvaluations` 和残差装配时间;单次诊断已能区分 block sparse、global sparse 和 dense 回退;
|
||||
2. 用同一套基准把方程声明图 A/B 扩展到更多自定义组件、活动接触和保守回退路径;大型 98 组件 XML 与首轮短算例已经覆盖可信内置路径;
|
||||
3. 补 1/8/32 单元规模曲线、1/2/4 并发吞吐和峰值 RSS;
|
||||
4. 记录状态/结果数组字节、任务队列深度;结果 JSON 字节可继续由基准工具测量;
|
||||
5. 再决定 equality group 真正消元/解析 Jacobian、结果按需计算和进程 worker 的实施深度。
|
||||
|
||||
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
|
||||
|
||||
@@ -504,7 +542,12 @@ O(组件 + 连接 + 代数结构)
|
||||
|
||||
- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。
|
||||
- 气动、机械和信号的专用闭合顺序。
|
||||
- 压力流量显式因果化快路径与 `least_squares` 回退。
|
||||
- 压力流量显式因果化快路径,以及可信声明图上的 union block sparse、global sparse→dense `least_squares` 兼容回退;失败候选不会污染原始 `x0`。
|
||||
- signal/stream/外部 volume 静态绑定,以及“全网语义初解 + stream-sensitive 方程块重算 + 保守全网回退”的闭合执行计划。
|
||||
- 明确区分物理岛、方程块和方程块未知量的诊断,并记录实际残差回调、Jacobian 模式、最后作用域和子块失败作用域。
|
||||
- BDF/Radau 状态 `jac_sparsity`,以及外部 volume 跨域双向依赖修正。
|
||||
- dense output 按采样跨越/状态事件惰性构造;状态事件模型仍保持每步插值能力。
|
||||
- 允许严格正亚帕试探压力的高刚度 RK45 路径;零压和负压仍不接受。
|
||||
- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。
|
||||
- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。
|
||||
- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。
|
||||
@@ -516,9 +559,17 @@ O(组件 + 连接 + 代数结构)
|
||||
- 采样上限、超时、心跳和任务名义保留时长。
|
||||
- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。
|
||||
|
||||
### 推断及必须实测
|
||||
### 已有初步实测、仍需扩大样本
|
||||
|
||||
- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,仿真隔离的四项 PR 氦气缓存已取得可见端到端收益。
|
||||
- 长氦气代表算例的积分阶段占约 90.5%,后处理约 5%。
|
||||
- 物理岛版闭合执行计划的历史短算例配对收益为 0.6%~21.4%;完整 high-stiffness optimized/forced-global 历史对照为 14.676 s/16.929 s,数值序列一致。这些基线保留用于纵向比较,但已不是当前方程块实现。
|
||||
- 当前大型分支 XML 的 stream 后续闭合为 9 个方程块/192 个未知量;`0~0.01 s` optimized/forced-global 为 16.200 s/18.584 s 且物理解/`series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s/74.658 s/85.103 s,积分统计一致。
|
||||
- 代数 sparse 扰动实验将真实残差回调由 3796 降至 164(约 11.6 倍耗时改善);它是回退微基准,不能外推为所有仿真的整体加速倍数。
|
||||
- 上述结论仍需在更大拓扑、更多真实工程和固定硬件环境复测。
|
||||
|
||||
### 推断及必须继续实测
|
||||
|
||||
- 哪一类闭合 pass、热物性或 Jacobian 估计占主要墙钟时间。
|
||||
- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。
|
||||
- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。
|
||||
- 各优化的实际收益;表中排序应在观测数据出现后更新。
|
||||
@@ -531,12 +582,16 @@ O(组件 + 连接 + 代数结构)
|
||||
| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` |
|
||||
| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` |
|
||||
| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` |
|
||||
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py:86-968` | `PressureFlowSolver.solve()` |
|
||||
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py` | `PressureFlowSolver.solve()`、方程关联图、sparse→dense 回退 |
|
||||
| stream 方程块闭合 | `app/simulation/solvers/algebraic_blocks.py` | `StreamPressureBlockSolver` |
|
||||
| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` |
|
||||
| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` |
|
||||
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
|
||||
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
|
||||
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
|
||||
| 启动暖机 | `app/simulation/warmup.py` | BDF、稀疏分组、`least_squares` sparse LSMR 路径 |
|
||||
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
|
||||
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
|
||||
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
|
||||
| 启动方式 | `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` | 通用仿真、事件、取消 |
|
||||
@@ -79,7 +79,7 @@
|
||||
| 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 |
|
||||
| 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. 常见术语翻译表
|
||||
|
||||
@@ -337,7 +337,7 @@ amesim_step0.out ──> amesim_forc.res [力源] amesim_forc.port_2 ── 机
|
||||
|
||||
### 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 是什么结构
|
||||
|
||||
@@ -554,7 +554,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解
|
||||
|
||||
### 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. 当前模型覆盖范围
|
||||
|
||||
@@ -629,7 +629,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解
|
||||
| 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` |
|
||||
| JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` |
|
||||
| XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` |
|
||||
| XML v3 当前格式 | `schemas/system-simulation-v3.xsd`、`docs/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` |
|
||||
| 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` |
|
||||
| 前端生成 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)
|
||||
+1
-1
@@ -111,7 +111,7 @@ System XML v3 是当前唯一支持的 XML 求解输入。根元素固定使用
|
||||
|
||||
`modelVersion` 必须与当前注册模型完全一致。版本不一致时返回
|
||||
`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
|
||||
|
||||
@@ -247,7 +247,7 @@ class ExampleComponent(Component):
|
||||
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
|
||||
|
||||
完整方程示例参见
|
||||
[`app/simulation/components/example.md`](../app/simulation/components/example.md)。
|
||||
[`app/simulation/components/example.md`](../../app/simulation/components/example.md)。
|
||||
|
||||
## 7. 界面显示声明
|
||||
|
||||
+44
-9
@@ -47,7 +47,7 @@
|
||||
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
|
||||
|
||||
当前示例是
|
||||
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。
|
||||
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../../app/simulation/examples/testmodel/dynamic_pipe.py)。
|
||||
|
||||
### 2.4 新增物理域
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
1. 本文档。
|
||||
2. 目标库的 `library.py`。
|
||||
3. 同分类中物理行为最接近的现有模型。
|
||||
4. [`core/base.py`](../app/simulation/core/base.py)。
|
||||
5. [`core/ports.py`](../app/simulation/core/ports.py)。
|
||||
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。
|
||||
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。
|
||||
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。
|
||||
4. [`core/base.py`](../../app/simulation/core/base.py)。
|
||||
5. [`core/ports.py`](../../app/simulation/core/ports.py)。
|
||||
6. [`core/metadata.py`](../../app/simulation/core/metadata.py)。
|
||||
7. [`core/catalog.py`](../../app/simulation/core/catalog.py)。
|
||||
8. [`registry.py`](../../app/simulation/registry.py) 中的启动校验。
|
||||
9. 与目标模型最接近的测试。
|
||||
|
||||
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
|
||||
@@ -111,6 +111,7 @@ app/simulation/components/experimental/junctions/tee.py
|
||||
```python
|
||||
MODEL_TYPE = "example_component"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PORTS = (...)
|
||||
PARAMETERS = (...)
|
||||
RESULT_VARIABLES = (...)
|
||||
@@ -153,6 +154,18 @@ def create(
|
||||
- `pressure_flow_equation_residuals()`。
|
||||
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
|
||||
|
||||
实现 `update_stream_outflows()` 或 `update_flow_temperature_references()` 的公开模型,
|
||||
还应在该公开类自身显式声明 `PRESSURE_FLOW_DEPENDS_ON_STREAM`:构成压力/流量方程
|
||||
会读取这些 hook 写入的焓或温度引用时设为 `True`,否则设为 `False`。省略声明、
|
||||
声明非法值或由自定义子类仅继承父类声明时,求解器会保守使用全网热流闭合;不要
|
||||
为了获得分块加速而错误声明 `False`。
|
||||
|
||||
`pressure_flow_equation_residuals()` 返回的每条 `EquationResidual.variables` 必须完整
|
||||
列出该残差实际读取的全部代数端口量(`p/m_flow/x/v/f`),不能只写“主要变量”。
|
||||
求解器会用这份声明编译稀疏 Jacobian 和独立方程块;漏写依赖可能让有限差分方向
|
||||
不完整。仓库内置组件会接受结构与数值依赖回归,外部自定义组件当前仍保守使用
|
||||
全网 dense 回退,直到具备同等的依赖验证边界。
|
||||
|
||||
### 6.2 `ThermodynamicVolumeComponent`
|
||||
|
||||
适用于包含质量和能量状态的气体容腔,例如:
|
||||
@@ -406,6 +419,27 @@ def create(
|
||||
- 必要正则化必须有物理解释,并通过边界测试保护。
|
||||
- 不得用画布坐标、连接线方向或组件名称决定方程。
|
||||
|
||||
### 12.1 可因果执行的残差语义
|
||||
|
||||
后端只会对经过结构门控的内置模型启用完全因果执行。除完整声明
|
||||
`variables` 外,这些模型还必须遵守以下可执行语义:
|
||||
|
||||
- `role="effort", relation="state"` 的残差写成
|
||||
`端口 effort - 状态给定值`,被约束的端口量系数必须为 `+1`。
|
||||
- `role="effort", relation="equal"` 的残差写成两个同类 effort 的差。
|
||||
- `role="flow", relation="sumToZero"` 按“流入组件为正”的约定求和,待消元
|
||||
flow 的系数必须为 `+1`。
|
||||
- `role="flow", relation="constitutive"` 写成
|
||||
`待消元 flow - 本构计算值`,待消元 flow 的系数必须为 `+1`。
|
||||
- `pressure_flow_equation_values()` 必须是无副作用的只读计算,返回顺序和长度
|
||||
必须与 `pressure_flow_equation_residuals()` 的编译结果永久一致;不得在求残差时
|
||||
修改端口、状态或活动集缓存。
|
||||
|
||||
求解器仍会在首次闭合、离散事件之后和固定周期执行完整残差审计。结构不满足、
|
||||
运行时覆盖不完整或审计不通过时,会立即熔断到原有残差/非线性求解路径。现场诊断
|
||||
时可在启动进程前设置 `SIMULATION_CAUSAL_FAST_PATH=0`,一键关闭该优化而不改变模型
|
||||
文件。
|
||||
|
||||
动态模型还必须:
|
||||
|
||||
- 状态向量长度稳定。
|
||||
@@ -435,6 +469,7 @@ from app.simulation.core.ports import PortDefinition
|
||||
class ExampleRestriction(AlgebraicComponent):
|
||||
MODEL_TYPE = "example_restriction"
|
||||
MODEL_VERSION = "1.0.0"
|
||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
|
||||
@@ -529,11 +564,11 @@ class ExampleRestriction(AlgebraicComponent):
|
||||
真实现有模型可参考:
|
||||
|
||||
- 储能元件:
|
||||
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py)
|
||||
[`cylinder.py`](../../app/simulation/components/experimental/storage/cylinder.py)
|
||||
- 阻性元件:
|
||||
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py)
|
||||
[`orifice.py`](../../app/simulation/components/experimental/flow/orifice.py)
|
||||
- 多端口连接元件:
|
||||
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py)
|
||||
[`tee.py`](../../app/simulation/components/experimental/junctions/tee.py)
|
||||
|
||||
## 14. 注册模型
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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. 设计边界
|
||||
|
||||
@@ -101,7 +101,7 @@ System
|
||||
| --- | --- | --- |
|
||||
| `tStart` | 仿真开始时刻 | 必须是有限数值 |
|
||||
| `tStop` | 仿真结束时刻 | 必须有限且大于 `tStart` |
|
||||
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0,且整个区间最多生成 10001 个采样点 |
|
||||
| `sampleStep` | 结果相邻采样点的时间间隔 | 必须大于 0;不设置固定的采样点数上限 |
|
||||
| `maxStep` | 自适应积分器单个内部步的上限 | 必须大于 0 |
|
||||
| `method` | 积分方法 | `RK45/RK23/DOP853/Radau/BDF/LSODA` |
|
||||
|
||||
@@ -111,9 +111,10 @@ System
|
||||
- `maxStep` 限制求解器内部一次最多前进多久;
|
||||
- 自适应求解器可以因为误差、事件或试探状态失败而走得比 `maxStep` 更短。
|
||||
|
||||
采样点数量会在创建时间数组前计算。若区间长度不可表示为有限数、请求超过 10001
|
||||
点,或在当前浮点精度下无法得到包含 `tStart/tStop` 的严格递增时间序列,输入会在
|
||||
仿真前被拒绝,不会把超大或重复的 `t_eval` 交给积分器。
|
||||
采样点数量会在创建时间数组前计算。若区间长度不可表示为有限数、点数超过当前
|
||||
运行时可表示的集合大小,或在当前浮点精度下无法得到包含 `tStart/tStop` 的严格
|
||||
递增时间序列,输入会在仿真前被拒绝。采样点不再受固定业务上限约束,但结果内存、
|
||||
序列化体积和浏览器负载仍会随“采样点数 × 输出变量数”线性增长。
|
||||
|
||||
工程 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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
+1992
-456
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
import { Background, BackgroundVariant } from "@xyflow/react";
|
||||
|
||||
export const CANVAS_GRID_SIZE = 18;
|
||||
|
||||
export type CanvasGridVisibility = {
|
||||
lines: boolean;
|
||||
dots: boolean;
|
||||
};
|
||||
|
||||
type CanvasGridBackgroundProps = {
|
||||
visibility: CanvasGridVisibility;
|
||||
};
|
||||
|
||||
export function CanvasGridBackground({
|
||||
visibility,
|
||||
}: CanvasGridBackgroundProps) {
|
||||
return (
|
||||
<>
|
||||
{visibility.lines ? (
|
||||
<Background
|
||||
color="#d6e0ea"
|
||||
gap={CANVAS_GRID_SIZE * 5}
|
||||
id="major-grid-lines"
|
||||
lineWidth={1}
|
||||
variant={BackgroundVariant.Lines}
|
||||
/>
|
||||
) : null}
|
||||
{visibility.dots ? (
|
||||
<Background
|
||||
color="#c4d0dd"
|
||||
gap={CANVAS_GRID_SIZE}
|
||||
id="minor-grid-dots"
|
||||
size={1}
|
||||
variant={BackgroundVariant.Dots}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { memo, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
AmesimPnpl01Symbol,
|
||||
@@ -665,7 +665,7 @@ export function componentSymbolVariantKey(
|
||||
return definition.variantKey?.(parameters) ?? "";
|
||||
}
|
||||
|
||||
export function ComponentSymbol({
|
||||
function ComponentSymbolImpl({
|
||||
symbol,
|
||||
surface,
|
||||
className,
|
||||
@@ -752,3 +752,6 @@ export function ComponentSymbol({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const ComponentSymbol = memo(ComponentSymbolImpl);
|
||||
ComponentSymbol.displayName = "ComponentSymbol";
|
||||
@@ -1,18 +1,59 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
BaseEdge,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
} from "@xyflow/react";
|
||||
import {
|
||||
createOrthogonalSegmentDetour,
|
||||
edgeSegmentAxis,
|
||||
moveOrthogonalCorner,
|
||||
moveOrthogonalSegment,
|
||||
orthogonalEdgePoints,
|
||||
orthogonalPolylinePath,
|
||||
type EdgeRouteData,
|
||||
type EdgeRoutePoint,
|
||||
} from "./edgeRouting";
|
||||
|
||||
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
||||
|
||||
export function ContactAwareEdge({
|
||||
type RouteDrag = {
|
||||
basePoints: EdgeRoutePoint[];
|
||||
cleanup: () => void;
|
||||
kind: "corner" | "detour" | "segment";
|
||||
pointerId: number;
|
||||
routeIndex: number;
|
||||
startPointer: EdgeRoutePoint;
|
||||
};
|
||||
|
||||
function longestSegmentIndex(points: EdgeRoutePoint[]) {
|
||||
let bestIndex = 0;
|
||||
let bestLength = -1;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const length = Math.hypot(
|
||||
points[index + 1].x - points[index].x,
|
||||
points[index + 1].y - points[index].y,
|
||||
);
|
||||
if (length > bestLength) {
|
||||
bestIndex = index;
|
||||
bestLength = length;
|
||||
}
|
||||
}
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function RoutedEdge({
|
||||
data,
|
||||
id,
|
||||
interactionWidth,
|
||||
markerEnd,
|
||||
markerStart,
|
||||
selected,
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -21,32 +62,198 @@ export function ContactAwareEdge({
|
||||
targetX,
|
||||
targetY,
|
||||
}: EdgeProps) {
|
||||
const isContactEdge = data?.isContactEdge === true;
|
||||
if (isContactEdge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
const reactFlow = useReactFlow();
|
||||
const dragRef = useRef<RouteDrag | null>(null);
|
||||
const edgeData = (data ?? {}) as EdgeRouteData;
|
||||
useEffect(
|
||||
() => () => {
|
||||
dragRef.current?.cleanup();
|
||||
dragRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const points = orthogonalEdgePoints({
|
||||
routePoints: edgeData.routePoints,
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetPosition,
|
||||
targetX,
|
||||
targetY,
|
||||
borderRadius: 0,
|
||||
});
|
||||
const edgePath = orthogonalPolylinePath(points, edgeData.crossingJumps);
|
||||
|
||||
const beginRouteDrag = (
|
||||
event: ReactPointerEvent<SVGElement>,
|
||||
basePoints: EdgeRoutePoint[],
|
||||
kind: RouteDrag["kind"],
|
||||
routeIndex: number,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragRef.current?.cleanup();
|
||||
|
||||
const drag: RouteDrag = {
|
||||
basePoints: basePoints.map((point) => ({ ...point })),
|
||||
cleanup: () => undefined,
|
||||
kind,
|
||||
pointerId: event.pointerId,
|
||||
routeIndex,
|
||||
startPointer: reactFlow.screenToFlowPosition(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
{ snapToGrid: false },
|
||||
),
|
||||
};
|
||||
const moveRoute = (pointerEvent: PointerEvent) => {
|
||||
if (
|
||||
dragRef.current !== drag ||
|
||||
pointerEvent.pointerId !== drag.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pointerEvent.preventDefault();
|
||||
pointerEvent.stopPropagation();
|
||||
const pointer = reactFlow.screenToFlowPosition(
|
||||
{ x: pointerEvent.clientX, y: pointerEvent.clientY },
|
||||
{ snapToGrid: false },
|
||||
);
|
||||
const delta = {
|
||||
x: pointer.x - drag.startPointer.x,
|
||||
y: pointer.y - drag.startPointer.y,
|
||||
};
|
||||
const nextPoints =
|
||||
drag.kind === "corner"
|
||||
? moveOrthogonalCorner(drag.basePoints, drag.routeIndex, delta)
|
||||
: drag.kind === "detour"
|
||||
? createOrthogonalSegmentDetour(
|
||||
drag.basePoints,
|
||||
drag.routeIndex,
|
||||
delta,
|
||||
)
|
||||
: moveOrthogonalSegment(
|
||||
drag.basePoints,
|
||||
drag.routeIndex,
|
||||
delta,
|
||||
);
|
||||
edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1));
|
||||
};
|
||||
const finishRoute = (pointerEvent: PointerEvent) => {
|
||||
if (
|
||||
dragRef.current !== drag ||
|
||||
pointerEvent.pointerId !== drag.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pointerEvent.preventDefault();
|
||||
pointerEvent.stopPropagation();
|
||||
drag.cleanup();
|
||||
dragRef.current = null;
|
||||
};
|
||||
drag.cleanup = () => {
|
||||
window.removeEventListener("pointermove", moveRoute);
|
||||
window.removeEventListener("pointerup", finishRoute);
|
||||
window.removeEventListener("pointercancel", finishRoute);
|
||||
};
|
||||
dragRef.current = drag;
|
||||
window.addEventListener("pointermove", moveRoute, { passive: false });
|
||||
window.addEventListener("pointerup", finishRoute);
|
||||
window.addEventListener("pointercancel", finishRoute);
|
||||
edgeData.onRouteEditStart?.(id);
|
||||
};
|
||||
|
||||
const editable = selected && edgeData.editable === true;
|
||||
const internalSegments = points
|
||||
.slice(0, -1)
|
||||
.map((start, index) => ({ end: points[index + 1], index, start }))
|
||||
.filter(({ index }) => index > 0 && index < points.length - 2);
|
||||
const internalCorners = points
|
||||
.map((point, index) => ({ index, point }))
|
||||
.filter(({ index }) => index > 0 && index < points.length - 1);
|
||||
const fallbackIndex = longestSegmentIndex(points);
|
||||
const fallbackStart = points[fallbackIndex];
|
||||
const fallbackEnd = points[fallbackIndex + 1];
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
interactionWidth={interactionWidth}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
path={edgePath}
|
||||
style={style}
|
||||
/>
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
interactionWidth={interactionWidth}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
path={edgePath}
|
||||
style={style}
|
||||
/>
|
||||
{editable
|
||||
? internalSegments.map(({ end, index, start }) => (
|
||||
<g
|
||||
className="manual-edge-segment-control"
|
||||
key={`${id}-segment-${index}`}
|
||||
>
|
||||
<line
|
||||
aria-label={`调整连接线段 ${index}`}
|
||||
className={`manual-edge-segment-handle ${edgeSegmentAxis(start, end)}`}
|
||||
data-edge-id={id}
|
||||
data-segment-index={index}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "segment", index)
|
||||
}
|
||||
x1={start.x}
|
||||
x2={end.x}
|
||||
y1={start.y}
|
||||
y2={end.y}
|
||||
/>
|
||||
<line
|
||||
aria-hidden="true"
|
||||
className="manual-edge-segment-guide"
|
||||
x1={start.x}
|
||||
x2={end.x}
|
||||
y1={start.y}
|
||||
y2={end.y}
|
||||
/>
|
||||
</g>
|
||||
))
|
||||
: null}
|
||||
{editable
|
||||
? internalCorners.map(({ index, point }) => (
|
||||
<circle
|
||||
aria-label={`双向调整连接线拐点 ${index}`}
|
||||
className="manual-edge-corner-handle"
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
data-corner-index={index}
|
||||
data-edge-id={id}
|
||||
key={`${id}-corner-${index}`}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "corner", index)
|
||||
}
|
||||
r={4.5}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{editable && internalCorners.length === 0 && fallbackEnd ? (
|
||||
<circle
|
||||
aria-label="双向调整连接线路由"
|
||||
className="manual-edge-route-handle"
|
||||
cx={(fallbackStart.x + fallbackEnd.x) / 2}
|
||||
cy={(fallbackStart.y + fallbackEnd.y) / 2}
|
||||
data-edge-id={id}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "detour", fallbackIndex)
|
||||
}
|
||||
r={5}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactAwareEdge(props: EdgeProps) {
|
||||
if (props.data?.isContactEdge === true) {
|
||||
return null;
|
||||
}
|
||||
return <RoutedEdge {...props} />;
|
||||
}
|
||||
|
||||
export const contactAwareEdgeTypes: EdgeTypes = {
|
||||
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
|
||||
};
|
||||
+1802
-260
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() {
|
||||
return (
|
||||
<SymbolSvg className="component-symbol-amesim-pnl0001-svg" domain="pneumatic">
|
||||
<PipeNetworkBody sections={["C", "R"]} />
|
||||
<PipeNetworkBody sections={["R", "C"]} />
|
||||
</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},任务可能已经卡死`;
|
||||
}
|
||||
@@ -2138,6 +2138,32 @@ textarea {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.results-system-heading-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.results-system-heading-tools button {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
color: #526172;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results-system-heading-tools button:hover,
|
||||
.results-system-heading-tools button:focus-visible {
|
||||
border-color: #c5cfda;
|
||||
color: #1d6fb8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.results-chart-heading {
|
||||
position: relative;
|
||||
z-index: 25;
|
||||
@@ -2265,6 +2291,18 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results-system-canvas .react-flow__node.component-icon-node,
|
||||
.results-system-canvas .react-flow__node.component-icon-node .sim-node {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.results-system-canvas
|
||||
.react-flow__node.component-icon-node
|
||||
.node-symbol-envelope {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.results-system-canvas .react-flow__handle {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
@@ -3609,3 +3647,133 @@ textarea {
|
||||
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") {
|
||||
return [
|
||||
physicalPort("port_2", "left", 10),
|
||||
physicalPort("port_3", "right", 20),
|
||||
physicalPort("port_2", "right", 10),
|
||||
physicalPort("port_3", "left", 20),
|
||||
];
|
||||
}
|
||||
if (modelType === "amesim_pnvo001") {
|
||||
return [
|
||||
signalPort("res", "left", "input", 5),
|
||||
physicalPort("port_2", "left", 10),
|
||||
physicalPort("port_3", "right", 20),
|
||||
physicalPort("port_2", "right", 10),
|
||||
physicalPort("port_3", "left", 20),
|
||||
];
|
||||
}
|
||||
if (
|
||||
@@ -784,8 +784,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async
|
||||
},
|
||||
{
|
||||
anchors: [
|
||||
{ portName: "port_2", x: 12.8, y: 24 },
|
||||
{ portName: "port_3", x: 51.2, y: 24 },
|
||||
{ portName: "port_3", x: 12.8, y: 24 },
|
||||
{ portName: "port_2", x: 51.2, y: 24 },
|
||||
],
|
||||
control: "fixed",
|
||||
modelType: "amesim_pnvo001_fixed",
|
||||
@@ -794,8 +794,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async
|
||||
{
|
||||
anchors: [
|
||||
{ portName: "res", x: 27.35, y: 43.2 },
|
||||
{ portName: "port_2", x: 12.8, y: 24 },
|
||||
{ portName: "port_3", x: 51.2, y: 24 },
|
||||
{ portName: "port_3", x: 12.8, y: 24 },
|
||||
{ portName: "port_2", x: 51.2, y: 24 },
|
||||
],
|
||||
control: "signal",
|
||||
modelType: "amesim_pnvo001",
|
||||
@@ -2575,6 +2575,14 @@ test("PNL00R 与 PNL0001-0003 使用 standard 画布和 60% 工作区占比", as
|
||||
};
|
||||
});
|
||||
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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2902,6 +2910,8 @@ test("恢复工程时采用当前目录端口并明确丢弃已删除端口的
|
||||
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await page.goto("/");
|
||||
|
||||
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||
@@ -3067,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({
|
||||
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(
|
||||
"data-connection-state",
|
||||
"idle",
|
||||
);
|
||||
await expect(manualDraft).toHaveCount(0);
|
||||
|
||||
await compatiblePhysicalPort.click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||
await expect(compatiblePhysicalPort).toHaveAttribute(
|
||||
@@ -3084,8 +3109,16 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
||||
);
|
||||
|
||||
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 expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||
await expect(manualDraft).toHaveCount(0);
|
||||
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
|
||||
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
|
||||
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
|
||||
@@ -3097,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(".react-flow__edge-interaction")).toHaveCount(1);
|
||||
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 expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
|
||||
@@ -3214,6 +3256,39 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
||||
await expect(signalInput).toHaveCSS("opacity", "0");
|
||||
await expect(signalOutput).toHaveCSS("pointer-events", "none");
|
||||
await expect(signalInput).toHaveCSS("pointer-events", "none");
|
||||
|
||||
const renderedEdges = page.locator(".flow-canvas .react-flow__edge");
|
||||
const clickEdgeMiddle = async (edgeIndex: number) => {
|
||||
const point = await renderedEdges
|
||||
.nth(edgeIndex)
|
||||
.locator(".react-flow__edge-interaction")
|
||||
.evaluate((path: SVGPathElement) => {
|
||||
const localPoint = path.getPointAtLength(path.getTotalLength() / 2);
|
||||
const matrix = path.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("Unable to resolve the edge screen position");
|
||||
}
|
||||
const screenPoint = new DOMPoint(
|
||||
localPoint.x,
|
||||
localPoint.y,
|
||||
).matrixTransform(matrix);
|
||||
return { x: screenPoint.x, y: screenPoint.y };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
};
|
||||
|
||||
await clickEdgeMiddle(0);
|
||||
await expect(renderedEdges).toHaveCount(2);
|
||||
await expect(renderedEdges.nth(0)).toHaveClass(
|
||||
/selected|editor-edge-selected/,
|
||||
);
|
||||
await clickEdgeMiddle(1);
|
||||
await expect(renderedEdges).toHaveCount(2);
|
||||
await expect(renderedEdges.nth(1)).toHaveClass(
|
||||
/selected|editor-edge-selected/,
|
||||
);
|
||||
await expect(page.getByText("System Simulation Flow", { exact: true })).toBeVisible();
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("端口图标随画布缩小且物理端口始终保持标准圆形", async ({ page }) => {
|
||||
|
||||
@@ -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 { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
expectAllNodesInsideCanvas,
|
||||
prepareApp,
|
||||
@@ -6,9 +8,38 @@ import {
|
||||
wideProject,
|
||||
} from "./fixtures";
|
||||
|
||||
async function readModelingViewport(page: Page) {
|
||||
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) {
|
||||
return page
|
||||
.locator(".flow-canvas .react-flow__viewport")
|
||||
.locator(`${canvasSelector} .react-flow__viewport`)
|
||||
.evaluate((viewport) => {
|
||||
const values =
|
||||
getComputedStyle(viewport)
|
||||
@@ -22,6 +53,114 @@ async function readModelingViewport(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readModelingViewport(page: Page) {
|
||||
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 }) => {
|
||||
await prepareApp(page);
|
||||
await page.goto("/");
|
||||
@@ -221,6 +360,50 @@ test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
|
||||
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 }) => {
|
||||
await prepareApp(page);
|
||||
await page.addInitScript((project) => {
|
||||
@@ -257,10 +440,24 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||
"system-simulation-flow:project:demo-system",
|
||||
JSON.stringify(project),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"system-simulation-flow:canvas-grid-visibility",
|
||||
JSON.stringify({ lines: true, dots: false }),
|
||||
);
|
||||
}, { project: wideProject, snapshot: resultSnapshot });
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "加载工程" }).click();
|
||||
await expect(
|
||||
page.locator(
|
||||
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator(
|
||||
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
const freeModelingHandle = page.locator(
|
||||
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
|
||||
);
|
||||
@@ -271,6 +468,24 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||
);
|
||||
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||
await expect(
|
||||
page.locator(
|
||||
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator(
|
||||
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(".results-system-canvas .react-flow__controls"),
|
||||
).toHaveCount(0);
|
||||
const fitSystemViewButton = page.getByRole("button", {
|
||||
name: "适应系统图窗口",
|
||||
exact: true,
|
||||
});
|
||||
await expect(fitSystemViewButton).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".results-system-canvas .react-flow__attribution"),
|
||||
).toHaveCount(0);
|
||||
@@ -278,6 +493,49 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const resultPane = page.locator(".results-system-canvas .react-flow__pane");
|
||||
const resultPaneBox = await resultPane.boundingBox();
|
||||
expect(resultPaneBox).not.toBeNull();
|
||||
const fittedResultViewport = await readViewport(
|
||||
page,
|
||||
".results-system-canvas",
|
||||
);
|
||||
await page.mouse.move(
|
||||
resultPaneBox!.x + resultPaneBox!.width / 2,
|
||||
resultPaneBox!.y + resultPaneBox!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
resultPaneBox!.x + resultPaneBox!.width / 2 + 180,
|
||||
resultPaneBox!.y + resultPaneBox!.height / 2 + 90,
|
||||
{ steps: 5 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewport = await readViewport(page, ".results-system-canvas");
|
||||
return Math.hypot(
|
||||
viewport.x - fittedResultViewport.x,
|
||||
viewport.y - fittedResultViewport.y,
|
||||
);
|
||||
})
|
||||
.toBeGreaterThan(100);
|
||||
const pannedResultViewport = await readViewport(
|
||||
page,
|
||||
".results-system-canvas",
|
||||
);
|
||||
await fitSystemViewButton.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewport = await readViewport(page, ".results-system-canvas");
|
||||
return Math.hypot(
|
||||
viewport.x - pannedResultViewport.x,
|
||||
viewport.y - pannedResultViewport.y,
|
||||
);
|
||||
})
|
||||
.toBeGreaterThan(100);
|
||||
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||
|
||||
await page.getByRole("tab", { name: "建模" }).click();
|
||||
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||
await expect(freeModelingHandle).toHaveAttribute(
|
||||
@@ -354,3 +612,242 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
|
||||
}
|
||||
});
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
await prepareApp(page);
|
||||
const pneumaticPort = (name: string, side: "left" | "right") => ({
|
||||
name,
|
||||
kind: "physical",
|
||||
domain: "pneumatic",
|
||||
nominalRole: "bidirectional",
|
||||
positiveFlowDirection: "intoComponent",
|
||||
side,
|
||||
});
|
||||
const pnplNode = (
|
||||
id: string,
|
||||
position: { x: number; y: number },
|
||||
rotation: 0 | 90 | 180 | 270,
|
||||
) => ({
|
||||
id,
|
||||
type: "simulationComponent",
|
||||
position,
|
||||
data: {
|
||||
label: id,
|
||||
componentType: "amesim_pnpl01",
|
||||
modelType: "amesim_pnpl01",
|
||||
modelVersion: "0.1.0",
|
||||
ports: [pneumaticPort("port_1", "left")],
|
||||
parameters: {},
|
||||
parameterUnits: {},
|
||||
parameterScientificNotation: {},
|
||||
rotation,
|
||||
mirrored: false,
|
||||
},
|
||||
});
|
||||
const targetNodeId = "amesim_pnch012_2";
|
||||
const overlapProject = {
|
||||
...wideProject,
|
||||
name: "overlapped-symbols",
|
||||
nodes: [
|
||||
{
|
||||
id: "generic_sensor_0",
|
||||
type: "simulationComponent",
|
||||
position: { x: 360, y: 390 },
|
||||
data: {
|
||||
label: "generic_sensor_0",
|
||||
componentType: "generic_sensor",
|
||||
modelType: "generic_sensor",
|
||||
modelVersion: "1.0.0",
|
||||
symbol: "symbol-not-registered",
|
||||
ports: [],
|
||||
parameters: {},
|
||||
parameterUnits: {},
|
||||
parameterScientificNotation: {},
|
||||
rotation: 0,
|
||||
mirrored: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: targetNodeId,
|
||||
type: "simulationComponent",
|
||||
position: { x: 569.2129060235693, y: 515.6448805401301 },
|
||||
data: {
|
||||
label: targetNodeId,
|
||||
componentType: "amesim_pnch012",
|
||||
modelType: "amesim_pnch012",
|
||||
modelVersion: "0.1.0",
|
||||
ports: [
|
||||
pneumaticPort("port_1", "left"),
|
||||
pneumaticPort("port_2", "right"),
|
||||
pneumaticPort("port_3", "left"),
|
||||
pneumaticPort("port_4", "right"),
|
||||
],
|
||||
parameters: {},
|
||||
parameterUnits: {},
|
||||
parameterScientificNotation: {},
|
||||
rotation: 0,
|
||||
mirrored: false,
|
||||
},
|
||||
},
|
||||
pnplNode(
|
||||
"amesim_pnpl01_7",
|
||||
{ x: 535.6129060235693, y: 523.6448805401301 },
|
||||
180,
|
||||
),
|
||||
pnplNode(
|
||||
"amesim_pnpl01_8",
|
||||
{ x: 587.2129060235693, y: 555.2448805401301 },
|
||||
90,
|
||||
),
|
||||
pnplNode(
|
||||
"amesim_pnpl01_9",
|
||||
{ x: 618.8129060235693, y: 523.6448805401301 },
|
||||
0,
|
||||
),
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const overlapSnapshot = {
|
||||
...resultSnapshot,
|
||||
id: "overlapped-symbols-result",
|
||||
project: overlapProject,
|
||||
};
|
||||
await page.addInitScript((snapshot) => {
|
||||
window.sessionStorage.setItem(
|
||||
"system-simulation-flow:latest-result",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
}, overlapSnapshot);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||
await expectAllNodesInsideCanvas(
|
||||
page,
|
||||
".results-system-canvas",
|
||||
overlapProject.nodes.length,
|
||||
);
|
||||
|
||||
const targetNode = page.locator(
|
||||
`.results-system-canvas .react-flow__node[data-id="${targetNodeId}"]`,
|
||||
);
|
||||
const targetEnvelope = targetNode.locator('[data-symbol-envelope="true"]');
|
||||
await expect(targetNode).toHaveClass(/component-icon-node/);
|
||||
await expect(targetNode).toHaveCSS("pointer-events", "none");
|
||||
await expect(targetEnvelope).toHaveCSS("pointer-events", "auto");
|
||||
|
||||
const pane = page.locator(".results-system-canvas .react-flow__pane");
|
||||
const paneBox = await pane.boundingBox();
|
||||
expect(paneBox).not.toBeNull();
|
||||
await page.mouse.click(paneBox!.x + 6, paneBox!.y + 6);
|
||||
await expect(targetNode).not.toHaveClass(/selected/);
|
||||
|
||||
const envelopeBox = await targetEnvelope.boundingBox();
|
||||
expect(envelopeBox).not.toBeNull();
|
||||
const targetPoint = {
|
||||
x: envelopeBox!.x + envelopeBox!.width / 2,
|
||||
y: envelopeBox!.y + envelopeBox!.height / 2,
|
||||
};
|
||||
const hitNodeId = await page.evaluate(({ x, y }) => {
|
||||
const element = document.elementFromPoint(x, y);
|
||||
return element?.closest<HTMLElement>(".react-flow__node")?.dataset.id ?? null;
|
||||
}, targetPoint);
|
||||
expect(hitNodeId).toBe(targetNodeId);
|
||||
await page.mouse.click(targetPoint.x, targetPoint.y);
|
||||
await expect(targetNode).toHaveClass(/selected/);
|
||||
await expect(
|
||||
page.locator(
|
||||
'.results-system-canvas .react-flow__node[data-id^="amesim_pnpl01_"].selected',
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
await page.getByRole("tab", { name: "结果", exact: true }).click();
|
||||
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 }) => {
|
||||
await prepareApp(page);
|
||||
await page.addInitScript((snapshot) => {
|
||||
@@ -49,6 +211,95 @@ test.beforeEach(async ({ page }) => {
|
||||
}, 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 ({
|
||||
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 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");
|
||||
});
|
||||
|
||||
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
|
||||
lxml>=5,<7
|
||||
numpy>=1.26,<3
|
||||
pydantic>=2,<3
|
||||
scipy>=1.13,<2
|
||||
uvicorn[standard]
|
||||
@@ -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
|
||||
}
|
||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user