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

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

更新日志仅记录已完成成果,并注明当前 HEAD 尚待真实 production 复跑与远端 workflow 验证。
2026-08-18 15:20:42 +00:00
huojiarong 53f8601fec 修正动态管与阀门诊断输出 2026-08-18 10:24:29 +00:00
huojiarong f725f038b6 完善 PNL00R 摩擦模型与八路回归 2026-08-18 09:30:29 +00:00
lujingze 684d28752a 更新八路仿真最新版本文件test-mql-8 2026-08-18 06:44:24 +00:00
lujingze b435daecf2 完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
2026-08-18 06:42:07 +00:00
huojiarong 143e8dd309 修复测试资源路径 2026-08-18 00:57:32 +00:00
huojiarong 0fa166c8e5 Fix pneumatic node zero-flow reversal 2026-08-17 09:24:35 +00:00
lujingze 16a7eb2d6c 完成求解器雅可比矩阵首轮优化,增加更新目录,整理了文档文件夹,增加了服务启动脚本 2026-08-17 07:33:31 +00:00
Codex 6bb0591d32 fix: align PNL0001 symbol causality 2026-08-16 11:37:02 +00:00
Codex cca9d1e883 fix: seed PNVO pipe series pressure 2026-08-16 11:34:35 +00:00
Codex 22b35b2945 perf: retain exact flow caches and compiled targets 2026-08-16 11:21:44 +00:00
Codex f09dfcf542 fix: bound reported pipe friction diagnostics 2026-08-16 11:03:21 +00:00
168 changed files with 411213 additions and 957 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.bat text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
+180
View File
@@ -0,0 +1,180 @@
name: Solver regression
on:
push:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
pull_request:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
schedule:
- cron: "17 3 * * 1-6"
- cron: "17 3 * * 0"
workflow_dispatch:
inputs:
suite:
description: Regression tier
required: true
default: quick
type: choice
options:
- quick
- historical
- main-long
case:
description: Longest main-model horizon (predecessors run first)
required: true
default: 0.2s
type: choice
options:
- 0.2s
- 1s
- 5s
- 10s
lane:
description: Output sampling lane
required: true
default: production
type: choice
options:
- solver-only
- production
concurrency:
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
quick:
if: >-
github.event_name == 'push' ||
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run solver foundation tests
env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: |
python -W error::ResourceWarning -m unittest \
tests.test_dependency_constraints \
tests.test_benchmark_regression \
tests.test_physical_state_v21 \
tests.test_test_mql_ame_contract \
tests.test_test_mql_8_regression \
tests.test_mql_full_branches_regression \
tests.test_pressure_flow_causal_execution \
tests.test_stream_pressure_block_solver \
tests.test_core_solver \
tests.test_supported_piston_tangent \
tests.test_three_piston_tangent \
tests.test_sparse_secant_jacobian \
tests.test_generic_jacobian_sparsity \
tests.test_generic_system_xml_simulation
historical-nightly:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run 0.81 and 2.10 second historical regression
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
--lane production \
--output artifacts/test-mql-full-branches.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: historical-solver-regression
path: artifacts/*.json
if-no-files-found: warn
main-periodic:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
runs-on: ubuntu-24.04
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
constraints/python312-linux-x86_64.lock
- name: Install hashed Linux release lock
run: |
python -m pip install \
--force-reinstall \
-r constraints/python312-linux-x86_64.lock
python -m pip check
- name: Run bounded progressive main-model regression
env:
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
--lane "$REQUESTED_LANE" \
--case "$REQUESTED_CASE" \
--output artifacts/test-mql-8-progressive.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: main-model-progressive-regression
path: artifacts/*.json
if-no-files-found: warn
+4
View File
@@ -12,6 +12,10 @@ htmlcov/
# Local virtual environments
.venv/
.venv-win/
# Local Linux toolchain (downloaded for the startup scripts)
.tools/node-*-linux-x64/
app/data/
frontend/node_modules/
frontend/dist/
+1
View File
@@ -0,0 +1 @@
3.12.3
+87 -10
View File
@@ -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)
+41 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
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
@@ -24,6 +24,7 @@ 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,
@@ -86,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
@@ -574,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(),
}
@@ -681,6 +686,7 @@ 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:
@@ -688,6 +694,7 @@ def run_system_xml_simulation(
xml_bytes,
progress_callback,
cancel_check,
activity_tracker,
)
performance = trace.snapshot()
@@ -712,6 +719,7 @@ 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
@@ -774,6 +782,7 @@ def _run_system_xml_simulation_profiled(
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(
@@ -862,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,
@@ -889,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:
@@ -899,18 +912,32 @@ 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(
{
result_event = {
"event": "result",
"progress": 100 if result_status == "completed" else latest_progress,
"phase": result_status,
@@ -919,8 +946,10 @@ def simulation_event_stream(
"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", "仿真失败"))
@@ -935,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,
@@ -947,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)
@@ -964,8 +996,7 @@ def simulation_event_stream(
try:
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
except queue.Empty:
yield json.dumps(
{
heartbeat_event = {
"event": "progress",
"progress": latest_progress,
"phase": latest_phase,
@@ -973,7 +1004,10 @@ def simulation_event_stream(
"heartbeat": True,
"simulatedTime": latest_simulated_time,
"totalTime": latest_total_time,
},
}
heartbeat_event.update(activity_tracker.snapshot().as_dict())
yield json.dumps(
heartbeat_event,
ensure_ascii=False,
separators=(",", ":"),
) + "\n"
+4 -4
View File
@@ -33,8 +33,8 @@ FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
当前关键文件:
@@ -88,7 +88,7 @@ Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行
`diagnostics.performance.propertyCache` 中返回。
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。
[`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。
## 当前阶段进度
@@ -311,7 +311,7 @@ print(result.used_modelica_reference)
## 基线结果
当前基线对比摘要来自:
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
[`testmodel_modelica_comparison_summary.txt`](../../tests/data/testmodel/testmodel_modelica_comparison_summary.txt)
当前四个主变量的最大误差为:
File diff suppressed because it is too large. Load diff
@@ -35,6 +35,7 @@ _FLOWSET_USES_CV = (ParameterCondition("flowset", (2.0,)),)
_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),)
_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="流量系数",
@@ -59,6 +60,9 @@ 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"),
@@ -464,6 +468,9 @@ 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"),
@@ -567,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,),
@@ -716,6 +724,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
upstream_temperature=self._upstream_temperature("port_3"),
)
@lru_cache(maxsize=32768)
def _one_way_flow_characteristics(
self,
*,
@@ -834,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,
@@ -888,6 +907,10 @@ 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"),
@@ -968,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,),
+477 -54
View File
@@ -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,13 +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"),
@@ -157,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:
@@ -187,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,
)
@@ -203,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
@@ -233,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,
@@ -260,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,
@@ -287,13 +417,17 @@ class AmesimPnl00r(AlgebraicComponent):
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
@@ -305,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, ...]:
@@ -355,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."""
@@ -705,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."""
@@ -775,29 +920,72 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
value *= tanh(max(smoothing_argument, 0.0))
return value
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)
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
)
return (
flow_coefficient
* self.area
* p_up
* mass_flow_parameter(pressure_ratio)
/ sqrt(T_up)
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
)
flow_coefficient = sqrt(self.diam / (resistance_length * 0.02))
magnitude = (
flow_coefficient
def target_flow(mass_flow: float) -> float:
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 (
sqrt(self.diam / (resistance_length * friction))
* self.area
* p_up
* mass_flow_parameter(pressure_ratio)
/ sqrt(T_up)
* pressure_ratio_flow_parameter
/ sqrt_temperature
)
for _iteration in range(16):
magnitude = (
sqrt(self.diam / (resistance_length * 0.02))
* self.area
* p_up
* pressure_ratio_flow_parameter
/ sqrt_temperature
)
for _iteration in range(max_iterations):
next_magnitude = target_flow(magnitude)
if abs(next_magnitude - magnitude) <= max(
1.0e-12,
@@ -821,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,
@@ -836,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, ...]:
@@ -852,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 (
@@ -905,6 +1214,98 @@ 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."""
@@ -1065,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 = (
@@ -1092,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, ...]:
@@ -1353,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)
@@ -1371,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,
@@ -1419,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,
@@ -1435,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.
@@ -102,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
* (
@@ -113,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
)
@@ -124,6 +163,9 @@ 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"),
@@ -161,6 +203,9 @@ 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.
@@ -230,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,7 +1,8 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from math import exp, isfinite, log
from typing import ClassVar
from app.simulation.core.errors import RecoverableTrialStateError
@@ -9,6 +10,8 @@ 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
@@ -52,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
@@ -70,6 +79,19 @@ 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:
@@ -309,6 +331,153 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
),
)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Implicitly differentiate the Peng-Robinson m/U/V recovery."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
def invalid(reason: str) -> ThermodynamicPropertiesLinearization:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason=reason,
)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return invalid("properties_primal_mismatch")
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return invalid("non_finite_tangent_input")
if props.T <= 2.2 * (1.0 + 1.0e-10):
return invalid("temperature_floor_boundary")
pressure_temperature_derivative = (
self.fluid.pressure_temperature_derivative_at_density(
props.T,
props.rho,
)
)
pressure_density_derivative = (
self.fluid.pressure_density_derivative_at_temperature(
props.T,
props.rho,
)
)
cv = (
self.cv_at_temperature(props.T)
+ self.fluid.residual_isochoric_heat_capacity_at_density(
props.T,
props.rho,
)
)
recovered_internal_energy = (
self.specific_internal_energy(props.T)
+ self.fluid.residual_specific_internal_energy_at_density(
props.T,
props.rho,
)
)
recovery_scale = max(
abs(props.u),
abs(cv * props.T) if isfinite(cv) else 0.0,
1.0,
)
if (
not all(
isfinite(value)
for value in (
pressure_temperature_derivative,
pressure_density_derivative,
cv,
recovered_internal_energy,
)
)
or cv <= 0.0
):
return invalid("invalid_peng_robinson_derivative")
if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale:
return invalid("properties_recovery_not_converged")
internal_energy_density_derivative = (
props.p - props.T * pressure_temperature_derivative
) / (props.rho * props.rho)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = (
mass_tangent / V - m * volume_tangent / (V * V)
)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = (
internal_energy_tangent
- internal_energy_density_derivative * density_tangent
) / cv
pressure_tangent = (
pressure_temperature_derivative * temperature_tangent
+ pressure_density_derivative * density_tangent
)
enthalpy_tangent = (
internal_energy_tangent
+ pressure_tangent / props.rho
- props.p * density_tangent / (props.rho * props.rho)
)
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
if not all(isfinite(value) for value in tangent_values):
return invalid("non_finite_property_tangent")
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
)
@dataclass(frozen=True)
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,
+2 -2
View File
@@ -1,7 +1,7 @@
# 元件建模规范与示例
规范的权威版本位于
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
修改中同步,不能让示例形成另一套规则。
@@ -279,4 +279,4 @@ models=(
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。
@@ -17,6 +17,9 @@ 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"),
@@ -116,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"]
@@ -17,6 +17,9 @@ 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"),
@@ -15,6 +15,9 @@ 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"),
+20 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
@@ -28,6 +28,13 @@ class Component(ABC):
# 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, ...]] = ()
@@ -245,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."""
+153 -1
View File
@@ -1,7 +1,8 @@
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
@@ -16,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.
@@ -50,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: ...
@@ -75,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:
@@ -139,6 +184,16 @@ 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
@@ -224,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",
)
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -15,7 +15,7 @@ DATA_DIR = (
)
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "data"
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
File diff suppressed because it is too large. Load diff
@@ -23,6 +23,8 @@ class TestMqlComparisonMetric:
max_abs_error: float
mean_abs_error: float
max_rel_error: float
undefined_rel_error_count: int
near_zero_baseline_count: int
final_abs_error: float
@@ -44,6 +46,10 @@ class TestMqlComparisonResult:
def max_rel_error(self) -> float:
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
@property
def undefined_rel_error_count(self) -> int:
return sum(metric.undefined_rel_error_count for metric in self.metrics)
class TestMqlComparisonError(ValueError):
"""Raised when Python and AMESim series cannot be aligned."""
@@ -57,6 +63,16 @@ def compare_test_mql_series(
data_paths: tuple[str, ...] | list[str] | None = None,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
"""Compare current values directly with AMESim simulation values.
``relative_floor`` only identifies near-zero baselines for reporting. It is
never substituted into the relative-error denominator. An exact zero
AMESim baseline has undefined relative error and is counted separately;
absolute error remains available for judgement.
"""
if relative_floor < 0.0:
raise TestMqlComparisonError("relative_floor cannot be negative.")
_validate_time_axis(python_times)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
metrics = []
@@ -70,11 +86,18 @@ def compare_test_mql_series(
amesim_values = amesim_results.series(data_path)
abs_errors = []
rel_errors = []
undefined_rel_error_count = 0
near_zero_baseline_count = 0
for time_value, python_value in zip(python_times, python_values):
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
abs_error = abs(python_value - amesim_value)
abs_errors.append(abs_error)
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
if abs(amesim_value) <= relative_floor:
near_zero_baseline_count += 1
if amesim_value == 0.0:
undefined_rel_error_count += 1
else:
rel_errors.append(abs_error / abs(amesim_value))
final_amesim_value = interpolate_series_value(
amesim_results.times,
amesim_values,
@@ -87,6 +110,8 @@ def compare_test_mql_series(
max_abs_error=max(abs_errors, default=0.0),
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
max_rel_error=max(rel_errors, default=0.0),
undefined_rel_error_count=undefined_rel_error_count,
near_zero_baseline_count=near_zero_baseline_count,
final_abs_error=abs(python_values[-1] - final_amesim_value),
)
)
@@ -153,8 +178,19 @@ def write_test_mql_comparison_csv(
float(time_value),
)
abs_error = abs(python_value - amesim_value)
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
row.extend([python_value, amesim_value, abs_error, rel_error])
rel_error = (
None
if amesim_value == 0.0
else abs_error / abs(amesim_value)
)
row.extend(
[
python_value,
amesim_value,
abs_error,
"" if rel_error is None else rel_error,
]
)
writer.writerow(row)
summary_lines = [
@@ -163,6 +199,7 @@ def write_test_mql_comparison_csv(
f"max_abs_error={metric.max_abs_error:.12g}, "
f"mean_abs_error={metric.mean_abs_error:.12g}, "
f"max_rel_error={metric.max_rel_error:.12%}, "
f"undefined_rel_error_count={metric.undefined_rel_error_count}, "
f"final_abs_error={metric.final_abs_error:.12g}"
)
for metric in comparison.metrics
File diff suppressed because it is too large. Load diff
+182 -14
View File
@@ -9,6 +9,7 @@ from app.simulation.solvers.algebraic import (
PRESSURE_LOWER_BOUND_PA,
AlgebraicSolveDiagnostics,
AlgebraicUnknown,
CausalFlowKernelStage,
ExplicitFlowStage,
PressureFlowSolver,
)
@@ -237,6 +238,27 @@ class StreamPressureBlockSolver:
)
for stage in pressure_flow_solver._explicit_flow_plan
)
secondary_coordinate = 0
secondary_kernel_plan: list[CausalFlowKernelStage] = []
for stage in self._selected_explicit_flow_plan:
coordinate_indices = tuple(
range(
secondary_coordinate,
secondary_coordinate + len(stage.assignments),
)
)
secondary_coordinate += len(stage.assignments)
secondary_kernel_plan.append(
CausalFlowKernelStage(
stage=stage,
coordinate_indices=coordinate_indices,
)
)
self._causal_secondary_flow_kernel_plan = tuple(secondary_kernel_plan)
self._causal_secondary_coordinate_values = [0.0] * secondary_coordinate
self._causal_v2_entry_values = [0.0] * len(
self._selected_flow_unknowns
)
self._selected_equation_evaluation = (
self._compile_selected_equation_evaluation()
if self.blocks
@@ -259,7 +281,11 @@ class StreamPressureBlockSolver:
self._causal_full_residual_audit_count = 0
self._causal_audit_failure_count = 0
self._causal_legacy_fallback_count = 0
self._causal_v2_fast_solve_count = 0
self._causal_v2_runtime_validation_failure_count = 0
self._causal_coordinate_fast_solve_count = 0
self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None
self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None
@property
def available(self) -> bool:
@@ -273,6 +299,13 @@ class StreamPressureBlockSolver:
and self.pressure_flow_solver.causal_fast_path_enabled
)
@property
def causal_executor_v2_enabled(self) -> bool:
return (
self.causal_fast_path_enabled
and self.pressure_flow_solver._causal_executor_v2_environment_enabled
)
def request_causal_audit(self) -> None:
self._causal_audit_required = True
@@ -280,7 +313,9 @@ class StreamPressureBlockSolver:
parent = self.pressure_flow_solver.causal_execution_diagnostics()
disabled_reason = self._causal_runtime_disabled_reason
if not bool(parent["enabled"]):
disabled_reason = str(parent["disabledReason"] or "parentCausalPathDisabled")
disabled_reason = str(
parent["disabledReason"] or "parentCausalPathDisabled"
)
elif not self._causal_fast_path_eligible:
disabled_reason = self._causal_fast_path_fallback_reason
verified = self._causal_last_verified_diagnostics
@@ -298,6 +333,34 @@ class StreamPressureBlockSolver:
"lastVerifiedMaxScaledResidual": (
verified.max_scaled_residual if verified is not None else None
),
"executorV2Configured": (
self.pressure_flow_solver._causal_executor_v2_environment_enabled
),
"executorV2Enabled": self.causal_executor_v2_enabled,
"executorV2FastSolveCount": self._causal_v2_fast_solve_count,
"executorV2RuntimeValidationFailureCount": (
self._causal_v2_runtime_validation_failure_count
),
"coordinateKernelConfigured": (
self.pressure_flow_solver._causal_coordinate_kernel_environment_enabled
),
"coordinateKernelEnabled": (
self.causal_executor_v2_enabled
and self.pressure_flow_solver.causal_coordinate_kernel_enabled
),
"coordinateKernelFastSolveCount": (
self._causal_coordinate_fast_solve_count
),
"compiledEffortUnknownCount": len(
self._causal_effort_entry_positions
),
"compiledFlowAssignmentCount": len(
self._causal_expected_flow_equation_ids
),
"compiledAssignmentCount": (
len(self._causal_effort_entry_positions)
+ len(self._causal_expected_flow_equation_ids)
),
}
def _disable_causal_fast_path(self, reason: str) -> None:
@@ -324,7 +387,7 @@ class StreamPressureBlockSolver:
return failed(str(parent_reason or "parentCausalPathIneligible"))
if solver._closed_resistance_pressure_plan:
return failed("specialClosedResistancePressureSeed")
if solver._pnor_pnl0001_series_plan:
if solver._resistance_pnl0001_series_plan:
return failed("specialSeriesPressureSeed")
selected = self._selected_equation_evaluation
if selected is None:
@@ -405,6 +468,28 @@ class StreamPressureBlockSolver:
self._causal_solves_since_audit = 0
self._causal_audit_required = False
self._causal_last_verified_diagnostics = diagnostics
self._causal_cached_fast_diagnostics = replace(
diagnostics,
message=(
"Compiled causal stream-pressure block completed; residuals "
"reuse the latest full audit."
),
evaluations=0,
residual_evaluations=0,
dense_fallback_used=False,
nonlinear_block_count=0,
nonlinear_block_unknown_count=0,
block_fallback_used=False,
block_fallback_reason=None,
residual_verified_this_solve=False,
causal_fast_path_used=True,
)
def _causal_v2_fast_diagnostics(self) -> AlgebraicSolveDiagnostics:
cached = self._causal_cached_fast_diagnostics
if cached is None:
raise RuntimeError("Stream causal execution has no residual audit.")
return cached
def _causal_fast_diagnostics(
self,
@@ -725,10 +810,10 @@ class StreamPressureBlockSolver:
f"{binding.neighbor.name}.{binding.neighbor_port}.p",
)
)
for binding in solver._pnor_pnl0001_series_plan:
for binding in solver._resistance_pnl0001_series_plan:
special_seed_target_ids.extend(
(
f"{binding.orifice.name}.{binding.orifice_port}.p",
f"{binding.resistance.name}.{binding.resistance_port}.p",
f"{binding.pipe.name}.{binding.pipe_port}.p",
)
)
@@ -774,19 +859,12 @@ class StreamPressureBlockSolver:
# closure deliberately mirrors ``solver.solve(effort_variables=())``:
# the primary global solve has already propagated equal pressures.
solver._seed_closed_resistance_pressures()
solver._seed_pnor_pnl0001_series_pressures()
solver._seed_resistance_pnl0001_series_pressures()
for unknown in self._selected_flow_unknowns:
unknown.write(0.0)
for stage in self._selected_explicit_flow_plan:
values = solver._evaluate_explicit_flow_stage(stage)
targets = tuple(
(
assignment,
assignment.unknown.read() - value,
)
for assignment, value in zip(stage.assignments, values)
)
for assignment, target_value in targets:
for assignment, target_value in zip(stage.assignments, values):
if isfinite(target_value):
assignment.unknown.write(target_value)
seeded_equation_ids.add(assignment.equation_id)
@@ -795,6 +873,45 @@ class StreamPressureBlockSolver:
unknown.write(entry_values[position])
return frozenset(seeded_equation_ids)
def _execute_compiled_secondary_flow_plan(self) -> str | None:
"""Execute selected flow assignments without equation-id sets."""
if self.pressure_flow_solver.causal_coordinate_kernel_enabled:
failure = (
self.pressure_flow_solver._execute_causal_coordinate_flow_stages(
self._causal_secondary_flow_kernel_plan,
self._causal_secondary_coordinate_values,
)
)
if failure == "causalFlowAssignmentCountMismatch":
return "causalSecondaryFlowAssignmentCountMismatch"
if failure == "nonFiniteCausalFlowAssignment":
return "nonFiniteCausalSecondaryFlowAssignment"
if failure and failure.startswith("causalFlowEvaluationFailed:"):
return "causalSecondaryFlowEvaluationFailed:" + failure.rsplit(
":", 1
)[-1]
return failure
for unknown in self._selected_flow_unknowns:
unknown.write(0.0)
for stage in self._selected_explicit_flow_plan:
try:
values = self.pressure_flow_solver._evaluate_explicit_flow_stage(
stage
)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, ValueError) as exc:
return f"causalSecondaryFlowEvaluationFailed:{type(exc).__name__}"
if len(values) != len(stage.assignments):
return "causalSecondaryFlowAssignmentCountMismatch"
for assignment, target_value in zip(stage.assignments, values):
if not isfinite(target_value):
return "nonFiniteCausalSecondaryFlowAssignment"
assignment.unknown.write(target_value)
return None
@staticmethod
def _equation_scales_from_specs(
specs: tuple[_EquationScaleSpec, ...],
@@ -1090,11 +1207,62 @@ class StreamPressureBlockSolver:
scale_context: Mapping[str, float] | None = None,
) -> StreamBlockSolveResult:
solver = self.pressure_flow_solver
context = dict(scale_context or solver.scale_context())
causal_candidate = self.causal_fast_path_enabled
causal_audit_due = (
self._causal_audit_is_due() if causal_candidate else False
)
causal_v2_candidate = (
causal_candidate
and solver._causal_executor_v2_environment_enabled
and not causal_audit_due
)
if causal_v2_candidate:
# The secondary causal proof rejects every special pressure seed,
# so this executor mutates selected flow coordinates only. Keep
# the minimal transactional snapshot for the rare fallback path.
v2_entry_values = self._causal_v2_entry_values
for position, unknown in enumerate(self._selected_flow_unknowns):
v2_entry_values[position] = unknown.state.m_flow
def restore_v2_entry_mutations() -> None:
for unknown, value in zip(
self._selected_flow_unknowns,
v2_entry_values,
):
unknown.state.m_flow = value
try:
v2_failure_reason = (
self._execute_compiled_secondary_flow_plan()
)
except BaseException:
restore_v2_entry_mutations()
raise
if v2_failure_reason is None:
diagnostics = self._causal_v2_fast_diagnostics()
self._causal_fast_solve_count += 1
self._causal_v2_fast_solve_count += 1
if solver.causal_coordinate_kernel_enabled:
self._causal_coordinate_fast_solve_count += 1
self._causal_solves_since_audit += 1
selected = self._selected_equation_evaluation
assert selected is not None
return StreamBlockSolveResult(
diagnostics=(diagnostics,),
scopes=(selected.scope_components,),
used_global_fallback=False,
)
restore_v2_entry_mutations()
self._causal_v2_runtime_validation_failure_count += 1
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path(v2_failure_reason)
causal_candidate = False
causal_audit_due = False
# Keep scale construction and the full mutation snapshot off the v2
# success path. Callers may still precompute a shared scale mapping;
# avoiding that producer requires a later Generic-system API change.
context = dict(scale_context or solver.scale_context())
entry_values = tuple(
unknown.read() for unknown in self._entry_mutated_unknowns
)
+794
View File
@@ -0,0 +1,794 @@
"""Executable reference IR for compile-proven causal algebraic programs.
The IR eliminates duplicate *logical* effort coordinates, but intentionally
keeps a compatibility scatter map to existing ``PortState`` objects. Stream
propagation, derivatives, and result collection still consume those objects;
this is a reference for a future flat backend, not physical slot deletion.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass, replace
from enum import StrEnum
from hashlib import sha256
import json
from math import isfinite
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import numpy as np
CAUSAL_NUMERIC_IR_SCHEMA_VERSION = 1
PRESSURE_LOWER_BOUND_PA = 0.0
class CausalIROpcode(StrEnum):
EFFORT_BROADCAST = "effort_broadcast"
EFFORT_DIRECT_RESIDUAL = "effort_direct_residual"
EFFORT_COMPONENT_RESIDUAL = "effort_component_residual"
FLOW_DIRECT = "flow_direct"
FLOW_COMPONENT_RESIDUAL = "flow_component_residual"
@dataclass(frozen=True, slots=True)
class CausalIRCompatibilitySlot:
slot: int
id: str
variable: str
@dataclass(frozen=True, slots=True)
class CausalIRCanonicalSlot:
slot: int
id: str
variable: str
kind: str
@dataclass(frozen=True, slots=True)
class CausalIREffortOperation:
opcode: CausalIROpcode
variable: str
result_slot: int
anchor_compatibility_slot: int
scatter_compatibility_slots: tuple[int, ...]
equation_id: str
@dataclass(frozen=True, slots=True)
class CausalIREffortEvaluation:
opcode: CausalIROpcode
output_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
equation_ids: tuple[str, ...]
evaluator_slot: int
@dataclass(frozen=True, slots=True)
class CausalIREffortStage:
variable: str
operations: tuple[CausalIREffortOperation, ...]
evaluations: tuple[CausalIREffortEvaluation, ...]
@dataclass(frozen=True, slots=True)
class CausalIRFlowOperation:
opcode: CausalIROpcode
output_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
equation_ids: tuple[str, ...]
evaluator_slot: int
@dataclass(frozen=True, slots=True)
class CausalIRFlowStage:
target_slots: tuple[int, ...]
scatter_compatibility_slots: tuple[int, ...]
equation_ids: tuple[str, ...]
operations: tuple[CausalIRFlowOperation, ...]
@dataclass(frozen=True, slots=True)
class CausalIRProgram:
"""Immutable callback-free structure used as the backend cache key."""
schema_version: int
canonical_slots: tuple[CausalIRCanonicalSlot, ...]
compatibility_slots: tuple[CausalIRCompatibilitySlot, ...]
reset_compatibility_slots: tuple[int, ...]
external_effort_compatibility_slots: tuple[int, ...]
effort_stages: tuple[CausalIREffortStage, ...]
flow_stages: tuple[CausalIRFlowStage, ...]
structural_signature: str
@property
def assignment_count(self) -> int:
return len(self.canonical_slots)
@property
def effort_group_count(self) -> int:
return sum(len(stage.operations) for stage in self.effort_stages)
@property
def flow_assignment_count(self) -> int:
return sum(len(stage.target_slots) for stage in self.flow_stages)
@property
def effort_scatter_count(self) -> int:
return sum(
len(operation.scatter_compatibility_slots)
for stage in self.effort_stages
for operation in stage.operations
)
@property
def eliminated_effort_replica_count(self) -> int:
return self.effort_scatter_count - self.effort_group_count
@property
def maximum_effort_stage_width(self) -> int:
return max((len(stage.operations) for stage in self.effort_stages), default=0)
@property
def maximum_flow_stage_width(self) -> int:
return max((len(stage.target_slots) for stage in self.flow_stages), default=0)
def structural_dict(self) -> dict[str, object]:
return {
"schemaVersion": self.schema_version,
"canonicalSlots": [
{
"slot": item.slot,
"id": item.id,
"variable": item.variable,
"kind": item.kind,
}
for item in self.canonical_slots
],
"compatibilitySlots": [
{"slot": item.slot, "id": item.id, "variable": item.variable}
for item in self.compatibility_slots
],
"resetCompatibilitySlots": list(self.reset_compatibility_slots),
"externalEffortCompatibilitySlots": list(
self.external_effort_compatibility_slots
),
"effortStages": [
{
"variable": stage.variable,
"operations": [
{
"opcode": operation.opcode.value,
"resultSlot": operation.result_slot,
"anchorCompatibilitySlot": (
operation.anchor_compatibility_slot
),
"scatterCompatibilitySlots": list(
operation.scatter_compatibility_slots
),
"equationId": operation.equation_id,
}
for operation in stage.operations
],
"evaluations": [
{
"opcode": evaluation.opcode.value,
"outputIndices": list(evaluation.output_indices),
"equationIndices": list(evaluation.equation_indices),
"equationIds": list(evaluation.equation_ids),
"evaluatorSlot": evaluation.evaluator_slot,
}
for evaluation in stage.evaluations
],
}
for stage in self.effort_stages
],
"flowStages": [
{
"targetSlots": list(stage.target_slots),
"scatterCompatibilitySlots": list(
stage.scatter_compatibility_slots
),
"equationIds": list(stage.equation_ids),
"operations": [
{
"opcode": operation.opcode.value,
"outputIndices": list(operation.output_indices),
"equationIndices": list(operation.equation_indices),
"equationIds": list(operation.equation_ids),
"evaluatorSlot": operation.evaluator_slot,
}
for operation in stage.operations
],
}
for stage in self.flow_stages
],
}
def calculate_structural_signature(self) -> str:
payload = json.dumps(
self.structural_dict(),
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return sha256(payload).hexdigest()
@dataclass(frozen=True, slots=True)
class CausalIRBindings:
readers: tuple[Callable[[], float], ...]
writers: tuple[Callable[[float], None], ...]
evaluators: tuple[Callable[[], object], ...]
@dataclass(slots=True)
class CausalIRWorkspace:
structural_signature: str
canonical_values: "np.ndarray[Any, Any]"
effort_residuals: "np.ndarray[Any, Any]"
effort_written: "np.ndarray[Any, Any]"
flow_values: "np.ndarray[Any, Any]"
flow_written: "np.ndarray[Any, Any]"
transaction_values: "np.ndarray[Any, Any]"
@dataclass(frozen=True, slots=True)
class CausalIRExecutionResult:
success: bool
fallback_reason: str | None
structural_signature: str
effort_assignment_count: int
flow_assignment_count: int
completed_effort_stage_count: int
completed_flow_stage_count: int
rolled_back: bool
StageObserver = Callable[
[str, int, tuple[int, ...], tuple[float, ...]],
None,
]
@dataclass(frozen=True, slots=True)
class CausalNumericIR:
"""Bound reference IR; its normal path performs no full snapshot."""
program: CausalIRProgram
bindings: CausalIRBindings
def create_workspace(self) -> CausalIRWorkspace:
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise RuntimeError("The causal numeric reference IR requires NumPy.") from exc
return CausalIRWorkspace(
structural_signature=self.program.structural_signature,
canonical_values=np.empty(
max(len(self.program.canonical_slots), 1), dtype=np.float64
),
effort_residuals=np.empty(
max(self.program.maximum_effort_stage_width, 1), dtype=np.float64
),
effort_written=np.empty(
max(self.program.maximum_effort_stage_width, 1), dtype=np.bool_
),
flow_values=np.empty(
max(self.program.maximum_flow_stage_width, 1), dtype=np.float64
),
flow_written=np.empty(
max(self.program.maximum_flow_stage_width, 1), dtype=np.bool_
),
transaction_values=np.empty(
max(len(self.program.compatibility_slots), 1), dtype=np.float64
),
)
def execute(
self,
workspace: CausalIRWorkspace,
*,
effort_variables: tuple[str, ...] = ("p",),
transactional: bool = False,
stage_observer: StageObserver | None = None,
) -> CausalIRExecutionResult:
"""Interpret the IR; transactional snapshots are audit-only."""
program = self.program
bindings = self.bindings
signature = program.structural_signature
if workspace.structural_signature != signature:
raise ValueError("Causal IR workspace belongs to a different program.")
if len(bindings.readers) != len(program.compatibility_slots) or len(
bindings.writers
) != len(program.compatibility_slots):
raise ValueError("Causal IR compatibility binding count is inconsistent.")
if any(variable not in {"p", "x", "v"} for variable in effort_variables):
return CausalIRExecutionResult(
False, "unsupportedEffortVariable", signature, 0, 0, 0, 0, False
)
snapshot_count = 0
if transactional:
try:
for slot, reader in enumerate(bindings.readers):
workspace.transaction_values[slot] = float(reader())
snapshot_count += 1
except MemoryError:
raise
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
return CausalIRExecutionResult(
False,
f"slotReadFailed:{type(exc).__name__}",
signature,
0,
0,
0,
0,
False,
)
effort_count = 0
flow_count = 0
completed_effort_stages = 0
completed_flow_stages = 0
def failed(reason: str) -> CausalIRExecutionResult:
rolled_back = False
if transactional:
for slot in range(snapshot_count):
bindings.writers[slot](float(workspace.transaction_values[slot]))
rolled_back = True
return CausalIRExecutionResult(
False,
reason,
signature,
effort_count,
flow_count,
completed_effort_stages,
completed_flow_stages,
rolled_back,
)
selected_efforts = frozenset(effort_variables)
for stage_index, stage in enumerate(program.effort_stages):
if stage.variable not in selected_efforts:
continue
width = len(stage.operations)
workspace.effort_written[:width] = False
for evaluation in stage.evaluations:
try:
evaluated = bindings.evaluators[evaluation.evaluator_slot]()
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL:
output = evaluation.output_indices[0]
workspace.effort_residuals[output] = float(evaluated)
workspace.effort_written[output] = True
continue
if not hasattr(evaluated, "__len__"):
raise TypeError("component evaluator returned no sequence")
for output, equation in zip(
evaluation.output_indices, evaluation.equation_indices
):
if equation >= len(evaluated):
raise IndexError("component equation disappeared")
workspace.effort_residuals[output] = float(evaluated[equation])
workspace.effort_written[output] = True
except MemoryError:
raise
except Exception as exc:
return failed(f"effortEvaluationFailed:{type(exc).__name__}")
if any(not bool(workspace.effort_written[index]) for index in range(width)):
return failed("effortEvaluationCoverageMismatch")
for output, operation in enumerate(stage.operations):
try:
anchor = float(bindings.readers[operation.anchor_compatibility_slot]())
target = anchor - float(workspace.effort_residuals[output])
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
return failed(f"effortAssignmentFailed:{type(exc).__name__}")
if not isfinite(target) or (
stage.variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
return failed("nonFiniteOrInvalidEffortAssignment")
workspace.canonical_values[operation.result_slot] = target
for slot in operation.scatter_compatibility_slots:
bindings.writers[slot](target)
effort_count += 1
completed_effort_stages += 1
if stage_observer is not None:
try:
stage_observer(
f"effort:{stage.variable}",
stage_index,
tuple(item.result_slot for item in stage.operations),
tuple(
float(workspace.canonical_values[item.result_slot])
for item in stage.operations
),
)
except MemoryError:
raise
except Exception as exc:
return failed(f"stageObserverFailed:{type(exc).__name__}")
try:
external_finite = all(
isfinite(float(bindings.readers[slot]()))
for slot in program.external_effort_compatibility_slots
)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
return failed(f"externalEffortReadFailed:{type(exc).__name__}")
if not external_finite:
return failed("nonFiniteExternalEffort")
for slot in program.reset_compatibility_slots:
bindings.writers[slot](0.0)
for stage_index, stage in enumerate(program.flow_stages):
width = len(stage.target_slots)
workspace.flow_written[:width] = False
for operation in stage.operations:
try:
evaluated = bindings.evaluators[operation.evaluator_slot]()
if operation.opcode is CausalIROpcode.FLOW_DIRECT:
output = operation.output_indices[0]
workspace.flow_values[output] = float(evaluated)
workspace.flow_written[output] = True
continue
if not hasattr(evaluated, "__len__"):
raise TypeError("component evaluator returned no sequence")
for output, equation in zip(
operation.output_indices, operation.equation_indices
):
if equation >= len(evaluated):
raise IndexError("component equation disappeared")
# Targets are zero before the stage; preserve -residual.
workspace.flow_values[output] = -float(evaluated[equation])
workspace.flow_written[output] = True
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
return failed(f"flowEvaluationFailed:{type(exc).__name__}")
if any(not bool(workspace.flow_written[index]) for index in range(width)):
return failed("flowAssignmentCoverageMismatch")
for output, (canonical, compatibility) in enumerate(
zip(stage.target_slots, stage.scatter_compatibility_slots)
):
target = float(workspace.flow_values[output])
if not isfinite(target):
return failed("nonFiniteFlowAssignment")
workspace.canonical_values[canonical] = target
bindings.writers[compatibility](target)
flow_count += 1
completed_flow_stages += 1
if stage_observer is not None:
try:
stage_observer(
"flow",
stage_index,
stage.target_slots,
tuple(float(workspace.flow_values[i]) for i in range(width)),
)
except MemoryError:
raise
except Exception as exc:
return failed(f"stageObserverFailed:{type(exc).__name__}")
return CausalIRExecutionResult(
True,
None,
signature,
effort_count,
flow_count,
completed_effort_stages,
completed_flow_stages,
False,
)
@dataclass(frozen=True, slots=True)
class CausalIRCompilation:
ir: CausalNumericIR | None
fallback_reason: str | None
@property
def supported(self) -> bool:
return self.ir is not None and self.fallback_reason is None
def _unsupported(reason: str) -> CausalIRCompilation:
return CausalIRCompilation(ir=None, fallback_reason=reason)
def _unique_slots(items: Iterable[int]) -> tuple[int, ...]:
return tuple(dict.fromkeys(int(item) for item in items))
def _compile_effort_evaluations(
operations: tuple[CausalIREffortOperation, ...],
anchor_evaluators: tuple[Callable[[], float], ...],
direct_residuals: tuple[bool, ...],
component_locations: dict[
str, tuple[object, Callable[[], tuple[float, ...]], int]
],
evaluators: list[Callable[[], object]],
) -> tuple[CausalIREffortEvaluation, ...]:
grouped: dict[int, list[tuple[int, int, str]]] = {}
component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {}
direct: list[tuple[int, Callable[[], float], str]] = []
for output, (operation, anchor_evaluate, direct_residual) in enumerate(
zip(operations, anchor_evaluators, direct_residuals)
):
location = (
None
if direct_residual
else component_locations.get(operation.equation_id)
)
if location is None:
direct.append((output, anchor_evaluate, operation.equation_id))
continue
owner, evaluate, equation = location
key = id(owner)
component_callbacks[key] = evaluate
grouped.setdefault(key, []).append((output, equation, operation.equation_id))
compiled: list[CausalIREffortEvaluation] = []
for output, evaluate, equation_id in direct:
evaluator = len(evaluators)
evaluators.append(evaluate)
compiled.append(
CausalIREffortEvaluation(
CausalIROpcode.EFFORT_DIRECT_RESIDUAL,
(output,),
(),
(equation_id,),
evaluator,
)
)
for key, entries in grouped.items():
evaluator = len(evaluators)
evaluators.append(component_callbacks[key])
compiled.append(
CausalIREffortEvaluation(
CausalIROpcode.EFFORT_COMPONENT_RESIDUAL,
tuple(item[0] for item in entries),
tuple(item[1] for item in entries),
tuple(item[2] for item in entries),
evaluator,
)
)
return tuple(compiled)
def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
"""Lower a compile-proven global plan; unsupported plans fail closed."""
if not bool(getattr(solver, "_causal_fast_path_eligible", False)):
return _unsupported(
str(
getattr(solver, "_causal_fast_path_fallback_reason", None)
or "causalProofNotAvailable"
)
)
try:
unknowns = tuple(getattr(solver, "unknowns"))
effort_plan = getattr(solver, "_causal_effort_plan_by_variable")
flow_plan = tuple(getattr(solver, "_explicit_flow_plan"))
component_plan = tuple(getattr(solver, "_component_equation_plan"))
reset_unknowns = tuple(
getattr(solver, "_explicit_flow_unknowns_by_variables")[
frozenset(("f", "m_flow"))
]
)
external_unknowns = tuple(
getattr(solver, "_causal_external_effort_unknowns")
)
except (AttributeError, KeyError, TypeError):
return _unsupported("unsupportedCausalSolverContract")
unknown_ids = tuple(str(item.id) for item in unknowns)
if len(set(unknown_ids)) != len(unknown_ids):
return _unsupported("duplicateAlgebraicUnknown")
compatibility_slot_by_id = {
unknown_id: slot for slot, unknown_id in enumerate(unknown_ids)
}
compatibility_slots = tuple(
CausalIRCompatibilitySlot(slot, unknown_id, str(unknown.variable))
for slot, (unknown_id, unknown) in enumerate(zip(unknown_ids, unknowns))
)
readers = tuple(item.read for item in unknowns)
writers = tuple(item.write for item in unknowns)
evaluators: list[Callable[[], object]] = []
canonical_slots: list[CausalIRCanonicalSlot] = []
component_locations: dict[
str, tuple[object, Callable[[], tuple[float, ...]], int]
] = {}
try:
for plan in component_plan:
for equation, template in enumerate(plan.templates):
component_locations[str(template.id)] = (
plan.component,
plan.evaluate,
equation,
)
except (AttributeError, TypeError):
return _unsupported("unsupportedComponentEvaluationContract")
effort_stages: list[CausalIREffortStage] = []
try:
for variable in ("p", "x", "v"):
operations: list[CausalIREffortOperation] = []
anchors: list[Callable[[], float]] = []
direct_residuals: list[bool] = []
for assignment in effort_plan[variable]:
result = len(canonical_slots)
equation_id = str(assignment.anchor.equation_id)
scatter = tuple(
compatibility_slot_by_id[item.id]
for item in assignment.members
)
if not scatter or len(set(scatter)) != len(scatter):
return _unsupported("invalidEffortScatterSlots")
canonical_slots.append(
CausalIRCanonicalSlot(
result,
f"effort:{variable}:{equation_id}",
variable,
"effort_group",
)
)
operations.append(
CausalIREffortOperation(
CausalIROpcode.EFFORT_BROADCAST,
variable,
result,
compatibility_slot_by_id[assignment.anchor.unknown.id],
scatter,
equation_id,
)
)
causal_evaluate = getattr(
assignment.anchor,
"causal_evaluate",
None,
)
anchors.append(
causal_evaluate
if causal_evaluate is not None
else assignment.anchor.evaluate
)
direct_residuals.append(causal_evaluate is not None)
operation_tuple = tuple(operations)
effort_stages.append(
CausalIREffortStage(
variable,
operation_tuple,
_compile_effort_evaluations(
operation_tuple,
tuple(anchors),
tuple(direct_residuals),
component_locations,
evaluators,
),
)
)
except (AttributeError, KeyError, TypeError):
return _unsupported("unsupportedEffortPlanContract")
flow_stages: list[CausalIRFlowStage] = []
try:
for stage in flow_plan:
scatter = tuple(
compatibility_slot_by_id[item.unknown.id]
for item in stage.assignments
)
equation_ids = tuple(str(item.equation_id) for item in stage.assignments)
if len(set(scatter)) != len(scatter):
return _unsupported("duplicateFlowTargetInStage")
targets: list[int] = []
for assignment in stage.assignments:
target = len(canonical_slots)
targets.append(target)
canonical_slots.append(
CausalIRCanonicalSlot(
target,
f"flow:{assignment.unknown.id}",
str(assignment.unknown.variable),
"flow_assignment",
)
)
covered: list[int] = []
operations: list[CausalIRFlowOperation] = []
for output, evaluate in stage.direct_evaluations:
output = int(output)
evaluator = len(evaluators)
evaluators.append(evaluate)
operations.append(
CausalIRFlowOperation(
CausalIROpcode.FLOW_DIRECT,
(output,),
(),
(equation_ids[output],),
evaluator,
)
)
covered.append(output)
for evaluation in stage.component_evaluations:
evaluator = len(evaluators)
evaluators.append(evaluation.evaluate)
outputs = tuple(int(item) for item in evaluation.assignment_indices)
operations.append(
CausalIRFlowOperation(
CausalIROpcode.FLOW_COMPONENT_RESIDUAL,
outputs,
tuple(int(item) for item in evaluation.equation_indices),
tuple(str(item) for item in evaluation.equation_ids),
evaluator,
)
)
covered.extend(outputs)
if sorted(covered) != list(range(len(scatter))):
return _unsupported("flowStageEvaluationCoverageMismatch")
flow_stages.append(
CausalIRFlowStage(
tuple(targets), scatter, equation_ids, tuple(operations)
)
)
except (AttributeError, IndexError, KeyError, TypeError):
return _unsupported("unsupportedFlowPlanContract")
try:
reset_slots = _unique_slots(
compatibility_slot_by_id[item.id] for item in reset_unknowns
)
external_slots = _unique_slots(
compatibility_slot_by_id[item.id] for item in external_unknowns
)
except (AttributeError, KeyError):
return _unsupported("unknownCausalBoundarySlot")
flow_scatter = tuple(
item for stage in flow_stages for item in stage.scatter_compatibility_slots
)
if len(set(flow_scatter)) != len(flow_scatter):
return _unsupported("duplicateExplicitFlowAssignment")
if set(flow_scatter) != set(reset_slots):
return _unsupported("incompleteExplicitFlowCoverage")
program = CausalIRProgram(
CAUSAL_NUMERIC_IR_SCHEMA_VERSION,
tuple(canonical_slots),
compatibility_slots,
reset_slots,
external_slots,
tuple(effort_stages),
tuple(flow_stages),
"",
)
program = replace(
program, structural_signature=program.calculate_structural_signature()
)
return CausalIRCompilation(
CausalNumericIR(
program,
CausalIRBindings(readers, writers, tuple(evaluators)),
),
None,
)
File diff suppressed because it is too large. Load diff
+387 -15
View File
@@ -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:
+593 -43
View File
@@ -12,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)
@@ -28,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
@@ -45,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."""
@@ -58,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,
@@ -73,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)
@@ -309,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))
@@ -374,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)
@@ -451,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(
@@ -565,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)
@@ -595,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.
@@ -617,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]
@@ -632,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."
@@ -676,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():
@@ -695,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"}:
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:
@@ -707,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,
@@ -716,7 +1063,7 @@ def _integrate_scipy_stepwise(
integration_end,
**solver_options,
)
except _IntegrationCancelled:
except IntegrationCancelled:
status = "cancelled"
message = cancellation_message()
break
@@ -724,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"
@@ -754,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."
@@ -765,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 > 16 or next_step <= minimum_step:
if recoverable_retry_count
<= _MAX_RECOVERABLE_RETRIES
else None
)
segment_recoverable_retry_diagnostics.append(
RecoverableRetryDiagnostics(
phase="step",
attempted_step=attempted_step,
reason=str(exc),
next_max_step=(
retry_steps[0]
if retry_steps is not None
else None
),
next_first_step=(
retry_steps[1]
if retry_steps is not None
else None
),
)
)
if retry_steps is None:
status = "failed"
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:
@@ -789,21 +1187,62 @@ 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 <= 16
and next_step > minimum_step
):
segment_max_step = next_step
if recoverable_retry_count
<= _MAX_RECOVERABLE_RETRIES
else None
)
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]
@@ -858,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(
@@ -915,7 +1357,6 @@ 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"
@@ -957,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),
@@ -970,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":
@@ -1034,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.
@@ -1045,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
@@ -1070,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,
)
@@ -1093,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,
@@ -1118,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
+20
View File
@@ -60,6 +60,16 @@ class StreamResolver:
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
@@ -122,6 +132,16 @@ class StreamResolver:
)
return values
@profile_phase("simulation.refresh", minimum_mode="audit")
def refresh_flow_temperature_references(self) -> None:
"""Refresh pressure-flow property inputs without changing stream outflows."""
connected = self.connected_temperature_reference_enthalpies()
for component in self._flow_temperature_reference_components:
component.update_flow_temperature_references(
connected[component.name]
)
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(self) -> None:
for component in self._dynamic_components:
File diff suppressed because it is too large. Load diff
+567
View File
@@ -0,0 +1,567 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from copy import copy
from dataclasses import dataclass, replace
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.ports import PortState
_STREAM_CACHE_ATTRIBUTE_NAMES = frozenset(
{
"_connected_h",
"temperature_reference_h",
}
)
def _is_stream_cache_attribute(name: str) -> bool:
"""Return whether an attribute belongs to the stream/temperature replay state.
Catalog components currently use ``_connected_h`` and
``temperature_reference_h``. The name-based extension keeps conservative
third-party caches recoverable without copying an entire component graph.
Components with opaque cache names can provide the explicit hooks documented
by :class:`ThermofluidTransactionPlan`.
"""
lowered = name.lower()
return (
name in _STREAM_CACHE_ATTRIBUTE_NAMES
or lowered.startswith("_stream_")
or "connected_h" in lowered
or "connected_enthalpy" in lowered
or "temperature_reference" in lowered
)
def _copy_cache_value(value: object) -> object:
"""Shallow-copy a stream cache without traversing the component graph."""
if isinstance(value, (dict, list, set, bytearray)):
return copy(value)
return value
@dataclass(frozen=True)
class ThermofluidWorstPort:
component: str
port: str
value: float
signed_delta: float
def as_dict(self) -> dict[str, object]:
return {
"component": self.component,
"port": self.port,
"value": self.value,
"signedDelta": self.signed_delta,
}
@dataclass(frozen=True)
class ThermofluidIterationDelta:
iteration: int
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
def as_dict(self) -> dict[str, object]:
return {
"iteration": self.iteration,
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
}
@dataclass(frozen=True)
class ThermofluidClosureSuccess:
rhs_time: float
iterations: int
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
@classmethod
def from_iteration(
cls,
rhs_time: float,
delta: ThermofluidIterationDelta,
) -> ThermofluidClosureSuccess:
return cls(
rhs_time=float(rhs_time),
iterations=delta.iteration,
max_delta=delta.max_delta,
scale=delta.scale,
tolerance=delta.tolerance,
worst_port=delta.worst_port,
)
def as_dict(self) -> dict[str, object]:
return {
"rhsTime": self.rhs_time,
"iterations": self.iterations,
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
}
@dataclass(frozen=True)
class ThermofluidClosureFailure:
failed_rhs_time: float
iterations: int
delta_tail: tuple[ThermofluidIterationDelta, ...]
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
failure_count: int = 0
@classmethod
def from_iterations(
cls,
failed_rhs_time: float,
deltas: Sequence[ThermofluidIterationDelta],
*,
tail_limit: int = 8,
) -> ThermofluidClosureFailure:
if not deltas:
raise ValueError("A thermofluid failure requires iteration diagnostics.")
final = deltas[-1]
return cls(
failed_rhs_time=float(failed_rhs_time),
iterations=final.iteration,
delta_tail=tuple(deltas[-tail_limit:]),
max_delta=final.max_delta,
scale=final.scale,
tolerance=final.tolerance,
worst_port=final.worst_port,
)
def as_dict(self) -> dict[str, object]:
return {
"failedRhsTime": self.failed_rhs_time,
"iterations": self.iterations,
"deltaTail": [item.as_dict() for item in self.delta_tail],
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
"failureCount": self.failure_count,
}
class ThermofluidClosureError(RecoverableTrialStateError):
"""Recoverable exhaustion of the stream/pressure-flow fixed point.
Stream propagation failures and algebraic-solver failures intentionally
retain their original exception types: rollback is still applied, but a
smaller ODE step is not known to repair those structural/numerical errors.
"""
def __init__(self, diagnostics: ThermofluidClosureFailure) -> None:
super().__init__(
"Stream enthalpy and pressure-flow coupling did not converge "
f"after {diagnostics.iterations} iterations at "
f"t={diagnostics.failed_rhs_time:.17g}."
)
self.diagnostics = diagnostics
class ThermofluidClosureDiagnostics:
"""Run-level RHS outcomes; maintenance/postprocessing calls do not write it."""
def __init__(self) -> None:
self.failure_count = 0
self.last_failure: ThermofluidClosureFailure | None = None
self.last_success: ThermofluidClosureSuccess | None = None
def record_success(self, success: ThermofluidClosureSuccess) -> None:
self.last_success = success
def record_failure(
self,
failure: ThermofluidClosureFailure,
) -> ThermofluidClosureFailure:
self.failure_count += 1
recorded = replace(failure, failure_count=self.failure_count)
self.last_failure = recorded
return recorded
def as_dict(self) -> dict[str, object]:
return {
"failureCount": self.failure_count,
"lastFailure": (
self.last_failure.as_dict()
if self.last_failure is not None
else None
),
"lastSuccess": (
self.last_success.as_dict()
if self.last_success is not None
else None
),
}
@dataclass(frozen=True)
class _PortValueBinding:
component_name: str
port_name: str
state: PortState
variable: str
@dataclass(frozen=True)
class _PortFieldPlan:
variable: str
states: tuple[PortState, ...]
@dataclass(frozen=True)
class _FlowBinding:
component_name: str
port_name: str
state: PortState
@dataclass(frozen=True)
class _ComponentCacheBinding:
component: object
attribute_names: tuple[str, ...]
attribute_name_set: frozenset[str]
snapshot_hook: Callable[[], object] | None
restore_hook: Callable[[object], None] | None
@dataclass
class ThermofluidTransactionSnapshot:
plan: ThermofluidTransactionPlan
port_values: tuple[list[float], ...]
component_cache_values: tuple[list[object], ...]
custom_cache_values: list[object | None]
diagnostic_values: list[object]
def restore(self) -> None:
plan = self.plan
plan._restore_port_values(self.port_values)
for binding, values, custom_value in zip(
plan.component_cache_bindings,
self.component_cache_values,
self.custom_cache_values,
):
component = binding.component
for name in tuple(getattr(component, "__dict__", {})):
if (
name.startswith("_causal_")
or _is_stream_cache_attribute(name)
) and name not in binding.attribute_name_set:
delattr(component, name)
for name, value in zip(binding.attribute_names, values):
setattr(component, name, _copy_cache_value(value))
if binding.restore_hook is not None:
binding.restore_hook(custom_value)
for owner, value in zip(
plan.diagnostic_owners,
self.diagnostic_values,
):
owner.last_diagnostics = value
class ThermofluidTransactionPlan:
"""Compiled, lightweight rollback boundary for one Generic RHS closure.
It snapshots active physical-port values, catalog stream-temperature caches,
component ``_causal_*`` seed fields, and resolver/solver last diagnostics.
A custom stream-aware component with an opaque mutable cache can implement
both ``snapshot_thermofluid_closure_cache()`` and
``restore_thermofluid_closure_cache(snapshot)``; these hooks are invoked in
addition to the standard name-based cache capture.
"""
def __init__(
self,
*,
port_value_bindings: tuple[_PortValueBinding, ...],
port_field_plans: tuple[_PortFieldPlan, ...],
flow_bindings: tuple[_FlowBinding, ...],
component_cache_bindings: tuple[_ComponentCacheBinding, ...],
component_count: int,
diagnostic_owners: tuple[object, ...],
) -> None:
self.port_value_bindings = port_value_bindings
self.port_field_plans = port_field_plans
self.flow_bindings = flow_bindings
self.component_cache_bindings = component_cache_bindings
self.component_count = component_count
self.diagnostic_owners = diagnostic_owners
self._snapshot = ThermofluidTransactionSnapshot(
plan=self,
port_values=tuple(
[0.0] * len(field.states)
for field in port_field_plans
),
component_cache_values=tuple(
[None] * len(binding.attribute_names)
for binding in component_cache_bindings
),
custom_cache_values=[None] * len(component_cache_bindings),
diagnostic_values=[None] * len(diagnostic_owners),
)
@classmethod
def compile(
cls,
network: object,
*,
diagnostic_owners: Sequence[object] = (),
) -> ThermofluidTransactionPlan:
components = tuple(getattr(network, "components").values())
port_value_bindings: list[_PortValueBinding] = []
port_states_by_variable: dict[str, list[PortState]] = {}
flow_bindings: list[_FlowBinding] = []
component_cache_bindings: list[_ComponentCacheBinding] = []
for component in components:
active_definitions = tuple(
definition
for definition in component.active_port_definitions
if definition.kind == "physical"
)
for definition in active_definitions:
state = component.get_port(definition.name)
flow_bindings.append(
_FlowBinding(component.name, definition.name, state)
)
for variable in definition.variables:
port_states_by_variable.setdefault(variable.name, []).append(state)
port_value_bindings.append(
_PortValueBinding(
component.name,
definition.name,
state,
variable.name,
)
)
attribute_names = tuple(
name
for name in getattr(component, "__dict__", {})
if name.startswith("_causal_")
or _is_stream_cache_attribute(name)
)
snapshot_hook = getattr(
component,
"snapshot_thermofluid_closure_cache",
None,
)
restore_hook = getattr(
component,
"restore_thermofluid_closure_cache",
None,
)
hooks_are_available = callable(snapshot_hook) and callable(restore_hook)
if attribute_names or hooks_are_available:
component_cache_bindings.append(
_ComponentCacheBinding(
component=component,
attribute_names=attribute_names,
attribute_name_set=frozenset(attribute_names),
snapshot_hook=(snapshot_hook if hooks_are_available else None),
restore_hook=(restore_hook if hooks_are_available else None),
)
)
owners = tuple(
dict.fromkeys(
owner
for owner in diagnostic_owners
if hasattr(owner, "last_diagnostics")
)
)
return cls(
port_value_bindings=tuple(port_value_bindings),
port_field_plans=tuple(
_PortFieldPlan(variable, tuple(states))
for variable, states in port_states_by_variable.items()
),
flow_bindings=tuple(flow_bindings),
component_cache_bindings=tuple(component_cache_bindings),
component_count=len(components),
diagnostic_owners=owners,
)
def capture(self) -> ThermofluidTransactionSnapshot:
# GenericFluidSystem executes one RHS serially. Reuse one compiled
# workspace rather than allocating a snapshot object and several outer
# tuples at every successful trial point.
snapshot = self._snapshot
self._capture_port_values(snapshot.port_values)
for binding, values in zip(
self.component_cache_bindings,
snapshot.component_cache_values,
):
for position, name in enumerate(binding.attribute_names):
values[position] = _copy_cache_value(
getattr(binding.component, name)
)
for position, binding in enumerate(self.component_cache_bindings):
snapshot.custom_cache_values[position] = (
binding.snapshot_hook()
if binding.snapshot_hook is not None
else None
)
for position, owner in enumerate(self.diagnostic_owners):
snapshot.diagnostic_values[position] = owner.last_diagnostics
return snapshot
def _capture_port_values(
self,
workspaces: tuple[list[float], ...],
) -> None:
for field, values in zip(self.port_field_plans, workspaces):
variable = field.variable
states = field.states
if variable == "p":
for position, state in enumerate(states):
values[position] = state.p
elif variable == "m_flow":
for position, state in enumerate(states):
values[position] = state.m_flow
elif variable == "h_outflow":
for position, state in enumerate(states):
values[position] = state.h_outflow
elif variable == "volume":
for position, state in enumerate(states):
values[position] = state.volume
elif variable == "volume_flow":
for position, state in enumerate(states):
values[position] = state.volume_flow
elif variable == "x":
for position, state in enumerate(states):
values[position] = state.x
elif variable == "v":
for position, state in enumerate(states):
values[position] = state.v
elif variable == "f":
for position, state in enumerate(states):
values[position] = state.f
else:
for position, state in enumerate(states):
values[position] = getattr(state, variable)
def _restore_port_values(
self,
workspaces: tuple[list[float], ...],
) -> None:
for field, values in zip(self.port_field_plans, workspaces):
variable = field.variable
states = field.states
if variable == "p":
for state, value in zip(states, values):
state.p = value
elif variable == "m_flow":
for state, value in zip(states, values):
state.m_flow = value
elif variable == "h_outflow":
for state, value in zip(states, values):
state.h_outflow = value
elif variable == "volume":
for state, value in zip(states, values):
state.volume = value
elif variable == "volume_flow":
for state, value in zip(states, values):
state.volume_flow = value
elif variable == "x":
for state, value in zip(states, values):
state.x = value
elif variable == "v":
for state, value in zip(states, values):
state.v = value
elif variable == "f":
for state, value in zip(states, values):
state.f = value
else:
for state, value in zip(states, values):
setattr(state, variable, value)
def flow_values(self) -> tuple[float, ...]:
return tuple(float(binding.state.m_flow) for binding in self.flow_bindings)
def measure_flow_delta(
self,
previous: Sequence[float],
*,
iteration: int,
relative_tolerance: float,
) -> ThermofluidIterationDelta:
current = self.flow_values()
scale = max(
(abs(value) for value in (*previous, *current)),
default=1.0,
)
scale = max(scale, 1.0)
worst_index = -1
worst_signed_delta = 0.0
max_delta = 0.0
for index, (old, new) in enumerate(zip(previous, current)):
signed_delta = new - old
magnitude = abs(signed_delta)
if magnitude > max_delta:
worst_index = index
worst_signed_delta = signed_delta
max_delta = magnitude
worst_port = None
if worst_index >= 0:
binding = self.flow_bindings[worst_index]
worst_port = ThermofluidWorstPort(
component=binding.component_name,
port=binding.port_name,
value=current[worst_index],
signed_delta=worst_signed_delta,
)
return ThermofluidIterationDelta(
iteration=int(iteration),
max_delta=max_delta,
scale=scale,
tolerance=float(relative_tolerance) * scale,
worst_port=worst_port,
)
def diagnostics(self) -> dict[str, int]:
stream_cache_slot_count = sum(
len(binding.attribute_names)
for binding in self.component_cache_bindings
)
return {
"physicalPortValueSlotCount": len(self.port_value_bindings),
"physicalFlowPortCount": len(self.flow_bindings),
"componentCount": self.component_count,
"cacheBindingCount": len(self.component_cache_bindings),
"streamAndCausalCacheSlotCount": stream_cache_slot_count,
"customCacheHookCount": sum(
binding.snapshot_hook is not None
for binding in self.component_cache_bindings
),
"diagnosticOwnerCount": len(self.diagnostic_owners),
}
+476 -61
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, replace
from math import floor, isfinite
import os
from sys import maxsize
from typing import Literal
from app.simulation.core.base import Component, DynamicComponent
@@ -12,6 +14,10 @@ from app.simulation.performance import performance_span, profile_phase
from app.simulation.property_cache import with_property_cache
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver
from app.simulation.solvers.jacobian import (
SparseJacobianCompatibilityError,
SparseSecantJacobian,
)
from app.simulation.solvers.mechanical import (
MechanicalConstraintGroup,
MechanicalStateReducer,
@@ -21,15 +27,66 @@ from app.simulation.solvers.pneumatic_storage import (
ideal_storage_group_is_reducible,
)
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from app.simulation.solvers.solver import (
IntegrationCancelled,
ODESolution,
SolveIVPConfig,
SolverActivityTracker,
integrate_ode,
)
from app.simulation.solvers.signal import SignalResolver
from app.simulation.solvers.stream import StreamResolver
from app.simulation.solvers.tangent import (
ThreePistonTangentCompilation,
ThreePistonTangentProvider,
compile_supported_piston_tangent_provider,
)
from app.simulation.solvers.thermofluid import (
ThermofluidClosureDiagnostics,
ThermofluidClosureError,
ThermofluidClosureFailure,
ThermofluidClosureSuccess,
ThermofluidTransactionPlan,
)
from app.simulation.systems.network import Endpoint, SimulationNetwork
SimulationProgressCallback = Callable[[float, str], None]
SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE = "SIMULATION_ODE_JACOBIAN_MODE"
def _requested_ode_jacobian_mode() -> Literal[
"optimized",
"hybrid",
"semi-analytic",
"scipy",
]:
value = os.getenv(
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE,
"scipy",
).strip().lower()
if value in {"optimized", "colored"}:
return "optimized"
if value in {"hybrid", "secant"}:
return "hybrid"
if value in {"semi-analytic", "semi_analytic", "analytic"}:
return "semi-analytic"
if value in {
"scipy",
"native",
"finite-difference",
"0",
"false",
"no",
"off",
}:
return "scipy"
raise ValueError(
f"{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE} must be "
"'optimized', 'hybrid', 'semi-analytic', or 'scipy'."
)
@dataclass(frozen=True)
@@ -66,10 +123,6 @@ class SimulationPreparationError(ValueError):
self.issues = issues
class ThermofluidClosureError(RuntimeError):
"""Raised when stream enthalpy and pressure-flow do not reach one fixed point."""
class SimulationSampleTimeError(ValueError):
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
@@ -261,14 +314,7 @@ def simulation_preparation_issues(
def simulation_sample_times(
config: SolveIVPConfig,
step: float,
*,
max_points: int = 10001,
) -> list[float]:
if max_points < 2:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_LIMIT_INVALID",
"Simulation sample limit must allow at least two points.",
)
t_start = float(config.t_start)
t_stop = float(config.t_stop)
if not isfinite(t_start) or not isfinite(t_stop):
@@ -293,33 +339,31 @@ def simulation_sample_times(
"Simulation stop time must be greater than start time.",
)
# Bound the grid before dividing by a potentially tiny step or allocating
# the result list. This avoids both float-to-int overflow and an OOM-sized
# ``range``/list when input comes from an external System XML document.
maximum_interval_count = max_points - 1
if step < duration / maximum_interval_count:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation sample count exceeds the limit of {max_points}; "
"increase sampleStep.",
)
ratio = duration / step
if not isfinite(ratio):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation sample count exceeds the limit of {max_points}; "
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
"Simulation sample count cannot be represented by this runtime; "
"increase sampleStep.",
)
interval_count = floor(ratio)
# There is no product-level point cap. Still reject a collection that the
# Python runtime cannot index before multiplying by the potentially huge
# interval count or allocating the output grid.
if interval_count > maxsize - 2:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
"Simulation sample count cannot be represented by this runtime; "
"increase sampleStep.",
)
interval_count = int(floor(ratio))
last_regular_time = t_start + interval_count * step
append_stop = last_regular_time < t_stop
requested_point_count = interval_count + 1 + int(append_stop)
if requested_point_count > max_points:
if requested_point_count > maxsize:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation requests {requested_point_count} samples; "
f"the limit is {max_points}.",
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
"Simulation sample count cannot be represented by this runtime; "
"increase sampleStep.",
)
times = [t_start]
@@ -380,6 +424,17 @@ class GenericFluidSystem:
self.signal_resolver = SignalResolver(network)
self.stream_resolver = StreamResolver(network)
self._thermofluid_closure_plan = self._build_thermofluid_closure_plan()
self._thermofluid_transaction_plan = ThermofluidTransactionPlan.compile(
network,
diagnostic_owners=(
self.signal_resolver,
self.pneumatic_volume_resolver,
self.stream_resolver,
self.pressure_flow_solver,
*self._thermofluid_closure_plan.secondary_pressure_solvers,
),
)
self._thermofluid_closure_diagnostics = ThermofluidClosureDiagnostics()
self.algebraic_solve_count = 0
self.algebraic_seeded_solve_count = 0
self.algebraic_nonlinear_solve_count = 0
@@ -398,6 +453,8 @@ class GenericFluidSystem:
self.signal_propagation_count = 0
self.pneumatic_volume_propagation_count = 0
self._jacobian_sparsity = None
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
self._activity_tracker: SolverActivityTracker | None = None
def _request_causal_residual_audit(self) -> None:
"""Make topology or mode boundaries verify the next causal closure."""
@@ -874,12 +931,69 @@ class GenericFluidSystem:
"colorGroupCount": group_count,
}
def _exact_ode_jacobian_rows(self) -> dict[int, dict[int, float]]:
"""Return mode-independent kinematic rows safe to evaluate exactly."""
rows: dict[int, dict[int, float]] = {}
cursor = 0
for entry in self.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
# A discrete endstop can replace x' = v with x' = 0 for the
# active constrained mode. Keep those rows numerical; free
# mechanical groups always have d(x')/d(v) = 1.
if not entry.discrete_endstop_components:
rows[cursor + 1] = {cursor: 1.0}
cursor += 2
else:
cursor += entry.state_size
return rows
@profile_phase(
"simulation.closure",
minimum_mode="audit",
reset_property_shadow=True,
)
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
def _close_current_state(
self,
time: float,
*,
record_rhs_outcome: bool = False,
) -> dict[str, dict[str, float]]:
if self._activity_tracker is not None:
self._activity_tracker.record_thermofluid_closure(time)
transaction = self._thermofluid_transaction_plan.capture()
last_algebraic_diagnostics = self._last_algebraic_diagnostics
last_algebraic_scope = self._last_algebraic_scope
try:
connected_h, success = self._close_current_state_unchecked(time)
except ThermofluidClosureError as exc:
transaction.restore()
self._last_algebraic_diagnostics = last_algebraic_diagnostics
self._last_algebraic_scope = last_algebraic_scope
self._request_causal_residual_audit()
if record_rhs_outcome:
failure = self._thermofluid_closure_diagnostics.record_failure(
exc.diagnostics
)
raise ThermofluidClosureError(failure) from None
raise
except BaseException:
transaction.restore()
self._last_algebraic_diagnostics = last_algebraic_diagnostics
self._last_algebraic_scope = last_algebraic_scope
self._request_causal_residual_audit()
raise
if record_rhs_outcome:
self._thermofluid_closure_diagnostics.record_success(success)
return connected_h
def _close_current_state_unchecked(
self,
time: float,
) -> tuple[
dict[str, dict[str, float]],
ThermofluidClosureSuccess,
]:
signal = self.signal_resolver.solve(time)
self.signal_propagation_count += signal.propagated
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
@@ -905,27 +1019,22 @@ class GenericFluidSystem:
closure_plan = self._thermofluid_closure_plan
self._last_algebraic_diagnostics = initial_algebraic
self._last_algebraic_scope = closure_plan.global_component_group
physical_ports = closure_plan.physical_ports
secondary_pressure_solvers = closure_plan.secondary_pressure_solvers
secondary_block_solvers = closure_plan.secondary_block_solvers
connected_h: dict[str, dict[str, float]] = {}
stream_diagnostics = []
coupling_deltas = []
max_coupling_iterations = 25
flow_relative_tolerance = 1.0e-12
for coupling_iteration in range(1, max_coupling_iterations + 1):
previous_flows = tuple(port.m_flow for port in physical_ports)
previous_flows = self._thermofluid_transaction_plan.flow_values()
stream, connected_h = self.stream_resolver.solve(
dynamic_ports_are_current=True,
)
stream_diagnostics.append(stream)
temperature_reference_h = (
self.stream_resolver.connected_temperature_reference_enthalpies()
)
for component in self.dynamic_components:
component.update_stream_outflows(connected_h[component.name])
component.update_flow_temperature_references(
temperature_reference_h[component.name]
)
self.stream_resolver.refresh_flow_temperature_references()
if secondary_pressure_solvers:
self.thermofluid_pressure_pass_count += 1
block_scale_context = (
@@ -977,26 +1086,23 @@ class GenericFluidSystem:
self._last_algebraic_diagnostics = algebraic
self._last_algebraic_scope = component_group
pressure_flow_solve_count += 1
current_flows = tuple(port.m_flow for port in physical_ports)
flow_scale = max(
[abs(value) for value in (*previous_flows, *current_flows)] + [1.0]
)
max_flow_delta = max(
(
abs(current - previous)
for previous, current in zip(previous_flows, current_flows)
),
default=0.0,
coupling_delta = self._thermofluid_transaction_plan.measure_flow_delta(
previous_flows,
iteration=coupling_iteration,
relative_tolerance=flow_relative_tolerance,
)
coupling_deltas.append(coupling_delta)
if (
not secondary_pressure_solvers
or max_flow_delta <= flow_relative_tolerance * flow_scale
or coupling_delta.max_delta <= coupling_delta.tolerance
):
break
else:
raise ThermofluidClosureError(
"Stream enthalpy and pressure-flow coupling did not converge "
f"after {max_coupling_iterations} iterations."
ThermofluidClosureFailure.from_iterations(
time,
coupling_deltas,
)
)
self.max_thermofluid_iterations = max(
self.max_thermofluid_iterations,
@@ -1039,7 +1145,10 @@ class GenericFluidSystem:
self.max_stream_iterations,
*(item.iterations for item in stream_diagnostics),
)
return connected_h
return connected_h, ThermofluidClosureSuccess.from_iteration(
time,
coupling_deltas[-1],
)
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(self) -> None:
@@ -1051,6 +1160,8 @@ class GenericFluidSystem:
self,
connected_h: dict[str, dict[str, float]],
) -> list[float]:
if self._activity_tracker is not None:
self._activity_tracker.record_phase("state_derivatives")
return self.pneumatic_storage_reducer.coupled_derivatives(
self.mechanical_state_reducer.state_derivatives(connected_h)
)
@@ -1064,8 +1175,15 @@ class GenericFluidSystem:
@profile_phase("simulation.rhs", minimum_mode="audit")
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector)
connected_h = self._close_current_state(_time)
return self._state_derivatives(connected_h)
connected_h = self._close_current_state(
_time,
record_rhs_outcome=True,
)
derivatives = self._state_derivatives(connected_h)
provider = self._ode_tangent_provider
if provider is not None:
provider.record_primal(_time, state_vector, connected_h)
return derivatives
def _append_current_state(self, series: dict[str, list[float]]) -> None:
for component in self.network.components.values():
@@ -1082,7 +1200,32 @@ class GenericFluidSystem:
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> GenericSimulationResult:
previous_activity_tracker = self._activity_tracker
self._activity_tracker = activity_tracker
try:
return self._simulate(
config,
sample_step=sample_step,
progress_callback=progress_callback,
cancel_check=cancel_check,
activity_tracker=activity_tracker,
)
finally:
self._activity_tracker = previous_activity_tracker
def _simulate(
self,
config: SolveIVPConfig,
*,
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> GenericSimulationResult:
if activity_tracker is not None:
activity_tracker.record_phase("initializing", config.t_start)
last_reported_progress = -1.0
last_reported_phase = ""
@@ -1111,12 +1254,21 @@ class GenericFluidSystem:
report_progress(0.0, "initializing", force=True)
with performance_span("simulation.sample_initialization"):
integration_config = config
mechanical_tolerance_plan = None
if isinstance(config.atol, (int, float)):
mechanical_tolerance_plan = (
self.mechanical_state_reducer.absolute_tolerance_plan(
float(config.atol),
mode=(
None
if config.method == "BDF"
else "legacy"
),
)
)
integration_config = replace(
config,
atol=self.mechanical_state_reducer.absolute_tolerances(
float(config.atol)
),
atol=list(mechanical_tolerance_plan.values),
)
t_eval = simulation_sample_times(config, sample_step)
signal_event_times = self.signal_resolver.event_times(
@@ -1156,12 +1308,94 @@ class GenericFluidSystem:
report_solver_time(time)
return self.rhs(time, state_vector)
jacobian = None
jacobian_fallback_reason: str | None = None
tangent_compilation: ThreePistonTangentCompilation | None = None
selected_tangent_provider: ThreePistonTangentProvider | None = None
self._ode_tangent_provider = None
requested_jacobian_mode = (
_requested_ode_jacobian_mode()
if jac_sparsity is not None
else "scipy"
)
if (
jac_sparsity is not None
and requested_jacobian_mode
in {"optimized", "hybrid", "semi-analytic"}
):
state_count = int(jac_sparsity.shape[0])
if (
requested_jacobian_mode == "hybrid"
and not self.pressure_flow_solver.causal_fast_path_enabled
):
jacobian_fallback_reason = "causalAlgebraicExecutionUnavailable"
elif int(jac_sparsity.nnz) >= state_count * state_count:
jacobian_fallback_reason = "denseStateDependencyPattern"
else:
exact_columns = None
if requested_jacobian_mode == "semi-analytic":
tangent_compilation = (
compile_supported_piston_tangent_provider(self)
)
if tangent_compilation.eligible:
provider = tangent_compilation.provider
if provider is None:
raise RuntimeError(
"An eligible tangent compilation has no provider."
)
selected_tangent_provider = provider
exact_columns = (
tangent_compilation.columns,
provider,
)
else:
jacobian_fallback_reason = (
f"semiAnalytic:{tangent_compilation.reason}"
)
if (
requested_jacobian_mode != "semi-analytic"
or tangent_compilation is not None
and tangent_compilation.eligible
):
def evaluate_jacobian_rhs(time, state):
if cancel_check is not None and cancel_check():
raise IntegrationCancelled
if activity_tracker is not None:
activity_tracker.record_rhs(float(time))
return monitored_rhs(
time,
[float(value) for value in state],
)
try:
jacobian = SparseSecantJacobian(
evaluate_jacobian_rhs,
jac_sparsity,
integration_config.atol,
exact_rows=self._exact_ode_jacobian_rows(),
exact_columns=exact_columns,
max_consecutive_reuses=(
1
if requested_jacobian_mode == "hybrid"
else 0
),
)
self._ode_tangent_provider = (
selected_tangent_provider
)
except SparseJacobianCompatibilityError as exc:
jacobian_fallback_reason = (
f"scipyCompatibility:{type(exc).__name__}"
)
def handle_state_transition(*args):
transition = self.mechanical_state_reducer.state_transition(*args)
if transition is not None:
self._request_causal_residual_audit()
return transition
try:
solution = integrate_ode(
rhs=monitored_rhs,
initial_state=initial_state,
@@ -1178,7 +1412,12 @@ class GenericFluidSystem:
else None
),
jac_sparsity=jac_sparsity,
jac=jacobian,
recoverable_trial_retries=True,
activity_tracker=activity_tracker,
)
finally:
self._ode_tangent_provider = None
if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status
integration_error = solution.error
@@ -1192,6 +1431,11 @@ class GenericFluidSystem:
else max(0.0, last_reported_progress)
)
report_progress(postprocess_progress, "postprocessing", force=True)
if activity_tracker is not None:
activity_tracker.record_phase(
"postprocessing",
furthest_solver_time,
)
times = [float(value) for value in solution.t]
if isinstance(solution, ODESolution):
solver_segment_diagnostics = [
@@ -1212,6 +1456,44 @@ class GenericFluidSystem:
"recoverableRetryCount": 0,
}
]
if jacobian is not None:
direct_jacobian = jacobian.diagnostics()
solver_segment_diagnostics[0].update(
{
"jacobianEvaluationCount": int(
direct_jacobian["jacobianEvaluationCount"]
),
"jacobianFullBuildCount": int(
direct_jacobian["fullBuildCount"]
),
"jacobianSecantReuseCount": int(
direct_jacobian["secantReuseCount"]
),
"jacobianAuditFailureCount": int(
direct_jacobian["auditFailureCount"]
),
"finiteDifferenceRhsEvaluationCount": int(
direct_jacobian[
"finiteDifferenceRhsEvaluationCount"
]
),
"jacobianBaseRhsEvaluationCount": int(
direct_jacobian["baseRhsEvaluationCount"]
),
"jacobianJvAuditRhsEvaluationCount": int(
direct_jacobian["jvAuditEvaluationCount"]
),
"exactColumnBuildCount": int(
direct_jacobian["exactColumnBuildCount"]
),
"exactColumnFallbackCount": int(
direct_jacobian["exactColumnFallbackCount"]
),
"jacobianAssemblySeconds": float(
direct_jacobian["assemblySeconds"]
),
}
)
solver_total_keys = (
"nfev",
"njev",
@@ -1225,21 +1507,123 @@ class GenericFluidSystem:
key: sum(int(segment[key]) for segment in solver_segment_diagnostics)
for key in solver_total_keys
}
jacobian_work_keys = (
"jacobianEvaluationCount",
"jacobianFullBuildCount",
"jacobianSecantReuseCount",
"jacobianAuditFailureCount",
"finiteDifferenceRhsEvaluationCount",
"jacobianBaseRhsEvaluationCount",
"jacobianJvAuditRhsEvaluationCount",
"exactColumnBuildCount",
"exactColumnFallbackCount",
"jacobianAssemblySeconds",
)
for key in jacobian_work_keys:
if any(key in segment for segment in solver_segment_diagnostics):
solver_totals[key] = sum(
segment.get(key, 0)
for segment in solver_segment_diagnostics
)
jacobian_diagnostics = (
self.jacobian_sparsity_diagnostics()
if integration_config.method in {"BDF", "Radau"}
else None
)
runtime_jacobian_diagnostics: dict[str, object] | None = None
if jacobian_diagnostics is not None:
color_group_count = int(jacobian_diagnostics["colorGroupCount"])
if jacobian is None:
for segment in solver_segment_diagnostics:
segment["finiteDifferenceRhsEstimate"] = (
int(segment["njev"]) * color_group_count
)
runtime_jacobian_diagnostics = {
"mode": "scipySparseFiniteDifference",
"fallbackReason": jacobian_fallback_reason,
"jacobianEvaluationCount": int(solver_totals["njev"]),
"fullBuildCount": int(solver_totals["njev"]),
"finiteDifferenceRhsEstimateIsExact": False,
}
else:
for segment in solver_segment_diagnostics:
segment["finiteDifferenceRhsEstimate"] = int(
segment.get("finiteDifferenceRhsEvaluationCount", 0)
) + int(
segment.get("jacobianJvAuditRhsEvaluationCount", 0)
)
runtime_jacobian_diagnostics = dict(jacobian.diagnostics())
runtime_jacobian_diagnostics.update(
{
"fallbackReason": jacobian_fallback_reason,
"finiteDifferenceRhsEstimateIsExact": True,
}
)
if tangent_compilation is not None:
runtime_jacobian_diagnostics["tangentCompilation"] = (
tangent_compilation.diagnostics()
)
if tangent_compilation.eligible and jacobian is not None:
runtime_jacobian_diagnostics["mode"] = (
"semiAnalyticExactColumns"
)
exact_builds = int(
runtime_jacobian_diagnostics[
"exactColumnBuildCount"
]
)
exact_fallbacks = int(
runtime_jacobian_diagnostics[
"exactColumnFallbackCount"
]
)
if exact_builds == 0 and exact_fallbacks == 0:
effective_mode = "notEvaluated"
elif exact_builds == 0:
effective_mode = "numericalFallbackOnly"
elif exact_fallbacks:
effective_mode = "mixedExactAndNumericalFallback"
else:
effective_mode = "exactColumns"
runtime_jacobian_diagnostics["effectiveMode"] = (
effective_mode
)
if exact_fallbacks:
runtime_jacobian_diagnostics[
"runtimeFallbackReason"
] = runtime_jacobian_diagnostics[
"lastExactColumnFallbackReason"
]
solver_totals["finiteDifferenceRhsEstimate"] = sum(
int(segment["finiteDifferenceRhsEstimate"])
for segment in solver_segment_diagnostics
)
if jacobian is None:
solver_totals["jacobianRhsEvaluationCountEstimate"] = (
int(solver_totals["finiteDifferenceRhsEstimate"])
+ int(solver_totals["njev"])
)
else:
solver_totals["jacobianRhsEvaluationCount"] = (
int(
solver_totals.get(
"finiteDifferenceRhsEvaluationCount",
0,
)
)
+ int(
solver_totals.get(
"jacobianBaseRhsEvaluationCount",
0,
)
)
+ int(
solver_totals.get(
"jacobianJvAuditRhsEvaluationCount",
0,
)
)
)
with performance_span("simulation.postprocessing"):
series: dict[str, list[float]] = {"time": []}
@@ -1265,6 +1649,11 @@ class GenericFluidSystem:
self._close_current_state(times[time_index])
self._append_current_state(series)
series["time"].append(times[time_index])
if activity_tracker is not None:
activity_tracker.record_phase(
"postprocessing",
times[time_index],
)
except Exception as exc:
run_status = "failed"
result_message = str(exc)
@@ -1285,7 +1674,16 @@ class GenericFluidSystem:
diagnostics = {
"integration": {
"method": integration_config.method,
"mechanicalAbsoluteTolerance": (
mechanical_tolerance_plan.as_dict()
if mechanical_tolerance_plan is not None
else {
"mode": "callerVector",
"stateCount": len(initial_state),
}
),
"jacobianSparsity": jacobian_diagnostics,
"jacobian": runtime_jacobian_diagnostics,
"segmentCount": len(solver_segment_diagnostics),
"segments": solver_segment_diagnostics,
"totals": solver_totals,
@@ -1356,6 +1754,12 @@ class GenericFluidSystem:
"stream": {
"maxIterationsPerSolve": self.max_stream_iterations,
"maxThermofluidIterations": self.max_thermofluid_iterations,
"thermofluidClosure": {
**self._thermofluid_closure_diagnostics.as_dict(),
"transaction": (
self._thermofluid_transaction_plan.diagnostics()
),
},
"last": (
self.stream_resolver.last_diagnostics.as_dict()
if self.stream_resolver.last_diagnostics is not None
@@ -1387,6 +1791,19 @@ class GenericFluidSystem:
for variable in self.network.result_variable_metadata()
if variable.key in series
)
final_simulated_time = (
float(series["time"][-1])
if series["time"]
else float(config.t_start)
)
if activity_tracker is not None:
activity_tracker.record_phase(
"complete" if run_status == "completed" else run_status,
final_simulated_time,
)
diagnostics["activity"] = (
activity_tracker.snapshot().as_dict()
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
@@ -1397,9 +1814,7 @@ class GenericFluidSystem:
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
final_simulated_time
),
requested_stop_time=float(config.t_stop),
variables=variables,
+40
View File
@@ -0,0 +1,40 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
title SystemSimulationApp Launcher
set "BACKEND_SCRIPT=%~dp0start-backend.bat"
set "FRONTEND_SCRIPT=%~dp0start-reactflow.bat"
if not exist "%BACKEND_SCRIPT%" (
echo [ERROR] start-backend.bat was not found:
echo %BACKEND_SCRIPT%
pause
exit /b 1
)
if not exist "%FRONTEND_SCRIPT%" (
echo [ERROR] start-reactflow.bat was not found:
echo %FRONTEND_SCRIPT%
pause
exit /b 1
)
echo Starting FastAPI and ReactFlow in separate windows...
set "LAUNCH_ERROR=0"
ver >nul
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%BACKEND_SCRIPT%"
if errorlevel 1 set "LAUNCH_ERROR=1"
ver >nul
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%FRONTEND_SCRIPT%"
if errorlevel 1 set "LAUNCH_ERROR=1"
if "%LAUNCH_ERROR%"=="1" (
echo.
echo [ERROR] One or more service windows could not be created.
pause
exit /b 1
)
exit /b 0
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -u
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
echo "[ERROR] start-all.sh requires Bash 4.3 or newer." >&2
exit 1
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
BACKEND_SCRIPT="$SCRIPT_DIR/start-backend.sh"
FRONTEND_SCRIPT="$SCRIPT_DIR/start-reactflow.sh"
if [[ ! -x "$BACKEND_SCRIPT" ]]; then
echo "[ERROR] Backend start script is missing or not executable:" >&2
echo " $BACKEND_SCRIPT" >&2
exit 1
fi
if [[ ! -x "$FRONTEND_SCRIPT" ]]; then
echo "[ERROR] Frontend start script is missing or not executable:" >&2
echo " $FRONTEND_SCRIPT" >&2
exit 1
fi
BACKEND_PID=""
FRONTEND_PID=""
signal_process_group() {
local signal="$1"
local pid="$2"
[[ -n "$pid" ]] || return 0
kill "-$signal" -- "-$pid" 2>/dev/null || kill "-$signal" "$pid" 2>/dev/null || true
}
process_group_is_running() {
local pid="$1"
[[ -n "$pid" ]] && kill -0 -- "-$pid" 2>/dev/null
}
cleanup() {
local backend_cleared=false
local force_kill=false
local frontend_cleared=false
local launcher_pid=$$
local timer_pid
trap '' INT TERM HUP
trap - EXIT
trap 'force_kill=true' ALRM
signal_process_group TERM "$BACKEND_PID"
signal_process_group TERM "$FRONTEND_PID"
(
sleep 5
kill -ALRM "$launcher_pid" 2>/dev/null || true
) &
timer_pid=$!
if [[ -n "$BACKEND_PID" ]]; then
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ "$force_kill" == false && -n "$FRONTEND_PID" ]]; then
wait "$FRONTEND_PID" 2>/dev/null || true
fi
while [[ "$force_kill" == false ]]; do
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
backend_cleared=true
fi
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
frontend_cleared=true
fi
if [[ "$backend_cleared" == true && "$frontend_cleared" == true ]]; then
break
fi
sleep 0.1
done
kill -KILL "$timer_pid" 2>/dev/null || true
wait "$timer_pid" 2>/dev/null || true
if [[ "$force_kill" == true ]]; then
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
backend_cleared=true
fi
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
frontend_cleared=true
fi
if [[ "$backend_cleared" == false ]]; then
signal_process_group KILL "$BACKEND_PID"
fi
if [[ "$frontend_cleared" == false ]]; then
signal_process_group KILL "$FRONTEND_PID"
fi
fi
if [[ -n "$BACKEND_PID" ]]; then
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "$FRONTEND_PID" ]]; then
wait "$FRONTEND_PID" 2>/dev/null || true
fi
trap - ALRM
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
echo "Starting FastAPI and ReactFlow..."
echo "Press Ctrl+C to stop both services."
echo
set -m
"$BACKEND_SCRIPT" &
BACKEND_PID=$!
"$FRONTEND_SCRIPT" &
FRONTEND_PID=$!
set +m
wait -n
EXIT_CODE=$?
exit "$EXIT_CODE"
+17 -3
View File
@@ -1,15 +1,28 @@
@echo off
setlocal
setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0"
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
title SystemSimulationApp FastAPI - 127.0.0.1:8000
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe"
set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe"
if not exist "%PYTHON_EXE%" (
echo [ERROR] Python virtual environment was not found:
echo %PYTHON_EXE%
echo.
echo Create it and install the backend dependencies first:
echo py -3 -m venv "%REPO_ROOT%\.venv-win"
echo "%PYTHON_EXE%" -m pip install -r "%REPO_ROOT%\requirements.txt"
echo.
pause
exit /b 1
)
pushd "%REPO_ROOT%" >nul
if errorlevel 1 (
echo [ERROR] Unable to enter the repository directory:
echo %REPO_ROOT%
echo.
pause
exit /b 1
)
@@ -27,4 +40,5 @@ if not "%EXIT_CODE%"=="0" (
pause
)
popd
exit /b %EXIT_CODE%
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
PYTHON_EXE="${SYSTEM_SIMULATION_PYTHON:-$REPO_ROOT/.venv/bin/python}"
if [[ "$PYTHON_EXE" != /* ]]; then
PYTHON_EXE="$REPO_ROOT/$PYTHON_EXE"
fi
if [[ ! -x "$PYTHON_EXE" ]]; then
echo "[ERROR] Python virtual environment was not found:" >&2
echo " $PYTHON_EXE" >&2
echo >&2
echo "Create it and install the backend dependencies first:" >&2
echo " python3 -m venv \"$REPO_ROOT/.venv\"" >&2
echo " \"$REPO_ROOT/.venv/bin/python\" -m pip install -r \"$REPO_ROOT/requirements.txt\"" >&2
exit 1
fi
cd "$REPO_ROOT"
echo "Starting FastAPI at http://127.0.0.1:8000"
echo "Press Ctrl+C to stop the service."
echo
exec "$PYTHON_EXE" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+77
View File
@@ -0,0 +1,77 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
set "FRONTEND_DIR=%REPO_ROOT%\frontend"
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
if not exist "%FRONTEND_DIR%\package.json" (
echo [ERROR] Frontend package.json was not found:
echo %FRONTEND_DIR%\package.json
echo.
pause
exit /b 1
)
set "NODE_DIR="
for /d %%D in ("%REPO_ROOT%\.tools\node-*-win-x64") do (
if not defined NODE_DIR if exist "%%~fD\node.exe" if exist "%%~fD\npm.cmd" (
"%%~fD\node.exe" -e "v=process.versions.node.split('.');M=+v[0];m=+v[1];process.exit((M===20&&m>=19)||(M===22&&m>=12)||M>=23?0:1)" >nul 2>&1
if not errorlevel 1 set "NODE_DIR=%%~fD"
)
)
if not defined NODE_DIR (
echo [ERROR] A compatible Node.js portable runtime was not found under:
echo %REPO_ROOT%\.tools
echo Vite requires Node.js 20.19+ or 22.12+.
echo.
pause
exit /b 1
)
set "NPM_EXE=%NODE_DIR%\npm.cmd"
if not exist "%NPM_EXE%" (
echo [ERROR] npm.cmd was not found:
echo %NPM_EXE%
echo.
pause
exit /b 1
)
if not exist "%FRONTEND_DIR%\node_modules\.bin\vite.cmd" (
echo [ERROR] Frontend dependencies are not installed.
echo Run the following command first:
echo cd /d "%FRONTEND_DIR%"
echo call "%NPM_EXE%" ci
echo.
pause
exit /b 1
)
pushd "%FRONTEND_DIR%" >nul
if errorlevel 1 (
echo [ERROR] Unable to enter the frontend directory:
echo %FRONTEND_DIR%
echo.
pause
exit /b 1
)
set "PATH=%NODE_DIR%;%PATH%"
echo Starting ReactFlow at http://127.0.0.1:5173
echo Press Ctrl+C to stop the service.
echo.
call "%NPM_EXE%" run dev -- --strictPort
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
pause
)
popd
exit /b %EXIT_CODE%
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
FRONTEND_DIR="$REPO_ROOT/frontend"
node_version_supported() {
local version="$1"
local major
local minor
[[ "$version" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
(( (major == 20 && minor >= 19) || (major == 22 && minor >= 12) || major >= 23 ))
}
if [[ ! -f "$FRONTEND_DIR/package.json" ]]; then
echo "[ERROR] Frontend package.json was not found:" >&2
echo " $FRONTEND_DIR/package.json" >&2
exit 1
fi
NODE_EXE=""
NPM_EXE=""
for candidate in "$REPO_ROOT"/.tools/node-*-linux-x64/bin; do
if [[ -x "$candidate/node" && -x "$candidate/npm" ]]; then
CANDIDATE_VERSION="$("$candidate/node" --version 2>/dev/null || true)"
if node_version_supported "$CANDIDATE_VERSION"; then
NODE_EXE="$candidate/node"
NPM_EXE="$candidate/npm"
PATH="$candidate:${PATH:-}"
break
fi
fi
done
export PATH
if [[ -z "$NODE_EXE" ]]; then
NODE_EXE="$(command -v node || true)"
NPM_EXE="$(command -v npm || true)"
fi
if [[ -z "$NODE_EXE" || -z "$NPM_EXE" ]]; then
echo "[ERROR] Node.js and npm were not found." >&2
echo "Install Node.js 20.19+ or 22.12+ and make node/npm available on PATH." >&2
exit 1
fi
NODE_VERSION="$("$NODE_EXE" --version 2>/dev/null || true)"
if ! node_version_supported "$NODE_VERSION"; then
echo "[ERROR] Unsupported Node.js version: $NODE_VERSION" >&2
echo "Vite requires Node.js 20.19+ or 22.12+ (Node.js 21 is not supported)." >&2
exit 1
fi
if [[ ! -x "$FRONTEND_DIR/node_modules/.bin/vite" ]]; then
echo "[ERROR] Frontend dependencies are not installed." >&2
echo "Run the following command first:" >&2
echo " cd \"$FRONTEND_DIR\" && \"$NPM_EXE\" ci" >&2
exit 1
fi
cd "$FRONTEND_DIR"
echo "Starting ReactFlow at http://127.0.0.1:5173"
echo "Press Ctrl+C to stop the service."
echo
exec "$NPM_EXE" run dev -- --strictPort
+12
View File
@@ -0,0 +1,12 @@
# Reference direct-dependency set for CPython 3.12.
#
# This intentionally pins only packages imported or invoked directly by the
# application. In particular, it does not pin uvicorn[standard]'s optional,
# platform-dependent transitive dependencies. Regenerate and validate these
# pins in a clean CPython 3.12 environment when intentionally upgrading them.
fastapi==0.141.1
lxml==6.1.1
numpy==2.5.2
pydantic==2.13.4
scipy==1.18.0
uvicorn==0.52.3
+31
View File
@@ -0,0 +1,31 @@
# Fully resolved release lock for CPython 3.12 on manylinux_2_28-compatible
# Linux x86_64. Resolved and wheel-verified on 2026-08-18.
#
# Install this file as a requirements file (with -r), not as a constraint
# (with -c). Each package is pinned to the wheel selected for the reference
# platform, and pip verifies that wheel against its SHA-256 digest.
--only-binary=:all:
--require-hashes
annotated-doc==0.0.5 --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101
annotated-types==0.8.0 --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
anyio==4.14.2 --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494
click==8.4.2 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
fastapi==0.141.1 --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3
h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
httptools==0.8.0 --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2
idna==3.18 --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2
lxml==6.1.1 --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818
numpy==2.5.2 --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8
pydantic==2.13.4 --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba
pydantic-core==2.46.4 --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce
python-dotenv==1.2.3 --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
scipy==1.18.0 --hash=sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2
starlette==1.6.0 --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c
typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
uvicorn[standard]==0.52.3 --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c
uvloop==0.22.1 --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4
watchfiles==1.2.0 --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5
websockets==17.0.1 --hash=sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a
+24 -52
View File
@@ -1,65 +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` 书写规范
- [后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
- [仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)
以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。
## 模型开发
1. 按日期填写日志。每个文件只记录一天的更新,文件名使用 `更新日志-YYYY-MM-DD.md`,标题使用 `# 更新日志 YYYY-MM-DD`。
2. 同一天内有多次上传或更新时,按各项工作的实际完成时间分段记录。每段使用北京时间、24 小时制的 `## HH:mm` 标注时间,并按完成时间从早到晚排列。
3. 不同时间完成的内容应分别记录,不要合并到同一时间段。一次上传或更新包含多项相关修改时,可以写在同一时间段内。
4. 语言应简短且信息充分,让 AI 和人都能快速理解。优先说明完成了什么、结果是什么、影响哪些范围,删除重复描述和无关过程。
5. 尽量使用通俗易懂的语言,减少难以理解的术语。必须使用专业术语时,应提供必要的简短说明。
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 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
模型。不要从前端兜底数据反推后端物理契约。
```markdown
# 更新日志 YYYY-MM-DD
## System XML
## HH:mm
- [System XML v3 协议(当前规范)](system-xml-v3.md)
- [System XML v3 XSD(当前 Schema)](../schemas/system-simulation-v3.xsd)
- 完成的修改、结果及影响范围。
新增模型时,模型类和组件库清单是后端事实来源;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) |
## AI 使用原则
- 先读规范和相邻模型,再改代码。
- 只从 `library.py` 受控登记公开模型。
- 不在前端复制后端端口、参数或默认值作为正式来源。
- 不覆盖用户已有改动。
- 不自行猜测缺失的物理方程。
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
- 完成的修改、结果及影响范围。
```
@@ -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` 的压力、力、位移和容积轨迹,并补齐事件边界语义。
@@ -263,11 +263,11 @@ signal、stream 和外部 volume resolver 已把静态组件列表、端口引
| 代数残差容差 | `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 逐步推进分派
@@ -384,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 开发和部署连接数
@@ -406,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 事件循环。
@@ -424,7 +424,7 @@ O(组件 + 连接 + 代数结构)
+ O(采样数 × 公开结果变量数)
```
当前采样上限是 10001。放大因素包括:
当前不设置固定采样点数上限,调用方必须根据模型输出变量数和可用内存选择 `sampleStep`。放大因素包括:
- 积分状态矩阵与后处理 `series` 在后处理阶段同时存在;
- 最终完整结果保存在全局任务记录中,又被编码为一个大型 NDJSON 行;
@@ -593,5 +593,5 @@ O(组件 + 连接 + 代数结构)
| 可选性能埋点 | `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)
@@ -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. 界面显示声明
@@ -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 猜测模型语义。
@@ -564,11 +564,11 @@ class ExampleRestriction(AlgebraicComponent):
真实现有模型可参考:
- 储能元件:
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py)
[`cylinder.py`](../../app/simulation/components/experimental/storage/cylinder.py)
- 阻性元件:
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py)
[`orifice.py`](../../app/simulation/components/experimental/flow/orifice.py)
- 多端口连接元件:
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py)
[`tee.py`](../../app/simulation/components/experimental/junctions/tee.py)
## 14. 注册模型
@@ -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 生产构建通过,差异格式检查无异常。
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig } from "@playwright/test";
import { fileURLToPath } from "node:url";
const channel = process.env.PLAYWRIGHT_CHANNEL?.trim();
const executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH?.trim();
const frontendRoot = fileURLToPath(new URL(".", import.meta.url));
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
const simulationEnvironment = {
...process.env,
SIMULATION_CAUSAL_EXECUTOR_V2: "1",
SIMULATION_CAUSAL_COORDINATE_KERNEL: "1",
SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS: "1",
SIMULATION_CAUSAL_DIRECT_EQUATION_READERS: "1",
SIMULATION_CAUSAL_FAST_PATH: "1",
SIMULATION_MECHANICAL_ATOL_MODE: "legacy",
SIMULATION_ODE_JACOBIAN_MODE: "scipy",
SIMULATIONAPP_PROPERTY_CACHE: "on",
};
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
timeout: 600_000,
expect: {
timeout: 15_000,
},
outputDir: "test-results/live-mql8",
use: {
baseURL: "http://127.0.0.1:14173",
...(channel ? { channel } : {}),
...(executablePath ? { launchOptions: { executablePath } } : {}),
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: "only-on-failure",
trace: "on",
},
webServer: [
{
command:
".venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 18181",
cwd: repositoryRoot,
env: simulationEnvironment,
url: "http://127.0.0.1:18181/api/components/catalog",
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 },
},
{
command:
"node --input-type=module -e \"import { createServer } from 'vite'; import react from '@vitejs/plugin-react'; const server = await createServer({ configFile: false, root: process.cwd(), plugins: [react()], server: { host: '127.0.0.1', port: 14173, strictPort: true, proxy: { '/api': 'http://127.0.0.1:18181' } } }); await server.listen();\"",
cwd: frontendRoot,
url: "http://127.0.0.1:14173",
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 },
},
],
});
+1042 -231
View File
File diff suppressed because it is too large. Load diff
+216 -9
View File
@@ -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,21 +62,119 @@ 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}
@@ -44,9 +183,77 @@ export function ContactAwareEdge({
path={edgePath}
style={style}
/>
{editable
? internalSegments.map(({ end, index, start }) => (
<g
className="manual-edge-segment-control"
key={`${id}-segment-${index}`}
>
<line
aria-label={`调整连接线段 ${index}`}
className={`manual-edge-segment-handle ${edgeSegmentAxis(start, end)}`}
data-edge-id={id}
data-segment-index={index}
onPointerDown={(event) =>
beginRouteDrag(event, points, "segment", index)
}
x1={start.x}
x2={end.x}
y1={start.y}
y2={end.y}
/>
<line
aria-hidden="true"
className="manual-edge-segment-guide"
x1={start.x}
x2={end.x}
y1={start.y}
y2={end.y}
/>
</g>
))
: null}
{editable
? internalCorners.map(({ index, point }) => (
<circle
aria-label={`双向调整连接线拐点 ${index}`}
className="manual-edge-corner-handle"
cx={point.x}
cy={point.y}
data-corner-index={index}
data-edge-id={id}
key={`${id}-corner-${index}`}
onPointerDown={(event) =>
beginRouteDrag(event, points, "corner", index)
}
r={4.5}
/>
))
: null}
{editable && internalCorners.length === 0 && fallbackEnd ? (
<circle
aria-label="双向调整连接线路由"
className="manual-edge-route-handle"
cx={(fallbackStart.x + fallbackEnd.x) / 2}
cy={(fallbackStart.y + fallbackEnd.y) / 2}
data-edge-id={id}
onPointerDown={(event) =>
beginRouteDrag(event, points, "detour", fallbackIndex)
}
r={5}
/>
) : null}
</>
);
}
export function ContactAwareEdge(props: EdgeProps) {
if (props.data?.isContactEdge === true) {
return null;
}
return <RoutedEdge {...props} />;
}
export const contactAwareEdgeTypes: EdgeTypes = {
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
};
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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>
);
}
+536
View File
@@ -0,0 +1,536 @@
import { Position } from "@xyflow/react";
export type EdgeRoutePoint = {
x: number;
y: number;
};
export type EdgeCrossingJump = EdgeRoutePoint & {
orientation: "horizontal" | "vertical";
};
export type EdgeRouteData = {
crossingJumps?: EdgeCrossingJump[];
editable?: boolean;
isContactEdge?: boolean;
onRouteEditStart?: (edgeId: string) => void;
onRoutePointsChange?: (edgeId: string, routePoints: EdgeRoutePoint[]) => void;
routePoints?: EdgeRoutePoint[];
};
export type EdgePointAxis = "horizontal" | "vertical";
const ROUTE_EPSILON = 0.01;
const CROSSING_JUMP_RADIUS = 6;
export function edgeAxisForPosition(position: Position): EdgePointAxis {
return position === Position.Left || position === Position.Right
? "horizontal"
: "vertical";
}
export function edgePointIsFinite(point: EdgeRoutePoint) {
return Number.isFinite(point.x) && Number.isFinite(point.y);
}
export function edgePointsMatch(
first: EdgeRoutePoint,
second: EdgeRoutePoint,
epsilon = ROUTE_EPSILON,
) {
return (
Math.abs(first.x - second.x) <= epsilon &&
Math.abs(first.y - second.y) <= epsilon
);
}
function appendDistinctPoint(
points: EdgeRoutePoint[],
point: EdgeRoutePoint,
) {
if (!edgePointIsFinite(point)) {
return;
}
const previous = points.at(-1);
if (!previous || !edgePointsMatch(previous, point)) {
points.push({ x: point.x, y: point.y });
}
}
export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
const distinct: EdgeRoutePoint[] = [];
points.forEach((point) => appendDistinctPoint(distinct, point));
if (distinct.length <= 2) {
return distinct;
}
const simplified: EdgeRoutePoint[] = [distinct[0]];
for (let index = 1; index < distinct.length - 1; index += 1) {
const previous = simplified.at(-1) as EdgeRoutePoint;
const current = distinct[index];
const next = distinct[index + 1];
const collinearX =
Math.abs(previous.x - current.x) <= ROUTE_EPSILON &&
Math.abs(current.x - next.x) <= ROUTE_EPSILON;
const collinearY =
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
if (!collinearX && !collinearY) {
simplified.push(current);
}
}
simplified.push(distinct.at(-1) as EdgeRoutePoint);
return simplified;
}
function appendOrthogonalLeg(
points: EdgeRoutePoint[],
target: EdgeRoutePoint,
firstAxis: EdgePointAxis,
) {
const source = points.at(-1);
if (!source || edgePointsMatch(source, target)) {
appendDistinctPoint(points, target);
return;
}
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
if (changesX && changesY) {
appendDistinctPoint(
points,
firstAxis === "horizontal"
? { x: target.x, y: source.y }
: { x: source.x, y: target.y },
);
}
appendDistinctPoint(points, target);
}
function appendEndpointAwareLeg(
points: EdgeRoutePoint[],
target: EdgeRoutePoint,
sourceAxis: EdgePointAxis,
targetAxis: EdgePointAxis,
) {
const source = points.at(-1);
if (!source || edgePointsMatch(source, target)) {
appendDistinctPoint(points, target);
return;
}
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
if (!changesX || !changesY) {
appendDistinctPoint(points, target);
return;
}
if (sourceAxis !== targetAxis) {
appendDistinctPoint(
points,
sourceAxis === "horizontal"
? { x: target.x, y: source.y }
: { x: source.x, y: target.y },
);
} else if (sourceAxis === "horizontal") {
const middleX = (source.x + target.x) / 2;
appendDistinctPoint(points, { x: middleX, y: source.y });
appendDistinctPoint(points, { x: middleX, y: target.y });
} else {
const middleY = (source.y + target.y) / 2;
appendDistinctPoint(points, { x: source.x, y: middleY });
appendDistinctPoint(points, { x: target.x, y: middleY });
}
appendDistinctPoint(points, target);
}
/** Builds a persisted orthogonal route through each exact user waypoint. */
export function buildOrthogonalRoutePoints(
source: EdgeRoutePoint,
target: EdgeRoutePoint,
sourcePosition: Position,
targetPosition: Position,
waypoints: EdgeRoutePoint[] = [],
) {
const sourceAxis = edgeAxisForPosition(sourcePosition);
const targetAxis = edgeAxisForPosition(targetPosition);
const points: EdgeRoutePoint[] = [{ ...source }];
let nextAxis = sourceAxis;
waypoints.filter(edgePointIsFinite).forEach((waypoint) => {
appendOrthogonalLeg(points, waypoint, nextAxis);
nextAxis = nextAxis === "horizontal" ? "vertical" : "horizontal";
});
appendEndpointAwareLeg(points, target, nextAxis, targetAxis);
return simplifyOrthogonalPoints(points).slice(1, -1);
}
/** Returns the rendered polyline, including both live port endpoints. */
export function orthogonalEdgePoints({
routePoints,
sourcePosition,
sourceX,
sourceY,
targetPosition,
targetX,
targetY,
}: {
routePoints?: EdgeRoutePoint[];
sourcePosition: Position;
sourceX: number;
sourceY: number;
targetPosition: Position;
targetX: number;
targetY: number;
}) {
const source = { x: sourceX, y: sourceY };
const target = { x: targetX, y: targetY };
const storedPoints = routePoints?.filter(edgePointIsFinite) ?? [];
if (storedPoints.length === 0) {
return [
source,
...buildOrthogonalRoutePoints(
source,
target,
sourcePosition,
targetPosition,
),
target,
];
}
const points: EdgeRoutePoint[] = [source];
const first = storedPoints[0];
if (
Math.abs(source.x - first.x) > ROUTE_EPSILON &&
Math.abs(source.y - first.y) > ROUTE_EPSILON
) {
appendDistinctPoint(
points,
edgeAxisForPosition(sourcePosition) === "horizontal"
? { x: first.x, y: source.y }
: { x: source.x, y: first.y },
);
}
storedPoints.forEach((point) => appendDistinctPoint(points, point));
const last = points.at(-1) as EdgeRoutePoint;
if (
Math.abs(last.x - target.x) > ROUTE_EPSILON &&
Math.abs(last.y - target.y) > ROUTE_EPSILON
) {
appendDistinctPoint(
points,
edgeAxisForPosition(targetPosition) === "horizontal"
? { x: last.x, y: target.y }
: { x: target.x, y: last.y },
);
}
appendDistinctPoint(points, target);
return simplifyOrthogonalPoints(points);
}
function pointOnSegmentInterior(
point: EdgeRoutePoint,
start: EdgeRoutePoint,
end: EdgeRoutePoint,
) {
const segmentLength = Math.hypot(end.x - start.x, end.y - start.y);
const fromStart = Math.hypot(point.x - start.x, point.y - start.y);
const fromEnd = Math.hypot(point.x - end.x, point.y - end.y);
return (
segmentLength > CROSSING_JUMP_RADIUS * 2 + 2 &&
fromStart > CROSSING_JUMP_RADIUS + 1 &&
fromEnd > CROSSING_JUMP_RADIUS + 1 &&
Math.abs(fromStart + fromEnd - segmentLength) <= 0.2
);
}
export function edgeSegmentAxis(
start: EdgeRoutePoint,
end: EdgeRoutePoint,
): EdgePointAxis {
return Math.abs(start.x - end.x) >= Math.abs(start.y - end.y)
? "horizontal"
: "vertical";
}
export function orthogonalPolylinePath(
points: EdgeRoutePoint[],
crossingJumps: EdgeCrossingJump[] = [],
) {
if (points.length === 0) {
return "";
}
let path = `M ${points[0].x} ${points[0].y}`;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const orientation = edgeSegmentAxis(start, end);
const horizontal = orientation === "horizontal";
const direction = horizontal
? Math.sign(end.x - start.x)
: Math.sign(end.y - start.y);
if (direction === 0) {
continue;
}
const jumps = crossingJumps
.filter(
(jump) =>
jump.orientation === orientation &&
pointOnSegmentInterior(jump, start, end),
)
.sort((first, second) =>
horizontal
? (first.x - second.x) * direction
: (first.y - second.y) * direction,
);
jumps.forEach((jump) => {
const before = horizontal
? { x: jump.x - direction * CROSSING_JUMP_RADIUS, y: start.y }
: { x: start.x, y: jump.y - direction * CROSSING_JUMP_RADIUS };
const after = horizontal
? { x: jump.x + direction * CROSSING_JUMP_RADIUS, y: start.y }
: { x: start.x, y: jump.y + direction * CROSSING_JUMP_RADIUS };
path += ` L ${before.x} ${before.y}`;
path += horizontal
? ` Q ${jump.x} ${jump.y - CROSSING_JUMP_RADIUS * 1.8} ${after.x} ${after.y}`
: ` Q ${jump.x + CROSSING_JUMP_RADIUS * 1.8} ${jump.y} ${after.x} ${after.y}`;
});
path += ` L ${end.x} ${end.y}`;
}
return path;
}
export type RoutedEdgeCrossingInput = {
id: string;
nodeIds: readonly [string, string];
points: EdgeRoutePoint[];
};
function valueInsideSegment(
value: number,
first: number,
second: number,
margin = CROSSING_JUMP_RADIUS + 2,
) {
const minimum = Math.min(first, second) + margin;
const maximum = Math.max(first, second) - margin;
return value > minimum && value < maximum;
}
/**
* Derives display-only circuit-style jump arcs. The horizontal route is chosen
* consistently, so recalculation never makes the bridge alternate between
* lines as the user edits nearby geometry.
*/
export function detectEdgeCrossingJumps(
edges: RoutedEdgeCrossingInput[],
) {
const jumpsByEdgeId = new Map<string, EdgeCrossingJump[]>();
for (let firstIndex = 0; firstIndex < edges.length; firstIndex += 1) {
const first = edges[firstIndex];
for (let secondIndex = firstIndex + 1; secondIndex < edges.length; secondIndex += 1) {
const second = edges[secondIndex];
for (let firstSegment = 0; firstSegment < first.points.length - 1; firstSegment += 1) {
const firstStart = first.points[firstSegment];
const firstEnd = first.points[firstSegment + 1];
const firstAxis = edgeSegmentAxis(firstStart, firstEnd);
for (let secondSegment = 0; secondSegment < second.points.length - 1; secondSegment += 1) {
const secondStart = second.points[secondSegment];
const secondEnd = second.points[secondSegment + 1];
const secondAxis = edgeSegmentAxis(secondStart, secondEnd);
if (firstAxis === secondAxis) {
continue;
}
const horizontal =
firstAxis === "horizontal"
? { edge: first, start: firstStart, end: firstEnd }
: { edge: second, start: secondStart, end: secondEnd };
const vertical =
firstAxis === "vertical"
? { start: firstStart, end: firstEnd }
: { start: secondStart, end: secondEnd };
const crossing = { x: vertical.start.x, y: horizontal.start.y };
if (
!valueInsideSegment(
crossing.x,
horizontal.start.x,
horizontal.end.x,
) ||
!valueInsideSegment(
crossing.y,
vertical.start.y,
vertical.end.y,
)
) {
continue;
}
const current = jumpsByEdgeId.get(horizontal.edge.id) ?? [];
if (
!current.some(
(jump) =>
Math.abs(jump.x - crossing.x) <= ROUTE_EPSILON &&
Math.abs(jump.y - crossing.y) <= ROUTE_EPSILON,
)
) {
current.push({ ...crossing, orientation: "horizontal" });
jumpsByEdgeId.set(horizontal.edge.id, current);
}
}
}
}
}
return jumpsByEdgeId;
}
/**
* Moves an internal orthogonal segment along its perpendicular axis. Both live
* endpoints remain untouched; the neighbouring perpendicular legs absorb the
* displacement.
*/
export function moveOrthogonalSegment(
points: EdgeRoutePoint[],
segmentIndex: number,
delta: EdgeRoutePoint,
) {
const nextPoints = points.map((point) => ({ ...point }));
if (segmentIndex <= 0 || segmentIndex >= nextPoints.length - 2) {
return nextPoints;
}
const start = nextPoints[segmentIndex];
const end = nextPoints[segmentIndex + 1];
if (edgeSegmentAxis(start, end) === "horizontal") {
start.y += delta.y;
end.y += delta.y;
} else {
start.x += delta.x;
end.x += delta.x;
}
return simplifyOrthogonalPoints(nextPoints);
}
/**
* Moves an internal bend in both axes while preserving an orthogonal route.
* Moving the bend also slides its two neighbouring bends along their existing
* perpendicular legs, which keeps the two live port endpoints fixed.
*/
export function moveOrthogonalCorner(
points: EdgeRoutePoint[],
cornerIndex: number,
delta: EdgeRoutePoint,
) {
const nextPoints = points.map((point) => ({ ...point }));
if (cornerIndex <= 0 || cornerIndex >= nextPoints.length - 1) {
return nextPoints;
}
const previous = nextPoints[cornerIndex - 1];
const corner = nextPoints[cornerIndex];
const incomingAxis = edgeSegmentAxis(previous, corner);
const outgoingAxis = edgeSegmentAxis(
corner,
nextPoints[cornerIndex + 1],
);
corner.x += delta.x;
corner.y += delta.y;
if (cornerIndex > 1) {
if (incomingAxis === "horizontal") {
previous.y = corner.y;
} else {
previous.x = corner.x;
}
} else if (incomingAxis === "horizontal") {
const middleX =
Math.abs(previous.x - corner.x) > ROUTE_EPSILON
? (previous.x + corner.x) / 2
: previous.x + 20;
nextPoints.splice(
cornerIndex,
0,
{ x: middleX, y: previous.y },
{ x: middleX, y: corner.y },
);
cornerIndex += 2;
} else {
const middleY =
Math.abs(previous.y - corner.y) > ROUTE_EPSILON
? (previous.y + corner.y) / 2
: previous.y + 20;
nextPoints.splice(
cornerIndex,
0,
{ x: previous.x, y: middleY },
{ x: corner.x, y: middleY },
);
cornerIndex += 2;
}
const movedCorner = nextPoints[cornerIndex];
const following = nextPoints[cornerIndex + 1];
if (cornerIndex < nextPoints.length - 2) {
if (outgoingAxis === "horizontal") {
following.y = movedCorner.y;
} else {
following.x = movedCorner.x;
}
} else if (outgoingAxis === "horizontal") {
const middleX =
Math.abs(movedCorner.x - following.x) > ROUTE_EPSILON
? (movedCorner.x + following.x) / 2
: following.x - 20;
nextPoints.splice(
cornerIndex + 1,
0,
{ x: middleX, y: movedCorner.y },
{ x: middleX, y: following.y },
);
} else {
const middleY =
Math.abs(movedCorner.y - following.y) > ROUTE_EPSILON
? (movedCorner.y + following.y) / 2
: following.y - 20;
nextPoints.splice(
cornerIndex + 1,
0,
{ x: movedCorner.x, y: middleY },
{ x: following.x, y: middleY },
);
}
return simplifyOrthogonalPoints(nextPoints);
}
/** Creates a movable dogleg when a selected route is still a straight line. */
export function createOrthogonalSegmentDetour(
points: EdgeRoutePoint[],
segmentIndex: number,
delta: EdgeRoutePoint,
) {
const start = points[segmentIndex];
const end = points[segmentIndex + 1];
if (!start || !end) {
return points.map((point) => ({ ...point }));
}
const firstThird = {
x: start.x + (end.x - start.x) / 3,
y: start.y + (end.y - start.y) / 3,
};
const secondThird = {
x: start.x + ((end.x - start.x) * 2) / 3,
y: start.y + ((end.y - start.y) * 2) / 3,
};
const detour =
edgeSegmentAxis(start, end) === "horizontal"
? [
{ x: firstThird.x + delta.x, y: start.y },
{ x: firstThird.x + delta.x, y: start.y + delta.y },
{ x: secondThird.x + delta.x, y: end.y + delta.y },
{ x: secondThird.x + delta.x, y: end.y },
]
: [
{ x: start.x, y: firstThird.y + delta.y },
{ x: start.x + delta.x, y: firstThird.y + delta.y },
{ x: end.x + delta.x, y: secondThird.y + delta.y },
{ x: end.x, y: secondThird.y + delta.y },
];
return simplifyOrthogonalPoints([
...points.slice(0, segmentIndex + 1),
...detour,
...points.slice(segmentIndex + 1),
]);
}
+39
View File
@@ -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;
}
+190
View File
@@ -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(" ");
}
+31
View File
@@ -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},任务可能已经卡死`;
}
+130
View File
@@ -3647,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
View File
@@ -1 +0,0 @@
powershell -ExecutionPolicy Bypass -File "%~dp0start-dev.ps1"
-14
View File
@@ -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
+48 -8
View File
@@ -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]);
}
}
});
@@ -3069,10 +3077,25 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await page.locator(".flow-canvas .react-flow__pane").click({
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(
@@ -3086,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");
@@ -3099,6 +3130,15 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
await expect(physicalEdge.locator(".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");
@@ -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 /);
});
+268
View File
@@ -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,6 +8,35 @@ import {
wideProject,
} from "./fixtures";
type ImportedProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
data?: { isContactEdge?: boolean };
};
type ImportedProject = {
nodes: Array<{ id: string }>;
edges: ImportedProjectEdge[];
};
const MQL_8_PROJECT_PATHS = [
new URL("../../../tests/data/test-mql-8%20.json", import.meta.url),
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
].map(fileURLToPath);
const MQL_8_PROJECT_PATH =
MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0];
const mql8Project = JSON.parse(
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
) as ImportedProject;
const mql8ResultSnapshot = {
...resultSnapshot,
id: "e2e-mql-8-result-system-bridges",
project: mql8Project as unknown as typeof resultSnapshot.project,
};
async function readViewport(page: Page, canvasSelector: string) {
return page
.locator(`${canvasSelector} .react-flow__viewport`)
@@ -26,6 +57,110 @@ 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("/");
@@ -225,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) => {
@@ -434,6 +613,95 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
}
});
test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({
page,
}) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, mql8ResultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(
page.locator(".results-system-canvas .react-flow__node"),
).toHaveCount(mql8Project.nodes.length);
await expect(
page.locator(
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .react-flow__edge-path',
),
).toHaveAttribute("d", / Q /);
});
test("从结果页返回建模页后保留用户调整的建模视口", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
}, { project: wideProject, snapshot: resultSnapshot });
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const modelingPane = page.locator(".flow-canvas .react-flow__pane");
const modelingPaneBox = await modelingPane.boundingBox();
expect(modelingPaneBox).not.toBeNull();
const fittedViewport = await readModelingViewport(page);
const pointer = {
x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55,
y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const viewport = await readModelingViewport(page);
return Math.hypot(
viewport.x - fittedViewport.x,
viewport.y - fittedViewport.y,
);
})
.toBeGreaterThan(100);
const beforeZoom = await readModelingViewport(page);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
const adjustedViewport = await readModelingViewport(page);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await page.getByRole("tab", { name: "建模" }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
wideProject.nodes.length,
);
await page.waitForTimeout(250);
const restoredViewport = await readModelingViewport(page);
expect(
Math.hypot(
restoredViewport.x - adjustedViewport.x,
restoredViewport.y - adjustedViewport.y,
),
).toBeLessThan(2);
expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3);
});
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
page,
}) => {
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
import { migratedPnvo001Mirrored } from "../../src/pnvoPortDisplayMigration";
const legacyPorts = [
{ name: "res", side: "left" },
{ name: "port_2", side: "left" },
{ name: "port_3", side: "right" },
];
const amesimPorts = [
{ name: "res", side: "left" },
{ name: "port_2", side: "right" },
{ name: "port_3", side: "left" },
];
test("旧 PNVO001 端口快照只迁移一次镜像方向", () => {
for (const modelType of [
"amesim_pnvo001_fixed",
"amesim_pnvo001",
]) {
const migrated = migratedPnvo001Mirrored(
modelType,
legacyPorts,
amesimPorts,
true,
);
expect(migrated).toBe(false);
expect(
migratedPnvo001Mirrored(
modelType,
amesimPorts,
amesimPorts,
migrated,
),
).toBe(false);
expect(
migratedPnvo001Mirrored(
modelType,
legacyPorts,
amesimPorts,
false,
),
).toBe(true);
}
expect(
migratedPnvo001Mirrored(
"amesim_pnor001",
legacyPorts,
amesimPorts,
true,
),
).toBe(true);
});
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "../../src/resultEventSeries";
const mechanicalComponent = {
data: {
ports: [{ name: "port_2", domain: "mechanical" }],
},
};
const mechanicalPortForce = {
scope: "port" as const,
portName: "port_2",
quantity: "force",
category: "flow",
};
test("只分离机械事件中的孤立力尖峰,同时保留连续曲线和原始值", () => {
const time = [0, 0.5, 0.75, 1, 1.5];
const eventIndices = resultStateTransitionSampleIndices(time, {
integration: {
segments: [
{ stateTransitionTimes: [0.75] },
{ stateTransitionTimes: [0.7500000000000001] },
],
},
});
expect([...eventIndices]).toEqual([2]);
const rawForce = [1, 2, 1e12, 3, 4];
const displayedForce = continuousMechanicalForceChartValues(
mechanicalPortForce,
mechanicalComponent,
rawForce,
eventIndices,
);
expect(displayedForce.separatedEventSampleCount).toBe(1);
expect(Number.isNaN(displayedForce.values[2])).toBe(true);
expect(Math.max(...displayedForce.values.filter(Number.isFinite))).toBe(4);
expect(rawForce[2]).toBe(1e12);
const path = segmentedChartPath(
time,
displayedForce.values,
(value) => value,
(value) => value,
);
expect(path.match(/\bM /g)).toHaveLength(1);
const smoothForce = [1, 2, 2.5, 3, 4];
const displayedSmoothForce = continuousMechanicalForceChartValues(
mechanicalPortForce,
mechanicalComponent,
smoothForce,
eventIndices,
);
expect(displayedSmoothForce.values).toBe(smoothForce);
expect(displayedSmoothForce.separatedEventSampleCount).toBe(0);
const componentForce = continuousMechanicalForceChartValues(
{
scope: "component",
portName: null,
quantity: "force",
category: "derived",
},
mechanicalComponent,
[1, 2, -1e12, 3, 4],
eventIndices,
);
expect(componentForce.separatedEventSampleCount).toBe(1);
expect(Number.isNaN(componentForce.values[2])).toBe(true);
const signalForce = continuousMechanicalForceChartValues(
{
scope: "component",
portName: null,
quantity: "force",
category: "signal",
},
mechanicalComponent,
[1, 2, -1e12, 3, 4],
eventIndices,
);
expect(signalForce.values[2]).toBe(-1e12);
expect(signalForce.separatedEventSampleCount).toBe(0);
const stateValues = [0, 0.5, 1e9, 1, 1.5];
const displayedState = continuousMechanicalForceChartValues(
{ ...mechanicalPortForce, quantity: "velocity" },
mechanicalComponent,
stateValues,
eventIndices,
);
expect(displayedState.values).toBe(stateValues);
expect(displayedState.values[2]).toBe(1e9);
const pneumaticForce = continuousMechanicalForceChartValues(
mechanicalPortForce,
{
data: {
ports: [{ name: "port_2", domain: "pneumatic" }],
},
},
rawForce,
eventIndices,
);
expect(pneumaticForce.values).toBe(rawForce);
const persistentContactForce = [0, 0, 1e6, 1e6, 1e6];
const displayedPersistentContactForce =
continuousMechanicalForceChartValues(
mechanicalPortForce,
mechanicalComponent,
persistentContactForce,
eventIndices,
);
expect(displayedPersistentContactForce.values).toBe(
persistentContactForce,
);
expect(
displayedPersistentContactForce.separatedEventSampleCount,
).toBe(0);
expect(
resultStateTransitionSampleIndices(time, {}),
).toEqual(new Set<number>());
});
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp, resultSnapshot } from "./fixtures";
@@ -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 秒",
);
});
+4
View File
@@ -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]
-24
View File
@@ -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
-31
View File
@@ -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%
@@ -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"
}
}
@@ -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 168 files, more files were not shown because too many files have changed in this diff. Show more