Compare commits

Author SHA1 Message Date
ljz 2f3f7d00ec 更新v2版本适配skill 2026-09-18 01:40:58 +08:00
ljz 7611f13208 修复循环信号与事件采样并接入 LSTP 接触定位,补充八路验证及复用实验
相较上一版 Jacobian 确定性复用更新,本次补齐事件边界一致性、结果两侧采样及接触事件定位;保留已有物性复用和组件力学公式。

- 统一 UD00 信号求值与下一事件查询的绝对时间边界,修复循环边界浮点舍入导致的阶段错位、重复或漏报,并覆盖零时长、多阶段及长周期场景。
- 引入原生输出语义 v2:保留规则网格真实时间,补充内部时间事件和状态事件的左邻及事件后采样,按保存时间、状态和离散模式重放结果。
- 两条代码生成路径均发出 LSTP 接触描述,默认定位间隙过零及非负力模式的力截断;仅在接受事件时更新防重复记录,增加 contactEvents 诊断计数。
- 补充 MASS/LSTP 独立事件实验、八路全曲线与驱动阶段配对评估,以及 Amesim 不连续点输出对照和力差定位报告;MASS 新增释放机制仍保留为独立实验。
- 保存局部 probe、context 访问与回退、shadow replay、R288 real skip/typed replay 及阀门数值尾部诊断工具和报告;未证明净收益的实验不启用为生产默认优化。
- 更新原生运行说明和元件建模规范,补充信号边界、输出语义、接触事件和实验依赖回归测试。

验证:五组专项回归共 34 项全部通过;37 个待提交 Python 文件语法检查通过;git diff --cached --check 通过。
2026-09-17 23:50:13 +08:00
ljz 1aac220084 优化 Jacobian 确定性复用并补充性能剖析与平台依赖文档
在单次 Jacobian 构建内按完整输入精确复用储气物性、PH 反算、密度和管路求根结果,保持原有求值副作用、差分政策与失败回退。八路模型求解 CPU 中位数减少 19.27%,循环和不循环的完整原始采样均与恢复基线一致。

增加独立的跨平台时间剖析工具,记录互斥阶段耗时、Newton/LU 统计、矩阵复用与内核复用,保存 UD00 两种工况的调查报告和机器可读汇总。

补充 Windows/Linux 运行、测试、原生编译和剖析所需依赖文档及索引,不修改依赖清单、版本锁或安装环境。

验证:8 项新增专项回归通过;2270 次完整 Jacobian 核对零差异;16 次剖析配对及预热运行保持完整数值一致。既有固定样本哈希失败和 Linux 实机验收限制见报告。
2026-09-16 13:39:53 +08:00
ljz 2b07d996cf 高温氦气物性补全;三通四通能量计算bug修正 2026-09-15 17:57:20 +08:00
ljz 6fc9afe41d 现有组件功能与Amesim组件比较对齐,补全功能;仿真服务启动环境检测,旧版json工程文件适配读取 2026-09-13 20:01:56 +08:00
lujingze b6c22a54f8 修复测试模型路径并完善四路八路回归与文档
- 按文件哈希区分当前八路工程和历史 Amesim 基准,修复测试与清单中的旧路径。
- 四路审计从 JSON 按需生成 XML,八路新增只核对模式,并显式使用 UTF-8 与 LF。
- 将缺失高刚度夹具的测试替换为当前八路完整 BDF 运行,明确未恢复原 RK45 专项覆盖。
- 更新前端大型工程测试、历史活动回放入口、数据说明、现行规范和相关报告。

验证:全量后台 374 项通过、1 项条件跳过;前端 11 项通过、历史独立服务用例 1 项跳过;四路八路 AME 审计通过。
环境、缓存和运行产物保持忽略;本次未实施工程 JSON 字段精简。
2026-09-12 15:49:09 +00:00
lujingze 44b6ea74ab 旧版前端工程文件导入时版本对比查验、审阅与仿真时部分阻挡功能实现;前端参数输入格式统一规范 2026-09-12 14:15:40 +00:00
lujingze 22579e51c9 缓存功能windows平台适配 2026-09-12 05:40:18 +00:00
lujingze 151e6e4b97 C内核按库功能拆解,编译结果缓存区构建,编译过程与已有缓存结果对照功能实现 2026-09-12 05:24:48 +00:00
lujingze aa4951b14e 优化雅可比矩阵计算;端口转发情况下仿真结果传输方式优化 2026-09-12 03:57:31 +00:00
lujingze 3bc4be3c06 优化原生结果编码传输与浏览器缓存,记录八路性能基线
原生结果series通过字节索引直传,C端使用Ryu精确回读编码和64 KiB批量写出;网页采用Float64缓存和CSV工作线程,减少结果处理与保存等待。

补充八路AME曲线核查、全流程分阶段计时、独立编码基准和复现工具,固定后续优化采用修正八路及rtol=1e-8。C写出1.1808→0.1638 s,点击到可查看8.0100→6.9756 s。

验证:最终10项编码专项、29项相关后端回归通过;8份原生结果逐位一致,16次网页结果/CSV/刷新恢复通过。前端构建及缓存/CSV专项在本轮结果处理工作中通过。环境、原始大结果与临时构建不纳入Git。
2026-09-11 15:09:15 +00:00
lujingze 808c484f5b C内核流量计算方法优化,前端文件名称读取优化 2026-09-11 08:48:56 +00:00
ljz 5d5a2e1843 优化端口传递物性参数方式,减少上下游元件重复计算量 2026-09-11 14:42:19 +08:00
ljz 91bd9fb252 前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解 2026-09-11 11:27:54 +08:00
ljz 0dcb465d84 Python内核代码移除,建模界面缩放bug修复 2026-09-10 11:57:39 +08:00
486 changed files with 272928 additions and 11450 deletions

No files matched your search

+5 -3
View File
@@ -4,9 +4,11 @@
# Regression manifests hash these files as raw bytes. Keep their checkout # Regression manifests hash these files as raw bytes. Keep their checkout
# representation identical on Windows and Linux so hashes remain portable. # representation identical on Windows and Linux so hashes remain portable.
tests/data/test-mql-8.xml text eol=lf tests/baselines/simulation/test_mql_full_branches/sources/test_mql-full-branches-01-04.xml text eol=lf
tests/data/test-mql-8.json text eol=lf
tests/data/test_mql-full-branches-01-04.xml text eol=lf
tests/baselines/simulation/**/*.json text eol=lf tests/baselines/simulation/**/*.json text eol=lf
tests/baselines/simulation/**/sources/* text eol=lf tests/baselines/simulation/**/sources/* text eol=lf
# Browser projects and relocated native references.
tests/data/*.json text eol=lf
tests/baselines/native/*.json text eol=lf
+41 -3
View File
@@ -49,7 +49,7 @@ jobs:
run: | run: |
python -m pip install -r constraints/python312-linux-x86_64.lock python -m pip install -r constraints/python312-linux-x86_64.lock
python -m pip check python -m pip check
- name: Validate portable metadata and IR contracts - name: Validate portable metadata and XML contracts
env: env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1" SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: | run: |
@@ -59,11 +59,34 @@ jobs:
tests.test_component_catalog \ tests.test_component_catalog \
tests.test_component_metadata \ tests.test_component_metadata \
tests.test_component_registry \ tests.test_component_registry \
tests.test_port_computation \
tests.test_native_schedule.DependencyGraphTests \
tests.test_medium_reference_contract \ tests.test_medium_reference_contract \
tests.test_system_xml_v3 \ tests.test_system_xml_v3 \
tests.test_system_numeric_ir_v2 \
tests.test_native_only_backend tests.test_native_only_backend
native-linux:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install native dependencies with the existing Linux setup
run: |
python -m venv .venv
.venv/bin/python -m pip install -r requirements-test.txt
bash bat/setup-native-linux.sh
- name: Check startup diagnostics, native cache and solver control on Linux
env:
SIMULATION_NATIVE_REQUIRE_TOOLCHAIN: "1"
run: |
.venv/bin/python -W error::ResourceWarning -m unittest \
tests.test_simulation_warmup tests.test_native_cache_platform \
tests.test_native_solver_control tests.test_native_worker_control \
tests.test_result_storage tests.test_native_sample_storage tests.test_native_result_transport -v
native-windows: native-windows:
runs-on: windows-2022 runs-on: windows-2022
timeout-minutes: 30 timeout-minutes: 30
@@ -85,6 +108,15 @@ jobs:
"SIMULATION_NATIVE_CC=$env:CONDA_PREFIX\Library\mingw-w64\bin\gcc.exe" >> $env:GITHUB_ENV "SIMULATION_NATIVE_CC=$env:CONDA_PREFIX\Library\mingw-w64\bin\gcc.exe" >> $env:GITHUB_ENV
python -m pip install -r requirements-test.txt python -m pip install -r requirements-test.txt
python -m pip check python -m pip check
- name: Verify native cache on the Windows runtime
env:
SIMULATION_NATIVE_REQUIRE_TOOLCHAIN: "1"
run: |
New-Item -ItemType Directory -Force test/ci-native-cache | Out-Null
python -W error::ResourceWarning -m unittest tests.test_native_cache_storage tests.test_native_cache_platform -v > test/ci-native-cache/regression.log 2>&1
$cacheRegressionExit = $LASTEXITCODE
Get-Content test/ci-native-cache/regression.log
if ($cacheRegressionExit -ne 0) { exit $cacheRegressionExit }
- name: Run catalog, numerical, API and schema regression - name: Run catalog, numerical, API and schema regression
run: | run: |
python -c "from app.simulation.native_codegen.build import toolchain; print(toolchain())" python -c "from app.simulation.native_codegen.build import toolchain; print(toolchain())"
@@ -92,10 +124,16 @@ jobs:
python -W error::ResourceWarning -m unittest discover -s tests python -W error::ResourceWarning -m unittest discover -s tests
- name: Complete the 10 second skill fixture in native RK45 - name: Complete the 10 second skill fixture in native RK45
run: | run: |
python -m app.simulation.native_codegen tests/data/native-skill-test.xml --output-dir test/ci-skill --method RK45 --max-step 0.001 --rtol 1e-7 --runs 1 --solve-only python -m app.simulation.native_codegen tests/fixtures/native-skill-test.xml --output-dir test/ci-skill --method RK45 --max-step 0.001 --rtol 1e-7 --runs 1 --solve-only
- if: always() - if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: native-skill-regression name: native-skill-regression
path: test/ci-skill/summary.json path: test/ci-skill/summary.json
if-no-files-found: warn if-no-files-found: warn
- if: always()
uses: actions/upload-artifact@v4
with:
name: native-windows-cache-regression
path: test/ci-native-cache/regression.log
if-no-files-found: warn
+1
View File
@@ -21,6 +21,7 @@ htmlcov/
/test/ /test/
app/data/ app/data/
/simresults/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
+4 -1
View File
@@ -4,6 +4,8 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 开发环境准备 ## 开发环境准备
Windows/Linux 的运行、测试、原生工具链及时间剖析依赖,统一见 [平台依赖说明](docs/standard/platform-dependencies.md)。2026-09-16 的 Jacobian 复用与剖析工具继续使用现有 SUNDIALS 7.4.0,本轮未新增运行库依赖;以下安装命令供准备环境时使用。
后端编排层统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本 后端编排层统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
`3.12.3`。`requirements.txt` 保留支持范围, `3.12.3`。`requirements.txt` 保留支持范围,
`constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本; `constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本;
@@ -86,7 +88,7 @@ Linux:
网页的 System XML 仿真默认使用 C 内核(`native`);后端入口和启动脚本使用同一默认值,不需要每次手动设置环境变量。启动日志显示 `Simulation numeric engine: native`,启动预热仅检查 C 工具链和 XML Schema,不执行 Python/SciPy 求解器预热。模型专用 EXE 在提交模型时生成或从缓存复用。 网页的 System XML 仿真默认使用 C 内核(`native`);后端入口和启动脚本使用同一默认值,不需要每次手动设置环境变量。启动日志显示 `Simulation numeric engine: native`,启动预热仅检查 C 工具链和 XML Schema,不执行 Python/SciPy 求解器预热。模型专用 EXE 在提交模型时生成或从缓存复用。
当前 C 构建支持 Windows x64,已覆盖组件库当前注册的 27 类模型(22 类 Amesim、5 类实验组件)。具体公式范围与连接限制见[原生后端说明](native/README.md);不支持的自定义模型或未收敛的连接会明确报错,不自动切回 Python。旧 Python 数值后端已删除;Linux 暂支持编辑、校验与生成,原生构建打包尚待实现。旧固定拓扑示例接口返回 HTTP 410,请改用统一 XML 接口。 当前 C 构建支持 Windows x64 和 Linux x86_64,已覆盖组件库当前注册的 27 类模型(22 类 Amesim、5 类实验组件)。具体公式范围与连接限制见[原生后端说明](native/README.md);不支持的自定义模型或未收敛的连接会明确报错,不自动切回 Python。旧 Python 数值后端已删除;Linux 原生工具链安装与静态链接说明见 [C 后端说明](native/README.md)。旧固定拓扑示例接口返回 HTTP 410,请改用统一 XML 接口。
## 后端接口 ## 后端接口
@@ -110,6 +112,7 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `require
## 文档 ## 文档
- [开发文档索引](docs/README.md) - [开发文档索引](docs/README.md)
- [现行规范索引与新组件注册流程](docs/standard/README.md)
- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-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-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md) - [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
+111 -29
View File
@@ -20,9 +20,12 @@ from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError from pydantic import BaseModel, ConfigDict, Field, StrictFloat, StrictStr, ValidationError
from app.project_parameters import prepare_project, version_warning
from app.simulation.performance import performance_span, profile_phase, profile_run from app.simulation.performance import performance_span, profile_phase, profile_run
from app.simulation.native_codegen.transport import NativeSeriesJson, serialize_result_parts
from app.simulation.config import SolverActivityTracker from app.simulation.config import SolverActivityTracker
from app.system_xml import ( from app.system_xml import (
SystemXmlDocument, SystemXmlDocument,
@@ -43,14 +46,19 @@ async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
import logging import logging
from app.simulation.backends import numeric_engine_name from app.simulation.backends import numeric_engine_name
from app.simulation.warmup import warm_up_simulation_runtime from app.simulation.warmup import SimulationRuntimeCheck
application.state.simulation_numeric_engine = numeric_engine_name() application.state.simulation_numeric_engine = numeric_engine_name()
logging.getLogger("uvicorn.error").info( logging.getLogger("uvicorn.error").info(
"Simulation numeric engine: %s", application.state.simulation_numeric_engine, "Simulation numeric engine: %s", application.state.simulation_numeric_engine,
) )
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict() runtime_check = SimulationRuntimeCheck()
yield application.state.simulation_runtime_check = runtime_check
runtime_check.start()
try:
yield
finally:
runtime_check.close()
app = FastAPI( app = FastAPI(
@@ -68,6 +76,11 @@ SimulationProgressEmitter = Callable[
] ]
SIMULATION_PHASE_MESSAGES = { SIMULATION_PHASE_MESSAGES = {
"initializing": "正在初始化状态与方程", "initializing": "正在初始化状态与方程",
"native-generation": "正在生成原生模型代码",
"native-cache-check": "正在检查已编译模型缓存",
"native-cache-hit": "已复用模型缓存,正在启动求解器",
"native-compilation": "正在编译模型与缺失的原生模块",
"native-linking": "正在链接模型可执行文件",
"integrating": "正在进行时间积分与压力流量求解", "integrating": "正在进行时间积分与压力流量求解",
"postprocessing": "正在整理采样结果", "postprocessing": "正在整理采样结果",
"cancelled": "正在整理已终止仿真的部分结果", "cancelled": "正在整理已终止仿真的部分结果",
@@ -141,8 +154,8 @@ class ReactFlowNodeData(BaseModel):
componentType: str = "component" componentType: str = "component"
modelType: str = "component" modelType: str = "component"
# Optional at the storage boundary so an incompatible project can still be # Optional at the storage boundary so an incompatible project can still be
# opened and inspected. Every execution path requires an exact registry # opened and inspected. Input adapters warn and select the current model;
# match before defaults, equations, or ports are consumed. # the normalized XML/numerical boundary still enforces an exact match.
modelVersion: str | None = None modelVersion: str | None = None
ports: list[ReactFlowPortDefinition] = Field(default_factory=list) ports: list[ReactFlowPortDefinition] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict) parameters: dict[str, Any] = Field(default_factory=dict)
@@ -179,17 +192,17 @@ class ReactFlowEdgePayload(BaseModel):
class ReactFlowSimulationConfig(BaseModel): class ReactFlowSimulationConfig(BaseModel):
t_start: float = 0.0 t_start: StrictFloat | StrictStr = 0.0
t_stop: float = 2.0 t_stop: StrictFloat | StrictStr = 2.0
step: float = 0.1 step: StrictFloat | StrictStr = 0.1
max_step: float = 0.005 max_step: StrictFloat | StrictStr = 0.005
method: str = "BDF" method: str = "BDF"
class ReactFlowProjectPayload(BaseModel): class ReactFlowProjectPayload(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
projectSchemaVersion: Literal[1] projectSchemaVersion: Literal[1, 2]
name: str = "untitled" name: str = "untitled"
nodes: list[ReactFlowNodePayload] = Field(default_factory=list) nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
edges: list[ReactFlowEdgePayload] = Field(default_factory=list) edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
@@ -330,13 +343,29 @@ def get_component_catalog() -> dict[str, object]:
return build_component_catalog() return build_component_catalog()
@app.get("/api/simulation/runtime-check")
def get_simulation_runtime_check(request: Request) -> Response:
report = request.app.state.simulation_runtime_check.snapshot()
return Response(content=json.dumps(report, ensure_ascii=False), media_type="application/json",
headers={"Cache-Control": "no-store"})
@app.post("/api/reactflow/system-xml") @app.post("/api/reactflow/system-xml")
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response: def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
try: try:
xml = build_reactflow_system_xml(payload) normalized, notices = prepare_project(payload)
xml = build_reactflow_system_xml(normalized)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(content=xml, media_type="application/xml") headers = {}
if notices:
warning = {**version_warning(notices), "totalCount": len(notices), "components": notices[:10]}
encoded = quote(json.dumps(warning, ensure_ascii=False))
while len(encoded) > 3800 and warning["components"]:
warning["components"] = warning["components"][:-1]
encoded = quote(json.dumps(warning, ensure_ascii=False))
headers["X-Component-Version-Warnings"] = encoded
return Response(content=xml, media_type="application/xml", headers=headers)
@app.post("/api/simulation-results/csv") @app.post("/api/simulation-results/csv")
@@ -504,10 +533,11 @@ def simulate_reactflow_test_mql(payload: ReactFlowProjectPayload) -> dict[str, o
@app.post("/api/reactflow/compile-model") @app.post("/api/reactflow/compile-model")
def compile_reactflow_model(payload: ReactFlowProjectPayload) -> dict[str, object]: def compile_reactflow_model(payload: ReactFlowProjectPayload) -> dict[str, object]:
try: try:
network = compile_reactflow_network(payload) normalized, notices = prepare_project(payload)
network = compile_reactflow_network(normalized)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True, **network.as_interface_dict()} return {"success": True, **network.as_interface_dict(), "warnings": [version_warning(notices)] if notices else []}
@app.post("/api/system-xml/validate") @app.post("/api/system-xml/validate")
@@ -652,7 +682,7 @@ async def simulate_system_xml_stream(request: Request) -> StreamingResponse:
simulation_id = request.headers.get("x-simulation-id") or uuid4().hex simulation_id = request.headers.get("x-simulation-id") or uuid4().hex
task = _register_simulation_task(simulation_id) task = _register_simulation_task(simulation_id)
return StreamingResponse( return StreamingResponse(
simulation_event_stream(await request.body(), task=task), simulation_event_stream(await request.body(), task=task, raw_series=True),
media_type="application/x-ndjson", media_type="application/x-ndjson",
headers={ headers={
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
@@ -679,13 +709,17 @@ def cancel_system_xml_simulation(
} }
@app.get("/api/system-xml/simulations/{simulation_id}") @app.get("/api/system-xml/simulations/{simulation_id}", response_model=None)
def get_system_xml_simulation(simulation_id: str) -> dict[str, object]: def get_system_xml_simulation(simulation_id: str) -> dict[str, object] | Response:
with SIMULATION_TASKS_LOCK: with SIMULATION_TASKS_LOCK:
task = SIMULATION_TASKS.get(simulation_id) task = SIMULATION_TASKS.get(simulation_id)
if task is None: if task is None:
raise HTTPException(status_code=404, detail="Simulation task was not found.") raise HTTPException(status_code=404, detail="Simulation task was not found.")
return _simulation_task_snapshot(task) snapshot = _simulation_task_snapshot(task)
result = snapshot.get("result")
if isinstance(result, dict) and isinstance(result.get("series"), NativeSeriesJson):
return StreamingResponse(iter(serialize_result_parts(snapshot)), media_type="application/json")
return snapshot
def run_system_xml_simulation( def run_system_xml_simulation(
@@ -693,6 +727,7 @@ def run_system_xml_simulation(
progress_callback: SimulationProgressEmitter | None = None, progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None, cancel_check: Callable[[], bool] | None = None,
activity_tracker: SolverActivityTracker | None = None, activity_tracker: SolverActivityTracker | None = None,
*, raw_series: bool = False,
) -> dict[str, object]: ) -> dict[str, object]:
with profile_run() as trace: with profile_run() as trace:
result = _run_system_xml_simulation_profiled( result = _run_system_xml_simulation_profiled(
@@ -700,6 +735,7 @@ def run_system_xml_simulation(
progress_callback, progress_callback,
cancel_check, cancel_check,
activity_tracker, activity_tracker,
raw_series=raw_series,
) )
performance = trace.snapshot() performance = trace.snapshot()
@@ -715,6 +751,7 @@ def _run_system_xml_simulation_profiled(
progress_callback: SimulationProgressEmitter | None = None, progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None, cancel_check: Callable[[], bool] | None = None,
activity_tracker: SolverActivityTracker | None = None, activity_tracker: SolverActivityTracker | None = None,
*, raw_series: bool = False,
) -> dict[str, object]: ) -> dict[str, object]:
from app.simulation.backends import simulate_network from app.simulation.backends import simulate_network
from app.simulation.results import SimulationPreparationError from app.simulation.results import SimulationPreparationError
@@ -738,9 +775,9 @@ def _run_system_xml_simulation_profiled(
emit(0, "validation", "正在校验 System XML") emit(0, "validation", "正在校验 System XML")
report = validate_system_xml_document(xml_bytes) report = validate_system_xml_document(xml_bytes)
document = _validated_xml_document_or_422(report) document = _validated_xml_document_or_422(report)
emit(0, "compilation", "正在编译组件与连接关系") emit(0, "compilation", "正在解析组件与连接关系")
network = _compile_xml_document_or_422(document) network = _compile_xml_document_or_422(document)
emit(0, "initialization", "模型编译完成,正在准备求解器") emit(0, "initialization", "连接关系已解析,正在准备原生模型")
def report_system_progress(progress: float, phase: str) -> None: def report_system_progress(progress: float, phase: str) -> None:
bounded_progress = min(1.0, max(0.0, progress)) bounded_progress = min(1.0, max(0.0, progress))
@@ -755,13 +792,21 @@ def _run_system_xml_simulation_profiled(
document.simulation.t_stop, document.simulation.t_stop,
) )
def report_property_warning(warning: dict) -> None:
fraction = (warning["time"] - document.simulation.t_start) / (
document.simulation.t_stop - document.simulation.t_start)
emit(round(100 * min(1.0, max(0.0, fraction))), "property-warning",
warning["message"], warning["time"], document.simulation.t_stop)
try: try:
result = simulate_network( result = simulate_network(
network, network,
document.simulation, document.simulation,
progress_callback=report_system_progress, progress_callback=report_system_progress,
warning_callback=report_property_warning,
cancel_check=cancel_check, cancel_check=cancel_check,
activity_tracker=activity_tracker, activity_tracker=activity_tracker,
raw_series=raw_series,
) )
except SimulationPreparationError as exc: except SimulationPreparationError as exc:
raise HTTPException( raise HTTPException(
@@ -799,7 +844,7 @@ def _run_system_xml_simulation_profiled(
"validation": report.as_dict(), "validation": report.as_dict(),
"simulation": document.as_model_data()["simulation"], "simulation": document.as_model_data()["simulation"],
"model": network.as_interface_dict(), "model": network.as_interface_dict(),
**result.as_dict(), **result.as_dict(raw_series=raw_series),
} }
@@ -807,7 +852,8 @@ def simulation_event_stream(
xml_bytes: bytes, xml_bytes: bytes,
*, *,
task: SimulationTaskRecord | None = None, task: SimulationTaskRecord | None = None,
) -> Iterator[str]: raw_series: bool = False,
) -> Iterator[str | bytes]:
events: queue.Queue[dict[str, object] | object] = queue.Queue() events: queue.Queue[dict[str, object] | object] = queue.Queue()
finished = object() finished = object()
latest_progress = 0 latest_progress = 0
@@ -828,6 +874,14 @@ def simulation_event_stream(
) -> None: ) -> None:
nonlocal latest_message, latest_phase, latest_progress nonlocal latest_message, latest_phase, latest_progress
nonlocal latest_simulated_time, latest_total_time nonlocal latest_simulated_time, latest_total_time
if phase == "property-warning":
# A warning is an event, not the current integration phase. Keep
# heartbeat/progress state intact while delivering every warning.
events.put({"event": "progress", "progress": latest_progress,
"phase": phase, "message": message,
"simulatedTime": simulated_time, "totalTime": total_time,
**activity_tracker.snapshot().as_dict()})
return
latest_progress = max(latest_progress, min(100, max(0, progress))) latest_progress = max(latest_progress, min(100, max(0, progress)))
latest_phase = phase latest_phase = phase
latest_message = message latest_message = message
@@ -857,6 +911,7 @@ def simulation_event_stream(
emit_progress, emit_progress,
task.cancel_event.is_set if task is not None else None, task.cancel_event.is_set if task is not None else None,
activity_tracker, activity_tracker,
**({"raw_series": True} if raw_series else {}),
) )
if task is not None: if task is not None:
result = _mark_simulation_task_result(task, result) result = _mark_simulation_task_result(task, result)
@@ -958,7 +1013,11 @@ def simulation_event_stream(
continue continue
if event is finished: if event is finished:
break break
yield json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n" if raw_series and isinstance(event, dict) and event.get("event") == "result":
yield from serialize_result_parts(event)
yield b"\n"
else:
yield json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
finally: finally:
if task is not None: if task is not None:
_request_simulation_task_cancel(task, "stalled") _request_simulation_task_cancel(task, "stalled")
@@ -1005,7 +1064,7 @@ def validate_reactflow_component_contract(
node: ReactFlowNodePayload, node: ReactFlowNodePayload,
component_spec: "ComponentModelSpec", component_spec: "ComponentModelSpec",
) -> dict[str, float]: ) -> dict[str, float]:
"""Validate the persisted model contract before consuming current defaults.""" """Validate the normalized SI model contract before consuming current defaults."""
if ( if (
node.data.componentType != node.data.modelType node.data.componentType != node.data.modelType
@@ -1074,6 +1133,7 @@ def validate_reactflow_execution_contract(
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
from app.simulation.registry import get_component_model_spec from app.simulation.registry import get_component_model_spec
validate_si_project(project)
system_attributes = { system_attributes = {
"schemaVersion": SYSTEM_XML_SCHEMA_VERSION, "schemaVersion": SYSTEM_XML_SCHEMA_VERSION,
"unitSystem": SYSTEM_XML_UNIT_SYSTEM, "unitSystem": SYSTEM_XML_UNIT_SYSTEM,
@@ -1151,7 +1211,8 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
edge.targetHandle, edge.targetHandle,
edge.id, edge.id,
) )
validate_compatible_ports(first, second, edge.id) validate_compatible_ports(first, second, edge.id,
f"{edge.source}.{first.name}", f"{edge.target}.{second.name}")
connection_node = ET.SubElement( connection_node = ET.SubElement(
connections_node, connections_node,
@@ -1203,11 +1264,18 @@ def validate_compatible_ports(
first: "PortDefinition", first: "PortDefinition",
second: "PortDefinition", second: "PortDefinition",
connection_id: str, connection_id: str,
first_label: str | None = None,
second_label: str | None = None,
) -> None: ) -> None:
from app.simulation.core.port_computation import PortSupplyError, port_supply_issue
if first.kind != second.kind: if first.kind != second.kind:
raise ValueError(f"Connection {connection_id} mixes physical and signal ports.") raise ValueError(f"Connection {connection_id} mixes physical and signal ports.")
if first.domain != second.domain: if first.domain != second.domain:
raise ValueError(f"Connection {connection_id} connects incompatible domains.") raise ValueError(f"Connection {connection_id} connects incompatible domains.")
supply_issue = port_supply_issue(first, second, first_label, second_label)
if supply_issue:
raise PortSupplyError(supply_issue)
if first.kind == "signal" and {first.nominal_role, second.nominal_role} != { if first.kind == "signal" and {first.nominal_role, second.nominal_role} != {
"input", "input",
"output", "output",
@@ -1272,6 +1340,7 @@ def _solver_model_from_reactflow(
) -> SolverModelInput: ) -> SolverModelInput:
from app.simulation.registry import get_component_model_spec from app.simulation.registry import get_component_model_spec
validate_si_project(project)
components: list[SolverComponentInput] = [] components: list[SolverComponentInput] = []
for node in project.nodes: for node in project.nodes:
spec = get_component_model_spec(node.data.modelType) spec = get_component_model_spec(node.data.modelType)
@@ -1422,6 +1491,7 @@ def _compile_solver_network(
edge.endpoint_b_port, edge.endpoint_b_port,
connection_id=edge.id, connection_id=edge.id,
) )
network.validate_port_supplies()
return network return network
@@ -1488,6 +1558,18 @@ def parameter_float(
return default return default
value = node.data.parameters.get(name, default) value = node.data.parameters.get(name, default)
try: try:
return float(value) if type(value) in (int, float) and isfinite(value):
except (TypeError, ValueError): return float(value)
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.") except OverflowError:
pass
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be a finite SI number; normalize expressions before execution.")
def validate_si_project(project: ReactFlowProjectPayload) -> None:
"""Guard the editor-independent numerical boundary, including time settings."""
if project.projectSchemaVersion != 1:
raise ValueError("Execution requires normalized SI data; preprocess project input first.")
for name in ("t_start", "t_stop", "step", "max_step"):
value = getattr(project.simulation, name)
if type(value) not in (int, float) or not isfinite(value):
raise ValueError(f"Simulation {name} must be a finite SI number.")
+250
View File
@@ -0,0 +1,250 @@
"""User-input adapter shared by HTTP and CLI; the numerical layer stays SI-only.
JSON v1 numbers (including decimal strings) were SI, but expressions used the
selected unit. JSON v2 consistently uses the selected unit for both. Missing
parameters use catalog defaults, which are always SI. Never infer a format from
magnitudes or relabel a legacy project without converting its values.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import re
UNIT_TABLE = json.loads((Path(__file__).resolve().parent.parent / "schemas" / "parameter-units.json").read_text(encoding="utf-8"))
DECIMAL = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z", re.ASCII)
TOKEN = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[A-Za-z_][A-Za-z_0-9]*|\*\*|[+*/^(),-]", re.ASCII)
def finite(value: float) -> float:
if not math.isfinite(value):
raise ValueError("Parameter expression must produce a finite real number.")
return value
def numeric_literal(value: object) -> float | None:
if type(value) in (int, float):
try:
return finite(float(value))
except OverflowError as exc:
raise ValueError("Parameter magnitude exceeds finite float range.") from exc
if isinstance(value, str) and DECIMAL.fullmatch(value.strip()):
return finite(float(value))
return None
def expression_value(source: str) -> float:
"""Same bounded recursive-descent grammar as parameterExpression.ts; no eval."""
source = source.strip().removeprefix("=").strip()
if not source or len(source) > 512:
raise ValueError("Parameter expression must contain 1..512 characters.")
tokens: list[str] = []
position = 0
while position < len(source):
if source[position].isspace():
position += 1
continue
match = TOKEN.match(source, position)
if match is None:
raise ValueError(f"Unsupported expression character at {position + 1}.")
tokens.append(match[0])
position = match.end()
if len(tokens) > 256:
raise ValueError("Parameter expression exceeds 256 tokens.")
tokens.append("")
index = 0
operations = 0
def current():
return tokens[index]
def take():
nonlocal index
token = current()
if token:
index += 1
return token
def operation():
nonlocal operations
operations += 1
if operations > 256:
raise ValueError("Parameter expression exceeds 256 operations.")
def depth_check(depth):
if depth > 32:
raise ValueError("Parameter expression exceeds 32 nesting levels.")
def additive(depth):
value = multiplicative(depth)
while current() in ("+", "-"):
op = take()
right = multiplicative(depth)
operation()
value = finite(value + right if op == "+" else value - right)
return value
def multiplicative(depth):
value = unary(depth)
while current() in ("*", "/"):
op = take()
right = unary(depth)
operation()
value = finite(value * right if op == "*" else value / right)
return value
def unary(depth):
depth_check(depth)
if current() in ("+", "-"):
op = take()
operation()
value = unary(depth + 1)
return value if op == "+" else -value
return power(depth)
def power(depth):
depth_check(depth)
value = primary(depth)
if current() in ("^", "**"):
take()
exponent = unary(depth + 1)
operation()
value = finite(math.pow(value, exponent))
return value
def primary(depth):
depth_check(depth)
token = take()
if token == "(":
value = additive(depth + 1)
if take() != ")":
raise ValueError("Missing closing parenthesis.")
return value
if token and (token[0].isdigit() or token[0] == "."):
return finite(float(token))
name = token.lower()
if token and (token[0].isalpha() or token[0] == "_"):
if current() != "(":
if name in ("pi", "e"):
return math.pi if name == "pi" else math.e
raise ValueError(f"Unknown identifier: {token}.")
depth_check(depth + 1)
take()
args = []
if current() != ")":
while True:
if len(args) >= 16:
raise ValueError("Functions accept at most 16 arguments.")
args.append(additive(depth + 1))
if current() != ",":
break
take()
if take() != ")":
raise ValueError("Missing function closing parenthesis.")
operation()
functions = {"sqrt": math.sqrt, "abs": abs, "sin": math.sin,
"cos": math.cos, "tan": math.tan, "asin": math.asin,
"acos": math.acos, "atan": math.atan, "exp": math.exp,
"ln": math.log, "log": math.log, "log10": math.log10,
"pow": math.pow}
if name in ("min", "max") and args:
return finite((min if name == "min" else max)(args))
if name not in functions or len(args) != (2 if name == "pow" else 1):
raise ValueError(f"Unsupported function or argument count: {token}.")
return finite(functions[name](*args))
raise ValueError("Expected a number, constant or function.")
try:
result = additive(0)
if current():
raise ValueError("Unexpected trailing expression content.")
return finite(result)
except (ArithmeticError, RecursionError) as exc:
raise ValueError("Invalid arithmetic or expression domain.") from exc
def unit_conversion(definition, unit: str) -> tuple[float, float]:
options = UNIT_TABLE.get(definition.quantity, {}) if definition.unit else {}
if unit in options:
scale, offset, _ = options[unit]
return scale, offset
if unit == definition.unit:
return 1.0, 0.0
raise ValueError(f"Unsupported unit '{unit}' for {definition.name} ({definition.quantity}).")
def prepare_project(project):
"""Copy external input to a current-version, numeric SI execution project.
Returns consolidated version notices to the caller. Does not mutate saved
data and does not weaken the strict XML/native model-version checks.
"""
from app.simulation.registry import get_component_model_spec
normalized = project.model_copy(deep=True)
notices = []
specs = {}
for node in normalized.nodes:
model = node.data
spec = specs.get(model.modelType)
if spec is None:
spec = get_component_model_spec(model.modelType)
specs[model.modelType] = spec
if model.componentType != spec.model_type:
raise ValueError(f"COMPONENT_MODEL_TYPE_MISMATCH: {node.id}.")
if model.modelVersion != spec.model_version:
notices.append({"componentId": node.id, "label": model.label or node.id,
"storedVersion": model.modelVersion,
"currentVersion": spec.model_version})
for name, value in model.parameters.items():
definition = spec.parameter_by_name.get(name)
if definition is None:
raise ValueError(f"Component '{node.id}' contains unsupported parameters: {name}.")
try:
scale, offset = unit_conversion(definition, model.parameterUnits.get(name, definition.unit))
number = numeric_literal(value)
is_expression = number is None
if is_expression:
if not isinstance(value, str) or definition.editor:
raise ValueError("Expected a numeric value; discrete parameters cannot use expressions.")
number = expression_value(value)
if project.projectSchemaVersion == 2 or is_expression:
number = finite(number * scale + offset)
model.parameters[name] = number
except ValueError as exc:
raise ValueError(f"{node.id}.{name}: {exc}") from exc
# Explicit legacy migrations also used by the browser.
if model.modelVersion == "0.1.0" and model.modelType == "amesim_forc":
model.parameters.setdefault("direction", 1.0)
if model.modelVersion == "0.1.0" and model.modelType == "amesim_lmechn1":
count = model.parameters.get("v1")
if count in range(1, 9):
for edge in normalized.edges:
if edge.source == node.id and edge.sourceHandle == "port_9":
edge.sourceHandle = f"port_{int(count) + 1}"
if edge.target == node.id and edge.targetHandle == "port_9":
edge.targetHandle = f"port_{int(count) + 1}"
model.parameters["sum"] = 1.0
# Historical LMECHN1 exposed only nine ports; use its migrated contract.
from app.main import ReactFlowPortDefinition
model.ports = [ReactFlowPortDefinition(name=p.name, kind=p.kind, domain=p.domain,
nominalRole=p.nominal_role, positiveFlowDirection=p.positive_flow_direction)
for p in spec.ports]
model.modelVersion = spec.model_version
model.parameterUnits = {p.name: p.unit for p in spec.parameters}
model.parameterScientificNotation = {}
for name in ("t_start", "t_stop", "step", "max_step"):
value = getattr(normalized.simulation, name)
number = numeric_literal(value)
if number is None:
number = expression_value(value)
setattr(normalized.simulation, name, number)
normalized.projectSchemaVersion = 1 # Internal numeric SI contract, never a v2 wire payload.
return normalized, notices
def version_warning(notices):
return {"code": "COMPONENT_MODEL_VERSION_WARNING",
"message": "旧版或版本未知的组件将使用当前模型执行,可能仿真失败或结果与实际不符。",
"components": notices}
+2
View File
@@ -14,4 +14,6 @@
旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。 旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。
旧 `ir/` 包、专属 schema、规范与测试已删除。当前 C 生成器直接使用经过校验的网络结构,不依赖旧 System IR v2。
运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。 运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。
+6 -5
View File
@@ -22,16 +22,17 @@ def numeric_engine_name(backend: str | None = None) -> str:
def simulation_config(simulation) -> SolveIVPConfig: def simulation_config(simulation) -> SolveIVPConfig:
# Preserve the existing XML execution accuracy. The XML # Match the validated native pipe/chamber accuracy. Per-state SI absolute
# protocol's future tolerance fields are a separate compatibility change. # floors are emitted by native_codegen.tolerances; XML tolerance fields
# remain a separate protocol change.
return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop, return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop,
method=simulation.method, rtol=1e-6, max_step=simulation.max_step) method=simulation.method, rtol=1e-8, max_step=simulation.max_step)
def simulate_network(network, simulation, *, progress_callback=None, def simulate_network(network, simulation, *, progress_callback=None,
cancel_check=None, activity_tracker=None, backend=None): warning_callback=None, cancel_check=None, activity_tracker=None, backend=None, raw_series=False):
numeric_engine_name(backend) numeric_engine_name(backend)
config = simulation_config(simulation) config = simulation_config(simulation)
from app.simulation.native_codegen.runner import simulate_native from app.simulation.native_codegen.runner import simulate_native
return simulate_native(network, config, sample_step=simulation.sample_step, progress_callback=progress_callback, return simulate_native(network, config, sample_step=simulation.sample_step, progress_callback=progress_callback,
cancel_check=cancel_check, activity_tracker=activity_tracker) warning_callback=warning_callback, cancel_check=cancel_check, activity_tracker=activity_tracker, raw_series=raw_series)
@@ -5,6 +5,7 @@ from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import ZERO_FLOW_SUPPLY
class AmesimPnpl01(AlgebraicComponent): class AmesimPnpl01(AlgebraicComponent):
"""AMESim PNPL01 zero pneumatic flow source. """AMESim PNPL01 zero pneumatic flow source.
@@ -15,7 +16,7 @@ class AmesimPnpl01(AlgebraicComponent):
""" """
MODEL_TYPE = 'amesim_pnpl01' MODEL_TYPE = 'amesim_pnpl01'
MODEL_VERSION = '0.1.0' MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'),) PORTS = (PortDefinition.pneumatic('port_1', computation=ZERO_FLOW_SUPPLY),)
PARAMETERS = () PARAMETERS = ()
RESULT_VARIABLES = () RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10) DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10)
@@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisp
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
from app.simulation.core.medium import GasMedium from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
_FLOW_COEFFICIENT_OPTIONS = (ParameterOption(1.0, 'Cq'), ParameterOption(2.0, 'Cv'), ParameterOption(3.0, 'Kv')) _FLOW_COEFFICIENT_OPTIONS = (ParameterOption(1.0, 'Cq'), ParameterOption(2.0, 'Cv'), ParameterOption(3.0, 'Kv'))
_FLOWSET_USES_CQ = (ParameterCondition('flowset', (1.0,)),) _FLOWSET_USES_CQ = (ParameterCondition('flowset', (1.0,)),)
_FLOWSET_USES_CV = (ParameterCondition('flowset', (2.0,)),) _FLOWSET_USES_CV = (ParameterCondition('flowset', (2.0,)),)
@@ -23,7 +24,7 @@ class AmesimPnor001(AlgebraicComponent):
""" """
MODEL_TYPE = 'amesim_pnor001' MODEL_TYPE = 'amesim_pnor001'
MODEL_VERSION = '0.3.0' MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area', 5e-06, label='孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='选择 Cq/面积方式时用于流量计算的有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area', 5e-06, label='孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='选择 Cq/面积方式时用于流量计算的有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'))
RESULT_VARIABLES = (ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=20)) RESULT_VARIABLES = (ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=20))
DISPLAY = ComponentDisplaySpec(label='PNOR001 常系数气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnor001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10, parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,)) DISPLAY = ComponentDisplaySpec(label='PNOR001 常系数气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnor001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10, parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,))
@@ -84,13 +85,13 @@ class AmesimPnor001(AlgebraicComponent):
class AmesimPnvo001FixedOpening(AlgebraicComponent): class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""Fixed-opening public variant of AMESim PNVO001. """Fixed-opening public variant of AMESim PNVO001.
Full PNVO001 has a signal input port. The current public component library Full PNVO001 has a signal input port. This optional variant exposes the
does not support signal simulation, so this model exposes the pneumatic pneumatic ports and replaces that signal with a normalized `opening`
ports and replaces the signal with a normalized `opening` parameter. parameter; use AmesimPnvo001SignalOpening for a time-varying control signal.
""" """
MODEL_TYPE = 'amesim_pnvo001_fixed' MODEL_TYPE = 'amesim_pnvo001_fixed'
MODEL_VERSION = '0.2.0' MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening', 1.0, label='固定开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='固定的归一化阀门开度;0 表示关闭,1 表示完全开启。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening', 1.0, label='固定开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='固定的归一化阀门开度;0 表示关闭,1 表示完全开启。'))
RESULT_VARIABLES = (ResultVariableDefinition('xv', label='有效开度', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=30)) RESULT_VARIABLES = (ResultVariableDefinition('xv', label='有效开度', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=30))
DISPLAY = ComponentDisplaySpec(label='PNVO001 固定开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001_fixed', ports=(PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,)) DISPLAY = ComponentDisplaySpec(label='PNVO001 固定开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001_fixed', ports=(PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
@@ -143,7 +144,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
"""AMESim PNVO001 signal-controlled pneumatic orifice.""" """AMESim PNVO001 signal-controlled pneumatic orifice."""
MODEL_TYPE = 'amesim_pnvo001' MODEL_TYPE = 'amesim_pnvo001'
MODEL_VERSION = '0.2.0' MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional')) PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening0', 1.0, label='初始开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening0', 1.0, label='初始开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。'))
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNVO001 信号开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001', ports=(PortDisplaySpec('res', 'left', order=5), PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,)) DISPLAY = ComponentDisplaySpec(label='PNVO001 信号开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001', ports=(PortDisplaySpec('res', 'left', order=5), PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
@@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisp
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import GasMedium from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY, THERMODYNAMIC_SUPPLY
_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition('mode', (1.0,)) _DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition('mode', (1.0,))
_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition('mode', (2.0,)) _DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition('mode', (2.0,))
_DYNAMIC_PIPE_PARAMETER_GROUPS = (ParameterGroupDisplaySpec(id='thermodynamics', label='热力学', parameters=('k', 'kth', 'extemp'), order=10),) _DYNAMIC_PIPE_PARAMETER_GROUPS = (ParameterGroupDisplaySpec(id='thermodynamics', label='热力学', parameters=('k', 'kth', 'extemp'), order=10),)
@@ -21,7 +22,7 @@ class AmesimPnl00r(AlgebraicComponent):
""" """
MODEL_TYPE = 'amesim_pnl00r' MODEL_TYPE = 'amesim_pnl00r'
MODEL_VERSION = '0.3.0' MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='参与摩擦压降计算的管路有效长度。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='参与摩擦压降计算的管路有效长度。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'))
RESULT_VARIABLES = (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=30), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=40)) RESULT_VARIABLES = (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=30), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=40))
DISPLAY = ComponentDisplaySpec(label='PNL00R 气动管路阻力', library_id='amesim', category_id='flow', symbol='amesim_pnl00r', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20) DISPLAY = ComponentDisplaySpec(label='PNL00R 气动管路阻力', library_id='amesim', category_id='flow', symbol='amesim_pnl00r', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20)
@@ -54,8 +55,8 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction.""" """AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
MODEL_TYPE = 'amesim_pnl0001' MODEL_TYPE = 'amesim_pnl0001'
MODEL_VERSION = '0.4.0' MODEL_VERSION = '0.4.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积、储气容积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效长度,用于计算储气容积、换热面积和摩擦压降。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'), ParameterDefinition('k', 1.35, label='多方指数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, maximum=2.0, description='mode=1 多方过程使用的指数;当前公开求解器保留该 AMESim 配置,尚未实现多方指数对状态方程的修正。', visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,)), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='mode=2 带换热过程使用的气体与外部环境对流换热系数。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='mode=2 带换热过程使用的外部环境绝对温度。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('mode', 2.0, label='热模型', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '多方过程'), ParameterOption(2.0, '带换热')), description='AMESim 原始编码:1 为多方过程,2 为带换热。当前公开求解器在多方模式下关闭环境换热,在带换热模式下按换热系数和外部温度计算环境换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对温度。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积、储气容积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效长度,用于计算储气容积、换热面积和摩擦压降。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'), ParameterDefinition('k', 1.35, label='多方指数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='mode=1 多方过程指数。采用理想气体储气关系,按质量变化计算温度与压力变化;k=1 为等温。流阻和端口焓仍使用选定介质。', visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,)), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='mode=2 带换热过程使用的气体与外部环境对流换热系数。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='mode=2 带换热过程使用的外部环境绝对温度。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('mode', 2.0, label='热模型', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '多方过程'), ParameterOption(2.0, '带换热')), description='AMESim 原始编码:1 为多方过程,2 为带换热。多方模式按多方指数计算状态变化,不使用流入焓和环境换热决定温度;带换热模式采用质量与能量守恒。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对温度。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=100), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=110), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=120), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=130)) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=100), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=110), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=120), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=130))
DISPLAY = ComponentDisplaySpec(label='PNL0001 C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=30, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS) DISPLAY = ComponentDisplaySpec(label='PNL0001 C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=30, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@@ -98,7 +99,7 @@ class AmesimPnl0002(AmesimPnl0001):
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance.""" """AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
MODEL_TYPE = 'amesim_pnl0002' MODEL_TYPE = 'amesim_pnl0002'
MODEL_VERSION = '0.6.0' MODEL_VERSION = '0.6.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = AmesimPnl0001.PARAMETERS PARAMETERS = AmesimPnl0001.PARAMETERS
RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNL0002 R-C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0002', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS) DISPLAY = ComponentDisplaySpec(label='PNL0002 R-C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0002', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@@ -117,7 +118,7 @@ class AmesimPnl0003(DynamicComponent):
state_size = 4 state_size = 4
MODEL_TYPE = 'amesim_pnl0003' MODEL_TYPE = 'amesim_pnl0003'
MODEL_VERSION = '0.4.0' MODEL_VERSION = '0.4.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + (ParameterDefinition('p1_0', 100000.0, label='端口 1 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T1_0', 293.15, label='端口 1 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p2_0', 100000.0, label='端口 2 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T2_0', 293.15, label='端口 2 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + (ParameterDefinition('p1_0', 100000.0, label='端口 1 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T1_0', 293.15, label='端口 1 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p2_0', 100000.0, label='端口 2 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T2_0', 293.15, label='端口 2 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = (ResultVariableDefinition('m1', '端口 1 侧质量', 'mass', 'kg', 'state', 10), ResultVariableDefinition('U1', '端口 1 侧内能', 'internal_energy', 'J', 'state', 20), ResultVariableDefinition('p1', '端口 1 侧压力', 'pressure', 'Pa', 'thermodynamic', 30), ResultVariableDefinition('T1', '端口 1 侧温度', 'temperature', 'K', 'thermodynamic', 40), ResultVariableDefinition('rho1', '端口 1 侧密度', 'density', 'kg/m³', 'thermodynamic', 50), ResultVariableDefinition('u1', '端口 1 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 60), ResultVariableDefinition('h1', '端口 1 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 70), ResultVariableDefinition('m2', '端口 2 侧质量', 'mass', 'kg', 'state', 80), ResultVariableDefinition('U2', '端口 2 侧内能', 'internal_energy', 'J', 'state', 90), ResultVariableDefinition('p2', '端口 2 侧压力', 'pressure', 'Pa', 'thermodynamic', 100), ResultVariableDefinition('T2', '端口 2 侧温度', 'temperature', 'K', 'thermodynamic', 110), ResultVariableDefinition('rho2', '端口 2 侧密度', 'density', 'kg/m³', 'thermodynamic', 120), ResultVariableDefinition('u2', '端口 2 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 130), ResultVariableDefinition('h2', '端口 2 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 140), ResultVariableDefinition('dmctr', '中心质量流量', 'mass_flow', 'kg/s', 'derived', 150), ResultVariableDefinition('re', 'Reynolds 数', 'dimensionless', '', 'derived', 160), ResultVariableDefinition('cm', '质量流量参数', 'dimensionless', '', 'derived', 170), ResultVariableDefinition('v', '平均气体速度', 'velocity', 'm/s', 'derived', 180), ResultVariableDefinition('ff', '摩擦因子', 'dimensionless', '', 'derived', 190)) RESULT_VARIABLES = (ResultVariableDefinition('m1', '端口 1 侧质量', 'mass', 'kg', 'state', 10), ResultVariableDefinition('U1', '端口 1 侧内能', 'internal_energy', 'J', 'state', 20), ResultVariableDefinition('p1', '端口 1 侧压力', 'pressure', 'Pa', 'thermodynamic', 30), ResultVariableDefinition('T1', '端口 1 侧温度', 'temperature', 'K', 'thermodynamic', 40), ResultVariableDefinition('rho1', '端口 1 侧密度', 'density', 'kg/m³', 'thermodynamic', 50), ResultVariableDefinition('u1', '端口 1 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 60), ResultVariableDefinition('h1', '端口 1 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 70), ResultVariableDefinition('m2', '端口 2 侧质量', 'mass', 'kg', 'state', 80), ResultVariableDefinition('U2', '端口 2 侧内能', 'internal_energy', 'J', 'state', 90), ResultVariableDefinition('p2', '端口 2 侧压力', 'pressure', 'Pa', 'thermodynamic', 100), ResultVariableDefinition('T2', '端口 2 侧温度', 'temperature', 'K', 'thermodynamic', 110), ResultVariableDefinition('rho2', '端口 2 侧密度', 'density', 'kg/m³', 'thermodynamic', 120), ResultVariableDefinition('u2', '端口 2 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 130), ResultVariableDefinition('h2', '端口 2 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 140), ResultVariableDefinition('dmctr', '中心质量流量', 'mass_flow', 'kg/s', 'derived', 150), ResultVariableDefinition('re', 'Reynolds 数', 'dimensionless', '', 'derived', 160), ResultVariableDefinition('cm', '质量流量参数', 'dimensionless', '', 'derived', 170), ResultVariableDefinition('v', '平均气体速度', 'velocity', 'm/s', 'derived', 180), ResultVariableDefinition('ff', '摩擦因子', 'dimensionless', '', 'derived', 190))
DISPLAY = ComponentDisplaySpec(label='PNL0003 C-R-C 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0003', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=50, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS) DISPLAY = ComponentDisplaySpec(label='PNL0003 C-R-C 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0003', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=50, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@@ -5,14 +5,17 @@ from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import NODE_BRANCH, NODE_REFERENCE
class _AmesimPneumaticNode(AlgebraicComponent): class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels. """Shared implementation for AMESim pneumatic junction submodels.
PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference. PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference.
Non-reference outlet ports use that reference temperature. When port 2 is Non-reference outlet ports use that reference temperature. Signed branch
an outlet, its enthalpy is the residual that closes the junction energy enthalpy flows are summed and delivered to port 2 independently of its net
balance, matching the AMESim dh2 causality. mass flow, matching AMESim dh2 causality even at zero net mass flow.
The finite h_outflow diagnostic cannot encode that zero-flow energy.
Branch volumes and volume rates are also summed towards port 2.
""" """
REFERENCE_PORT = 'port_2' REFERENCE_PORT = 'port_2'
@@ -27,7 +30,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
"""AMESim PN3NODE2 pneumatic three-port junction.""" """AMESim PN3NODE2 pneumatic three-port junction."""
MODEL_TYPE = 'amesim_pn3node2' MODEL_TYPE = 'amesim_pn3node2'
MODEL_VERSION = '0.3.0' MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH))
PARAMETERS = () PARAMETERS = ()
RESULT_VARIABLES = () RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='PN3NODE2 三端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_pn3node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30)), order=10) DISPLAY = ComponentDisplaySpec(label='PN3NODE2 三端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_pn3node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30)), order=10)
@@ -41,7 +44,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction.""" """AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = 'amesim_p4node2' MODEL_TYPE = 'amesim_p4node2'
MODEL_VERSION = '0.3.0' MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH), PortDefinition.pneumatic('port_4', computation=NODE_BRANCH))
PARAMETERS = () PARAMETERS = ()
RESULT_VARIABLES = () RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='P4NODE2 四端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_p4node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20) DISPLAY = ComponentDisplaySpec(label='P4NODE2 四端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_p4node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
@@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.medium import GasMedium from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
AMESIM_REFERENCE_PRESSURE_PA = 101300.0 AMESIM_REFERENCE_PRESSURE_PA = 101300.0
class AmesimPnrp17(AlgebraicComponent): class AmesimPnrp17(AlgebraicComponent):
@@ -19,7 +20,7 @@ class AmesimPnrp17(AlgebraicComponent):
""" """
MODEL_TYPE = 'amesim_pnrp17' MODEL_TYPE = 'amesim_pnrp17'
MODEL_VERSION = '0.1.0' MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5')) PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5'))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('dp', 0.2, label='活塞直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='活塞外径;与活塞杆直径共同确定有效受压面积。'), ParameterDefinition('dr', 0.001, label='活塞杆直径', quantity='length', unit='m', minimum=0.0, description='穿过气室一侧的活塞杆直径,必须不大于活塞直径。'), ParameterDefinition('x0', 0.0, label='初始腔长', quantity='length', unit='m', description='机械端位移均为零时的气动腔长度。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('dp', 0.2, label='活塞直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='活塞外径;与活塞杆直径共同确定有效受压面积。'), ParameterDefinition('dr', 0.001, label='活塞杆直径', quantity='length', unit='m', minimum=0.0, description='穿过气室一侧的活塞杆直径,必须不大于活塞直径。'), ParameterDefinition('x0', 0.0, label='初始腔长', quantity='length', unit='m', description='机械端位移均为零时的气动腔长度。'))
RESULT_VARIABLES = (ResultVariableDefinition('volume', '扫掠容积', 'volume', 'm3', 'derived', 10), ResultVariableDefinition('volume_flow', '扫掠容积变化率', 'volume_flow', 'm3/s', 'derived', 20), ResultVariableDefinition('length', '气动腔长度', 'length', 'm', 'derived', 30), ResultVariableDefinition('pressure_force', '气压力', 'force', 'N', 'derived', 40)) RESULT_VARIABLES = (ResultVariableDefinition('volume', '扫掠容积', 'volume', 'm3', 'derived', 10), ResultVariableDefinition('volume_flow', '扫掠容积变化率', 'volume_flow', 'm3/s', 'derived', 20), ResultVariableDefinition('length', '气动腔长度', 'length', 'm', 'derived', 30), ResultVariableDefinition('pressure_force', '气压力', 'force', 'N', 'derived', 40))
DISPLAY = ComponentDisplaySpec(label='PNRP17 气动活塞', library_id='amesim', category_id='mechanical', symbol='amesim_pnrp17', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_3', 'left', order=20), PortDisplaySpec('port_2', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40), PortDisplaySpec('port_5', 'right', order=50)), order=60) DISPLAY = ComponentDisplaySpec(label='PNRP17 气动活塞', library_id='amesim', category_id='mechanical', symbol='amesim_pnrp17', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_3', 'left', order=20), PortDisplaySpec('port_2', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40), PortDisplaySpec('port_5', 'right', order=50)), order=60)
@@ -61,7 +61,7 @@ class AmesimMecmas21(DynamicComponent):
MODEL_TYPE = 'amesim_mecmas21' MODEL_TYPE = 'amesim_mecmas21'
MODEL_VERSION = '0.2.0' MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2')) PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2'))
PARAMETERS = (ParameterDefinition('mass', 1.0, label='质量', quantity='mass', unit='kg', minimum=0.0, minimum_exclusive=True, description='平动质量,必须大于零。'), ParameterDefinition('fstick', 0.0, label='静摩擦力', quantity='force', unit='N', minimum=0.0, description='AMESim 静摩擦阈值。当前公开求解器尚未实现静摩擦保持与脱离逻辑,因此该参数暂不参与力计算。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('fcoul', 0.0, label='库仑摩擦力', quantity='force', unit='N', minimum=0.0, description='滑动时的库仑摩擦力绝对值,方向与速度相反。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('rvisc', 0.0, label='黏性摩擦系数', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='与速度成正比的黏性摩擦系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('wind', 0.0, label='风阻系数', quantity='windage', unit='N/(m/s)^2', minimum=0.0, description='与速度平方成正比、方向与速度相反的风阻系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('dvel', 1e-06, label='粘滞速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='高级摩擦模型的低速切换阈值;当前公开求解器尚未实现对应的高级静摩擦公式。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION)), ParameterDefinition('restdvel', 1e-06, label='恢复速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='恢复碰撞低于该入射速度时按无回弹处理。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('restcoeff', 0.65, label='恢复系数', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='恢复碰撞后的速度与碰撞前速度绝对值之比。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('astrib', 0.001, label='Stribeck 常数', quantity='velocity', unit='m/s', minimum=0.0, description='高级 Stribeck 摩擦模型的速度常数;当前公开求解器尚未实现对应的 Stribeck 公式。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, _MECMAS21_STRIBECK_ENABLED)), ParameterDefinition('xmin', -1.0, label='下位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的下边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmin', 1000000000.0, label='下限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性下限位的接触刚度。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmin', 10000.0, label='下限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性下限位的最大接触阻尼。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmin', 0.0001, label='下限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性下限位阻尼由零增至全值所需的穿透量。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('xmax', 0.8, label='上位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的上边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmax', 1000000000.0, label='上限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性上限位的接触刚度。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmax', 10000.0, label='上限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性上限位的最大接触阻尼。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmax', 0.0001, label='上限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性上限位阻尼由零增至全值所需的穿透量。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('theta', 0.0, label='倾角(度)', quantity='dimensionless', unit='', description='源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端,-90° 表示端口 1 位于最高端。当前一维公开求解器尚未加入由该倾角产生的重力分量。'), ParameterDefinition('useFriction', 2.0, label='启用摩擦', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '否'), ParameterOption(2.0, '是')), description='AMESim 原始编码:1 表示不启用摩擦,2 表示启用摩擦。当前公开模型在启用时实现黏性摩擦、风阻和基础库仑摩擦;高级静摩擦与 Stribeck 公式尚未实现。'), ParameterDefinition('stoptype', 1.0, label='限位类型', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '理想限位'), ParameterOption(2.0Line truncated PARAMETERS = (ParameterDefinition('mass', 1.0, label='质量', quantity='mass', unit='kg', minimum=0.0, minimum_exclusive=True, description='平动质量,必须大于零。'), ParameterDefinition('fstick', 0.0, label='静摩擦力', quantity='force', unit='N', minimum=0.0, description='静止时可抵消的外力阈值;超过阈值后进入滑动,停止后重新保持。恢复碰撞模式不使用干摩擦。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('fcoul', 0.0, label='库仑摩擦力', quantity='force', unit='N', minimum=0.0, description='滑动时的库仑摩擦力绝对值,方向与速度相反;启用干摩擦时不得大于 fstick。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('rvisc', 0.0, label='黏性摩擦系数', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='与速度成正比的黏性摩擦系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('wind', 0.0, label='风阻系数', quantity='windage', unit='N/(m/s)^2', minimum=0.0, description='与速度平方成正比、方向与速度相反的风阻系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('dvel', 1e-06, label='粘滞速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='高级摩擦模型的低速保持区间半宽;减速进入该区间时检查静摩擦保持条件。必须大于零。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION)), ParameterDefinition('restdvel', 1e-06, label='恢复速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='恢复碰撞低于该入射速度时按无回弹处理。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('restcoeff', 0.65, label='恢复系数', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='恢复碰撞后的速度与碰撞前速度绝对值之比。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('astrib', 0.001, label='Stribeck 常数', quantity='velocity', unit='m/s', minimum=0.0, description='高级 Stribeck 摩擦模型的速度常数,控制摩擦力从静摩擦阈值向库仑摩擦力的指数过渡;必须大于零。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, _MECMAS21_STRIBECK_ENABLED)), ParameterDefinition('xmin', -1.0, label='下位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的下边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmin', 1000000000.0, label='下限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性下限位的接触刚度,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmin', 10000.0, label='下限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性下限位的最大接触阻尼,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmin', 0.0001, label='下限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性下限位阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('xmax', 0.8, label='上位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的上边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmax', 1000000000.0, label='上限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性上限位的接触刚度,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmax', 10000.0, label='上限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性上限位的最大接触阻尼,启用弹性限位时必须大于零。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmax', 0.0001, label='上限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性上限位阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('theta', 0.0, label='倾角(度)', quantity='dimensionless', unit='', description='源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端,-90° 表示端口 1 位于最高端。按标准重力加速度 9.80665 m/s² 计算沿运动方向的重力分量;正倾角产生正向加速度。'), ParameterDefinition('useFriction', 2.0, label='启用摩擦', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '否'), ParameterOption(2.0, '是')), description='AMELine truncated
RESULT_VARIABLES = (ResultVariableDefinition('a', '加速度', 'acceleration', 'm/s2', 'state', 10), ResultVariableDefinition('v', '速度', 'velocity', 'm/s', 'state', 20), ResultVariableDefinition('x', '位移', 'length', 'm', 'state', 30), ResultVariableDefinition('Fvisc', '黏性摩擦力', 'force', 'N', 'derived', 40), ResultVariableDefinition('Ffric', '干摩擦力', 'force', 'N', 'derived', 50), ResultVariableDefinition('Fmin', '下限位力', 'force', 'N', 'derived', 60), ResultVariableDefinition('Fmax', '上限位力', 'force', 'N', 'derived', 70)) RESULT_VARIABLES = (ResultVariableDefinition('a', '加速度', 'acceleration', 'm/s2', 'state', 10), ResultVariableDefinition('v', '速度', 'velocity', 'm/s', 'state', 20), ResultVariableDefinition('x', '位移', 'length', 'm', 'state', 30), ResultVariableDefinition('Fvisc', '黏性摩擦力', 'force', 'N', 'derived', 40), ResultVariableDefinition('Ffric', '干摩擦力', 'force', 'N', 'derived', 50), ResultVariableDefinition('Fmin', '下限位力', 'force', 'N', 'derived', 60), ResultVariableDefinition('Fmax', '上限位力', 'force', 'N', 'derived', 70))
DISPLAY = ComponentDisplaySpec(label='MECMAS21 一维质量', library_id='amesim', category_id='mechanical', symbol='amesim_mecmas21', ports=(PortDisplaySpec('port_2', 'left', order=10), PortDisplaySpec('port_1', 'right', order=20)), order=30, parameter_groups=(ParameterGroupDisplaySpec(id='friction', label='摩擦', parameters=('frictionType', 'strib', 'astrib', 'fstick', 'fcoul', 'rvisc', 'wind', 'dvel'), order=10), ParameterGroupDisplaySpec(id='endstops', label='限位', parameters=('discContactOption', 'xmax', 'Kbmax', 'Dbmax', 'Pdmax', 'xmin', 'Kbmin', 'Dbmin', 'Pdmin', 'restcoeff', 'restdvel'), order=20))) DISPLAY = ComponentDisplaySpec(label='MECMAS21 一维质量', library_id='amesim', category_id='mechanical', symbol='amesim_mecmas21', ports=(PortDisplaySpec('port_2', 'left', order=10), PortDisplaySpec('port_1', 'right', order=20)), order=30, parameter_groups=(ParameterGroupDisplaySpec(id='friction', label='摩擦', parameters=('frictionType', 'strib', 'astrib', 'fstick', 'fcoul', 'rvisc', 'wind', 'dvel'), order=10), ParameterGroupDisplaySpec(id='endstops', label='限位', parameters=('discContactOption', 'xmax', 'Kbmax', 'Dbmax', 'Pdmax', 'xmin', 'Kbmin', 'Dbmin', 'Pdmin', 'restcoeff', 'restdvel'), order=20)))
state_size = 2 state_size = 2
@@ -94,7 +94,7 @@ class AmesimLstp00a(AlgebraicComponent):
MODEL_TYPE = 'amesim_lstp00a' MODEL_TYPE = 'amesim_lstp00a'
MODEL_VERSION = '0.2.0' MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2')) PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2'))
PARAMETERS = (ParameterDefinition('na', 10.0, label='有效圈数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='几何刚度模式下使用的弹簧有效圈数。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('gap0', 0.0, label='初始间隙', quantity='length', unit='m', description='两个机械端口开始产生接触力前的初始间隙。'), ParameterDefinition('kcont', 1000000.0, label='接触刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='数值刚度模式下直接指定的接触刚度。', visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,)), ParameterDefinition('G', 85700000000.0, label='剪切模量', quantity='pressure', unit='Pa', minimum=0.0, description='几何刚度模式下的材料剪切模量。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('sdiam', 0.02, label='弹簧直径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧平均直径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('wdiam', 0.002, label='线径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧线径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('rcont', 0.0, label='接触阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='接触穿透过程中使用的最大阻尼系数。'), ParameterDefinition('Pdis', 1e-07, label='满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='接触阻尼由零增至全值所需的穿透量。'), ParameterDefinition('stiffmode', 1.0, label='刚度模式', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '数值刚度'), ParameterOption(2.0, '几何参数')), description='AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。当前公开求解器尚未实现由几何参数换算刚度,几何模式仍沿用已保存的接触刚度值。'), ParameterDefinition('discContactOption', 1.0, label='允许负接触力', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '允许负接触力'), ParameterOption(2.0, '不允许负接触力')), description='AMESim 原始编码:1 保留阻尼项可能产生的负接触力,2 将接触力限制为非负。')) PARAMETERS = (ParameterDefinition('na', 10.0, label='有效圈数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='几何刚度模式下使用的弹簧有效圈数。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('gap0', 0.0, label='初始间隙', quantity='length', unit='m', description='两个机械端口开始产生接触力前的初始间隙。'), ParameterDefinition('kcont', 1000000.0, label='接触刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='数值刚度模式下直接指定的接触刚度,执行仿真时必须大于零。', visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,)), ParameterDefinition('G', 85700000000.0, label='剪切模量', quantity='pressure', unit='Pa', minimum=0.0, description='几何刚度模式下的材料剪切模量,允许为零;此时仅保留接触阻尼。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('sdiam', 0.02, label='弹簧直径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧平均直径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('wdiam', 0.002, label='线径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧线径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('rcont', 0.0, label='接触阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='接触穿透过程中使用的最大阻尼系数。'), ParameterDefinition('Pdis', 1e-07, label='满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='接触阻尼增至最大值约 63.2% 时的穿透量;阻尼随穿透量按指数规律趋近最大值。'), ParameterDefinition('stiffmode', 1.0, label='刚度模式', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '数值刚度'), ParameterOption(2.0, '几何参数')), description='AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。几何模式按 G×线径⁴/(8×平均直径³×有效圈数) 计算刚度;此时不使用 kcont。'), ParameterDefinition('discContactOption', 1.0, label='允许负接触力', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '允许负接触力'), ParameterOption(2.0, '不允许负接触力')), description='AMESim 原始编码:1 保留阻尼项可能产生的负接触力,2 将接触力限制为非负。'))
RESULT_VARIABLES = (ResultVariableDefinition('gap', '间隙', 'length', 'm', 'derived', 10), ResultVariableDefinition('penetration', '穿透', 'length', 'm', 'derived', 20), ResultVariableDefinition('force', '接触力', 'force', 'N', 'derived', 30)) RESULT_VARIABLES = (ResultVariableDefinition('gap', '间隙', 'length', 'm', 'derived', 10), ResultVariableDefinition('penetration', '穿透', 'length', 'm', 'derived', 20), ResultVariableDefinition('force', '接触力', 'force', 'N', 'derived', 30))
DISPLAY = ComponentDisplaySpec(label='LSTP00A 弹性接触', library_id='amesim', category_id='mechanical', symbol='amesim_lstp00a', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=(ParameterGroupDisplaySpec(id='stiffness', label='刚度', parameters=('kcont', 'G', 'sdiam', 'wdiam', 'na'), order=10), ParameterGroupDisplaySpec(id='contact', label='接触', parameters=('gap0', 'rcont', 'Pdis'), order=20))) DISPLAY = ComponentDisplaySpec(label='LSTP00A 弹性接触', library_id='amesim', category_id='mechanical', symbol='amesim_lstp00a', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=(ParameterGroupDisplaySpec(id='stiffness', label='刚度', parameters=('kcont', 'G', 'sdiam', 'wdiam', 'na'), order=10), ParameterGroupDisplaySpec(id='contact', label='接触', parameters=('gap0', 'rcont', 'Pdis'), order=20)))
@@ -20,6 +20,12 @@ class AmesimIdealAirMedium(IdealGasMedium):
@dataclass(frozen=True) @dataclass(frozen=True)
class AmesimHeliumPengRobinsonMedium(IdealGasMedium): class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
"""Helium identity; reference constants below describe the lower range.
The native evaluator uses Amesim's piecewise NASA heat capacity/enthalpy
(6000 K transition) and viscosity (1000/5000 K transitions), together
with PR departure properties. ``cp_ref`` is not a global constant Cp.
"""
SUBSTANCE_ID: ClassVar[str] = 'helium' SUBSTANCE_ID: ClassVar[str] = 'helium'
PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson' PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson'
nasa_cp_over_R: ClassVar[float] = 2.5 nasa_cp_over_R: ClassVar[float] = 2.5
@@ -0,0 +1,74 @@
"""External Amesim encodings, separate from the saved public model contract.
Apply these mappings only when reading Amesim parameters. A browser JSON/XML
already uses public values: applying the mapping again changes its meaning.
Reviewed against the installed Amesim 2404 submodel parameter declarations.
"""
from __future__ import annotations
AMESIM_CHOICE_VALUES = {
("amesim_ud00", "nstages"): {i: i for i in range(1, 9)},
("amesim_ud00", "iscyclic"): {1: 0, 2: 1},
("amesim_lstp00a", "stiffmode"): {1: 1, 2: 2},
("amesim_lstp00a", "discContactOption"): {1: 1, 2: 2},
("amesim_mecmas21", "useFriction"): {1: 1, 2: 2},
("amesim_mecmas21", "stoptype"): {1: 1, 2: 2, 3: 3, 4: 4},
("amesim_mecmas21", "discContactOption"): {1: 1, 2: 2},
("amesim_mecmas21", "strib"): {1: 1, 2: 2},
("amesim_mecmas21", "frictionType"): {1: 1, 2: 2},
("amesim_lmechn1", "sum"): {1: 1},
**{(model, "mode"): {1: 1, 2: 2} for model in
("amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003")},
**{(model, "flowset"): {1: 1, 2: 2, 3: 3} for model in
("amesim_pnor001", "amesim_pnvo001", "amesim_pnvo001_fixed")},
}
def amesim_choice_to_public(model_type: str, parameter: str, value: float) -> float:
mapping = AMESIM_CHOICE_VALUES.get((model_type, parameter))
if mapping is None:
return value
if value not in mapping:
raise ValueError(f"{model_type}.{parameter}: unknown Amesim encoding {value}; "
f"expected one of {tuple(mapping)}")
return float(mapping[value])
def contact_stiffness(component) -> float:
"""SI constant lowering; the contact force is evaluated in C."""
if int(component.stiffmode) == 1:
if component.kcont <= 0:
raise ValueError(f"{component.name}: LSTP00A 数值刚度模式要求 kcont > 0")
return component.kcont
if component.G < 0:
raise ValueError(f"{component.name}: LSTP00A 几何刚度模式要求 G >= 0")
for name in ("sdiam", "wdiam", "na"):
if getattr(component, name) <= 0:
raise ValueError(f"{component.name}: LSTP00A 几何刚度模式要求 {name} > 0")
return component.G * component.wdiam**4 / (8 * component.sdiam**3 * component.na)
def validate_numerical_semantics(component) -> None:
"""Reject requested behavior that cannot yet be faithfully executed.
Keep this at numerical compilation, so incomplete/future configurations
can still be edited and saved. Disabled friction settings have no effect.
"""
if component.model_type == "amesim_mecmas21" and component.use_friction and int(component.stoptype) != 3:
for name in ('dvel', 'astrib'):
if getattr(component, name) <= 0:
raise ValueError(f"{component.name}: MECMAS21 启用摩擦时 {name} 必须大于零(Amesim 参数约束)")
if component.fcoul > component.fstick:
raise ValueError(f"{component.name}: MECMAS21 库仑摩擦 fcoul 不能大于静摩擦 fstick(Amesim 参数约束)")
if component.model_type == 'amesim_mecmas21':
if int(component.stoptype) in (1, 2, 3) and component.xmin > component.xmax:
raise ValueError(f'{component.name}: MECMAS21 限位要求 xmin <= xmax')
if int(component.stoptype) == 2:
for name in ('Kbmin', 'Kbmax', 'Dbmin', 'Dbmax'):
if getattr(component, name) <= 0:
raise ValueError(f'{component.name}: MECMAS21 弹性限位要求 {name} > 0')
if component.model_type == 'amesim_mecmas21' and int(component.stoptype) == 3 and component.restdvel <= 0:
raise ValueError(f'{component.name}: MECMAS21 恢复碰撞模式要求 restdvel > 0(Amesim 参数约束)')
if component.model_type == "amesim_lstp00a":
contact_stiffness(component)
@@ -7,6 +7,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import GasMedium from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class AmesimPnch023(ThermodynamicVolumeComponent): class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange. """AMESim PNCH023 simple pneumatic chamber with heat exchange.
@@ -18,7 +19,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
""" """
MODEL_TYPE = 'amesim_pnch023' MODEL_TYPE = 'amesim_pnch023'
MODEL_VERSION = '0.1.0' MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol', 0.057, label='气室容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='气室内部用于储存气体的固定有效容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol', 0.057, label='气室容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='气室内部用于储存气体的固定有效容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNCH023 固定容积气室', library_id='amesim', category_id='storage', symbol='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10) DISPLAY = ComponentDisplaySpec(label='PNCH023 固定容积气室', library_id='amesim', category_id='storage', symbol='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10)
@@ -46,14 +47,15 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
"""AMESim PNCH012 variable-volume pneumatic chamber. """AMESim PNCH012 variable-volume pneumatic chamber.
AMESim supplies four external volume and volume-rate inputs through the AMESim supplies four external volume and volume-rate inputs through the
chamber ports. Fixed/prescribed contributions remain available as SI chamber ports. The SI vol1..4 parameters specify initial external volumes;
parameters, while connected moving-boundary components can now add live dvol1..4 prescribe constant rates integrated from the simulation start.
volume and volume-rate values through the pneumatic connector contract. Connected moving boundaries supply their live geometry and rate directly,
including through reference nodes, without integrating that geometry again.
""" """
MODEL_TYPE = 'amesim_pnch012' MODEL_TYPE = 'amesim_pnch012'
MODEL_VERSION = '0.1.0' MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional')) PORTS = tuple(PortDefinition.pneumatic(f'port_{i}', computation=THERMODYNAMIC_SUPPLY) for i in range(1, 5))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s')) PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110)) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110))
DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20) DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
-14
View File
@@ -1,14 +0,0 @@
# 元件开发示例
权威规则见 [组件模型建模规范](../../../docs/standard/component-model-authoring-spec-v1.md)。当前模型采用 Python 声明、C 数值实现。
以气瓶为例:
1. 在 [cylinder.py](experimental/storage/cylinder.py) 声明 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、`DISPLAY` 和 `create()`。
2. 构造函数调用 `set_parameter_values()`、`register_declared_port()`,保存介质选择和容积。不要在 Python 中计算密度、内能或状态导数。
3. 通过 `EQUATIONS` 声明端口压力与气瓶状态之间的约束;只保存变量名和关系。
4. 在 [extended.py](../native_codegen/extended.py) 分配状态及输出位置,生成 `native_medium_init()` 初始化调用和气瓶质量/能量导数计算。
5. 公共物性和数值公式由 [kernels.c](../../../native/components/kernels.c) 实现,积分和事件由 `native/runtime/` 处理。
6. 加入组件库 `library.py` 及 C 版本白名单,验证目录/XML 合同、边界输入、逆流、守恒、RK45/BDF 和输出键。
新增模型的参考值应来自独立解析结果、外部可信结果或已有冻结基准;不恢复第二套 Python 数值实现。
@@ -6,12 +6,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
class Orifice(AlgebraicComponent): class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice.""" """Python port of ModelicaModels.Myorifice."""
MODEL_TYPE = 'orifice' MODEL_TYPE = 'orifice'
MODEL_VERSION = '1.0.0' MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'), PortDefinition.pneumatic('port_b', nominal_role='outlet')) PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
PARAMETERS = (ParameterDefinition('K', 1e-05, label='流量系数', quantity='flow_coefficient', unit='kg/(s*Pa^0.5)', minimum=0.0), ParameterDefinition('opening', 1.0, label='开度', minimum=0.0, maximum=1.0)) PARAMETERS = (ParameterDefinition('K', 1e-05, label='流量系数', quantity='flow_coefficient', unit='kg/(s*Pa^0.5)', minimum=0.0), ParameterDefinition('opening', 1.0, label='开度', minimum=0.0, maximum=1.0))
RESULT_VARIABLES = () RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='孔板/阀门', library_id='experimental', category_id='flow', symbol='orifice', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=40) DISPLAY = ComponentDisplaySpec(label='孔板/阀门', library_id='experimental', category_id='flow', symbol='orifice', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=40)
@@ -7,12 +7,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
class ResistivePipe(AlgebraicComponent): class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation.""" """Quasi-steady Darcy resistance used by topology-driven simulation."""
MODEL_TYPE = 'pipe' MODEL_TYPE = 'pipe'
MODEL_VERSION = '1.0.0' MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'), PortDefinition.pneumatic('port_b', nominal_role='outlet')) PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
PARAMETERS = (ParameterDefinition('length', 5.0, label='长度', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('diameter', 0.02, label='直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('lambda_darcy', 0.02, label='摩阻系数', minimum=0.0), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) PARAMETERS = (ParameterDefinition('length', 5.0, label='长度', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('diameter', 0.02, label='直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('lambda_darcy', 0.02, label='摩阻系数', minimum=0.0), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = () RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='管段', library_id='experimental', category_id='flow', symbol='pipe', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=30) DISPLAY = ComponentDisplaySpec(label='管段', library_id='experimental', category_id='flow', symbol='pipe', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=30)
@@ -6,12 +6,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class Cylinder(ThermodynamicVolumeComponent): class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder.""" """Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = 'cylinder' MODEL_TYPE = 'cylinder'
MODEL_VERSION = '1.0.0' MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet'),) PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=THERMODYNAMIC_SUPPLY),)
PARAMETERS = (ParameterDefinition('volume', 0.01, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 35000000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) PARAMETERS = (ParameterDefinition('volume', 0.01, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 35000000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='气瓶', library_id='experimental', category_id='storage', symbol='cylinder', ports=(PortDisplaySpec('port_b', 'right'),), order=10) DISPLAY = ComponentDisplaySpec(label='气瓶', library_id='experimental', category_id='storage', symbol='cylinder', ports=(PortDisplaySpec('port_b', 'right'),), order=10)
@@ -6,12 +6,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import IdealGasMedium from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class Tank(ThermodynamicVolumeComponent): class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank.""" """Python port of ModelicaModels.Mytank."""
MODEL_TYPE = 'tank' MODEL_TYPE = 'tank'
MODEL_VERSION = '1.0.0' MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'),) PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=THERMODYNAMIC_SUPPLY),)
PARAMETERS = (ParameterDefinition('volume', 0.1, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) PARAMETERS = (ParameterDefinition('volume', 0.1, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='贮箱', library_id='experimental', category_id='storage', symbol='tank', ports=(PortDisplaySpec('port_a', 'left'),), order=20) DISPLAY = ComponentDisplaySpec(label='贮箱', library_id='experimental', category_id='storage', symbol='tank', ports=(PortDisplaySpec('port_a', 'left'),), order=20)
+152
View File
@@ -0,0 +1,152 @@
"""Compile-time variable supply contracts, separate from physical flow direction.
Equation ports may participate in a simultaneous solve. Fixed ports (for example
an Amesim node's reference/branch ports) require complementary local supplies.
These declarations do not add numerical state or Python evaluation callbacks.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Mapping
if TYPE_CHECKING:
from .ports import PortDefinition
VARIABLE_LABELS = {
"p": "压力", "T": "温度", "m_flow": "质量流率", "H_flow": "能量流率",
}
@dataclass(frozen=True)
class PortComputation:
inputs: tuple[str, ...] = ()
outputs: tuple[str, ...] = ()
mode: Literal["equation", "fixed"] = "equation"
# Output p/T aliases an input on another port of the same component.
reference_port: str | None = None
def __post_init__(self) -> None:
if self.mode not in {"equation", "fixed"}:
raise ValueError(f"Unknown port computation mode: {self.mode}")
members = (*self.inputs, *self.outputs)
if len(set(members)) != len(members) or set(members) - VARIABLE_LABELS.keys():
raise ValueError("Port computation variables must be unique, supported quantities.")
if self.reference_port and not {"p", "T"}.issubset(self.outputs):
raise ValueError("A reference alias must supply pressure and temperature.")
def as_dict(self) -> dict[str, object]:
return {
"mode": self.mode,
"inputs": list(self.inputs),
"outputs": list(self.outputs),
**({"referencePort": self.reference_port} if self.reference_port else {}),
}
THERMODYNAMIC_SUPPLY = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"))
FLOW_SUPPLY = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"))
ZERO_FLOW_SUPPLY = PortComputation(outputs=("m_flow", "H_flow"))
IMPLICIT_PNEUMATIC = PortComputation()
NODE_REFERENCE = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"), mode="fixed")
NODE_BRANCH = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"),
mode="fixed", reference_port="port_2")
@dataclass(frozen=True)
class PortSupplyIssue:
code: str
message: str
endpoint: tuple[str, str] | None = None
class PortSupplyError(ValueError):
def __init__(self, issue: PortSupplyIssue):
self.issue = issue
super().__init__(f"{issue.code}: {issue.message}")
def port_supply_issue(first: PortDefinition, second: PortDefinition,
first_label: str | None = None, second_label: str | None = None
) -> PortSupplyIssue | None:
"""Check fixed causality; ordinary equation-to-equation links stay legal."""
if first.kind != second.kind or first.domain != second.domain:
return None # Domain/type checks own their existing, more specific errors.
a, b = first.computation, second.computation
if not any(item and item.mode == "fixed" for item in (a, b)):
return None
for consumer, supplier, consumer_label, supplier_label in (
(a, b, first_label or first.name, second_label or second.name),
(b, a, second_label or second.name, first_label or first.name),
):
if consumer is None:
continue
missing = [name for name in consumer.inputs
if supplier is None or name not in supplier.outputs]
if missing:
quantities = "、".join(VARIABLE_LABELS[name] for name in missing)
return PortSupplyIssue(
"CONNECTION_VARIABLE_SUPPLY_MISSING",
f"{consumer_label} 需要对端提供{quantities},但 {supplier_label} 未提供;"
"请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。",
)
return None
def reference_supply_issues(
ports: Mapping[tuple[str, str], PortDefinition],
adjacency: Mapping[tuple[str, str], tuple[str, str]],
) -> list[PortSupplyIssue]:
"""Follow declared aliases to reject a reference ring without an origin.
This is a supply check, not a whole-system execution scheduler. Reference
chains are iterative to support deep networks without Python recursion.
"""
issues = []
resolved: dict[str, set[tuple[str, str]]] = {'p': set(), 'T': set()}
for endpoint, port in ports.items():
contract = port.computation
if not contract or contract.mode != "fixed" or not {"p", "T"}.issubset(contract.inputs):
continue
if endpoint not in adjacency:
continue # Existing unconnected-port checks handle incomplete drawings.
for variable in ("p", "T"):
current = endpoint
visited: set[tuple[str, str]] = set()
chain: list[str] = []
while True:
if current in resolved[variable]:
resolved[variable].update(visited)
break
if current in visited:
issues.append(PortSupplyIssue(
"REFERENCE_SUPPLY_CYCLE",
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考形成循环,"
f"没有实际提供者:{' → '.join(chain)} → {'.'.join(current)}。",
endpoint,
))
break
visited.add(current)
chain.append('.'.join(current))
supplier = adjacency.get(current)
if supplier is None:
issues.append(PortSupplyIssue(
"REFERENCE_SUPPLY_UNCONNECTED",
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考链在 "
f"{'.'.join(current)} 中断:该参考输入尚未连接。", endpoint,
))
break
supplied = ports.get(supplier)
supply = supplied.computation if supplied else None
if supply is None or variable not in supply.outputs:
# Direct errors are already reported per connection. An
# indirect failure is explained at that failing connection.
break
if supply.reference_port is None:
resolved[variable].update(visited)
break
chain.append('.'.join(supplier))
current = supplier[0], supply.reference_port
if current not in ports:
raise ValueError(f"Invalid reference port declaration: {current}")
return issues
+6
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal from typing import Literal
from .port_computation import IMPLICIT_PNEUMATIC, PortComputation
PortKind = Literal["physical", "signal"] PortKind = Literal["physical", "signal"]
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"] PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
@@ -45,6 +47,7 @@ class PortDefinition:
nominal_role: PortNominalRole nominal_role: PortNominalRole
positive_flow_direction: Literal["intoComponent"] | None = None positive_flow_direction: Literal["intoComponent"] | None = None
variables: tuple[PortVariableDefinition, ...] = () variables: tuple[PortVariableDefinition, ...] = ()
computation: PortComputation | None = None
@classmethod @classmethod
def pneumatic( def pneumatic(
@@ -52,6 +55,7 @@ class PortDefinition:
name: str, name: str,
*, *,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional", nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
computation: PortComputation = IMPLICIT_PNEUMATIC,
) -> PortDefinition: ) -> PortDefinition:
return cls( return cls(
name=name, name=name,
@@ -59,6 +63,7 @@ class PortDefinition:
domain="pneumatic", domain="pneumatic",
nominal_role=nominal_role, nominal_role=nominal_role,
positive_flow_direction="intoComponent", positive_flow_direction="intoComponent",
computation=computation,
variables=( variables=(
PortVariableDefinition( PortVariableDefinition(
"p", "p",
@@ -188,6 +193,7 @@ class PortDefinition:
"nominalRole": self.nominal_role, "nominalRole": self.nominal_role,
"positiveFlowDirection": self.positive_flow_direction, "positiveFlowDirection": self.positive_flow_direction,
"variables": [variable.as_interface_dict() for variable in self.variables], "variables": [variable.as_interface_dict() for variable in self.variables],
**({"computation": self.computation.as_dict()} if self.computation else {}),
} }
-40
View File
@@ -1,40 +0,0 @@
"""Whole-system numeric intermediate representation."""
from .compiler import compile_system_ir
from .schema import * # noqa: F403 - this package is the public schema facade.
from .schema import __dict__ as _schema_namespace
from .validation import (
IRValidationIssue,
IRValidationReport,
SystemIRValidationError,
require_valid_system_ir,
validate_system_ir,
)
__all__ = [
"compile_system_ir",
"IRValidationIssue",
"IRValidationReport",
"SystemIRValidationError",
"require_valid_system_ir",
"validate_system_ir",
*sorted(
name
for name in _schema_namespace
if name.startswith("IR")
or name.startswith("SystemIR")
or name.startswith("SYSTEM_NUMERIC_IR")
or name
in {
"CURRENT_SYSTEM_IR_VERSION",
"NATIVE_NUMERIC_ABI_VERSION",
"canonical_json_bytes",
"native_artifact_key",
"operation_read_slots",
"operation_write_slots",
}
),
]
del _schema_namespace
-14
View File
@@ -1,14 +0,0 @@
"""Retired object-engine IR adapter; the independent v2 schema is retained.
Current native execution is generated by native_codegen.compile_native_program.
It does not yet emit the separate System IR v2 execution-plan format.
"""
def compile_system_ir(*args, **kwargs):
"""Fail explicitly for callers of the removed GenericFluidSystem adapter."""
raise NotImplementedError(
"The GenericFluidSystem to System IR v2 exporter was retired with the Python "
"numerical engine. Use native_codegen.compiler.compile_native_program(network) "
"for the current C executable path. System IR v2 schema and validation remain available."
)
-973
View File
@@ -1,973 +0,0 @@
"""Callback-free data contract for the whole-system numeric IR.
System IR v2 describes a compiled simulation model. It is intentionally a
pure, immutable data graph: Python functions, model objects, object addresses,
and run-local diagnostic state are not part of this module's wire contract.
"""
from __future__ import annotations
from dataclasses import dataclass, fields, is_dataclass
from enum import StrEnum
from hashlib import sha256
import json
from math import isfinite
from struct import pack
from typing import ClassVar
from unicodedata import normalize
SYSTEM_NUMERIC_IR_SCHEMA_ID = "system-numeric-ir"
SYSTEM_NUMERIC_IR_SCHEMA_MAJOR = 2
SYSTEM_NUMERIC_IR_SCHEMA_MINOR = 0
SYSTEM_NUMERIC_IR_COMPILER_ID = "generic-fluid-system"
SYSTEM_NUMERIC_IR_COMPILER_VERSION = "2.0.0"
NATIVE_NUMERIC_ABI_VERSION = 1
class IRDType(StrEnum):
FLOAT64 = "float64"
INT32 = "int32"
class IRBufferKind(StrEnum):
TIME = "time"
STATE_INPUT = "state_input"
DERIVATIVE_OUTPUT = "derivative_output"
LOCAL_STATE = "local_state"
LOCAL_DERIVATIVE = "local_derivative"
ALGEBRAIC = "algebraic"
SIGNAL = "signal"
PARAMETER = "parameter"
CONSTANT = "constant"
MODE = "mode"
WORK_FLOAT = "work_float"
WORK_INT = "work_int"
EVENT_OUTPUT = "event_output"
JACOBIAN_VALUE = "jacobian_value"
RESULT_OUTPUT = "result_output"
RUNTIME_INPUT = "runtime_input"
class IRKernelPhase(StrEnum):
PRIMAL = "primal"
RESIDUAL = "residual"
DERIVATIVE = "derivative"
PROPERTY = "property"
EVENT = "event"
RESET = "reset"
JACOBIAN = "jacobian"
OUTPUT = "output"
class IRKernelAvailability(StrEnum):
NATIVE = "native"
REFERENCE_ONLY = "reference_only"
class IRPortKind(StrEnum):
PHYSICAL = "physical"
SIGNAL = "signal"
class IRPortNominalRole(StrEnum):
INLET = "inlet"
OUTLET = "outlet"
BIDIRECTIONAL = "bidirectional"
INPUT = "input"
OUTPUT = "output"
class IRPositiveFlowDirection(StrEnum):
INTO_COMPONENT = "intoComponent"
class IRVariableRole(StrEnum):
EFFORT = "effort"
FLOW = "flow"
STREAM = "stream"
SIGNAL = "signal"
class IRConnectionRule(StrEnum):
EQUAL = "equal"
SUM_TO_ZERO = "sumToZero"
STREAM_MIX = "streamMix"
DIRECTED = "directed"
class IREquationOwner(StrEnum):
COMPONENT = "component"
CONNECTION = "connection"
class IREquationRelation(StrEnum):
EQUAL = "equal"
SUM_TO_ZERO = "sumToZero"
CONSTITUTIVE = "constitutive"
STATE = "state"
class IRPressureFlowScopeKind(StrEnum):
NETWORK = "network"
PHYSICAL_ISLAND = "physical_island"
EQUATION_BLOCK = "equation_block"
class IRStageKind(StrEnum):
STATE_REDUCE = "state_reduce"
SIGNAL = "signal"
MECHANICAL_EQUIVALENCE = "mechanical_equivalence"
DYNAMIC_VOLUME = "dynamic_volume"
PROPERTY = "property"
PRESSURE_FLOW = "pressure_flow"
STREAM = "stream"
TEMPERATURE_REFERENCE = "temperature_reference"
THERMOFLUID_FIXED_POINT = "thermofluid_fixed_point"
MECHANICAL_ACCELERATION = "mechanical_acceleration"
DERIVATIVE_REDUCE = "derivative_reduce"
EVENT = "event"
JACOBIAN = "jacobian"
OUTPUT = "output"
RESET = "reset"
class IREntryPointKind(StrEnum):
RHS = "rhs"
EVENTS = "events"
JACOBIAN = "jacobian"
OUTPUTS = "outputs"
class IRBlockKind(StrEnum):
SEQUENCE = "sequence"
FIXED_POINT = "fixed_point"
STREAM_SCC = "stream_scc"
class IRStepKind(StrEnum):
STAGE = "stage"
BLOCK = "block"
class IRStateMapKind(StrEnum):
SCATTER = "scatter"
DERIVATIVE_GATHER = "derivative_gather"
class IREventDirection(StrEnum):
DECREASING = "decreasing"
ANY = "any"
INCREASING = "increasing"
class IRFailurePolicy(StrEnum):
FAIL = "fail"
RETRY_SMALLER_STEP = "retry_smaller_step"
class IRCacheKind(StrEnum):
PROPERTY = "property"
PRESSURE_FLOW = "pressure_flow"
STREAM = "stream"
JACOBIAN = "jacobian"
OUTPUT = "output"
class IRCapabilityLevel(StrEnum):
NATIVE = "native"
REFERENCE_ONLY = "reference_only"
UNSUPPORTED = "unsupported"
class IRDiagnosticSeverity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
class IROpcode(StrEnum):
FILL = "fill"
COPY = "copy"
SCATTER = "scatter"
LINEAR_COMBINATION = "linear_combination"
STATE_MAP = "state_map"
KERNEL_CALL = "kernel_call"
EFFORT_BROADCAST = "effort_broadcast"
FLOW_ASSIGN = "flow_assign"
CHECK_FINITE = "check_finite"
@dataclass(frozen=True, slots=True)
class IRSchemaVersion:
schema_id: str = SYSTEM_NUMERIC_IR_SCHEMA_ID
major: int = SYSTEM_NUMERIC_IR_SCHEMA_MAJOR
minor: int = SYSTEM_NUMERIC_IR_SCHEMA_MINOR
CURRENT_SYSTEM_IR_VERSION = IRSchemaVersion()
@dataclass(frozen=True, slots=True)
class IRSlotRef:
buffer: IRBufferKind
index: int
@dataclass(frozen=True, slots=True)
class IRBufferSpec:
kind: IRBufferKind
dtype: IRDType
size: int
initial_float_values: tuple[float, ...] = ()
initial_int_values: tuple[int, ...] = ()
@dataclass(frozen=True, slots=True)
class IRValueSpec:
value_id: str
slot: IRSlotRef
semantic: str
role: str
quantity: str
unit: str
scale: float
lower_bound: float | None = None
upper_bound: float | None = None
owner_component_index: int | None = None
@dataclass(frozen=True, slots=True)
class IRKernelPhaseSpec:
"""Phase capability tag; each call's ordered slot lists define its C-01 ABI.
Fixed component-kernel signatures deliberately belong to the C-02 contract.
Recording placeholder arities here would make the current reference-only
kernels look more strictly specified than they are.
"""
phase: IRKernelPhase
@dataclass(frozen=True, slots=True)
class IRKernelSpec:
kernel_id: str
model_type: str
model_version: str
implementation_version: str
availability: IRKernelAvailability
unavailable_reason: str | None
phases: tuple[IRKernelPhaseSpec, ...]
parameter_count: int
state_count: int
mode_count: int
workspace_float_count: int
workspace_int_count: int
@dataclass(frozen=True, slots=True)
class IRComponentInstance:
instance_id: str
kernel_index: int
parameter_slots: tuple[IRSlotRef, ...]
state_slots: tuple[IRSlotRef, ...]
derivative_slots: tuple[IRSlotRef, ...]
mode_slots: tuple[IRSlotRef, ...]
port_indices: tuple[int, ...]
port_slots: tuple[IRSlotRef, ...]
output_indices: tuple[int, ...]
workspace_float_slots: tuple[IRSlotRef, ...]
workspace_int_slots: tuple[IRSlotRef, ...]
@dataclass(frozen=True, slots=True)
class IRPortVariable:
variable_id: str
name: str
role: IRVariableRole
connection_rule: IRConnectionRule
quantity: str
unit: str
result_visible: bool
slot: IRSlotRef
@dataclass(frozen=True, slots=True)
class IRPortSpec:
port_id: str
component_index: int
name: str
kind: IRPortKind
domain: str
nominal_role: IRPortNominalRole
positive_flow_direction: IRPositiveFlowDirection | None
variables: tuple[IRPortVariable, ...]
@dataclass(frozen=True, slots=True)
class IRConnectionVariable:
name: str
rule: IRConnectionRule
endpoint_a_slot: IRSlotRef
endpoint_b_slot: IRSlotRef
@dataclass(frozen=True, slots=True)
class IRConnectionSpec:
connection_id: str
kind: IRPortKind
domain: str
endpoint_a_port_index: int
endpoint_b_port_index: int
variables: tuple[IRConnectionVariable, ...]
@dataclass(frozen=True, slots=True)
class IRMediumSpec:
medium_id: str
name: str
implementation_id: str
implementation_version: str
parameter_slots: tuple[IRSlotRef, ...]
component_indices: tuple[int, ...]
@dataclass(frozen=True, slots=True)
class IRCSRPattern:
row_count: int
column_count: int
row_pointers: tuple[int, ...]
column_indices: tuple[int, ...]
@property
def nonzero_count(self) -> int:
return len(self.column_indices)
@dataclass(frozen=True, slots=True)
class IRCSRMatrix:
pattern: IRCSRPattern
values: tuple[float, ...]
@dataclass(frozen=True, slots=True)
class IRStateReducer:
solver_state_count: int
local_state_slots: tuple[IRSlotRef, ...]
raw_derivative_slots: tuple[IRSlotRef, ...]
state_scatter: IRCSRMatrix
derivative_gather: IRCSRMatrix
initial_state: tuple[float, ...]
absolute_tolerances: tuple[float, ...]
@dataclass(frozen=True, slots=True)
class IRFillOperation:
opcode: ClassVar[IROpcode] = IROpcode.FILL
target_slots: tuple[IRSlotRef, ...]
value: float
@dataclass(frozen=True, slots=True)
class IRCopyOperation:
opcode: ClassVar[IROpcode] = IROpcode.COPY
source_slot: IRSlotRef
target_slot: IRSlotRef
@dataclass(frozen=True, slots=True)
class IRScatterOperation:
opcode: ClassVar[IROpcode] = IROpcode.SCATTER
source_slot: IRSlotRef
target_slots: tuple[IRSlotRef, ...]
@dataclass(frozen=True, slots=True)
class IRLinearCombinationOperation:
opcode: ClassVar[IROpcode] = IROpcode.LINEAR_COMBINATION
source_slots: tuple[IRSlotRef, ...]
weights: tuple[float, ...]
target_slot: IRSlotRef
bias: float = 0.0
@dataclass(frozen=True, slots=True)
class IRStateMapOperation:
opcode: ClassVar[IROpcode] = IROpcode.STATE_MAP
map_kind: IRStateMapKind
source_slots: tuple[IRSlotRef, ...]
target_slots: tuple[IRSlotRef, ...]
@dataclass(frozen=True, slots=True)
class IRKernelCallOperation:
opcode: ClassVar[IROpcode] = IROpcode.KERNEL_CALL
kernel_index: int
component_index: int | None
phase: IRKernelPhase
read_slots: tuple[IRSlotRef, ...]
write_slots: tuple[IRSlotRef, ...]
equation_indices: tuple[int, ...] = ()
@dataclass(frozen=True, slots=True)
class IREffortBroadcastOperation:
opcode: ClassVar[IROpcode] = IROpcode.EFFORT_BROADCAST
variable: str
anchor_slot: IRSlotRef
residual_slot: IRSlotRef
result_slot: IRSlotRef
scatter_slots: tuple[IRSlotRef, ...]
equation_id: str
lower_bound: float | None = None
upper_bound: float | None = None
@dataclass(frozen=True, slots=True)
class IRFlowAssignmentOperation:
opcode: ClassVar[IROpcode] = IROpcode.FLOW_ASSIGN
value_slot: IRSlotRef
result_slot: IRSlotRef
scatter_slots: tuple[IRSlotRef, ...]
equation_id: str
@dataclass(frozen=True, slots=True)
class IRCheckFiniteOperation:
opcode: ClassVar[IROpcode] = IROpcode.CHECK_FINITE
slots: tuple[IRSlotRef, ...]
error_code: str
IROperation = (
IRFillOperation
| IRCopyOperation
| IRScatterOperation
| IRLinearCombinationOperation
| IRStateMapOperation
| IRKernelCallOperation
| IREffortBroadcastOperation
| IRFlowAssignmentOperation
| IRCheckFiniteOperation
)
def operation_read_slots(operation: IROperation) -> tuple[IRSlotRef, ...]:
if isinstance(operation, IRFillOperation):
return ()
if isinstance(operation, (IRCopyOperation, IRScatterOperation)):
return (operation.source_slot,)
if isinstance(operation, (IRLinearCombinationOperation, IRStateMapOperation)):
return operation.source_slots
if isinstance(operation, IRKernelCallOperation):
return operation.read_slots
if isinstance(operation, IREffortBroadcastOperation):
return (operation.anchor_slot, operation.residual_slot)
if isinstance(operation, IRFlowAssignmentOperation):
return (operation.value_slot,)
if isinstance(operation, IRCheckFiniteOperation):
return operation.slots
raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.")
def operation_write_slots(operation: IROperation) -> tuple[IRSlotRef, ...]:
if isinstance(operation, IRFillOperation):
return operation.target_slots
if isinstance(operation, IRCopyOperation):
return (operation.target_slot,)
if isinstance(operation, IRScatterOperation):
return operation.target_slots
if isinstance(operation, IRLinearCombinationOperation):
return (operation.target_slot,)
if isinstance(operation, IRStateMapOperation):
return operation.target_slots
if isinstance(operation, IRKernelCallOperation):
return operation.write_slots
if isinstance(operation, IREffortBroadcastOperation):
return (operation.result_slot, *operation.scatter_slots)
if isinstance(operation, IRFlowAssignmentOperation):
return (operation.result_slot, *operation.scatter_slots)
if isinstance(operation, IRCheckFiniteOperation):
return ()
raise TypeError(f"Unsupported IR operation: {type(operation).__name__}.")
@dataclass(frozen=True, slots=True)
class IRStage:
stage_id: str
kind: IRStageKind
operations: tuple[IROperation, ...]
declared_read_slots: tuple[IRSlotRef, ...]
declared_write_slots: tuple[IRSlotRef, ...]
@dataclass(frozen=True, slots=True)
class IRStepRef:
kind: IRStepKind
index: int
@dataclass(frozen=True, slots=True)
class IRConvergenceSpec:
monitor_slots: tuple[IRSlotRef, ...]
absolute_tolerance: float
relative_tolerance: float
max_iterations: int
relaxation: float
rollback_slots: tuple[IRSlotRef, ...]
failure_policy: IRFailurePolicy
@dataclass(frozen=True, slots=True)
class IRExecutionBlock:
block_id: str
kind: IRBlockKind
steps: tuple[IRStepRef, ...]
convergence: IRConvergenceSpec | None = None
@dataclass(frozen=True, slots=True)
class IREntryPoint:
kind: IREntryPointKind
steps: tuple[IRStepRef, ...]
input_slots: tuple[IRSlotRef, ...]
output_slots: tuple[IRSlotRef, ...]
@dataclass(frozen=True, slots=True)
class IRCausalEffortStageRef:
variable: str
stage_index: int
@dataclass(frozen=True, slots=True)
class IRCausalPlan:
plan_id: str
scope_component_indices: tuple[int, ...]
source_schema_version: int
source_structural_signature: str | None
fallback_reason: str | None
canonical_slots: tuple[IRSlotRef, ...]
compatibility_slots: tuple[IRSlotRef, ...]
reset_slots: tuple[IRSlotRef, ...]
external_effort_slots: tuple[IRSlotRef, ...]
effort_stages: tuple[IRCausalEffortStageRef, ...]
flow_stage_indices: tuple[int, ...]
@dataclass(frozen=True, slots=True)
class IRAlgebraicUnknown:
unknown_id: str
component_index: int
port_index: int
variable: str
role: IRVariableRole
slot: IRSlotRef
scale: float
lower_bound: float | None = None
upper_bound: float | None = None
@dataclass(frozen=True, slots=True)
class IRPressureFlowEquation:
equation_id: str
owner: IREquationOwner
owner_index: int
relation: IREquationRelation
role: IRVariableRole | None
variable_slots: tuple[IRSlotRef, ...]
residual_slot: IRSlotRef
scale: float
@dataclass(frozen=True, slots=True)
class IRPressureFlowBlock:
block_id: str
unknown_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
jacobian_pattern: IRCSRPattern
@dataclass(frozen=True, slots=True)
class IRPressureFlowScope:
scope_id: str
kind: IRPressureFlowScopeKind
component_indices: tuple[int, ...]
unknown_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
block_indices: tuple[int, ...]
causal_plan_index: int | None
residual_tolerance: float
max_evaluations: int
sparse_pattern_trusted: bool
sparse_fallback_reason: str | None
@dataclass(frozen=True, slots=True)
class IRPressureFlowPlan:
unknowns: tuple[IRAlgebraicUnknown, ...]
equations: tuple[IRPressureFlowEquation, ...]
blocks: tuple[IRPressureFlowBlock, ...]
scopes: tuple[IRPressureFlowScope, ...]
global_scope_index: int
secondary_scope_indices: tuple[int, ...]
pressure_lower_bound: float
@dataclass(frozen=True, slots=True)
class IRStreamSCC:
scc_id: str
node_slots: tuple[IRSlotRef, ...]
block_index: int
@dataclass(frozen=True, slots=True)
class IRStreamEdge:
source_scc_index: int
target_scc_index: int
@dataclass(frozen=True, slots=True)
class IRStreamPlan:
plan_id: str
node_slots: tuple[IRSlotRef, ...]
strongly_connected_components: tuple[IRStreamSCC, ...]
condensed_edges: tuple[IRStreamEdge, ...]
topological_scc_indices: tuple[int, ...]
@dataclass(frozen=True, slots=True)
class IRThermofluidPlan:
physical_port_indices: tuple[int, ...]
global_component_indices: tuple[int, ...]
stream_plan_index: int
secondary_pressure_scope_indices: tuple[int, ...]
sensitive_component_indices: tuple[int, ...]
maximum_iterations: int
flow_relative_tolerance: float
uses_conservative_global_solver: bool
conservative_fallback_reason: str | None
@dataclass(frozen=True, slots=True)
class IRTransactionPlan:
snapshot_slots: tuple[IRSlotRef, ...]
flow_slots: tuple[IRSlotRef, ...]
cache_component_indices: tuple[int, ...]
cache_attribute_ids: tuple[str, ...]
diagnostic_owner_ids: tuple[str, ...]
restores_on_recoverable_failure: bool
restores_on_fatal_failure: bool
@dataclass(frozen=True, slots=True)
class IRModeValueSpec:
value: int
name: str
@dataclass(frozen=True, slots=True)
class IRModeSpec:
mode_id: str
slot: IRSlotRef
owner_component_indices: tuple[int, ...]
values: tuple[IRModeValueSpec, ...]
initial_value: int
@dataclass(frozen=True, slots=True)
class IRFiniteDifferenceColumn:
column_index: int
value_indices: tuple[int, ...]
relative_step: float
@dataclass(frozen=True, slots=True)
class IRJacobianPlan:
pattern: IRCSRPattern
value_slots: tuple[IRSlotRef, ...]
color_groups: tuple[tuple[int, ...], ...]
fill_steps: tuple[IRStepRef, ...]
analytic_value_indices: tuple[int, ...]
local_finite_difference_columns: tuple[IRFiniteDifferenceColumn, ...]
@dataclass(frozen=True, slots=True)
class IRModeGuard:
mode_slot: IRSlotRef
allowed_values: tuple[int, ...]
@dataclass(frozen=True, slots=True)
class IREventSpec:
event_id: str
event_kind: str
owner_component_indices: tuple[int, ...]
root_slot: IRSlotRef
direction: IREventDirection
terminal: bool
priority: int
mode_guards: tuple[IRModeGuard, ...]
reset_steps: tuple[IRStepRef, ...]
invalidated_caches: tuple[IRCacheKind, ...]
restarts_integrator: bool
@dataclass(frozen=True, slots=True)
class IROutputSpec:
output_id: str
component_index: int
scope: str
port_name: str | None
name: str
label: str
category: str
quantity: str
unit: str
order: int
source_slot: IRSlotRef
output_slot: IRSlotRef
scale: float = 1.0
offset: float = 0.0
@dataclass(frozen=True, slots=True)
class IRComponentCapability:
component_index: int
level: IRCapabilityLevel
supported_phases: tuple[IRKernelPhase, ...]
missing_features: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class IRCapabilityIssue:
code: str
severity: IRDiagnosticSeverity
scope_id: str
message: str
@dataclass(frozen=True, slots=True)
class IRCapabilityReport:
system_level: IRCapabilityLevel
components: tuple[IRComponentCapability, ...]
issues: tuple[IRCapabilityIssue, ...]
@dataclass(frozen=True, slots=True)
class SystemIR:
version: IRSchemaVersion
model_id: str
model_version: str
compiler_id: str
compiler_version: str
numeric_dtype: IRDType
buffers: tuple[IRBufferSpec, ...]
values: tuple[IRValueSpec, ...]
kernels: tuple[IRKernelSpec, ...]
components: tuple[IRComponentInstance, ...]
mediums: tuple[IRMediumSpec, ...]
ports: tuple[IRPortSpec, ...]
connections: tuple[IRConnectionSpec, ...]
state_reducer: IRStateReducer
causal_plans: tuple[IRCausalPlan, ...]
pressure_flow: IRPressureFlowPlan
stream_plans: tuple[IRStreamPlan, ...]
thermofluid: IRThermofluidPlan
stages: tuple[IRStage, ...]
blocks: tuple[IRExecutionBlock, ...]
entry_points: tuple[IREntryPoint, ...]
transaction: IRTransactionPlan
modes: tuple[IRModeSpec, ...]
jacobian: IRJacobianPlan
events: tuple[IREventSpec, ...]
outputs: tuple[IROutputSpec, ...]
capabilities: IRCapabilityReport
required_features: tuple[str, ...] = ()
def canonical_json_bytes(self) -> bytes:
return canonical_json_bytes(self)
def calculate_structural_signature(self) -> str:
return sha256(self.canonical_json_bytes()).hexdigest()
@property
def structural_signature(self) -> str:
return self.calculate_structural_signature()
@dataclass(frozen=True, slots=True)
class IRNativeBuildIdentity:
abi_version: int
target_triple: str
compiler_id: str
compiler_version: str
compile_flags: tuple[str, ...]
floating_point_policy: str
kernel_library_signature: str
@dataclass(frozen=True, slots=True)
class _IRNativeArtifactKeyInput:
program_signature: str
build: IRNativeBuildIdentity
_CANONICAL_TYPE_NAMES: tuple[tuple[type[object], str], ...] = (
(IRSchemaVersion, "schema_version"),
(IRSlotRef, "slot_ref"),
(IRBufferSpec, "buffer"),
(IRValueSpec, "value"),
(IRKernelPhaseSpec, "kernel_phase"),
(IRKernelSpec, "kernel"),
(IRComponentInstance, "component"),
(IRPortVariable, "port_variable"),
(IRPortSpec, "port"),
(IRConnectionVariable, "connection_variable"),
(IRConnectionSpec, "connection"),
(IRMediumSpec, "medium"),
(IRCSRPattern, "csr_pattern"),
(IRCSRMatrix, "csr_matrix"),
(IRStateReducer, "state_reducer"),
(IRFillOperation, "operation"),
(IRCopyOperation, "operation"),
(IRScatterOperation, "operation"),
(IRLinearCombinationOperation, "operation"),
(IRStateMapOperation, "operation"),
(IRKernelCallOperation, "operation"),
(IREffortBroadcastOperation, "operation"),
(IRFlowAssignmentOperation, "operation"),
(IRCheckFiniteOperation, "operation"),
(IRStage, "stage"),
(IRStepRef, "step_ref"),
(IRConvergenceSpec, "convergence"),
(IRExecutionBlock, "block"),
(IREntryPoint, "entry_point"),
(IRCausalEffortStageRef, "causal_effort_stage"),
(IRCausalPlan, "causal_plan"),
(IRAlgebraicUnknown, "algebraic_unknown"),
(IRPressureFlowEquation, "pressure_flow_equation"),
(IRPressureFlowBlock, "pressure_flow_block"),
(IRPressureFlowScope, "pressure_flow_scope"),
(IRPressureFlowPlan, "pressure_flow_plan"),
(IRStreamSCC, "stream_scc"),
(IRStreamEdge, "stream_edge"),
(IRStreamPlan, "stream_plan"),
(IRThermofluidPlan, "thermofluid_plan"),
(IRTransactionPlan, "transaction_plan"),
(IRModeValueSpec, "mode_value"),
(IRModeSpec, "mode"),
(IRFiniteDifferenceColumn, "finite_difference_column"),
(IRJacobianPlan, "jacobian_plan"),
(IRModeGuard, "mode_guard"),
(IREventSpec, "event"),
(IROutputSpec, "output"),
(IRComponentCapability, "component_capability"),
(IRCapabilityIssue, "capability_issue"),
(IRCapabilityReport, "capability_report"),
(SystemIR, "system_ir"),
(IRNativeBuildIdentity, "native_build"),
(_IRNativeArtifactKeyInput, "native_artifact_key_input"),
)
_OPERATION_TYPES = (
IRFillOperation,
IRCopyOperation,
IRScatterOperation,
IRLinearCombinationOperation,
IRStateMapOperation,
IRKernelCallOperation,
IREffortBroadcastOperation,
IRFlowAssignmentOperation,
IRCheckFiniteOperation,
)
def _canonical_type_name(value: object) -> str:
value_type = type(value)
for candidate, name in _CANONICAL_TYPE_NAMES:
if value_type is candidate:
return name
raise TypeError(f"Unsupported IR schema object: {value_type.__name__}.")
def _canonical_float(value: float) -> object:
numeric = float(value)
if not isfinite(numeric):
raise ValueError("Canonical IR JSON does not permit NaN or infinity.")
if numeric == 0.0:
numeric = 0.0
return {"$float64": pack(">d", numeric).hex()}
def _canonical_value(value: object) -> object:
if value is None or isinstance(value, bool):
return value
if isinstance(value, StrEnum):
return value.value
if isinstance(value, int):
return value
if isinstance(value, float):
return _canonical_float(value)
if isinstance(value, str):
return normalize("NFC", value)
if isinstance(value, tuple):
return [_canonical_value(item) for item in value]
if is_dataclass(value) and not isinstance(value, type):
payload: dict[str, object] = {"$type": _canonical_type_name(value)}
if isinstance(value, _OPERATION_TYPES):
payload["opcode"] = value.opcode.value
for item in fields(value):
payload[item.name] = _canonical_value(getattr(value, item.name))
return payload
raise TypeError(
"Canonical IR JSON accepts only schema dataclasses, tuples, enums, and "
f"scalar values; received {type(value).__name__}."
)
def canonical_json_bytes(value: object) -> bytes:
"""Return the exact platform-independent canonical JSON byte sequence."""
return json.dumps(
_canonical_value(value),
ensure_ascii=True,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def native_artifact_key(
program: SystemIR,
build: IRNativeBuildIdentity,
) -> str:
"""Build cache key; target details never contaminate the program hash."""
if build.abi_version != NATIVE_NUMERIC_ABI_VERSION:
raise ValueError(
"Native build ABI does not match NATIVE_NUMERIC_ABI_VERSION."
)
for field_name, value in (
("target_triple", build.target_triple),
("compiler_id", build.compiler_id),
("compiler_version", build.compiler_version),
("floating_point_policy", build.floating_point_policy),
):
if not value:
raise ValueError(f"Native build {field_name} must not be empty.")
if any(not flag for flag in build.compile_flags):
raise ValueError("Native build flags must not contain empty entries.")
if (
len(build.kernel_library_signature) != 64
or any(
character not in "0123456789abcdef"
for character in build.kernel_library_signature
)
):
raise ValueError(
"Native kernel library signature must be lowercase SHA-256 hex."
)
payload = _IRNativeArtifactKeyInput(program.structural_signature, build)
return sha256(canonical_json_bytes(payload)).hexdigest()
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -64,7 +64,7 @@ def main():
measured = rows[1:] measured = rows[1:]
summary = { summary = {
"source": str(args.input.resolve()), "sourceSha256": sha256(args.input.read_bytes()).hexdigest(), "source": str(args.input.resolve()), "sourceSha256": sha256(args.input.read_bytes()).hexdigest(),
"xmlSha256": sha256(xml).hexdigest(), "executable": str(package / "model.exe"), "xmlSha256": sha256(xml).hexdigest(), "executable": str(package / build.executable.name),
"cachedExecutable": str(build.executable), "cachedExecutable": str(build.executable),
"buildKey": build.manifest["buildKey"], "buildSeconds": build.seconds, "buildKey": build.manifest["buildKey"], "buildSeconds": build.seconds,
"preparationSeconds": preparation, "cacheHit": build.cache_hit, "preparationSeconds": preparation, "cacheHit": build.cache_hit,
+354 -70
View File
@@ -1,23 +1,44 @@
"""Reproducible native builds and a checked, model-specific executable cache.""" """On-demand native modules, checked object reuse and bounded executable caches."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from hashlib import sha256 from hashlib import sha256
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from typing import Any, Callable
from .compiler import NativeProgram from .compiler import NativeProgram
from .modules import component_modules
from .processes import command_environment, is_driver_launch_failure, is_transient_process_error, LAUNCH_RETRY_DELAYS
from .cache_storage import acquire_cache_lease, touch_cache_entry, prune_cache
LOGGER = logging.getLogger(__name__)
WINDOWS = os.name == 'nt'
class NativeCommandError(RuntimeError):
def __init__(self, message, command, *, exit_code=None, stdout=b'', stderr='', error_type='CalledProcessError', attempts=1):
super().__init__(message)
self.details = {'command': list(command), 'cwd': str(Path.cwd()), 'exitCode': exit_code,
'stdout': stdout.decode('utf-8',errors='replace')[-8000:], 'stderr': stderr[-8000:],
'errorType': error_type, 'attempts': attempts}
ROOT = Path(__file__).resolve().parents[3] ROOT = Path(__file__).resolve().parents[3]
NATIVE = ROOT / "native" NATIVE = ROOT / "native"
CACHE = ROOT / "app/data/native-builds" CACHE = ROOT / "app/data/native-builds"
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense") LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
COMPILER_FLAGS = ("-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math")
RUNTIME_SOURCES = ("runtime/main.c", "runtime/common.c", "runtime/sample_storage.c", "runtime/rk45.c",
"runtime/cvode_solver.c", "runtime/json_numbers.c", "encoding/ryu/d2s.c")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -26,94 +47,357 @@ class NativeBuild:
manifest: dict manifest: dict
cache_hit: bool cache_hit: bool
seconds: float seconds: float
details: dict = field(default_factory=dict)
_lease: Any = field(default=None, repr=False, compare=False)
_cache: Path | None = field(default=None, repr=False, compare=False)
def close(self) -> None:
"""Release this caller's executable pin after its last use."""
if self._lease is not None:
self._lease.close()
if self._cache is not None:
_prune(self._cache)
def _hash(path: Path) -> str: def _hash(path: Path) -> str:
return sha256(path.read_bytes()).hexdigest() return sha256(path.read_bytes()).hexdigest()
def _key(value: object) -> str:
return sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _runtime_sources(program: NativeProgram) -> list[Path]:
return [NATIVE / name for name in RUNTIME_SOURCES] + [
NATIVE / "components/modules" / f"{name}.c"
for name in component_modules(program.source + "\n" + program.header)
]
def _budget(name: str, default: int) -> int:
value = os.environ.get(name, str(default))
if not re.fullmatch(r"[0-9]+", value):
raise ValueError(f"{name} must be a nonnegative integer in MiB.")
return int(value) * 1024**2
def _prune(cache: Path) -> dict:
return prune_cache(cache,
model_limit_bytes=_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256),
object_limit_bytes=_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128))
def _checked_manifest(target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict | None:
if not target.exists() and not target.is_symlink():
return None
message = f"Native cache integrity check failed: {target}"
if target.is_symlink() or not target.is_dir():
raise RuntimeError(message)
try:
path = target / "manifest.json"
if path.is_symlink():
raise ValueError("linked manifest")
manifest = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or manifest.get(key_name) != key or manifest.get("cacheVersion") != version:
raise ValueError("identity mismatch")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, dict) or set(artifacts) != required:
raise ValueError("incomplete artifacts")
if key_name == "objectKey":
recipe = {name: manifest[name] for name in ("cacheVersion", "sourceName", "preprocessedSha256", "compiler")}
if _key(recipe) != key:
raise ValueError("object identity mismatch")
else:
recipe = manifest.get("buildIdentity")
if not isinstance(recipe, dict) or _key(recipe) != key or manifest.get("objectKeys") != recipe["objectKeys"]:
raise ValueError("model identity mismatch")
if artifacts["model.c"] != recipe["sourceSha256"] or artifacts["model.h"] != recipe["headerSha256"]:
raise ValueError("model source identity mismatch")
if _key({name: manifest[name] for name in recipe["contractKeys"]}) != recipe["contractSha256"]:
raise ValueError("model contract mismatch")
if artifacts["THIRD_PARTY_NOTICES.txt"] != recipe["notice"]:
raise ValueError("license artifact mismatch")
for name in required:
if name.lower().endswith(".dll") and artifacts[name] != recipe["dependencies"][name]:
raise ValueError("runtime dependency artifact mismatch")
for name, digest in artifacts.items():
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in (".", ".."):
raise ValueError("invalid artifact name")
path = target / name
if path.is_symlink() or not path.is_file() or not isinstance(digest, str) or _hash(path) != digest:
raise ValueError("artifact mismatch")
return manifest
except (OSError, ValueError, TypeError, KeyError) as exc:
raise RuntimeError(message) from exc
def _publish(stage: Path, target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict:
try:
stage.rename(target)
except OSError:
# Another reader/build may publish the same content while we compile.
# Its artifacts need not be byte-identical (e.g. PE timestamps), but
# must satisfy the same complete identity and its own recorded hashes.
winner = _checked_manifest(target, key_name, key, version, required)
if winner is None:
raise
shutil.rmtree(stage)
return winner
manifest = _checked_manifest(target, key_name, key, version, required)
assert manifest is not None
return manifest
def _command(command: list[str], *, log: list[str], timeout: float = 120,
require_stdout: bool = False) -> bytes:
environment = command_environment(command[0])
for attempt in range(len(LAUNCH_RETRY_DELAYS) + 1):
try:
result = subprocess.run(command, capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL, close_fds=True, env=environment,
creationflags=subprocess.CREATE_NO_WINDOW if WINDOWS else 0)
except OSError as exc:
if not is_transient_process_error(exc) or attempt == len(LAUNCH_RETRY_DELAYS):
raise
reason = f'{type(exc).__name__}: {exc}'
else:
stderr = result.stderr.decode('utf-8', errors='replace')
log.append(' '.join(command) + '\n' + stderr)
empty = result.returncode == 0 and require_stdout and not result.stdout.strip()
transient = is_driver_launch_failure(stderr) or (empty and WINDOWS)
if not result.returncode and not empty:
return result.stdout
reason = 'Compiler returned no identification output.' if empty else stderr[-3000:]
if not transient or attempt == len(LAUNCH_RETRY_DELAYS):
prefix = ('GCC 已启动,但编译子进程启动失败(已完成短间隔重试);'
if is_driver_launch_failure(stderr) else 'Native compilation failed: ')
raise NativeCommandError(prefix + reason, command, exit_code=result.returncode,
stdout=result.stdout, stderr=stderr, error_type='EmptyCompilerOutput' if empty else 'CalledProcessError',
attempts=attempt+1)
delay = LAUNCH_RETRY_DELAYS[attempt]
log.append(f'Compiler launch retry {attempt+1}/{len(LAUNCH_RETRY_DELAYS)} after {delay:g}s: {reason}')
LOGGER.warning('Compiler process launch failed; retry %d/%d in %.2fs: %s',
attempt+1, len(LAUNCH_RETRY_DELAYS), delay, reason.strip())
time.sleep(delay)
raise AssertionError('Unreachable compiler retry state')
def toolchain() -> tuple[str, Path, str]: def toolchain() -> tuple[str, Path, str]:
if os.name != "nt" and not sys.platform.startswith("linux"):
raise RuntimeError("Native builds support Windows and Linux; this platform is not supported.")
compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc") compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
if not compiler: if not compiler:
raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.") raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.")
base = Path(os.environ.get("SUNDIALS_ROOT", str(Path(sys.base_prefix) / "Library"))) configured = os.environ.get("SUNDIALS_ROOT")
candidates = ([Path(configured)] if configured else
[Path(sys.base_prefix) / "Library"] if os.name == "nt" else
[Path(sys.prefix) / "native/sundials-7.4.0", Path("/usr/local"), Path("/usr")])
base = next((path for path in candidates if (path / "include/cvode/cvode.h").is_file()), candidates[0])
if not (base / "include/cvode/cvode.h").is_file(): if not (base / "include/cvode/cvode.h").is_file():
raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.") raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.")
version = subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True, timeout=15).stdout.splitlines()[0] if os.name == 'nt':
compiler = str(Path(shutil.which(compiler) or compiler).resolve())
version = _command([compiler, '--version'], log=[], timeout=15, require_stdout=True).decode('utf-8', errors='replace').strip().splitlines()[0]
return compiler, base, version return compiler, base, version
def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild: def platform_build_inputs(sundials: Path) -> tuple[list[str], list[Path], list[Path], str]:
start = time.perf_counter() """Share production flags and dependencies with the startup smoke check."""
compiler, sundials, compiler_version = toolchain() flags = list(COMPILER_FLAGS)
runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").glob("*.h")) executable_name = "model.exe" if os.name == "nt" else "model"
flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"]
if os.name == "nt": if os.name == "nt":
flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"] flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"]
libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES] libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES]
dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES] dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES]
vc_runtime = sundials / "bin/vcruntime140.dll" if (sundials / "bin/vcruntime140.dll").is_file():
if vc_runtime.is_file(): dlls.append(sundials / "bin/vcruntime140.dll")
dlls.append(vc_runtime)
else: else:
raise RuntimeError("Native v1 build packaging currently supports Windows x64; Linux packaging is pending.") flags += ["-D_POSIX_C_SOURCE=200809L"]
sources = {str(p.relative_to(ROOT)): _hash(p) for p in runtime} candidates = [sundials / "lib", sundials / "lib64", sundials / "lib/x86_64-linux-gnu"]
sources["native/THIRD_PARTY_NOTICES.txt"] = _hash(NATIVE / "THIRD_PARTY_NOTICES.txt") directory = next((p for p in candidates if all((p / f"libsundials_{name}.a").is_file() for name in LIBRARIES)), None)
dependencies = {str(p.name): _hash(p) for p in libraries + dlls} if directory is None:
# Header hashes include precision/index ABI settings as well as library APIs. raise RuntimeError("SUNDIALS static libraries not found; set SUNDIALS_ROOT.")
for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol"): libraries = [directory / f"libsundials_{name}.a" for name in LIBRARIES]
for path in sorted((sundials / "include" / directory).glob("*.h")): dlls = []
dependencies[f"{directory}/{path.name}"] = _hash(path) return flags, libraries, dlls, executable_name
identity = dict(source=program.source, header=program.header, contract=program.manifest(), sources=sources,
dependencies=dependencies, compiler=compiler_version, flags=flags,
platform=sys.platform, abi=1) def link_library_arguments(libraries: list[Path]) -> list[str]:
signature = sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() arguments = list(map(str, libraries))
if sys.platform.startswith("linux"):
arguments = ["-Wl,--start-group", *arguments, "-Wl,--end-group"]
return arguments
def build_native(program: NativeProgram, *, cache_dir: Path | None = None,
progress_callback: Callable[[str], None] | None = None) -> NativeBuild:
started = time.perf_counter()
if progress_callback:
progress_callback("native-cache-check")
compiler, sundials, compiler_version = toolchain()
flags, libraries, dlls, executable_name = platform_build_inputs(sundials)
cache = (cache_dir or CACHE).resolve() cache = (cache_dir or CACHE).resolve()
target = cache / signature
manifest_path = target / "manifest.json"
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if all((target / name).is_file() and _hash(target / name) == digest
for name, digest in manifest["artifacts"].items()):
return NativeBuild(target / "model.exe", manifest, True, time.perf_counter()-start)
raise RuntimeError(f"Native cache integrity check failed: {target}")
cache.mkdir(parents=True, exist_ok=True) cache.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix="building-", dir=cache)) for kind in ("models", "objects"):
(stage / "model.c").write_text(program.source, encoding="utf-8") path = cache / kind
(stage / "model.h").write_text(program.header, encoding="utf-8") if path.is_symlink():
command = [compiler, *flags, "-I", str(stage), "-I", str(NATIVE / "include"), raise RuntimeError(f"Native cache directory must not be a symbolic link: {path}")
"-I", str(sundials / "include"), str(stage / "model.c"), path.mkdir(exist_ok=True)
*[str(p) for p in runtime if p.suffix == ".c"], _budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256)
*map(str, libraries), "-lm", "-o", str(stage / "model.exe")] _budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128)
result = subprocess.run(command, capture_output=True, text=True, timeout=120) work_key = sha256(os.urandom(32)).hexdigest()
(stage / "build.log").write_text(result.stdout + result.stderr, encoding="utf-8") work_lease = acquire_cache_lease(cache, "models", work_key)
if result.returncode:
raise RuntimeError(f"Native compilation failed; see {stage / 'build.log'}: {result.stderr[-3000:]}")
for library in dlls:
shutil.copy2(library, stage / library.name)
shutil.copy2(NATIVE / "THIRD_PARTY_NOTICES.txt", stage / "THIRD_PARTY_NOTICES.txt")
manifest = {
**program.manifest(), "buildKey": signature, "compiler": compiler_version,
"compilerFlags": flags, "sourceHashes": sources, "dependencyHashes": dependencies,
"artifacts": {p.name: _hash(p) for p in stage.iterdir() if p.name != "build.log"},
}
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
try: try:
stage.rename(target) stage = Path(tempfile.mkdtemp(prefix=f"building-{work_key}-", dir=cache))
except OSError: except BaseException:
# A concurrent compiler may have published the identical cache first. work_lease.close()
if not manifest_path.is_file(): raise
raise logs: list[str] = []
published = json.loads(manifest_path.read_text(encoding="utf-8")) model_lease = None
# PE linker timestamps can differ between concurrent equivalent builds. try:
# Validate the winning build against its own hashes and our identity. (stage / "model.c").write_text(program.source, encoding="utf-8", newline="\n")
if published.get("buildKey") != signature or not all( (stage / "model.h").write_text(program.header, encoding="utf-8", newline="\n")
(target / name).is_file() and _hash(target / name) == digest compiler_path = Path(shutil.which(compiler) or compiler).resolve()
for name, digest in published["artifacts"].items() compiler_identity = {"version": compiler_version, "binarySha256": _hash(compiler_path),
): "target": _command([compiler, "-dumpmachine"], log=logs, require_stdout=True).decode().strip(),
raise RuntimeError("Concurrent native build did not produce the expected artifacts.") "flags": flags, "platform": sys.platform}
manifest = published runtime = _runtime_sources(program)
if stage.resolve().parent != cache: sources = [stage / "model.c", *runtime]
raise RuntimeError("Unexpected native build staging directory.") native_headers = {p: _hash(p) for p in (NATIVE / "include").rglob("*.h")}
shutil.rmtree(stage) dependency_headers = {p: _hash(p) for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol")
return NativeBuild(target / "model.exe", manifest, False, time.perf_counter()-start) for p in (sundials / "include" / directory).glob("*.h")}
preprocessing_start = time.perf_counter()
# Preprocessed bytes include the actual transitive headers and all
# effective macros/ABI switches. Compile precisely these bytes, so a
# header edit cannot race the cache identity and the compiler input.
def preprocess(source: Path) -> dict:
local_log: list[str] = []
source_hash = _hash(source)
data = _command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={stage}=/generated",
"-I", str(stage), "-I", str(NATIVE / "include"), "-I", str(sundials / "include"),
str(source)], log=local_log)
name = "model.c" if source == stage / "model.c" else "native/" + source.relative_to(NATIVE).as_posix()
if _hash(source) != source_hash:
raise RuntimeError(f"Native source changed during preprocessing: {source}")
digest = sha256(data).hexdigest()
identity = {"cacheVersion": 1, "sourceName": name, "preprocessedSha256": digest, "compiler": compiler_identity}
return {"sourceName": name, "sourceSha256": source_hash, "data": data,
"objectKey": _key(identity), "identity": identity, "log": local_log}
with ThreadPoolExecutor(max_workers=min(4, len(sources))) as pool:
units = list(pool.map(preprocess, sources))
for unit in units:
logs.extend(unit.pop("log"))
preprocessing_seconds = time.perf_counter() - preprocessing_start
if any(_hash(path) != digest for path, digest in {**native_headers, **dependency_headers}.items()):
raise RuntimeError("Native headers changed during preprocessing; retry with stable sources.")
dependencies = {p.name: _hash(p) for p in libraries + dlls}
notice = (NATIVE / "THIRD_PARTY_NOTICES.txt").read_bytes()
identity = {"cacheVersion": 2, "sourceSha256": sha256(program.source.encode()).hexdigest(),
"headerSha256": sha256(program.header.encode()).hexdigest(),
"contractSha256": _key(program.manifest()), "contractKeys": sorted(program.manifest()),
"objectKeys": [unit["objectKey"] for unit in units],
"compiler": compiler_identity, "dependencies": dependencies, "notice": sha256(notice).hexdigest()}
signature = _key(identity)
target = cache / "models" / signature
required = {"model.c", "model.h", executable_name, "THIRD_PARTY_NOTICES.txt", *(p.name for p in dlls)}
model_lease = acquire_cache_lease(cache, "models", signature)
manifest = _checked_manifest(target, "buildKey", signature, 2, required)
details = {"selectedModules": list(component_modules(program.source + "\n" + program.header)),
"unitCount": len(units), "objectCacheHits": 0, "objectCompilations": 0,
"preprocessSeconds": preprocessing_seconds, "compileSeconds": 0.0,
"compileWallSeconds": 0.0, "linkSeconds": 0.0, "modelCacheHit": manifest is not None}
details['compilerLaunchRetries'] = sum(line.startswith('Compiler launch retry ') for line in logs)
if manifest is not None:
if progress_callback:
progress_callback("native-cache-hit")
touch_cache_entry(cache, "models", signature)
details["pruning"] = _prune(cache)
result = NativeBuild(target / executable_name, manifest, True, time.perf_counter() - started,
details, model_lease, cache)
model_lease = None
return result
if progress_callback:
progress_callback("native-compilation")
compile_start = time.perf_counter()
def compile_unit(index_unit: tuple[int, dict]) -> dict:
index, unit = index_unit
key = unit["objectKey"]
destination = cache / "objects" / key
with acquire_cache_lease(cache, "objects", key):
object_manifest = _checked_manifest(destination, "objectKey", key, 1, {"unit.o"})
hit = object_manifest is not None
elapsed = 0.0
local_log: list[str] = []
if not hit:
temporary = Path(tempfile.mkdtemp(prefix=f"building-{key}-", dir=cache / "objects"))
try:
(temporary / "unit.i").write_bytes(unit["data"])
before = time.perf_counter()
_command([compiler, *flags, "-x", "cpp-output", "-c", str(temporary / "unit.i"),
"-o", str(temporary / "unit.o")], log=local_log)
elapsed = time.perf_counter() - before
(temporary / "unit.i").unlink()
object_manifest = {**unit["identity"], "objectKey": key, "artifacts": {"unit.o": _hash(temporary / "unit.o")}}
(temporary / "manifest.json").write_text(json.dumps(object_manifest, indent=2) + "\n")
_publish(temporary, destination, "objectKey", key, 1, {"unit.o"})
finally:
if temporary.exists():
shutil.rmtree(temporary)
# Linking uses private copies, so another model's LRU pass can
# safely reclaim the shared object after this lease is released.
local_object = stage / f"object-{index}.o"
shutil.copyfile(destination / "unit.o", local_object)
touch_cache_entry(cache, "objects", key)
return {"path": local_object, "hit": hit, "seconds": elapsed, "log": local_log}
with ThreadPoolExecutor(max_workers=min(4, len(units))) as pool:
objects = list(pool.map(compile_unit, enumerate(units)))
details["compileWallSeconds"] = time.perf_counter() - compile_start
details["compileSeconds"] = sum(item["seconds"] for item in objects)
details["objectCacheHits"] = sum(item["hit"] for item in objects)
details["objectCompilations"] = len(objects) - details["objectCacheHits"]
for item in objects:
logs.extend(item["log"])
link_libraries = link_library_arguments(libraries)
if progress_callback:
progress_callback("native-linking")
before_link = time.perf_counter()
_command([compiler, *flags, *[str(item["path"]) for item in objects], *link_libraries,
"-lm", "-o", str(stage / executable_name)], log=logs)
details['compilerLaunchRetries'] = sum(line.startswith('Compiler launch retry ') for line in logs)
details["linkSeconds"] = time.perf_counter() - before_link
if {p.name: _hash(p) for p in libraries + dlls} != dependencies:
raise RuntimeError("Native dependencies changed during linking; retry with a stable toolchain.")
for item in objects:
item["path"].unlink()
for library in dlls:
shutil.copyfile(library, stage / library.name)
if _hash(stage / library.name) != dependencies[library.name]:
raise RuntimeError("Native runtime dependency changed during copying; retry with a stable toolchain.")
(stage / "THIRD_PARTY_NOTICES.txt").write_bytes(notice)
(stage / "build.log").write_text("\n".join(logs), encoding="utf-8")
manifest = {**program.manifest(), "cacheVersion": 2, "buildKey": signature, "buildIdentity": identity,
"compiler": compiler_version, "compilerFlags": flags,
"sourceHashes": {unit["sourceName"]: unit["sourceSha256"] for unit in units},
"nativeHeaderHashes": {"native/" + p.relative_to(NATIVE).as_posix(): digest
for p, digest in native_headers.items()},
"dependencyHashes": dependencies, "selectedModules": details["selectedModules"],
"objectKeys": [unit["objectKey"] for unit in units],
"artifacts": {name: _hash(stage / name) for name in sorted(required)}}
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
manifest = _publish(stage, target, "buildKey", signature, 2, required)
touch_cache_entry(cache, "models", signature)
details["pruning"] = _prune(cache)
result = NativeBuild(target / executable_name, manifest, False, time.perf_counter() - started,
details, model_lease, cache)
model_lease = None
return result
finally:
if model_lease is not None:
model_lease.close()
# Failed compilations never publish partial entries or retain unlimited
# model sources/preprocessed files. Error stderr is included in the exception.
try:
if stage.exists():
shutil.rmtree(stage)
finally:
work_lease.close()
@@ -0,0 +1,459 @@
"""Leases and bounded LRU eviction for immutable native cache entries.
Builds publish and validate their own artifacts. Readers hold a shared lease
before checking an entry and until they finish using it. Eviction only removes
an entry while holding the corresponding nonblocking exclusive lease.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
import errno
import os
from pathlib import Path
import re
import shutil
import stat
import weakref
MODEL_LIMIT_BYTES = 256 * 1024**2
OBJECT_LIMIT_BYTES = 128 * 1024**2
LOCK_SHARDS = 128
_KINDS = frozenset(("models", "objects"))
_KEY = re.compile(r"[0-9a-f]{64}\Z")
# tempfile.mkdtemp uses eight lowercase letters, digits or underscores. Older
# stages lacking a lease key are deliberately outside automatic cleanup.
_STAGE = re.compile(r"building-([0-9a-f]{64})-[a-z0-9_]{8}\Z")
if os.name == "nt":
import ctypes
from ctypes import wintypes
import msvcrt
class _Offset(ctypes.Structure):
_fields_ = [("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD)]
class _OffsetUnion(ctypes.Union):
_fields_ = [("offset", _Offset), ("Pointer", ctypes.c_void_p)]
class _Overlapped(ctypes.Structure):
_fields_ = [
("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
("offset", _OffsetUnion), ("hEvent", wintypes.HANDLE),
]
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_lock_file = _kernel32.LockFileEx
_lock_file.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_Overlapped),
]
_lock_file.restype = wintypes.BOOL
_unlock_file = _kernel32.UnlockFileEx
_unlock_file.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
wintypes.DWORD, ctypes.POINTER(_Overlapped),
]
_unlock_file.restype = wintypes.BOOL
else:
import fcntl
def _validate(kind: str, key: str) -> None:
if kind not in _KINDS or not isinstance(key, str) or _KEY.fullmatch(key) is None:
raise ValueError("Native cache leases require models/objects and a lowercase SHA-256 key.")
def _reparse(info: os.stat_result) -> bool:
# Junctions as well as symbolic links are excluded on Windows.
return stat.S_ISLNK(info.st_mode) or bool(
getattr(info, "st_file_attributes", 0)
& getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
)
def _directory(path: Path) -> os.stat_result | None:
try:
info = path.lstat()
except FileNotFoundError:
return None
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
return None
return info
def _require_directory(path: Path, *, create: bool = False) -> None:
if create:
path.mkdir(parents=True, exist_ok=True)
if _directory(path) is None:
raise RuntimeError(f"Native cache directory must be a real directory: {path}")
def _lock(fd: int, *, exclusive: bool, blocking: bool) -> bool:
if os.name == "nt":
# os.open supplies a synchronous handle. LockFileEx therefore really
# waits without FAIL_IMMEDIATELY; unlike msvcrt.locking it also supports
# overlapping shared leases, including multiple builds in one process.
# The one-byte lock may extend beyond EOF, so lock files remain empty.
overlapped = _Overlapped()
flags = (2 if exclusive else 0) | (0 if blocking else 1)
if _lock_file(msvcrt.get_osfhandle(fd), flags, 0, 1, 0, ctypes.byref(overlapped)):
return True
code = ctypes.get_last_error()
if not blocking and code == 33: # ERROR_LOCK_VIOLATION
return False
raise ctypes.WinError(code)
operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
if not blocking:
operation |= fcntl.LOCK_NB
while True:
try:
fcntl.flock(fd, operation)
return True
except InterruptedError:
continue
except OSError as exc:
if not blocking and exc.errno in (errno.EACCES, errno.EAGAIN):
return False
raise
def _release(fd: int, owner_pid: int) -> None:
# A forked child's finalizer must not explicitly unlock its parent's flock.
# Closing the inherited descriptor alone leaves the parent's lease intact.
with suppress(OSError):
if os.getpid() == owner_pid:
if os.name == "nt":
overlapped = _Overlapped()
_unlock_file(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped))
else:
fcntl.flock(fd, fcntl.LOCK_UN)
with suppress(OSError):
os.close(fd)
class CacheLease:
"""An acquired cache-use lock; close explicitly or use as a context manager."""
__slots__ = ("path", "kind", "key", "exclusive", "_finalizer", "__weakref__")
def __init__(self, fd: int, path: Path, kind: str, key: str, exclusive: bool):
self.path = path
self.kind = kind
self.key = key
self.exclusive = exclusive
self._finalizer = weakref.finalize(self, _release, fd, os.getpid())
@property
def closed(self) -> bool:
return not self._finalizer.alive
def close(self) -> None:
self._finalizer()
def __enter__(self) -> CacheLease:
if self.closed:
raise RuntimeError("Cannot reuse a closed native cache lease.")
return self
def __exit__(self, *_: object) -> None:
self.close()
def acquire_cache_lease(
cache_dir: Path, kind: str, key: str, *, exclusive: bool = False,
blocking: bool = True,
) -> CacheLease | None:
"""Acquire a shared-use/exclusive-eviction lease; return None only if busy.
Locks use 128 stable shards per namespace, bounding metadata as model keys
accumulate. A collision can defer eviction but cannot admit unsafe deletion.
Lock files must never be unlinked while the application is running: waiters
may already hold the old inode. There are at most 256 empty lock files.
"""
_validate(kind, key)
shard = int(key[:8], 16) % LOCK_SHARDS
return _acquire_lock_file(
Path(cache_dir).absolute(), kind, key, f"{kind}-{shard:03d}.lock",
exclusive=exclusive, blocking=blocking,
)
def _acquire_lock_file(
cache: Path, kind: str, key: str, lock_name: str, *, exclusive: bool,
blocking: bool,
) -> CacheLease | None:
_require_directory(cache, create=True)
lock_dir = cache / ".locks"
_require_directory(lock_dir, create=True)
lock_path = lock_dir / lock_name
try:
info = lock_path.lstat()
except FileNotFoundError:
info = None
if info is not None and (_reparse(info) or not stat.S_ISREG(info.st_mode)):
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
fd = os.open(lock_path, flags, 0o600)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
os.set_inheritable(fd, False)
if not _lock(fd, exclusive=exclusive, blocking=blocking):
os.close(fd)
return None
return CacheLease(fd, cache / kind / key, kind, key, exclusive)
except BaseException:
os.close(fd)
raise
def touch_cache_entry(cache_dir: Path, kind: str, key: str) -> None:
"""Mark an immutable entry as recently used while its caller holds a lease."""
_validate(kind, key)
cache = Path(cache_dir).absolute()
_require_directory(cache)
_require_directory(cache / kind)
entry = cache / kind / key
_require_directory(entry)
# Directory mtime is separate from the checksummed artifact/manifest bytes.
# Windows Python may expose utime without the no-follow operation. The
# checks above reject symlinks and reparse points before either call, and
# the caller's use lease protects the entry from application eviction.
if os.utime in os.supports_follow_symlinks:
os.utime(entry, None, follow_symlinks=False)
else:
os.utime(entry, None)
@dataclass(frozen=True)
class _Entry:
path: Path
size: int
mtime_ns: int
device: int
inode: int
def _tree_size(path: Path) -> int | None:
"""Count regular file bytes without reading files or following any links."""
size = 0
try:
with os.scandir(path) as children:
for child in children:
info = child.stat(follow_symlinks=False)
if _reparse(info):
return None
if stat.S_ISREG(info.st_mode):
size += info.st_size
elif stat.S_ISDIR(info.st_mode):
nested = _tree_size(Path(child.path))
if nested is None:
return None
size += nested
else:
return None
except FileNotFoundError:
return None # A concurrent sweep may already have removed this entry.
return size
def _scan(namespace: Path) -> tuple[list[_Entry], int]:
entries = []
skipped = 0
if _directory(namespace) is None:
try:
namespace.lstat()
except FileNotFoundError:
return entries, 0
return entries, 1
with os.scandir(namespace) as children:
for child in children:
if _KEY.fullmatch(child.name) is None:
skipped += 1
continue
path = Path(child.path)
info = _directory(path)
if info is None:
skipped += 1
continue
size = _tree_size(path)
if size is None:
skipped += 1
continue
entries.append(_Entry(path, size, info.st_mtime_ns, info.st_dev, info.st_ino))
return entries, skipped
def _empty_report(limit: int) -> dict:
return {
"limitBytes": limit, "beforeBytes": 0, "afterBytes": 0,
"removedBytes": 0, "removedEntries": 0, "skippedInUse": 0,
"skippedChanged": 0, "skippedUnmanaged": 0, "oversizedEntries": 0,
"overLimitBytes": 0, "skippedConcurrentSweep": False, "errors": [],
}
def _prune_kind(cache: Path, kind: str, limit: int) -> dict:
report = _empty_report(limit)
namespace = cache / kind
try:
entries, skipped = _scan(namespace)
except OSError as exc:
report["errors"].append(str(exc))
return report
report["skippedUnmanaged"] = skipped
remaining = report["beforeBytes"] = sum(entry.size for entry in entries)
remaining_count = len(entries)
for entry in sorted(entries, key=lambda item: (item.mtime_ns, item.path.name)):
if remaining <= limit:
break
# Keep the last entry if it alone exceeds the budget. Older oversized
# entries are still evictable, so they cannot accumulate unboundedly.
if remaining_count == 1 and entry.size > limit:
continue
try:
lease = acquire_cache_lease(cache, kind, entry.path.name, exclusive=True, blocking=False)
if lease is None:
report["skippedInUse"] += 1
continue
with lease:
info = _directory(entry.path)
if info is None:
report["skippedChanged"] += 1
continue
if (info.st_dev, info.st_ino, info.st_mtime_ns) != (
entry.device, entry.inode, entry.mtime_ns,
):
# It was republished or reused after sorting; defer it to
# the next sweep instead of evicting a freshly used build.
report["skippedChanged"] += 1
continue
size = _tree_size(entry.path)
if size is None:
report["skippedUnmanaged"] += 1
continue
shutil.rmtree(entry.path)
remaining -= entry.size
remaining_count -= 1
report["removedBytes"] += size
report["removedEntries"] += 1
except (OSError, RuntimeError) as exc:
# Cleanup is best effort. A locked executable, permissions or a
# damaged unrelated cache entry must not fail a valid simulation.
report["errors"].append(str(exc))
try:
current, _ = _scan(namespace)
report["afterBytes"] = sum(entry.size for entry in current)
report["oversizedEntries"] = sum(entry.size > limit for entry in current)
except OSError as exc:
report["errors"].append(str(exc))
report["afterBytes"] = remaining
report["overLimitBytes"] = max(0, report["afterBytes"] - limit)
return report
def _empty_orphan_report() -> dict:
return {
"orphanStagesRemoved": 0, "orphanStagesBytes": 0,
"skippedInUse": 0, "skippedUnmanaged": 0,
"skippedConcurrentSweep": False, "errors": [],
}
def _prune_orphan_stages(cache: Path) -> dict:
"""Remove only named stages whose builder's lease is no longer held.
Callers acquire the embedded key's shared lease BEFORE creating the stage
and retain it until publication or cleanup. The OS releases that protection
if the builder is killed; no age threshold or unreliable PID test is needed.
Root/models stages use models leases; objects stages use objects leases.
"""
report = _empty_orphan_report()
for namespace, kind in ((cache, "models"), (cache / "models", "models"),
(cache / "objects", "objects")):
try:
if _directory(namespace) is None:
continue
with os.scandir(namespace) as children:
candidates = []
for child in children:
match = _STAGE.fullmatch(child.name)
if match is not None:
candidates.append((Path(child.path), match.group(1)))
elif child.name.startswith("building-"):
report["skippedUnmanaged"] += 1
except OSError as exc:
report["errors"].append(f"{namespace}: {exc}")
continue
for stage, key in candidates:
try:
lease = acquire_cache_lease(cache, kind, key, exclusive=True, blocking=False)
if lease is None:
report["skippedInUse"] += 1
continue
with lease:
if _directory(stage) is None:
report["skippedUnmanaged"] += 1
continue
size = _tree_size(stage)
if size is None:
report["skippedUnmanaged"] += 1
continue
shutil.rmtree(stage)
report["orphanStagesRemoved"] += 1
report["orphanStagesBytes"] += size
except (OSError, RuntimeError) as exc:
report["errors"].append(f"{stage}: {exc}")
return report
def prune_cache(
cache_dir: Path, *, model_limit_bytes: int = MODEL_LIMIT_BYTES,
object_limit_bytes: int = OBJECT_LIMIT_BYTES,
) -> dict:
"""Apply separate soft LRU byte budgets to managed models and objects.
In-use entries (including conservative shard collisions), the final entry
when it alone exceeds the budget, unknown names, links and legacy root-level
builds are retained. Returned counts explain any remaining overage. The
cache may transiently exceed its budgets while builds or solvers are active.
Strictly named stages abandoned by killed builders are removed under their
matching lease and counted separately in the orphanStages report.
"""
for limit in (model_limit_bytes, object_limit_bytes):
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
raise ValueError("Native cache byte limits must be nonnegative integers.")
cache = Path(cache_dir).absolute()
empty = {
"models": _empty_report(model_limit_bytes),
"objects": _empty_report(object_limit_bytes),
"orphanStages": _empty_orphan_report(),
}
try:
if _directory(cache) is None:
# Do not create a missing cache or inspect an alias to a directory.
return empty
# One additional stable lock file (257 total) serializes sweep decisions
# without serializing readers/builds. Otherwise simultaneous sweeps can
# each remove a different entry and incorrectly discard the final one.
sweep = _acquire_lock_file(
cache, "models", "0" * 64, "prune.lock", exclusive=True, blocking=False,
)
if sweep is None:
for report in empty.values():
report["skippedConcurrentSweep"] = True
return empty
with sweep:
orphan_stages = _prune_orphan_stages(cache)
return {
"models": _prune_kind(cache, "models", model_limit_bytes),
"objects": _prune_kind(cache, "objects", object_limit_bytes),
"orphanStages": orphan_stages,
}
except (OSError, RuntimeError) as exc:
for report in empty.values():
report["errors"].append(str(exc))
return empty
+50 -11
View File
@@ -12,6 +12,8 @@ from math import isfinite
from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.systems.network import SimulationNetwork from app.simulation.systems.network import SimulationNetwork
from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS
from .tolerances import state_absolute_tolerance
from .jacobian import JacobianStructure
class NativeCapabilityError(ValueError): class NativeCapabilityError(ValueError):
@@ -33,6 +35,8 @@ class NativeProgram:
state_keys: tuple[str, ...] state_keys: tuple[str, ...]
variables: tuple[ResultVariableMetadata, ...] variables: tuple[ResultVariableMetadata, ...]
component_types: tuple[str, ...] component_types: tuple[str, ...]
evaluation_schedule: dict | None = None
jacobian_structure: dict | None = None
def manifest(self) -> dict: def manifest(self) -> dict:
return { return {
@@ -40,7 +44,9 @@ class NativeProgram:
"variables": [v.as_dict() for v in self.variables], "variables": [v.as_dict() for v in self.variables],
"componentTypes": self.component_types, "componentTypes": self.component_types,
"componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types}, "componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types},
"jacobianPolicy": "CVODE default; no custom Jacobian", "jacobianPolicy": (self.jacobian_structure or {}).get("policy", "CVODE default; no custom Jacobian"),
"jacobianStructure": self.jacobian_structure,
"evaluationSchedule": self.evaluation_schedule or {"strategy": "storage-anchored", "cyclicBlockCount": 0},
} }
@@ -65,13 +71,19 @@ def _number(value):
def compile_native_program(network: SimulationNetwork) -> NativeProgram: def compile_native_program(network: SimulationNetwork) -> NativeProgram:
network.validate_port_supplies()
from .extended import catalog_contracts from .extended import catalog_contracts
contracts = catalog_contracts() contracts = catalog_contracts()
from app.simulation.components.amesim.semantics import validate_numerical_semantics
for component in network.components.values(): for component in network.components.values():
if type(component) is not contracts.get(component.model_type): if type(component) is not contracts.get(component.model_type):
raise NativeCapabilityError(f'{component.name}: no native contract for {component.model_type}') raise NativeCapabilityError(f'{component.name}: no native contract for {component.model_type}')
if component.MODEL_VERSION != SUPPORTED_VERSIONS.get(component.model_type): if component.MODEL_VERSION != SUPPORTED_VERSIONS.get(component.model_type):
raise NativeCapabilityError(f'{component.name}: native kernel version does not match the component contract') raise NativeCapabilityError(f'{component.name}: native kernel version does not match the component contract')
try:
validate_numerical_semantics(component)
except ValueError as exc:
raise NativeCapabilityError(str(exc)) from exc
# Preserve the compact, verified schedule for its supported topology. # Preserve the compact, verified schedule for its supported topology.
# Both paths emit C; this never falls back to Python numerical execution. # Both paths emit C; this never falls back to Python numerical execution.
try: try:
@@ -156,6 +168,8 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
state_keys, state_index = [], {} state_keys, state_index = [], {}
for c in components: for c in components:
fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ()) fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ())
if c.model_type == "amesim_pnch012" and sum(c.external_volume_rates.values()) != 0:
fields += ("_volume_displacement",)
for field in fields: for field in fields:
key = f"{c.name}.{field}" key = f"{c.name}.{field}"
state_index[key] = len(state_keys) state_index[key] = len(state_keys)
@@ -246,17 +260,25 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
rate = "0.0" rate = "0.0"
if c.model_type == "amesim_pnch012": if c.model_type == "amesim_pnch012":
volume += "".join(" + " + get(p, "volume") for p in connected) volume += "".join(" + " + get(p, "volume") for p in connected)
if key(c, '_volume_displacement') in state_index:
index = si(c, '_volume_displacement')
init.append(f"y[{index}] = 0.0;")
lines.append(f"dy[{index}] = {_number(sum(c.external_volume_rates.values()))};")
volume += f" + y[{index}]"
put(c, "vol", f"fmax({_number(c.cvol0 / 100)}, {volume})") put(c, "vol", f"fmax({_number(c.cvol0 / 100)}, {volume})")
rate = _number(sum(c.external_volume_rates.values())) + "".join(" + " + get(p, "volume_flow") for p in connected) rate = _number(sum(c.external_volume_rates.values())) + "".join(" + " + get(p, "volume_flow") for p in connected)
put(c, "dvol", f"{get(c, 'vol')} <= {_number(c.cvol0 / 100)} ? 0.0 : ({rate})") limit = _number(c.cvol0 / 100)
put(c, "dvol", f"({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})")
volume, rate = get(c, "vol"), get(c, "dvol") volume, rate = get(c, "vol"), get(c, "dvol")
volume_expressions[c.name] = (volume, rate) volume_expressions[c.name] = (volume, rate)
# Match the Python constructor: m/U use configured storage volume; # p0/T0 refer to the complete connected volume at the initial positions.
# connected moving volumes subsequently change recovered p/T. initial_volume = base
initial_volume = max(base, c.cvol0 / 100) if c.model_type == "amesim_pnch012" else base if c.model_type == "amesim_pnch012":
initial_volume += sum(p.effective_area * (p.x0 + mass_at(p, "port_5").x0 - mass_at(p, "port_4").x0) for p in connected)
initial_volume = max(initial_volume, c.cvol0 / 100)
init.append(f"if (!native_gas_init({_number(c.p0)}, {_number(c.T0)}, {_number(initial_volume)}, &y[{si(c, 'm')}])) return 0;") init.append(f"if (!native_gas_init({_number(c.p0)}, {_number(c.T0)}, {_number(initial_volume)}, &y[{si(c, 'm')}])) return 0;")
lines.append(f"NativeGas gas_{gi};") lines.append(f"NativeGas gas_{gi};")
lines.append(f"if (!native_gas(y[{si(c, 'm')}], y[{si(c, 'U')}], {volume}, &gas_{gi})) return 0;") lines.append(f"if (!native_gas_context(properties, y[{si(c, 'm')}], y[{si(c, 'U')}], {volume}, &gas_{gi})) return 0;")
for field in ("m", "U"): for field in ("m", "U"):
put(c, field, f"y[{si(c, field)}]") put(c, field, f"y[{si(c, field)}]")
for field in ("p", "T", "rho", "u", "h"): for field in ("p", "T", "rho", "u", "h"):
@@ -273,14 +295,14 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
continue continue
a, b = chamber_at(c, "port_2"), chamber_at(c, "port_3") a, b = chamber_at(c, "port_2"), chamber_at(c, "port_3")
put(c, "xv", f"fmax(0.0, fmin(1.0, {get(c, 'res.signal')}))") put(c, "xv", f"fmax(0.0, fmin(1.0, {get(c, 'res.signal')}))")
lines.append(f"if (!native_orifice({get(a, 'p')}, {get(b, 'p')}, {get(a, 'h')}, {get(b, 'h')}, {_number(c.effective_cq * c.maximum_area)}, {get(c, 'xv')}, &{get(c, 'port_2.m_flow')}, &{get(c, 'cm')}, &{get(c, 'gasvel')})) return 0;") lines.append(f"if (!native_orifice_context(properties, {get(a, 'p')}, {get(b, 'p')}, {get(a, 'h')}, {get(b, 'h')}, {_number(c.effective_cq * c.maximum_area)}, {get(c, 'xv')}, &{get(c, 'port_2.m_flow')}, &{get(c, 'cm')}, &{get(c, 'gasvel')})) return 0;")
assigned.update((key(c, "cm"), key(c, "gasvel"))) assigned.update((key(c, "cm"), key(c, "gasvel")))
put(c, "port_3.m_flow", f"-{get(c, 'port_2.m_flow')}") put(c, "port_3.m_flow", f"-{get(c, 'port_2.m_flow')}")
put(c, "port_2.h_outflow", get(b, "h")) put(c, "port_2.h_outflow", get(b, "h"))
put(c, "port_3.h_outflow", get(a, "h")) put(c, "port_3.h_outflow", get(a, "h"))
for pname in ("port_2", "port_3"): for pname in ("port_2", "port_3"):
other = adjacent[c.name, pname] other = adjacent[c.name, pname]
# A resistance-to-resistance stream path requires a fuller IR. # Resistance-to-resistance streams need the extended pressure/enthalpy closure.
if network.components[other[0]] not in chambers: if network.components[other[0]] not in chambers:
raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.") raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.")
lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};") lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};")
@@ -351,6 +373,11 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
raise NativeCapabilityError(f"Native output coverage is incomplete: {sorted(missing)}") raise NativeCapabilityError(f"Native output coverage is incomplete: {sorted(missing)}")
stops = [(si(c, "v"), c.xmin, c.xmax) for c in masses if int(c.stoptype) == 1] stops = [(si(c, "v"), c.xmin, c.xmax) for c in masses if int(c.stoptype) == 1]
from .contacts import contact_table
contact_source, contact_header = contact_table([
(si(mass_at(c, 'port_1'), 'v'), si(mass_at(c, 'port_2'), 'v'),
c.gap0, c.kcont, c.rcont, c.Pdis, int(c.discContactOption), 1)
for c in components if c.model_type == 'amesim_lstp00a'])
stop_c = ",".join(f"{{{v},{_number(lo)},{_number(hi)},0,0,0,0}}" for v, lo, hi in stops) or "{0,0,0,0,0,0,0}" stop_c = ",".join(f"{{{v},{_number(lo)},{_number(hi)},0,0,0,0}}" for v, lo, hi in stops) or "{0,0,0,0,0,0,0}"
next_event = [] next_event = []
for index, c in signal_specs: for index, c in signal_specs:
@@ -358,31 +385,43 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});") next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});")
else: else:
next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));") next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));")
jacobian = JacobianStructure.dense(len(state_keys), 'Compact storage-anchored lowering retains default differences')
source = '\n'.join([ source = '\n'.join([
'#include "model.h"', '#include <math.h>', *declarations, '#include "model.h"', '#include <math.h>', *declarations,
f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};", f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};",
f"const double model_atol[NSTATES] = {{{','.join('1e-12' if k.rsplit('.',1)[1] in ('v','x') else '1e-8' for k in state_keys)}}};", contact_source,
f"const double model_atol[NSTATES] = {{{','.join(map(state_absolute_tolerance, state_keys))}}};",
"const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};", "const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};",
"int model_init(double *y) {", *init, "return 1; }", "int model_init(double *y) {", *init, "return 1; }",
"int model_eval(double t, const double *y, double *dy, double *w) {", "(void)t;", *lines, "static int model_eval_internal(double t, const double *y, double *dy, double *w, NativePropertyTemperatures *temperatures) {", "(void)t;",
f"NativePropertyState property_states[{min(256,max(16,4*len(components)))}];",
"NativePropertyCache property_cache, *properties=&property_cache;",
"native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));",
"properties->temperatures=temperatures;", *lines,
"for (int i=0;i<NSTATES;i++) if (!isfinite(dy[i])) return 0;", "for (int i=0;i<NSTATES;i++) if (!isfinite(dy[i])) return 0;",
"for (int i=0;i<NOUTPUTS;i++) if (!isfinite(w[i])) return 0;", "for (int i=0;i<NOUTPUTS;i++) if (!isfinite(w[i])) return 0;",
"return 1; }", "return 1; }",
"int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,NULL);}",
"int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,temperatures);}",
"double model_next_break(double t, double end) { (void)t; double result=end;", *next_event, "double model_next_break(double t, double end) { (void)t; double result=end;", *next_event,
"return result; }", "", "return result; }", "",
]) ])
header = f'''#ifndef GENERATED_NATIVE_MODEL_H header = f'''#ifndef GENERATED_NATIVE_MODEL_H
#define GENERATED_NATIVE_MODEL_H #define GENERATED_NATIVE_MODEL_H
#include "kernels.h" #include "kernels.h"
#define MODEL_PROPERTY_TEMPERATURES 1
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
#define NSTATES {len(state_keys)} #define NSTATES {len(state_keys)}
#define NOUTPUTS {len(variables)} #define NOUTPUTS {len(variables)}
#define NSTOPS {len(stops)} #define NSTOPS {len(stops)}
{contact_header}
extern const NativeStop model_stops[{max(1,len(stops))}]; extern const NativeStop model_stops[{max(1,len(stops))}];
extern const double model_atol[NSTATES]; extern const double model_atol[NSTATES];
extern const char *const model_output_keys[NOUTPUTS]; extern const char *const model_output_keys[NOUTPUTS];
{chr(10).join(jacobian.header_lines())}
int model_init(double *y); int model_init(double *y);
int model_eval(double t, const double *y, double *dy, double *w); int model_eval(double t, const double *y, double *dy, double *w);
double model_next_break(double t, double end); double model_next_break(double t, double end);
#endif #endif
''' '''
return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components}))) return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components})),jacobian_structure=jacobian.manifest())
+24
View File
@@ -0,0 +1,24 @@
"""Emit state-only LSTP event descriptors for both native lowering paths."""
from .compiler import _number
def contact_table(contacts):
"""Rows: velocity indices, gap, stiffness, damping, depth, force option,
subtract-first flag. Keep the force expression's floating operation order.
Position follows velocity in each reviewed mechanical state pair.
"""
rows = []
for v1, v2, gap, stiffness, damping, depth, option, subtract_first in contacts:
rows.append('{' + ','.join((str(v1), str(v2), _number(gap),
_number(stiffness), _number(damping), _number(depth),
str(option), str(subtract_first))) + '}')
count = len(rows)
header = f'''#define NCONTACTS {count}
typedef struct {{
int velocity1, velocity2;
double gap0, stiffness, damping, depth;
int signed_force, subtract_first;
}} NativeContact;
extern const NativeContact model_contacts[{max(1, count)}];'''
source = f'const NativeContact model_contacts[{max(1, count)}] = {{' + (','.join(rows) or '{0,0,0,0,0,0,0,0}') + '};'
return source, header
+314 -79
View File
@@ -6,10 +6,17 @@ All thermodynamics, flow/stream closure and derivatives execute in the EXE.
from __future__ import annotations from __future__ import annotations
import json import json
import math
import re
from importlib import import_module from importlib import import_module
from graphlib import TopologicalSorter
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
from .contracts import SUPPORTED_VERSIONS from .contracts import SUPPORTED_VERSIONS
from .schedule import Computation, EvaluationSchedule, references
from .tolerances import state_absolute_tolerance
from .jacobian import StateDependencies, expression_inputs
from app.simulation.components.amesim.semantics import contact_stiffness, validate_numerical_semantics
GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001', GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001',
@@ -19,6 +26,10 @@ RESISTORS = {'amesim_pnor001', 'amesim_pnvo001_fixed', 'amesim_pnvo001',
'amesim_pnl00r', 'pipe', 'orifice'} 'amesim_pnl00r', 'pipe', 'orifice'}
def is_polytropic(c):
return c.model_type in {'amesim_pnl0001', 'amesim_pnl0002', 'amesim_pnl0003'} and int(c.mode) == 1
def catalog_contracts(): def catalog_contracts():
from app.simulation.components.amesim.library import LIBRARY as a from app.simulation.components.amesim.library import LIBRARY as a
from app.simulation.components.experimental.library import LIBRARY as e from app.simulation.components.experimental.library import LIBRARY as e
@@ -30,7 +41,7 @@ def catalog_contracts():
return result return result
def linear_schedule(equations, unknowns, free_flows=()): def linear_assignments(equations, unknowns, free_flows=()):
"""Eliminate constant coefficients; retain RHS expressions as C temporaries. """Eliminate constant coefficients; retain RHS expressions as C temporaries.
Exact pivot elimination avoids a numeric pseudoinverse and its tiny spurious Exact pivot elimination avoids a numeric pseudoinverse and its tiny spurious
@@ -68,15 +79,21 @@ def linear_schedule(equations, unknowns, free_flows=()):
else: else:
target[k] = value target[k] = value
pivots.append(key) pivots.append(key)
lines = [f'double b{i} = {expr};' for i, (_, expr) in enumerate(equations) lines = [(f'b{i}', expr) for i, (_, expr) in enumerate(equations)
if any(i in rows[j][1] for j in range(len(pivots)))] if any(i in rows[j][1] for j in range(len(pivots)))]
for j, key in enumerate(pivots): for j, key in enumerate(pivots):
expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0' expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0'
lines.append(f'{key} = {expr};') lines.append((key, expr))
return lines return lines
def linear_schedule(equations, unknowns, free_flows=()):
return [f'{"double " if key.startswith("b") else ""}{key} = {expr};'
for key, expr in linear_assignments(equations, unknowns, free_flows)]
def compile_extended_program(network): def compile_extended_program(network):
network.validate_port_supplies()
components = list(network.components.values()) components = list(network.components.values())
contracts = catalog_contracts() contracts = catalog_contracts()
for c in components: for c in components:
@@ -84,6 +101,10 @@ def compile_extended_program(network):
raise NativeCapabilityError(f'{c.name}: no native contract for {c.model_type} / {type(c).__name__}') raise NativeCapabilityError(f'{c.name}: no native contract for {c.model_type} / {type(c).__name__}')
if c.MODEL_VERSION != SUPPORTED_VERSIONS.get(c.model_type): if c.MODEL_VERSION != SUPPORTED_VERSIONS.get(c.model_type):
raise NativeCapabilityError(f'{c.name}: native kernel version does not match the component contract') raise NativeCapabilityError(f'{c.name}: native kernel version does not match the component contract')
try:
validate_numerical_semantics(c)
except ValueError as exc:
raise NativeCapabilityError(str(exc)) from exc
variables = tuple(v for c in components for v in c.result_variable_metadata()) variables = tuple(v for c in components for v in c.result_variable_metadata())
if not variables: if not variables:
raise NativeCapabilityError('Simulation requires a runtime component; medium definitions alone have no outputs') raise NativeCapabilityError('Simulation requires a runtime component; medium definitions alone have no outputs')
@@ -121,8 +142,11 @@ def compile_extended_program(network):
mass_groups = {} mass_groups = {}
for c in components: for c in components:
if c.model_type in GAS_TYPES: if c.model_type in GAS_TYPES:
fields = ('m1', 'U1', 'm2', 'U2') if c.model_type == 'amesim_pnl0003' else ('m', 'U') thermal = 'T' if is_polytropic(c) else 'U'
fields = ('m1', thermal+'1', 'm2', thermal+'2') if c.model_type == 'amesim_pnl0003' else ('m', thermal)
add_states(c, fields, [0.0]*len(fields)) add_states(c, fields, [0.0]*len(fields))
if c.model_type == 'amesim_pnch012' and sum(c.external_volume_rates.values()) != 0:
add_states(c, ('_volume_displacement',), (0.0,))
elif c.model_type == 'amesim_mecmas21': elif c.model_type == 'amesim_mecmas21':
root = groups.find((c.name, 'port_1')) root = groups.find((c.name, 'port_1'))
mass_groups.setdefault(root, []).append(c) mass_groups.setdefault(root, []).append(c)
@@ -134,12 +158,24 @@ def compile_extended_program(network):
raise NativeCapabilityError('Rigidly connected masses require consistent initial x/v') raise NativeCapabilityError('Rigidly connected masses require consistent initial x/v')
for field in ('v', 'x'): for field in ('v', 'x'):
states[c.name, field] = states[ref.name, field] states[c.name, field] = states[ref.name, field]
friction_groups = []
for group in mass_groups.values():
dry = [c for c in group if c.use_friction and int(c.stoptype) != 3 and c.fstick > 0]
if dry:
ref = group[0]
threshold = min(c.dvel if int(c.frictionType) == 2 else 0 for c in dry)
mode = (1 if ref.v0 > 0 else -1) if abs(ref.v0) > threshold else 0
if mode == 0:
initial[states[ref.name, 'v']] = 0.0
add_states(ref, ('_friction_mode',), (mode,))
friction_groups.append((group, dry, threshold))
if len(state_keys)>1024 or len(variables)>16384: if len(state_keys)>1024 or len(variables)>16384:
raise NativeCapabilityError('Native model exceeds the 1024-state / 16384-output resource limit') raise NativeCapabilityError('Native model exceeds the 1024-state / 16384-output resource limit')
# Algebraic models use an internal constant state; the public state map stays empty. # Algebraic models use an internal constant state; the public state map stays empty.
nstates = max(len(state_keys), 1) nstates = max(len(state_keys), 1)
if not initial: if not initial:
initial = [0.0] initial = [0.0]
dependencies = StateDependencies(len(state_keys))
lines, declarations, breaks = [], [], [] lines, declarations, breaks = [], [], []
assigned = set() assigned = set()
def w(c, field): def w(c, field):
@@ -147,6 +183,7 @@ def compile_extended_program(network):
return f'w[{slots[name+"."+field]}]' return f'w[{slots[name+"."+field]}]'
def put(c, field, expr, dest=None): def put(c, field, expr, dest=None):
(lines if dest is None else dest).append(f'{w(c,field)} = {expr};') (lines if dest is None else dest).append(f'{w(c,field)} = {expr};')
dependencies.expression(w(c,field), expr)
assigned.add((c if isinstance(c,str) else c.name)+'.'+field) assigned.add((c if isinstance(c,str) else c.name)+'.'+field)
def y(c, field): def y(c, field):
return f'y[{states[c.name,field]}]' return f'y[{states[c.name,field]}]'
@@ -223,6 +260,30 @@ def compile_extended_program(network):
if temperature and obj.model_type in NODES-{'tee'}: if temperature and obj.model_type in NODES-{'tee'}:
other = adjacent[(obj.name, 'port_2')] other = adjacent[(obj.name, 'port_2')]
return f'h[{pi[other]}]' return f'h[{pi[other]}]'
# Reference nodes aggregate branch quantities towards port 2. Follow this
# directed contract, not the undirected pressure group (which can span
# unrelated volumes). Port-supply validation has already rejected cycles.
reference_nodes = {c.name: c for c in components if c.model_type in NODES-{'tee'}}
node_graph = {c.name: [other[0] for name in pnames(c) if name != 'port_2'
for other in [adjacent[ep(c, name)]]
if other[0] in reference_nodes and other[1] == 'port_2']
for c in reference_nodes.values()}
node_order = [reference_nodes[name] for name in TopologicalSorter(node_graph).static_order()]
volume_sources = {ep(c, 'port_1'): [c] for c in pistons}
node_energy = {ep(c, 'port_2'): f'node_energy[{i}]' for i, c in enumerate(node_order)}
for c in node_order:
volume_sources[ep(c, 'port_2')] = [source for name in pnames(c) if name != 'port_2'
for source in volume_sources.get(adjacent[ep(c, name)], ())]
def energy_into(c, name, outflow_h):
other = adjacent[ep(c, name)]
# Net mass flow can be zero while the sum of branch enthalpy flows
# remains nonzero. Carry that signed energy directly, without H/q.
if other in node_energy:
return node_energy[other]
return f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{outflow_h})'
gases, anchor, port_gas, volume_rate = {}, {}, {}, {} gases, anchor, port_gas, volume_rate = {}, {}, {}, {}
anchor_partitions = {} anchor_partitions = {}
gas_count = 0 gas_count = 0
@@ -233,11 +294,19 @@ def compile_extended_program(network):
if kind == 'amesim_pnch023': if kind == 'amesim_pnch023':
volume, rate = num(c.cvol), '0.0' volume, rate = num(c.cvol), '0.0'
elif kind == 'amesim_pnch012': elif kind == 'amesim_pnch012':
attached = [d for d in pistons if adjacent[ep(d,'port_1')][0] == c.name] attached = [source for name in pnames(c)
for source in volume_sources.get(adjacent[ep(c, name)], ())]
volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached) volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached)
if (c.name, '_volume_displacement') in states:
volume += '+'+y(c, '_volume_displacement')
target = f'dy[{states[c.name,"_volume_displacement"]}]'
expr = num(sum(c.external_volume_rates.values()))
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
put(c, 'vol', f'fmax({num(c.cvol0/100)}, {volume})') put(c, 'vol', f'fmax({num(c.cvol0/100)}, {volume})')
rate = num(sum(c.external_volume_rates.values()))+''.join('+'+w(d,'volume_flow') for d in attached) rate = num(sum(c.external_volume_rates.values()))+''.join('+'+w(d,'volume_flow') for d in attached)
put(c, 'dvol', f'{w(c,"vol")} <= {num(c.cvol0/100)} ? 0.0 : ({rate})') limit = num(c.cvol0/100)
put(c, 'dvol', f'({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})')
volume, rate = w(c,'vol'), w(c,'dvol') volume, rate = w(c,'vol'), w(c,'dvol')
elif kind in ('cylinder', 'tank'): elif kind in ('cylinder', 'tank'):
volume, rate = num(c.V), '0.0' volume, rate = num(c.V), '0.0'
@@ -258,10 +327,27 @@ def compile_extended_program(network):
initial_volume=(max(c.cvol0+sum(c.external_volumes.values()),c.cvol0/100) initial_volume=(max(c.cvol0+sum(c.external_volumes.values()),c.cvol0/100)
if kind=='amesim_pnch012' else if kind=='amesim_pnch012' else
c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume) c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume)
gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;') if kind == 'amesim_pnch012':
lines.append(f'if(!native_medium_gas({medium(c)}, {y(c,"m"+suffix)}, {y(c,"U"+suffix)}, {V}, &{gas})) return 0;') def initial_x(d, port):
for field in ('m', 'U'): return mass_groups[groups.find(ep(d, port))][0].x0
put(c, field+suffix, y(c, field+suffix)) initial_volume = max(c.cvol0 + sum(c.external_volumes.values()) + sum(
d.effective_area * (d.x0 + initial_x(d, 'port_5') - initial_x(d, 'port_4'))
for d in attached), c.cvol0 / 100)
thermal = ('T' if is_polytropic(c) else 'U') + suffix
if is_polytropic(c):
gas_initializers += [f'{y(c,"m"+suffix)}={num(p0*initial_volume/(c.medium.R_gas*T0))};', f'{y(c,thermal)}={num(T0)};']
gas_function = 'native_polytropic_gas_context'
else:
gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;')
gas_function = 'native_medium_gas_context'
gas_arguments = f'{medium(c)}, {y(c,"m"+suffix)}, {y(c,thermal)}, {V}, &{gas}'
lines.append(f'if(!(jacobian ? native_jacobian_gas(&jacobian->gases[{gas_count-1}],{gas_function},{gas_arguments},&jacobian->stats) : '
f'{gas_function}(gas_properties,{gas_arguments}))) return 0;')
gas_inputs = expression_inputs(f'{y(c,"m"+suffix)}+{y(c,thermal)}+({V})')
for field in ('p','T','rho','u','h'):
dependencies.assign(gas+'.'+field, gas_inputs)
put(c, 'm'+suffix, y(c, 'm'+suffix))
put(c, 'U'+suffix, f'{y(c,"m"+suffix)}*{gas}.u' if is_polytropic(c) else y(c,'U'+suffix))
for field in ('p','T','rho','u','h'): for field in ('p','T','rho','u','h'):
put(c, field+suffix, gas+'.'+field) put(c, field+suffix, gas+'.'+field)
anchored = pnames(c) anchored = pnames(c)
@@ -281,6 +367,10 @@ def compile_extended_program(network):
if len(partitions)<2: continue if len(partitions)<2: continue
if any(volume is None for _,volume in partitions.values()) or len({id(c.medium) for c,_ in partitions.values()})!=1: if any(volume is None for _,volume in partitions.values()) or len({id(c.medium) for c,_ in partitions.values()})!=1:
raise NativeCapabilityError('Direct gas-storage coupling requires compatible fixed pipe compliances; insert a resistance between independent chambers') raise NativeCapabilityError('Direct gas-storage coupling requires compatible fixed pipe compliances; insert a resistance between independent chambers')
laws = {(is_polytropic(c), c.k if is_polytropic(c) else None) for c,_ in partitions.values()}
if len(laws) != 1:
raise NativeCapabilityError('Directly coupled pipe compliances require the same thermodynamic mode and polytropic exponent')
poly = next(iter(laws))[0]
offsets=[];volumes=[] offsets=[];volumes=[]
for (name,half),(c,volume) in partitions.items(): for (name,half),(c,volume) in partitions.items():
offsets.append(states[name,'m'+(str(half) if half else '')]);volumes.append(volume) offsets.append(states[name,'m'+(str(half) if half else '')]);volumes.append(volume)
@@ -290,24 +380,30 @@ def compile_extended_program(network):
if max(values)-min(values)>1e-9*max(1,*map(abs,values)): if max(values)-min(values)>1e-9*max(1,*map(abs,values)):
raise NativeCapabilityError('Ideally coupled pipe compliances require consistent initial pressure and temperature') raise NativeCapabilityError('Ideally coupled pipe compliances require consistent initial pressure and temperature')
total=sum(volumes) total=sum(volumes)
project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',energy='+ '+'.join(f'y[{i+1}]' for i in offsets)+';') thermal_sum = '+'.join(f'y[{i+1}]*{num(v/total)}' if poly else f'y[{i+1}]' for i,v in zip(offsets,volumes))
project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',thermal='+thermal_sum+';')
for i,volume in zip(offsets,volumes): for i,volume in zip(offsets,volumes):
project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=energy*{num(volume/total)};'] project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=thermal*{num(1 if poly else volume/total)};']
project.append('}') project.append('}')
coupled.append((root,offsets,volumes)) coupled.append((root,offsets,volumes,poly))
dependencies.project_states(offsets)
dependencies.project_states([i+1 for i in offsets])
for root, gas in anchor.items(): for root, gas in anchor.items():
lines.append(f'p[{pgi[root]}]={gas}.p;') lines.append(f'p[{pgi[root]}]={gas}.p;')
default_h = next(iter(gases.values()))+'.h' if gases else '0.0' dependencies.assign(f'p[{pgi[root]}]', (gas+'.p',))
for endpoint, idx in pi.items(): h_initial = {f'h[{pi[endpoint]}]': gas+'.h' for endpoint,gas in port_gas.items()}
lines.append(f'h[{idx}]={port_gas.get(endpoint,default_h.removesuffix(".h"))}.h;' if endpoint in port_gas else f'h[{idx}]={default_h};')
for c in components:
if c.model_type in {'amesim_pnpl01','amesim_pnrp17'}:
# Closed-end ports retain their declared zero outflow enthalpy.
lines.append(f'{h(c,"port_1")}=0.0;')
flow_lines, flow_known, flow_eq = [], set(), [] operations, flow_known, flow_eq = [], set(), []
pipe_cache_count = 0
def pipe_flow(expression):
nonlocal pipe_cache_count
result = f'native_pipe_flow_cached_context(properties,&pipe_cache[{pipe_cache_count}],{expression})'
pipe_cache_count += 1
return result
def flow(c, name, expr): def flow(c, name, expr):
target=q(c,name);flow_lines.append(f'{target}={expr};');flow_known.add(target) target=q(c,name)
operations.append(Computation.assignment(f'flow:{c.name}.{name}',target,expr))
flow_known.add(target)
def flow_equation(terms): def flow_equation(terms):
flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0')) flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0'))
for c in components: for c in components:
@@ -328,26 +424,29 @@ def compile_extended_program(network):
flow(c,a,f'{num(c.K_eff)}*copysign(sqrt(fabs({pa}-{pb})),{pa}-{pb})') flow(c,a,f'{num(c.K_eff)}*copysign(sqrt(fabs({pa}-{pb})),{pa}-{pb})')
elif kind=='pipe': elif kind=='pipe':
resistance=c.lambda_darcy*c.L/c.D resistance=c.lambda_darcy*c.L/c.D
flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density({medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})') flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density_context(properties,{medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})')
elif kind=='amesim_pnl00r': elif kind=='amesim_pnl00r':
T=f'native_temperature_ph({medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})' T=f'native_temperature_ph_context(properties,{medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})'
flow(c,a,f'native_pipe_flow({medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0)') flow(c,a,pipe_flow(f'{medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0'))
else: else:
opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0' opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0'
if kind!='amesim_pnor001': if kind!='amesim_pnor001':
value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening) value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening)
put(c,'xv',value) put(c,'xv',value)
area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area) area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area)
flow_lines.append(f'if(!native_medium_orifice({medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening},&{q(c,a)},&{w(c,"cm")},&{w(c,"gasvel")})) return 0;') inputs=f'{medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening}'
outputs=(q(c,a),w(c,'cm'),w(c,'gasvel'))
code=f'if(!native_medium_orifice_context(properties,{inputs},&{outputs[0]},&{outputs[1]},&{outputs[2]})) return 0;'
operations.append(Computation(f'flow:{c.name}.{a}',outputs,references(inputs),(code,)))
flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel')) flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel'))
flow(c,b,f'-{q(c,a)}') flow(c,b,f'-{q(c,a)}')
elif kind in ('amesim_pnl0001','amesim_pnl0002'): elif kind in ('amesim_pnl0001','amesim_pnl0002'):
gas=gases[c.name,0] gas=gases[c.name,0]
for name in (['port_1'] if kind.endswith('1') else names): for name in (['port_1'] if kind.endswith('1') else names):
T=gas+'.T' # Resistance uses the gas arriving from the upstream side,
if kind.endswith('2'): # including inflow into a PNL0001 storage volume.
T=f'({p(c,name)}>{gas}.p?native_temperature_ph({medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)' T=f'({p(c,name)}>{gas}.p?native_temperature_ph_context(properties,{medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)'
flow(c,name,f'native_pipe_flow({medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1)') flow(c,name,pipe_flow(f'{medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1'))
for edge in network.connections: for edge in network.connections:
if edge.domain=='pneumatic': if edge.domain=='pneumatic':
flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0')) flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0'))
@@ -356,47 +455,43 @@ def compile_extended_program(network):
for terms,rhs in flow_eq: for terms,rhs in flow_eq:
known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known) known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known)
reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known)) reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known))
free_flows={f'q[{pi[e]}]' for root,_,_ in coupled for e in pneu if groups.find(e)==root} free_flows={f'q[{pi[e]}]' for root,_,_,_ in coupled for e in pneu if groups.find(e)==root}
flow_lines += linear_schedule(reduced,unknownq,free_flows) flow_bindings=linear_assignments(reduced,unknownq,free_flows)
flow_temporary_count=max((int(key[1:])+1 for key,_ in flow_bindings if key.startswith('b')),default=0)
for key,expr in flow_bindings:
target=re.sub(r'\bb(\d+)\b',r'fb[\1]',key)
value=re.sub(r'\bb(\d+)\b',r'fb[\1]',expr)
operations.append(Computation.assignment('connection:'+key,target,value,'linear'))
unknownp=[root for root in pgroups if root not in anchor] unknownp=[root for root in pgroups if root not in anchor]
residuals=[]
for root in unknownp: for root in unknownp:
terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known] terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known]
if not terms: if not terms:
raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation') raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation')
residuals.append(' + '.join(terms)) expr=' + '.join(terms)
# A monotone nodal mass-balance solve. This is an algebraic connection operations.append(Computation('pressure:'+str(root),(f'p[{pgi[root]}]',),references(expr),(),'pressure',expr))
# closure; the integration solver's Jacobian policy is unchanged.
pressure_lines=[]
if unknownp:
if not anchor:
raise NativeCapabilityError('Pneumatic pressure network has no storage pressure anchor')
pressure_lines += ['double plo=INFINITY,phi=0;', *[f'plo=fmin(plo,{g}.p);phi=fmax(phi,{g}.p);' for g in anchor.values()]]
pressure_lines += [f'p[{pgi[root]}]=.5*(plo+phi);' for root in unknownp]
pressure_lines += ['int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {']
for root,expr in zip(unknownp,residuals):
pressure_lines += ['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',f'p[{pgi[root]}]=.5*(lo+hi);',
'if(!model_flows(p,h,g,w,q)) return 0;', f'if(({expr})>0) hi=p[{pgi[root]}];else lo=p[{pgi[root]}];','}}']
pressure_lines += ['if(!model_flows(p,h,g,w,q)) return 0;', 'double residual=0;', *[f'residual=fmax(residual,fabs({expr}));' for expr in residuals],
'if(residual<1e-11) { pressure_ok=1;break; }','}', 'if(!pressure_ok) return 0;']
else:
pressure_lines=['if(!model_flows(p,h,g,w,q)) return 0;']
stream_lines=[]
for c in components: for c in components:
names=pnames(c);kind=c.model_type names=pnames(c);kind=c.model_type
if kind in {'amesim_pnpl01','amesim_pnrp17'}: if kind in {'amesim_pnpl01','amesim_pnrp17'}:
stream_lines.append(f'{h(c,"port_1")}={hin(c,"port_1")};') operations.append(Computation.assignment(f'alias:{c.name}.port_1',h(c,'port_1'),hin(c,'port_1'),'alias'))
elif kind in RESISTORS: elif kind in RESISTORS:
a,b=names a,b=names
stream_lines += [f'{h(c,a)}={hin(c,b)};',f'{h(c,b)}={hin(c,a)};'] for name,other in ((a,b),(b,a)):
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,other),'alias'))
elif kind in NODES: elif kind in NODES:
stream_lines += ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]] if kind!='tee':
# Reference copies have no flow dependency. Keep them separate
# from the reference port's finite h_outflow diagnostic below;
# actual reference energy is carried by node_energy, not q*h.
for name in names:
if name!='port_2':
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,'port_2'),'alias'))
stream_lines = ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]]
if kind=='tee': if kind=='tee':
stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]] stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]]
else: else:
stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', *[f'{h(c,name)}=ref;' for name in names if name!='port_2'],f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;', stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;',
*[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'], *[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'],
f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);', f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);',
'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);', 'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);',
@@ -405,10 +500,44 @@ def compile_extended_program(network):
if c.name+'.T' in slots: if c.name+'.T' in slots:
raise NativeCapabilityError('Unexpected node temperature output contract') raise NativeCapabilityError('Unexpected node temperature output contract')
stream_lines += ['}'] stream_lines += ['}']
if pneu: outputs=tuple(h(c,name) for name in (names if kind=='tee' else ['port_2']))
lines += ['int closure_ok=0;',f'for(int closure=0;closure<{max(64,4*len(pneu))};closure++) {{',f'double previous[{len(pneu)}];',f'for(int i=0;i<{len(pneu)};i++) previous[i]=h[i];',*pressure_lines,*stream_lines, inputs=frozenset(q(c,name) for name in names)|frozenset(hin(c,name) for name in names)
'double change=0;',f'for(int i=0;i<{len(pneu)};i++) change=fmax(change,fabs(h[i]-previous[i])/fmax(1,fabs(h[i])));', operations.append(Computation(f'stream:{c.name}',outputs,inputs,tuple(stream_lines),'stream'))
'if(change<1e-12) {closure_ok=1;break;}','}', 'if(!closure_ok) return 0;', 'if(!model_flows(p,h,g,w,q)) return 0;']
# Current state/parameter/signal values are available before this phase.
# Default h guesses are deliberately absent from the known-source map.
labels={f'w[{i}]': key for key,i in slots.items()}
known={f'w[{slots[key]}]': 'prepared:'+key for key in assigned}
gas_origins={gas: 'state:'+','.join(f'{name}.{field}{str(half) if half else ""}' for field in ('m','U'))
for (name,half),gas in gases.items()}
for gas,origin in gas_origins.items():
for field in ('p','T','rho','u','h'):
known[gas+'.'+field]=origin
for root,gas in anchor.items():
known[f'p[{pgi[root]}]']=gas_origins[gas]
for endpoint,gas in port_gas.items():
known[f'h[{pi[endpoint]}]']=gas_origins[gas]
for endpoint,index in pi.items():
labels[f'h[{index}]']='.'.join(endpoint)+'.h_outflow'
labels[f'q[{index}]']='.'.join(endpoint)+'.m_flow'
for root,index in pgi.items():
labels[f'p[{index}]']='pressure:'+','.join('.'.join(ep) for ep in pneu if groups.find(ep)==root)
schedule=EvaluationSchedule(operations,known,labels)
for operation in operations:
dependencies.computation(operation)
for target,expr in h_initial.items():
if target not in schedule.producers:
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
schedule_helpers, scheduled_lines=schedule.emit()
lines += scheduled_lines
if node_order:
lines.append(f'double node_energy[{len(node_order)}];')
for c in node_order:
target = node_energy[ep(c, 'port_2')]
expr = ' + '.join(energy_into(c, name, h(c, name)) for name in pnames(c) if name != 'port_2')
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
for c in components: for c in components:
for name in pnames(c): for name in pnames(c):
for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]: for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]:
@@ -429,7 +558,7 @@ def compile_extended_program(network):
elif kind=='amesim_lstp00a': elif kind=='amesim_lstp00a':
put(c,'gap',f'{num(c.gap0)}+{w(c,"port_2.x")}-{w(c,"port_1.x")}') put(c,'gap',f'{num(c.gap0)}+{w(c,"port_2.x")}-{w(c,"port_1.x")}')
put(c,'penetration',f'fmax(-{w(c,"gap")},0)') put(c,'penetration',f'fmax(-{w(c,"gap")},0)')
put(c,'force',f'native_contact({w(c,"penetration")},{w(c,"port_1.v")}-{w(c,"port_2.v")},{num(c.kcont)},{num(c.rcont)},{num(c.Pdis)},{int(c.discContactOption)})') put(c,'force',f'native_contact({w(c,"penetration")},{w(c,"port_1.v")}-{w(c,"port_2.v")},{num(contact_stiffness(c))},{num(c.rcont)},{num(c.Pdis)},{int(c.discContactOption)})')
feq += [({w(c,'port_1.f'):1},w(c,'force')),({w(c,'port_2.f'):1},'-'+w(c,'force'))] feq += [({w(c,'port_1.f'):1},w(c,'force')),({w(c,'port_2.f'):1},'-'+w(c,'force'))]
elif kind=='amesim_pnrp17': elif kind=='amesim_pnrp17':
put(c,'pressure_force',f'({w(c,"port_1.p")}-101300)*{num(c.effective_area)}') put(c,'pressure_force',f'({w(c,"port_1.p")}-101300)*{num(c.effective_area)}')
@@ -439,16 +568,56 @@ def compile_extended_program(network):
elif kind=='amesim_mecmas21': elif kind=='amesim_mecmas21':
v,x=w(c,'v'),w(c,'x') v,x=w(c,'v'),w(c,'x')
put(c,'Fvisc',f'-{num(c.rvisc)}*{v}' if c.use_friction else '0.0') put(c,'Fvisc',f'-{num(c.rvisc)}*{v}' if c.use_friction else '0.0')
put(c,'Ffric',f'{v}>0?-{num(c.fcoul)}:({v}<0?{num(c.fcoul)}:0)' if c.use_friction else '0.0') put(c,'Ffric','0.0')
for field,penetration,velocity,suffix in [('Fmin',num(c.xmin)+'-'+x,'-'+v,'min'),('Fmax',x+'-'+num(c.xmax),v,'max')]: for field,penetration,velocity,suffix in [('Fmin',num(c.xmin)+'-'+x,'-'+v,'min'),('Fmax',x+'-'+num(c.xmax),v,'max')]:
expr=f'native_limit_force({penetration},{velocity},{num(getattr(c,"Kb"+suffix))},{num(getattr(c,"Db"+suffix))},{num(getattr(c,"Pd"+suffix))},{int(c.discContactOption)})' if int(c.stoptype)==2 else '0.0' expr=f'native_limit_force({penetration},{velocity},{num(getattr(c,"Kb"+suffix))},{num(getattr(c,"Db"+suffix))},{num(getattr(c,"Pd"+suffix))},{int(c.discContactOption)})' if int(c.stoptype)==2 else '0.0'
put(c,field,expr) put(c,field,expr)
ref=mass_groups[groups.find(ep(c,'port_1'))][0] ref=mass_groups[groups.find(ep(c,'port_1'))][0]
extra=f'{w(c,"Fvisc")}+{w(c,"Ffric")}+{w(c,"Fmin")}-{w(c,"Fmax")}' extra=f'{w(c,"Fvisc")}+{w(c,"Ffric")}+{w(c,"Fmin")}-{w(c,"Fmax")}'
if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})' if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})'
extra += f'+({num(c.mass * 9.80665 * math.sin(math.radians(c.theta)))})'
feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})')) feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})'))
unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()] unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()]
lines += linear_schedule(feq,unknownf) # Keep the same elimination/order while registering its structured bindings.
mechanical_assignments = linear_assignments(feq, unknownf)
for target, expression in mechanical_assignments:
lines.append(f'{"double " if target.startswith("b") else ""}{target} = {expression};')
dependencies.expression(target, expression)
for index, (group, dry, threshold) in enumerate(friction_groups):
ref = group[0]
drive = f'friction_drive_{index}'
expression = f'{num(sum(c.mass for c in group))}*{w(ref,"a")}'
lines.append(f'double {drive}={expression};')
dependencies.expression(drive, expression)
# Amesim ideal stops take priority over dry friction. A blocked mass
# carries its load through the stop, not through an extra friction force.
blocked = f'friction_blocked_{index}'
ideal = [c for c in group if int(c.stoptype) == 1]
conditions = []
for c in ideal:
x, v = y(ref, 'x'), y(ref, 'v')
conditions += [f'({x}<={num(c.xmin+1e-12*max(abs(c.xmin),1))} && {v}<=1e-12 && {drive}<=0)',
f'({x}>={num(c.xmax-1e-12*max(abs(c.xmax),1))} && {v}>=-1e-12 && {drive}>=0)']
blocked_expression = ' || '.join(conditions) or '0'
lines.append(f'double {blocked}=({blocked_expression});')
dependencies.expression(blocked, blocked_expression)
lines.append(f'{drive}={blocked}?0:{drive};')
dependencies.expression(drive, f'{blocked}?0:{drive}')
lines.append(f'if(friction_drives) friction_drives[{index}]={drive};')
capacity = sum(c.fstick for c in dry)
for c in dry:
put(c, 'Ffric', f'{blocked}?0:native_dry_friction({y(ref,"v")},{drive}*{num(c.fstick/capacity)},'
f'{num(c.fcoul)},{num(c.fstick)},{num(c.astrib)},'
f'{int(int(c.frictionType)==2 and int(c.strib)==2)},{y(ref,"_friction_mode")})')
lines.append(f'dy[{states[ref.name,"_friction_mode"]}]=0;')
dependencies.expression(f'dy[{states[ref.name,"_friction_mode"]}]', '0')
if friction_groups:
# The first linear solve supplies the drive without dry friction. The
# second redistributes actual friction forces through the same network.
# No pressure/flow iteration or mutable RHS mode is introduced.
for target, expression in mechanical_assignments:
lines.append(f'{target}={expression};')
dependencies.expression(target, expression)
for c in components: for c in components:
for name in mnames(c): assigned.add(c.name+'.'+name+'.f') for name in mnames(c): assigned.add(c.name+'.'+name+'.f')
if c.model_type=='amesim_lmechn1': if c.model_type=='amesim_lmechn1':
@@ -458,6 +627,8 @@ def compile_extended_program(network):
ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x'] ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x']
limits=[c for c in group if int(c.stoptype) in (1,3)] limits=[c for c in group if int(c.stoptype) in (1,3)]
lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};'] lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};']
dependencies.expression(f'dy[{vi}]', w(ref,'a'))
dependencies.expression(f'dy[{xi}]', y(ref,'v'))
if limits: if limits:
lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits) lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits)
if lower>upper or ref.x0<lower-1e-12 or ref.x0>upper+1e-12: if lower>upper or ref.x0<lower-1e-12 or ref.x0>upper+1e-12:
@@ -469,6 +640,7 @@ def compile_extended_program(network):
thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0)) thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0))
stops.append((vi,lower,upper,*restitution,*thresholds)) stops.append((vi,lower,upper,*restitution,*thresholds))
lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);') lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);')
dependencies.stop_motion(vi, xi)
for c in group: put(c,'a',f'dy[{vi}]') for c in group: put(c,'a',f'dy[{vi}]')
for c in components: for c in components:
@@ -480,12 +652,12 @@ def compile_extended_program(network):
if kind=='amesim_pnl0003': if kind=='amesim_pnl0003':
a,b=gases[c.name,1],gases[c.name,2] a,b=gases[c.name,1],gases[c.name,2]
center=w(c,'dmctr') center=w(c,'dmctr')
put(c,'dmctr',f'native_pipe_flow({medium(c)},{a}.p,{b}.p,{a}.p>={b}.p?{a}.T:{b}.T,{num(c.diam)},{num(c.le)},{num(c.rr)},3)') put(c,'dmctr',f'native_pipe_flow_context(properties,{medium(c)},{a}.p,{b}.p,{a}.p>={b}.p?{a}.T:{b}.T,{num(c.diam)},{num(c.le)},{num(c.rr)},3)')
for half in halves: for half in halves:
gas=gases[c.name,half];suffix=str(half) if half else '' gas=gases[c.name,half];suffix=str(half) if half else ''
names=['port_'+suffix] if half else pnames(c) names=['port_'+suffix] if half else pnames(c)
mass=' + '.join(q(c,name) for name in names) mass=' + '.join(q(c,name) for name in names)
energy=' + '.join(f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{gas}.h)' for name in names) energy=' + '.join(energy_into(c, name, gas+'.h') for name in names)
if half: if half:
sign='-' if half==1 else '+' sign='-' if half==1 else '+'
mass+=sign+center mass+=sign+center
@@ -494,68 +666,131 @@ def compile_extended_program(network):
if kind.startswith('amesim_pnch'): if kind.startswith('amesim_pnch'):
heat=f'{num(c.kth*c.sth)}*({num(c.extemp)}-{gas}.T)-{gas}.p*({volume_rate[c.name]})' heat=f'{num(c.kth*c.sth)}*({num(c.extemp)}-{gas}.T)-{gas}.p*({volume_rate[c.name]})'
elif kind.startswith('amesim_pnl') and int(c.mode)!=1: elif kind.startswith('amesim_pnl') and int(c.mode)!=1:
temp=f'.5*({gases[c.name,1]}.T+{gases[c.name,2]}.T)' if half else gas+'.T' temp=gas+'.T'
heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))' heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))'
lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,"U"+suffix]}]={energy}+({heat});'] thermal = ('T' if is_polytropic(c) else 'U') + suffix
thermal_rhs = f'{num(c.k-1)}*{gas}.T/({y(c,"m"+suffix)})*({mass})' if is_polytropic(c) else f'{energy}+({heat})'
lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,thermal]}]={thermal_rhs};']
dependencies.expression(f'dy[{states[c.name,"m"+suffix]}]', mass)
dependencies.expression(f'dy[{states[c.name,thermal]}]', thermal_rhs)
if kind.startswith('amesim_pnl'): if kind.startswith('amesim_pnl'):
diag=[] diag=[]
if kind=='amesim_pnl0002': if kind=='amesim_pnl0002':
gas=gases[c.name,0] gas=gases[c.name,0]
for name in pnames(c): for name in pnames(c):
flow=q(c,name);pp=f'({flow}>=0?fmax({p(c,name)},1):fmax({gas}.p,1))' flow=q(c,name);pp=f'({flow}>=0?fmax({p(c,name)},1):fmax({gas}.p,1))'
temp=f'({flow}>=0?fmax(native_temperature_ph({medium(c)},{pp},{hin(c,name,True)}),1):{gas}.T)' temp=f'({flow}>=0?fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{hin(c,name,True)}),1):{gas}.T)'
diag.append((flow,pp,temp,c.le/2,0)) diag.append((flow,pp,temp,c.le/2,0))
elif kind=='amesim_pnl0003': elif kind=='amesim_pnl0003':
a,b=gases[c.name,1],gases[c.name,2];flow=w(c,'dmctr') a,b=gases[c.name,1],gases[c.name,2];flow=w(c,'dmctr')
diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)] diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)]
else: else:
pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)' pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)'
temp=gases[c.name,0]+'.T' if kind=='amesim_pnl0001' else f'fmax(native_temperature_ph({medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)' if kind=='amesim_pnl0001':
gas=gases[c.name,0]
temp=f'({pa}>{gas}.p?fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{hin(c,"port_1",True)}),1):{gas}.T)'
else:
temp=f'fmax(native_temperature_ph_context(properties,{medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)'
diag=[(q(c,'port_1'),pp,temp,c.le,0)] diag=[(q(c,'port_1'),pp,temp,c.le,0)]
lines.append('{ double d[4],acc[4]={0};') lines.append('{ double d[4],acc[4]={0};')
for flow,pp,temp,length,diagnostic in diag: for flow,pp,temp,length,diagnostic in diag:
lines.append(f'native_pipe_diagnostics({medium(c)},{flow},{pp},{temp},{num(c.diam)},{num(length)},{num(c.rr)},{diagnostic},d);') lines.append(f'native_pipe_diagnostics_context(properties,{medium(c)},{flow},{pp},{temp},{num(c.diam)},{num(length)},{num(c.rr)},{diagnostic},d);')
if len(diag)>1: lines.append('d[2]=fabs(d[2]);') if len(diag)>1: lines.append('d[2]=fabs(d[2]);')
lines.append('for(int i=0;i<4;i++) acc[i]+=d[i];') lines.append('for(int i=0;i<4;i++) acc[i]+=d[i];')
for i,field in enumerate(('re','cm','v','ff')): for i,field in enumerate(('re','cm','v','ff')):
expr=f'acc[{i}]/{len(diag)}' expr=f'acc[{i}]/{len(diag)}'
put(c,field,f'fmin({expr},64000000)' if field=='ff' else expr) put(c,field,f'fmin({expr},64000000)' if field=='ff' else expr)
lines.append('}') lines.append('}')
for _,offsets,volumes in coupled: for _,offsets,volumes,poly in coupled:
lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',energy='+ '+'.join(f'dy[{i+1}]' for i in offsets)+';') thermal_sum = '+'.join(f'dy[{i+1}]*{num(v/sum(volumes))}' if poly else f'dy[{i+1}]' for i,v in zip(offsets,volumes))
lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',thermal='+thermal_sum+';')
for i,volume in zip(offsets,volumes): for i,volume in zip(offsets,volumes):
lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=energy*{num(volume/sum(volumes))};') lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=thermal*{num(1 if poly else volume/sum(volumes))};')
dependencies.assign(f'dy[{i}]', (f'dy[{j}]' for j in offsets))
dependencies.assign(f'dy[{i+1}]', (f'dy[{j+1}]' for j in offsets))
lines.append('}') lines.append('}')
missing=set(slots)-assigned missing=set(slots)-assigned
if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}') if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}')
if not state_keys: lines.append('dy[0]=0;') if not state_keys: lines.append('dy[0]=0;')
jacobian = dependencies.build()
memo_kernels = ('native_temperature_ph_context(', 'native_density_context(',
'native_pipe_flow_context(', 'native_pipe_flow_cached_context(')
has_memo_work = gas_count > 0 or any(name in '\n'.join([*lines,*schedule_helpers]) for name in memo_kernels)
reuse_enabled = jacobian.enabled and 0 < jacobian.color_count < nstates and has_memo_work
jacobian_info = jacobian.manifest(canonical_rhs=True)
jacobian_info['reuse'] = dict(enabled=reuse_enabled,gasSlots=gas_count,scalarSlots=1024,
scope='one Jacobian construction',key='kernel and bit-exact complete physical inputs',
policy='immutable canonical baseline for gas, PH inversion, density and pipe roots; changed inputs recomputed',
verification='uncached canonical dense differences')
np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu)) np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu))
source='\n'.join(['#include "model.h"','#include <math.h>',*declarations, from .contacts import contact_table
contact_source, contact_header = contact_table([
(states[mass_groups[groups.find(ep(c, 'port_1'))][0].name, 'v'],
states[mass_groups[groups.find(ep(c, 'port_2'))][0].name, 'v'],
c.gap0, contact_stiffness(c), c.rcont, c.Pdis, int(c.discContactOption), 0)
for c in components if c.model_type == 'amesim_lstp00a'])
# Advanced-friction reference runs at 5 N and 7 N confirm Amesim 2404's
# 0.1% breakaway hysteresis. Simple friction uses the exact static threshold.
source='\n'.join(['#include "model.h"','#include <math.h>','#include <string.h>',*declarations,
f'const NativeFriction model_frictions[{max(1,len(friction_groups))}] = {{'+(','.join(
'{'+str(states[group[0].name,'v'])+','+str(states[group[0].name,'_friction_mode'])+','+num(threshold)+','+
num(sum(c.fstick*(1.001 if int(c.frictionType)==2 else 1) for c in dry))+'}'
for group,dry,threshold in friction_groups) or '{0,0,0,0}')+'};',
f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};', f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};',
'const double model_atol[NSTATES] = {'+','.join('1e-12' if k.endswith(('.v','.x')) else '1e-8' for k in state_keys or ['dummy'])+'};', contact_source,
'const double model_atol[NSTATES] = {'+','.join(map(state_absolute_tolerance, state_keys or ['dummy']))+'};',
'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};', 'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};',
'static int model_flows(const double *p,const double *h,const NativeGas *g,double *w,double *q) {', *jacobian.source_lines(),
'(void)p;(void)h;(void)g;(void)w;(void)q;',*flow_lines,'return 1;}', *schedule_helpers,
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}', 'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
'int model_eval(double t,const double *y,double *dy,double *w) {', 'static int model_eval_internal(double t,const double *y,double *dy,double *w,int canonical,double *friction_drives,NativePropertyTemperatures *temperatures,ModelJacobianWorkspace *jacobian) {',
'(void)friction_drives;(void)jacobian;',
f'NativePropertyState property_states[{min(256,max(16,4*gas_count+2*len(components)))}];',
'NativePropertyCache property_cache, *properties=&property_cache;',
'native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));',
'properties->temperatures=temperatures;',
'properties->jacobian=jacobian?&jacobian->scalars:NULL;',
'NativePropertyCache *gas_properties=canonical?NULL:properties;(void)gas_properties;',
*(['double projected[NSTATES];for(int i=0;i<NSTATES;i++) projected[i]=y[i];',*project,'y=projected;'] if project else []), *(['double projected[NSTATES];for(int i=0;i<NSTATES;i++) projected[i]=y[i];',*project,'y=projected;'] if project else []),
f'double p[{np}]={{0}},h[{nq}]={{0}},q[{nq}]={{0}};NativeGas g[{ng}];', f'double p[{np}]={{0}},h[{nq}]={{0}},q[{nq}]={{0}};NativeGas g[{ng}];',
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;(void)model_flows;',*lines, f'double fb[{max(1,flow_temporary_count)}]={{0}};(void)fb;',
*([f'NativePipeCache pipe_cache[{max(1,pipe_cache_count)}]={{0}};(void)pipe_cache;'] if pneu else []),
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;',*lines,
'for(int i=0;i<NSTATES;i++) if(!isfinite(dy[i])) return 0;', 'for(int i=0;i<NSTATES;i++) if(!isfinite(dy[i])) return 0;',
'for(int i=0;i<NOUTPUTS;i++) if(!isfinite(w[i])) return 0;','return 1;}', 'for(int i=0;i<NOUTPUTS;i++) if(!isfinite(w[i])) return 0;','return 1;}',
'int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,0,NULL,NULL,NULL);}',
'int model_eval_jacobian(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,1,NULL,NULL,NULL);}',
'void model_jacobian_begin(ModelJacobianWorkspace *workspace) {memset(workspace,0,sizeof(*workspace));workspace->scalars.entries=workspace->scalar_entries;workspace->scalars.capacity=MODEL_JACOBIAN_SCALAR_COUNT;workspace->scalars.recording=1;}',
'int model_eval_jacobian_reuse(double t,const double *y,double *dy,double *w,ModelJacobianWorkspace *workspace) {int ok=model_eval_internal(t,y,dy,w,1,NULL,NULL,workspace);if(workspace)workspace->scalars.recording=0;return ok;}',
'int model_friction_drives(double t,const double *y,double *drives) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,drives,NULL,NULL);}',
'int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,NULL,temperatures,NULL);}',
'double model_next_break(double t,double end) { double result=end;(void)t;',*breaks,'return result;}','']) 'double model_next_break(double t,double end) { double result=end;(void)t;',*breaks,'return result;}',''])
header=f'''#ifndef GENERATED_NATIVE_MODEL_H header=f'''#ifndef GENERATED_NATIVE_MODEL_H
#define GENERATED_NATIVE_MODEL_H #define GENERATED_NATIVE_MODEL_H
#include "kernels.h" #include "kernels.h"
#define MODEL_JACOBIAN_GAS_REUSE {int(reuse_enabled)}
#define MODEL_JACOBIAN_GAS_COUNT {gas_count}
#define MODEL_JACOBIAN_SCALAR_COUNT 1024
typedef struct {{ NativeJacobianGasMemo gases[{ng}]; NativeJacobianGasStats stats;
NativeJacobianScalars scalars; NativeJacobianScalarEntry scalar_entries[MODEL_JACOBIAN_SCALAR_COUNT]; }} ModelJacobianWorkspace;
void model_jacobian_begin(ModelJacobianWorkspace *workspace);
int model_eval_jacobian_reuse(double t,const double *y,double *dy,double *w,ModelJacobianWorkspace *workspace);
#define MODEL_PROPERTY_TEMPERATURES {int(any(key[1] == 'peng_robinson' for key in media))}
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
#define NSTATES {nstates} #define NSTATES {nstates}
#define NOUTPUTS {max(1,len(variables))} #define NOUTPUTS {max(1,len(variables))}
#define NSTOPS {len(stops)} #define NSTOPS {len(stops)}
#define NFRICTIONS {len(friction_groups)}
{contact_header}
extern const NativeFriction model_frictions[{max(1,len(friction_groups))}];
int model_friction_drives(double t,const double *y,double *drives);
extern const NativeStop model_stops[{max(1,len(stops))}]; extern const NativeStop model_stops[{max(1,len(stops))}];
extern const double model_atol[NSTATES]; extern const double model_atol[NSTATES];
extern const char *const model_output_keys[NOUTPUTS]; extern const char *const model_output_keys[NOUTPUTS];
{chr(10).join(jacobian.header_lines(canonical_rhs=True))}
int model_init(double *y); int model_init(double *y);
int model_eval(double t,const double *y,double *dy,double *w); int model_eval(double t,const double *y,double *dy,double *w);
double model_next_break(double t,double end); double model_next_break(double t,double end);
#endif #endif
''' '''
return NativeProgram(source,header,tuple(state_keys),variables,tuple(sorted({c.model_type for c in components}))) return NativeProgram(source,header,tuple(state_keys),variables,tuple(sorted({c.model_type for c in components})),schedule.report(),jacobian_info)
+7 -72
View File
@@ -1,85 +1,20 @@
"""CLI input adapter; XML stays the numerical backend's execution contract.""" """CLI input adapter shared with HTTP; numerical execution accepts SI XML only."""
from __future__ import annotations from __future__ import annotations
import ast
from copy import deepcopy
import json import json
from math import isfinite
import operator
from pathlib import Path from pathlib import Path
import warnings
from app.project_parameters import expression_value as arithmetic_value, prepare_project, version_warning
from app.system_xml import validate_system_xml_document from app.system_xml import validate_system_xml_document
# Matches the editor's displayed-unit conversions. Stored numeric values are
# already SI; only arithmetic expressions are evaluated in the selected unit.
_SCALES = {
"area": {"m2": 1, "cm2": 1e-4, "mm2": 1e-6},
"length": {"m": 1, "cm": .01, "mm": .001},
"pressure": {"Pa": 1, "kPa": 1e3, "MPa": 1e6, "bar": 1e5},
"volume": {"m3": 1, "L": .001, "mL": 1e-6},
}
_OPERATIONS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow}
def arithmetic_value(source: str) -> float:
"""Bounded arithmetic only: never evaluate code, names or function calls."""
source = source.strip().removeprefix("=").strip()
if len(source) > 512:
raise ValueError("Parameter expression exceeds 512 characters.")
tree = ast.parse(source, mode="eval")
if sum(1 for _ in ast.walk(tree)) > 256:
raise ValueError("Parameter expression is too complex.")
def visit(node, depth=0):
if depth > 32:
raise ValueError("Parameter expression is nested too deeply.")
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
result = float(node.value)
elif isinstance(node, ast.UnaryOp) and type(node.op) in (ast.UAdd, ast.USub):
result = visit(node.operand, depth+1) * (-1 if isinstance(node.op, ast.USub) else 1)
elif isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS:
a, b = visit(node.left, depth+1), visit(node.right, depth+1)
if isinstance(node.op, ast.Pow) and abs(b) > 16:
raise ValueError("CLI parameter exponents must be between -16 and 16.")
result = _OPERATIONS[type(node.op)](a, b)
else:
raise ValueError("CLI supports arithmetic expressions only; export complex expressions as XML from the editor.")
if not isinstance(result, (int, float)) or not isfinite(result):
raise ValueError("Parameter expression must produce a finite real number.")
return result
return float(visit(tree.body))
def project_xml(data: dict) -> bytes: def project_xml(data: dict) -> bytes:
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
from app.simulation.registry import get_component_model_spec normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(data))
normalized = deepcopy(data) if notices:
for node in normalized.get("nodes", []): warnings.warn(json.dumps(version_warning(notices), ensure_ascii=False), UserWarning, stacklevel=2)
model = node["data"] return build_reactflow_system_xml(normalized)
spec = get_component_model_spec(model["modelType"])
for name, value in list(model.get("parameters", {}).items()):
if not isinstance(value, str):
continue
try:
number = float(value)
except ValueError:
definition = spec.parameter_by_name[name]
if definition.editor:
raise ValueError(f"{node['id']}.{name}: a discrete parameter cannot be an expression.")
number = arithmetic_value(value)
unit = model.get("parameterUnits", {}).get(name, definition.unit)
if definition.quantity in _SCALES:
if unit not in _SCALES[definition.quantity]:
raise ValueError(f"Unsupported parameter unit: {unit}.")
number *= _SCALES[definition.quantity][unit]
elif definition.quantity == "temperature" and unit == "degC":
number += 273.15
elif unit != definition.unit:
raise ValueError(f"Unsupported parameter unit: {unit}.")
if not isfinite(number):
raise ValueError(f"{node['id']}.{name}: parameter must be finite.")
model["parameters"][name] = number
return build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(normalized))
def load_input(path: Path): def load_input(path: Path):
+214
View File
@@ -0,0 +1,214 @@
"""Conservative state dependencies for compiler-owned expressions.
This is structural bookkeeping, not C parsing or numerical Jacobian evaluation.
Native multi-output operations, projections and mode-dependent writes must be
registered explicitly by the lowering path. Unresolved reachable inputs disable
coloring for the complete model.
"""
from collections import deque
from dataclasses import dataclass
import hashlib
import json
import re
# Reviewed expression leaves. Unknown arrays/scalars remain unresolved instead
# of silently becoming constants; the flow schedule supplies its own explicit IR.
_ARRAY = re.compile(r'\b[A-Za-z_]\w*\[\d+\](?:\.[A-Za-z_]\w*)?|\bgas_\d+\.[A-Za-z_]\w*')
_IDENTIFIER = re.compile(r'\b[A-Za-z_]\w*\b')
_NUMBER = re.compile(r'(?<![\w.])(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?')
_FUNCTIONS = frozenset({
'fmax', 'fmin', 'fabs', 'sqrt', 'copysign', 'pow',
'native_signal', 'native_contact', 'native_limit_force', 'native_dry_friction',
'native_pipe_flow_context', 'native_pipe_flow_cached_context',
'native_temperature_ph_context', 'native_density_context',
})
_CONSTANTS = frozenset({'t', 'properties', 'NAN', 'INFINITY', 'NULL', 'true', 'false'})
def expression_inputs(expression):
"""Extract leaves from a reviewed expression; unfamiliar names fail closed."""
inputs = set()
def leaf(match):
key = match.group()
# Per-evaluation cache scratch is an implementation detail of reviewed
# kernels; each value is determined by that kernel's explicit arguments.
if not re.fullmatch(r'pipe_cache\[\d+\]', key):
inputs.add(key)
return ' '
remainder = _ARRAY.sub(leaf, expression)
remainder = _NUMBER.sub(' ', remainder)
for name in _IDENTIFIER.findall(remainder):
if name in _FUNCTIONS or name in _CONSTANTS or re.fullmatch(r'(?:medium|signal)_\d+', name):
continue
inputs.add(name)
return frozenset(inputs)
@dataclass(frozen=True)
class JacobianStructure:
state_count: int
rows: tuple[tuple[int, ...], ...] = ()
colors: tuple[int, ...] = ()
reason: str | None = None
@classmethod
def dense(cls, state_count, reason):
return cls(state_count, reason=reason)
@property
def enabled(self):
return self.reason is None
@property
def color_count(self):
return max(self.colors, default=-1) + 1 if self.enabled else self.state_count
@property
def nonzeros(self):
return sum(map(len, self.rows)) if self.enabled else self.state_count ** 2
def manifest(self, *, canonical_rhs=False):
# Use proven coloring automatically when it reduces finite-difference
# groups; unsupported or unhelpful patterns retain dense differences.
runtime_eligible = self.enabled and 0 < self.color_count < self.state_count
fallback_reason = None if runtime_eligible else (
self.reason or 'Coloring does not reduce finite-difference groups')
pattern = {'rows': self.rows, 'columnColors': self.colors}
policy = ('CVODE colored forward differences; canonical-property-cache RHS' if canonical_rhs else 'CVODE colored forward differences') if runtime_eligible else 'CVODE default dense differences'
return {
'policyScope': 'default-runtime',
'defaultRuntimePolicy': policy,
'verification': '--verify-jacobian',
'policy': policy,
'rhsPolicy': 'canonical-property-cache finite differences' if canonical_rhs and runtime_eligible else 'ordinary model_eval',
'runtimeEligible': runtime_eligible, 'runtimeFallbackReason': fallback_reason,
'canonicalRhs': canonical_rhs, 'ordinaryRhsUnchanged': True,
'enabled': self.enabled, 'reason': self.reason, 'stateCount': self.state_count,
'nonzeros': self.nonzeros, 'density': self.nonzeros / self.state_count ** 2 if self.state_count else 0,
'colorCount': self.color_count,
'patternSha256': hashlib.sha256(json.dumps(pattern, separators=(',', ':')).encode()).hexdigest() if self.enabled else None,
'columnColors': list(self.colors) if self.enabled else None,
}
def header_lines(self, *, canonical_rhs=False):
lines = [f'#define MODEL_JACOBIAN_COLORED {int(self.enabled)}',
f'#define MODEL_JACOBIAN_CANONICAL_RHS {int(canonical_rhs)}',
f'#define MODEL_JACOBIAN_COLOR_COUNT {self.color_count}',
f'#define MODEL_JACOBIAN_NNZ {self.nonzeros}']
if canonical_rhs:
lines += ['int model_eval_jacobian(double t, const double *y, double *dy, double *w);']
if self.enabled:
lines += ['extern const int model_jacobian_column_color[NSTATES];',
'extern const int model_jacobian_col_ptr[NSTATES+1];',
'extern const int model_jacobian_row_index[MODEL_JACOBIAN_NNZ];']
return lines
def source_lines(self):
if not self.enabled:
return []
columns = [[] for _ in range(self.state_count)]
for row, entries in enumerate(self.rows):
for column in entries:
columns[column].append(row)
pointers = [0]
indices = []
for column in columns:
indices.extend(column)
pointers.append(len(indices))
return [f'const int {name}[{len(values)}] = {{'+','.join(map(str, values))+'};'
for name, values in [('model_jacobian_column_color', self.colors),
('model_jacobian_col_ptr', pointers),
('model_jacobian_row_index', indices)]]
class StateDependencies:
def __init__(self, state_count):
self.state_count = state_count
self.seeds = {f'y[{i}]': 1 << i for i in range(state_count)}
self.inputs = {}
def assign(self, target, inputs):
# Union repeated writes, rather than dropping dependencies from another
# branch or an earlier in-place value (stop motion is mode dependent).
self.inputs.setdefault(target, set()).update(inputs)
def expression(self, target, expression):
self.assign(target, expression_inputs(expression))
def project_states(self, offsets):
refs = {f'y[{i}]' for i in offsets}
for target in refs:
self.assign(target, refs)
def stop_motion(self, velocity_index, position_index):
refs = {f'y[{velocity_index}]', f'y[{position_index}]',
f'dy[{velocity_index}]', f'dy[{position_index}]'}
for target in (f'dy[{velocity_index}]', f'dy[{position_index}]'):
self.assign(target, refs)
def computation(self, operation):
# A schedule SCC reaches a fixed point in build(), so every member gains
# every external state dependency even through pressure/stream loops.
for output in operation.outputs:
self.assign(output, operation.inputs)
def build(self):
if self.state_count == 0:
return JacobianStructure.dense(1, 'Algebraic-only internal state uses default differences')
required = {f'dy[{i}]' for i in range(self.state_count)}
pending = list(required)
reachable = set()
missing = set()
while pending:
key = pending.pop()
if key in reachable:
continue
reachable.add(key)
if key not in self.inputs and key not in self.seeds:
missing.add(key)
pending.extend(self.inputs.get(key, ()))
if missing:
return JacobianStructure.dense(self.state_count,
'Unresolved structural inputs: '+', '.join(sorted(missing)[:16]))
consumers = {key: set() for key in reachable}
for target in reachable:
for source in self.inputs.get(target, ()):
consumers[source].add(target)
masks = dict(self.seeds)
queue = deque(key for key in self.seeds if key in reachable)
queued = set(queue)
while queue:
source = queue.popleft()
queued.remove(source)
for target in consumers[source]:
merged = masks.get(target, 0) | masks[source]
if merged != masks.get(target, 0):
masks[target] = merged
if target not in queued:
queue.append(target)
queued.add(target)
# A diagonal overestimate is safe and keeps isolated/dummy rows valid
# without introducing zero-length C arrays or uncolored state columns.
rows = tuple(tuple(column for column in range(self.state_count)
if (masks.get(f'dy[{row}]', 0) | (1 << row)) >> column & 1)
for row in range(self.state_count))
conflicts = [set() for _ in range(self.state_count)]
for entries in rows:
for column in entries:
conflicts[column].update(set(entries) - {column})
colors = {}
while len(colors) < self.state_count:
column = max((i for i in range(self.state_count) if i not in colors),
key=lambda i: (len({colors[j] for j in conflicts[i] if j in colors}),
len(conflicts[i]), -i))
used = {colors[j] for j in conflicts[column] if j in colors}
color = 0
while color in used:
color += 1
colors[column] = color
ordered = tuple(colors[i] for i in range(self.state_count))
for entries in rows:
if len({ordered[column] for column in entries}) != len(entries):
raise AssertionError('Jacobian coloring contains a row conflict')
return JacobianStructure(self.state_count, rows, ordered)
+36
View File
@@ -0,0 +1,36 @@
"""Reviewed native component module exports and their compilation dependencies."""
from __future__ import annotations
import re
EXPORTS = {
"properties": frozenset({
"native_properties_init", "native_temperature_ph_context", "native_density_context",
"native_gas_init", "native_gas", "native_gas_context", "native_medium_init",
"native_density", "native_temperature_ph", "native_viscosity",
"native_medium_gas", "native_medium_gas_context", "native_polytropic_gas_context",
"native_jacobian_gas",
"native_jacobian_scalar_get", "native_jacobian_scalar_put",
}),
"orifice": frozenset({"native_orifice", "native_orifice_context", "native_medium_orifice", "native_medium_orifice_context"}),
"pipe": frozenset({"native_pipe_resistance", "native_pipe_flow", "native_pipe_flow_context",
"native_pipe_flow_cached", "native_pipe_flow_cached_context",
"native_pipe_diagnostics", "native_pipe_diagnostics_context"}),
"mechanics": frozenset({"native_contact", "native_stop_motion", "native_limit_force", "native_dry_friction"}),
"signal": frozenset({"native_signal", "native_signal_break"}),
}
DEPENDENCIES = {"orifice": ("properties",), "pipe": ("properties",)}
def component_modules(source: str) -> tuple[str, ...]:
# Generated C refers to these functions directly. Include identifier uses
# (also function pointers), conservatively including any comments/strings.
identifiers = set(re.findall(r"\bnative_[A-Za-z0-9_]+\b", source))
selected = {name for name, exports in EXPORTS.items() if identifiers & exports}
pending = list(selected)
while pending:
for dependency in DEPENDENCIES.get(pending.pop(), ()):
if dependency not in selected:
selected.add(dependency)
pending.append(dependency)
return tuple(name for name in EXPORTS if name in selected)
@@ -0,0 +1,35 @@
"""Portable process setup and classification for the native compiler driver.
MinGW maps different CreateProcess failures to ENOENT. That text does not prove
the compiler/subprogram is missing; never treat a C diagnostic as a launch retry.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import shutil
IS_WINDOWS = os.name == 'nt'
LAUNCH_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.35, 0.5)
def command_environment(executable: str) -> dict[str, str] | None:
if not IS_WINDOWS:
return None # Preserve the Linux toolchain and environment unchanged.
env = os.environ.copy()
path_key = next((key for key in env if key.upper() == 'PATH'), 'PATH')
directory = str(Path(shutil.which(executable) or executable).resolve().parent)
env[path_key] = directory + os.pathsep + env.get(path_key, '')
return env
def is_driver_launch_failure(stderr: str) -> bool:
# GCC 8 emits "gcc.exe: error: CreateProcess: ..."; other releases add
# "cannot execute 'cc1':". Anchor the driver diagnostic, not source text.
return IS_WINDOWS and bool(re.search(
r'(?im)^(?:[^\r\n]*[/\\])?[\w.+-]*gcc(?:\.exe)?: (?:fatal )?error: (?:cannot execute [^\r\n]*: )?CreateProcess:', stderr))
def is_transient_process_error(exc: OSError) -> bool:
return IS_WINDOWS and getattr(exc, 'winerror', None) in (5, 32, 1450, 1455)
@@ -0,0 +1,17 @@
"""User-facing wording shared by live events and saved result diagnostics."""
def format_property_warning(warning: dict) -> dict:
domains = {
"equation-of-state": "状态方程及热力性质的真实气体修正项",
"ideal-caloric": "理想气体热容、焓和内能",
"dynamic-viscosity": "动力黏度",
}
high = warning["direction"] == "high"
message = (
f"氦气物性有效温度越界:{domains[warning['property']]},"
f"t = {warning['time']:.9g} s,T = {warning['temperature']:.9g} K "
f"{'>' if high else '<'} {'Tmax' if high else 'Tmin'} = {warning['limit']:.9g} K。"
"仿真继续计算,超范围物性不保证有效性。"
)
return {**warning, "message": message}
@@ -0,0 +1,327 @@
"""Quota-controlled, append-only result archives in PROJECT/simresults.
Reservations cover state AND future output blocks before the worker may write.
A global process lock serializes grants; per-run leases protect active writers.
Grants are persisted in bounded windows; individual blocks consume local credit
without scanning history or synchronously rewriting the manifest.
Only recognized archives are evicted, oldest first. Unknown user files count
toward the quota but are never deleted. File sizes are logical bytes, not the
filesystem's allocation-unit overhead.
"""
from __future__ import annotations
from contextlib import contextmanager
import ctypes
import json
import os
from pathlib import Path
import re
import stat
import struct
import threading
import time
from uuid import uuid4
import zlib
from .cache_storage import acquire_cache_lease, _acquire_lock_file, _reparse, _require_directory
RESULT_ROOT = Path(__file__).resolve().parents[3] / "simresults"
DEFAULT_LIMIT_BYTES = 1024**3
_RESERVATION_WINDOW_BYTES = 16 * 1024**2
_RUN = re.compile(r"run-([0-9a-f]{64})\Z")
_FILES = frozenset(("manifest.json", "manifest.tmp", "states.bin", "outputs.bin"))
_HEADER = struct.Struct("<8sQQQQ")
class ResultQuotaError(RuntimeError):
pass
def result_archive_path(result_id: str, *, root: Path | None = None) -> Path:
"""Locate an archive by ID under this installation's result root.
Never use a saved absolute directory as a locator: older manifests may
contain one from a different machine. Moving a stopped project and starting
it again establishes its new root through __file__ above.
"""
if not isinstance(result_id, str) or _RUN.fullmatch(result_id) is None:
raise ValueError("Invalid result archive ID.")
base = Path(root or RESULT_ROOT).absolute()
_require_directory(base)
path = base / result_id
_require_directory(path)
return path
def result_limit_bytes() -> int:
raw = os.environ.get("SIMULATION_RESULT_STORAGE_MB", str(DEFAULT_LIMIT_BYTES // 1024**2))
if not re.fullmatch(r"[0-9]+", raw) or int(raw) < 1:
raise ValueError("SIMULATION_RESULT_STORAGE_MB must be a positive integer in MiB.")
return int(raw) * 1024**2
def _alive(pid: int | None) -> bool:
if not pid:
return False
if os.name == "nt":
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
kernel.OpenProcess.restype = ctypes.c_void_p
kernel.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel.OpenProcess(0x100000, False, pid) # SYNCHRONIZE
if not handle:
return ctypes.get_last_error() == 5 # Access denied: conservatively pin.
try:
return kernel.WaitForSingleObject(handle, 0) != 0
finally:
kernel.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _size(path: Path) -> int:
info = path.lstat()
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
return info.st_size
return sum(_size(child) for child in path.iterdir())
def _record(path: Path) -> dict | None:
try:
info = (path / "manifest.json").lstat()
if _reparse(info) or not stat.S_ISREG(info.st_mode):
return None
data = json.loads((path / "manifest.json").read_bytes())
if (data.get("format") != "simulation-blocks-v1" or
data.get("id") != path.name or type(data.get("reservedBytes")) is not int or
data["reservedBytes"] < 0 or type(data.get("createdAt")) is not int or
type(data.get("finishedAt", data["createdAt"])) is not int or
data.get("status") not in ("running", "completed", "cancelled", "failed", "interrupted") or
(data.get("workerPid") is not None and
(type(data["workerPid"]) is not int or data["workerPid"] <= 0))):
return None
return data
except (OSError, ValueError, AttributeError):
return None
def scan_blocks(path: Path, *, expected_columns: int | None = None) -> dict:
"""Validate a committed prefix using bounded reads; ignore an incomplete tail.
A CRC mismatch is reported as corruption, never a valid sample. This reader
works after process termination without loading the trajectory into RAM.
"""
result = {"blocks": 0, "samples": 0, "validBytes": 0, "storedUntil": None,
"incompleteTail": False, "corrupt": False}
if not path.exists():
return result
with path.open("rb") as stream:
size = os.fstat(stream.fileno()).st_size
while stream.tell() < size:
raw = stream.read(_HEADER.size)
if len(raw) != _HEADER.size:
result["incompleteTail"] = True
break
magic, sequence, rows, columns, expected_crc = _HEADER.unpack(raw)
if (magic != b"SIMBLK01" or sequence != result["blocks"] or not 0 < rows <= 1024 or
not columns or (expected_columns is not None and columns != expected_columns)):
result["corrupt"] = True
break
length = rows * columns * 8
if length + 8 > size - stream.tell():
result["incompleteTail"] = True
break
# The first column is always time; at most 1024 timestamps.
times = stream.read(rows * 8)
crc = zlib.crc32(times)
remaining = length - len(times)
while remaining:
block = stream.read(min(65536, remaining))
if not block:
raise OSError("Result block changed during inspection.")
crc = zlib.crc32(block, crc)
remaining -= len(block)
if stream.read(8) != b"COMMIT01" or crc != expected_crc:
result["corrupt"] = True
break
result["blocks"] += 1
result["samples"] += rows
result["validBytes"] = stream.tell()
result["storedUntil"] = struct.unpack_from("<d", times, len(times) - 8)[0]
return result
class ResultArchive:
def __init__(self, metadata: dict, *, root: Path | None = None, limit_bytes: int | None = None):
self.root = Path(root or RESULT_ROOT).absolute()
_require_directory(self.root, create=True)
self.limit = result_limit_bytes() if limit_bytes is None else limit_bytes
if self.limit <= 0:
raise ValueError("Result quota must be positive.")
self.key = uuid4().hex + uuid4().hex
self.path = self.root / ("run-" + self.key)
self.lease = acquire_cache_lease(self.root, "models", self.key)
self.closed = False
self._mutex = threading.RLock()
self._credit = 0
# This is accounting credit, not an allocated memory/disk buffer. Bound
# speculative grants for small quotas and concurrent simulations.
self._reservation_window = min(_RESERVATION_WINDOW_BYTES, self.limit // 16)
self.data = {"format": "simulation-blocks-v1", "id": self.path.name,
"createdAt": time.time_ns(), "status": "running", "workerPid": None,
"metadata": metadata, "reservedBytes": 0}
# Covers final scalars, JSON framing and both copies during atomic update.
self.metadata_budget = max(65536, len(json.dumps(self.data).encode()) * 8)
self.data["reservedBytes"] = self.metadata_budget
try:
with self._locked():
self._make_room(self.metadata_budget)
self.path.mkdir()
self._publish()
except BaseException:
self.lease.close()
raise
@contextmanager
def _locked(self):
with _acquire_lock_file(self.root, "models", "0" * 64, "results-quota.lock",
exclusive=True, blocking=True):
yield
def _publish(self):
encoded = json.dumps(self.data, ensure_ascii=False, allow_nan=False,
separators=(",", ":")).encode("utf-8")
if len(encoded) * 2 > self.metadata_budget:
raise ResultQuotaError("Result metadata exceeded its reserved space.")
temporary = self.path / "manifest.tmp"
with temporary.open("wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path / "manifest.json")
def _make_room(self, extra: int) -> int:
"""Make room for required bytes and return free bytes BEFORE this grant.
Caller holds the global quota lock through publishing the reservation.
Optional ahead-of-write credit must never trigger extra eviction.
"""
usage = 0
candidates = []
for path in self.root.iterdir():
info = path.lstat()
actual = _size(path)
match = _RUN.fullmatch(path.name)
record = (_record(path) if match and stat.S_ISDIR(info.st_mode) and not _reparse(info) else None)
if record is None:
usage += actual
continue
lease = acquire_cache_lease(self.root, "models", match[1], exclusive=True, blocking=False)
active = lease is None or (record.get("status") == "running" and _alive(record.get("workerPid")))
if lease is not None:
lease.close()
usage += max(actual, record["reservedBytes"]) if active else actual
if not active and path != self.path:
candidates.append((record.get("finishedAt", record["createdAt"]), path, match[1], actual))
if usage + extra <= self.limit:
return self.limit - usage
for _, path, key, actual in sorted(candidates):
lease = acquire_cache_lease(self.root, "models", key, exclusive=True, blocking=False)
if lease is None:
continue
with lease:
# No recursive deletion, links, unknown files, or paths outside
# the configured root. User-owned additions prevent eviction.
if path.resolve().parent != self.root.resolve():
continue
files = list(path.iterdir())
if any(p.name not in _FILES or _reparse(p.lstat()) or
not stat.S_ISREG(p.lstat().st_mode) for p in files):
continue
for item in files:
item.unlink()
path.rmdir()
usage -= actual
if usage + extra <= self.limit:
return self.limit - usage
raise ResultQuotaError("Result storage quota exhausted; active runs and unrelated files were retained.")
def worker_started(self, pid: int):
with self._mutex, self._locked():
self._ensure_open()
self.data["workerPid"] = pid
self._publish()
def _ensure_open(self):
if self.closed:
raise OSError("Result archive is closed.")
def reserve(self, amount: int):
if type(amount) is not int or not 0 < amount <= self.limit:
raise ResultQuotaError("Invalid or oversized result block reservation.")
with self._mutex:
self._ensure_open()
if amount <= self._credit:
self._credit -= amount
return
needed = amount - self._credit
with self._locked():
available = self._make_room(needed)
grant = min(available, max(needed, self._reservation_window))
previous = self.data["reservedBytes"]
self.data["reservedBytes"] += grant
try:
# Publish BEFORE granting permission to write, so another
# process (or an orphan worker) cannot reuse our quota.
self._publish()
except BaseException:
self.data["reservedBytes"] = previous
raise
self._credit += grant - amount
def finish(self, payload: dict | None = None, error: str | None = None) -> dict:
with self._mutex:
self._ensure_open()
return self._finish(payload, error)
def _finish(self, payload: dict | None, error: str | None) -> dict:
try:
metadata = self.data["metadata"]
states = scan_blocks(self.path / "states.bin",
expected_columns=len(metadata["stateColumns"]) if "stateColumns" in metadata
else max(1, len(metadata["stateKeys"])) + 1 if "stateKeys" in metadata else None)
outputs = scan_blocks(self.path / "outputs.bin",
expected_columns=len(metadata["outputColumns"]) if "outputColumns" in metadata
else max(1, len(metadata["variables"])) + 1 if "variables" in metadata else None)
status = payload.get("status", "failed") if payload is not None else "interrupted"
if states["corrupt"] or outputs["corrupt"]:
status = "failed"
summary = {"id": self.path.name, "directory": f"simresults/{self.path.name}",
"directoryBase": "project", "limitBytes": self.limit,
"status": status, "states": states, "outputs": outputs}
with self._locked():
# Release unused ahead-of-write credit even if a result reader
# subsequently pins this completed archive with a shared lease.
self.data["reservedBytes"] -= self._credit
self._credit = 0
self.data.update(status=status, finishedAt=time.time_ns(), blocks=summary,
error=error, workerPid=None)
if payload is not None:
self.data["result"] = {k: v for k, v in payload.items()
if k not in ("series", "buildDetails", "resultStorage")}
self._publish()
return summary
finally:
self.closed = True
self.lease.close()
def close(self):
with self._mutex:
if not self.closed:
self.finish(error="Execution ended before a result was returned.")
+164 -32
View File
@@ -12,56 +12,129 @@ import time
from app.simulation.config import SolveIVPConfig from app.simulation.config import SolveIVPConfig
from app.simulation.results import GenericSimulationResult from app.simulation.results import GenericSimulationResult
from .transport import NativeSeriesJson, read_indexed_result
from .build import NativeBuild, build_native from .build import NativeBuild, build_native
from .compiler import NativeCapabilityError, compile_native_program from .compiler import NativeCapabilityError, compile_native_program
from .result_storage import ResultArchive, ResultQuotaError
from .property_warnings import format_property_warning
_WORKER_SHUTDOWN_GRACE_SECONDS = 5.0
def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *, def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
run_dir: Path, record_samples=True, cancel_check=None, run_dir: Path, record_samples=True, cancel_check=None,
progress_callback=None, activity_tracker=None, timeout=300.0) -> dict: progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False) -> dict:
# Reject unsupported calls before allocating quota or evicting any history.
if config.method not in ("RK45", "BDF"): if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.") raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None: if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None:
raise NativeCapabilityError("Native v1 uses the existing default gas/mechanical absolute tolerances and automatic initial step.") raise NativeCapabilityError("Native uses generated per-state absolute tolerances and automatic initial step; custom config.atol/first_step are not supported.")
if any((run_dir / name).exists() for name in ("result.json", "cancel.request", "result-index.json")):
raise ValueError("Native execution requires a fresh run directory.")
archive = None
if record_samples:
metadata = {key: build.manifest[key] for key in ("buildKey", "stateKeys", "variables")
if key in build.manifest}
if "stateKeys" in metadata:
# Algebraic-only models carry one private constant integrator state.
metadata["stateColumns"] = ["time", *(metadata["stateKeys"] or [None])]
if "variables" in metadata:
metadata["outputColumns"] = ["time", *([v["key"] for v in metadata["variables"]] or [""])]
metadata["simulation"] = {"method": config.method, "start": config.t_start,
"stop": config.t_stop, "sampleStep": sample_step,
"maxStep": config.max_step, "rtol": config.rtol}
archive = ResultArchive(metadata)
try:
payload = _execute_native(build, config, sample_step, run_dir=run_dir,
record_samples=record_samples, cancel_check=cancel_check,
progress_callback=progress_callback, warning_callback=warning_callback, activity_tracker=activity_tracker,
timeout=timeout, raw_series=raw_series, archive=archive)
if archive is not None:
payload["resultStorage"] = archive.finish(payload)
return payload
except BaseException as exc:
if archive is not None and not archive.closed:
try:
archive.finish(error=str(exc))
except Exception as storage_error:
exc.add_note(f"Could not finalize result archive: {storage_error}")
raise
finally:
if archive is not None:
archive.close()
def _execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
run_dir: Path, record_samples=True, cancel_check=None,
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False,
archive: ResultArchive | None = None) -> dict:
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
output = run_dir / "result.json" output = run_dir / "result.json"
cancel_path = run_dir / "cancel.request" cancel_path = run_dir / "cancel.request"
if output.exists() or cancel_path.exists(): index_path = run_dir / "result-index.json"
raise ValueError("Native execution requires a fresh run directory.")
command = [str(build.executable), "--method", config.method, command = [str(build.executable), "--method", config.method,
"--start", str(config.t_start), "--stop", str(config.t_stop), "--start", str(config.t_start), "--stop", str(config.t_stop),
"--sample-step", str(sample_step), "--max-step", str(config.max_step), "--sample-step", str(sample_step), "--max-step", str(config.max_step),
"--rtol", str(config.rtol), "--timeout", str(timeout), "--rtol", str(config.rtol), "--timeout", str(timeout),
"--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())] "--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())]
if raw_series:
command.extend(["--result-index", str(index_path.resolve())])
if not record_samples: if not record_samples:
command.append("--solve-only") command.append("--solve-only")
if archive is not None:
command.extend(["--sample-file", str(archive.path / "states.bin"),
"--output-block-file", str(archive.path / "outputs.bin"), "--storage-control"])
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
started = time.perf_counter() started = time.perf_counter()
process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL, # A fast worker may finish before the monitor's first iteration. Honor a
# cancellation already requested during preparation before spawning it.
cancelled_at = started if cancel_check is not None and cancel_check() else None
if cancelled_at is not None:
cancel_path.write_text("cancel\n", encoding="ascii")
process = subprocess.Popen(command, cwd=build.executable.parent,
stdin=subprocess.PIPE if archive is not None else subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", creationflags=creationflags) text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
messages: queue.Queue[str] = queue.Queue() messages: queue.Queue[str] = queue.Queue(maxsize=128)
reader_stop = threading.Event()
def read_stderr(): def read_stderr():
assert process.stderr is not None assert process.stderr is not None
for line in process.stderr: for line in process.stderr:
messages.put(line) while not reader_stop.is_set():
try:
messages.put(line, timeout=.1)
break
except queue.Full:
continue
if reader_stop.is_set():
break
reader = threading.Thread(target=read_stderr, daemon=True) reader = threading.Thread(target=read_stderr, daemon=True)
reader.start() reader.start()
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
cancelled_at = None
last_time = config.t_start last_time = config.t_start
last_nfev = last_accepted = 0
forced_cancel = False
property_warnings = []
try: try:
if archive is not None:
archive.worker_started(process.pid)
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
with (run_dir / "worker.log").open("w", encoding="utf-8") as log: with (run_dir / "worker.log").open("w", encoding="utf-8") as log:
while process.poll() is None or not messages.empty() or reader.is_alive(): while process.poll() is None or not messages.empty() or reader.is_alive():
now = time.perf_counter() now = time.perf_counter()
if cancel_check is not None and cancel_check() and cancelled_at is None: if cancel_check is not None and cancel_check() and cancelled_at is None:
cancel_path.write_text("cancel\n", encoding="ascii") cancel_path.write_text("cancel\n", encoding="ascii")
cancelled_at = now cancelled_at = now
if now-started > timeout+5 or (cancelled_at is not None and now-cancelled_at > 5): cancellation_expired = (cancelled_at is not None and
now-cancelled_at > _WORKER_SHUTDOWN_GRACE_SECONDS)
deadline_expired = timeout > 0 and now-started > timeout+_WORKER_SHUTDOWN_GRACE_SECONDS
if process.poll() is None and (cancellation_expired or deadline_expired):
process.kill() process.kill()
process.wait(timeout=5) process.wait(timeout=5)
raise RuntimeError("Native worker was terminated after failing to return within its time limit.") if cancelled_at is not None:
forced_cancel = True
break
raise RuntimeError("Native worker time limit expired; the unresponsive process was terminated before it returned results.")
try: try:
line = messages.get(timeout=0.05) line = messages.get(timeout=0.05)
except queue.Empty: except queue.Empty:
@@ -71,8 +144,33 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
event = json.loads(line) event = json.loads(line)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if event.get("phase") == "storage-reserve" and archive is not None:
reply = "ok\n"
try:
archive.reserve(event.get("bytes"))
except ResultQuotaError as exc:
reply = "full\n"
log.write(f"Result storage reservation failed: {exc}\n")
except OSError as exc:
reply = "io\n"
log.write(f"Result storage I/O failed: {exc}\n")
try:
process.stdin.write(reply)
process.stdin.flush()
except (BrokenPipeError, OSError):
if process.poll() is None:
raise
continue
if event.get("phase") == "property-warning":
warning = format_property_warning(event["warning"])
property_warnings.append(warning)
if warning_callback:
warning_callback(warning)
continue
if event.get("phase") == "integrating": if event.get("phase") == "integrating":
last_time = max(last_time, min(config.t_stop, float(event["time"]))) last_time = max(last_time, min(config.t_stop, float(event["time"])))
last_nfev = int(event["nfev"])
last_accepted = int(event["acceptedSteps"])
if progress_callback: if progress_callback:
progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating") progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating")
if activity_tracker is not None: if activity_tracker is not None:
@@ -81,40 +179,73 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
if process.poll() is None: if process.poll() is None:
process.kill() process.kill()
process.wait(timeout=5) process.wait(timeout=5)
reader_stop.set()
reader.join(timeout=2) reader.join(timeout=2)
if process.stdin is not None:
process.stdin.close()
if process.stderr is not None: if process.stderr is not None:
process.stderr.close() process.stderr.close()
if not output.is_file(): if forced_cancel:
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.") # A killed worker cannot publish trustworthy result artifacts. Retain
payload = json.loads(output.read_text(encoding="utf-8")) # only its last progress report; do not invent a trajectory or totals.
if process.returncode not in (0, 2): payload = {
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.") "success": False, "status": "cancelled",
"message": "Simulation cancelled; the unresponsive worker was terminated. No complete result artifact is available; time and counters are the last reported progress.",
"backend": "native-c", "method": config.method,
"simulatedUntil": last_time, "nfev": last_nfev, "acceptedSteps": last_accepted,
"series": {}, "final": {}, "finalState": [],
"solverControl": {"reason": "cancelled", "operation": "worker-termination",
"forcedTermination": True, "workerExitCode": process.returncode,
"resultAvailable": False, "statisticsComplete": False},
}
else:
if not output.is_file():
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
if process.returncode not in (0, 2):
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
try:
payload = (read_indexed_result(output, index_path) if raw_series
else json.loads(output.read_text(encoding="utf-8")))
except OSError as exc:
raise RuntimeError(f"Cannot read native worker result artifacts: {exc}") from exc
payload["propertyWarnings"] = (
property_warnings if forced_cancel else
[format_property_warning(w) for w in payload.get("propertyWarnings", [])])
payload["processWallSeconds"] = time.perf_counter()-started payload["processWallSeconds"] = time.perf_counter()-started
payload["buildKey"] = build.manifest["buildKey"] payload["buildKey"] = build.manifest["buildKey"]
payload["cacheHit"] = build.cache_hit payload["cacheHit"] = build.cache_hit
payload["buildSeconds"] = build.seconds payload["buildSeconds"] = build.seconds
payload["buildDetails"] = build.details
if activity_tracker is not None: if activity_tracker is not None:
activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"]) activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"])
return payload return payload
def simulate_native(network, config, *, sample_step, progress_callback=None, def simulate_native(network, config, *, sample_step, progress_callback=None,
cancel_check=None, activity_tracker=None): warning_callback=None, cancel_check=None, activity_tracker=None, raw_series=False):
if config.method not in ("RK45", "BDF"): if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.") raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if progress_callback: if progress_callback:
progress_callback(0.0, "initializing") progress_callback(0.0, "native-generation")
program = compile_native_program(network) program = compile_native_program(network)
build = build_native(program) build = build_native(program, progress_callback=(
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory: (lambda phase: progress_callback(0.0, phase)) if progress_callback else None
data = execute_native(build, config, sample_step, run_dir=Path(directory), ))
cancel_check=cancel_check, progress_callback=progress_callback, try:
activity_tracker=activity_tracker) with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
totals = { data = execute_native(build, config, sample_step, run_dir=Path(directory),
"nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"], cancel_check=cancel_check, progress_callback=progress_callback,
"acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"], warning_callback=warning_callback,
"stateTransitionCount": data["stateTransitions"], "solverStartCount": data["solverStarts"], activity_tracker=activity_tracker, raw_series=raw_series)
} finally:
# Keep the executable pinned through process exit and result reading;
# only then may cache eviction reclaim this completed model version.
build.close()
totals = {target: data[source] for source, target in (
("nfev", "nfev"), ("njev", "njev"), ("nlu", "nlu"),
("acceptedSteps", "acceptedStepCount"), ("rejectedSteps", "rejectedStepCount"),
("stateTransitions", "stateTransitionCount"), ("solverStarts", "solverStartCount"),
) if source in data}
if progress_callback: if progress_callback:
fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start) fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start)
progress_callback(fraction, "complete" if data["success"] else data["status"]) progress_callback(fraction, "complete" if data["success"] else data["status"])
@@ -122,7 +253,8 @@ def simulate_native(network, config, *, sample_step, progress_callback=None,
success=data["success"], status=data["status"], message=data["message"], success=data["success"], status=data["status"], message=data["message"],
simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop, simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop,
variables=program.variables, series=data["series"], final=data["final"], variables=program.variables, series=data["series"], final=data["final"],
diagnostics={"backend": "native-c", "native": {k: v for k, v in data.items() diagnostics={"backend": "native-c", "propertyWarnings": data.get("propertyWarnings", []), "native": {k: v for k, v in data.items()
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "totals": totals}, if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "rtol": config.rtol, "totals": totals},
"stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])}, "stateCount": len(program.state_keys), "sampleCount": (data["series"].sample_count if isinstance(data["series"], NativeSeriesJson)
else len(data["series"].get("time", [])))},
) )
+257
View File
@@ -0,0 +1,257 @@
"""Dependency ordering and local algebraic blocks for generated C expressions.
This module only arranges reviewed C computations. It never evaluates a model
numerically and has no dependency on the retired Python numerical backend.
"""
from __future__ import annotations
from dataclasses import dataclass
import heapq
import re
from .compiler import NativeCapabilityError
# Only compiler-owned array expressions are inspected, never arbitrary user C.
_REFERENCE = re.compile(r"\b(?:[phqw]|fb)\[\d+\]|\bg\[\d+\]\.[A-Za-z_]\w*")
def references(expression: str) -> frozenset[str]:
return frozenset(_REFERENCE.findall(expression))
@dataclass(frozen=True)
class Computation:
key: str
outputs: tuple[str, ...]
inputs: frozenset[str]
code: tuple[str, ...]
kind: str = "flow"
residual: str | None = None
@classmethod
def assignment(cls, key, target, expression, kind="flow"):
return cls(key, (target,), references(expression), (f'{target}={expression};',), kind)
@dataclass(frozen=True)
class Block:
members: tuple[int, ...]
cyclic: bool
class EvaluationSchedule:
def __init__(self, computations, known, labels=None):
self.computations = tuple(computations)
self.labels = labels or {}
self.producers = {}
for i, op in enumerate(self.computations):
for output in op.outputs:
if output in self.producers:
raise NativeCapabilityError(f'Multiple native producers for {self.label(output)}')
self.producers[output] = i
# An iterative initial guess is not a known source if an equation owns it.
used = set().union(*(op.inputs for op in self.computations)) if self.computations else set()
self.known = {key: value for key, value in known.items() if key not in self.producers and key in used}
self.dependencies = []
for op in self.computations:
missing = op.inputs - self.producers.keys() - self.known.keys()
if missing:
raise NativeCapabilityError(f'{op.key}: missing native input sources: {sorted(map(self.label, missing))}')
self.dependencies.append({self.producers[key] for key in op.inputs if key in self.producers})
self.blocks = self._blocks()
def label(self, key):
return self.labels.get(key, key)
def _blocks(self):
"""Iterative SCC discovery followed by deterministic topological order."""
count = len(self.computations)
consumers = [set() for _ in range(count)]
for target, sources in enumerate(self.dependencies):
for source in sources:
consumers[source].add(target)
visited, finish = set(), []
for start in range(count):
if start in visited:
continue
visited.add(start)
stack = [(start, iter(sorted(consumers[start])))]
while stack:
node, edges = stack[-1]
child = next(edges, None)
if child is None:
finish.append(node)
stack.pop()
elif child not in visited:
visited.add(child)
stack.append((child, iter(sorted(consumers[child]))))
groups, owner = [], {}
for start in reversed(finish):
if start in owner:
continue
index = len(groups)
owner[start] = index
members, stack = [], [start]
while stack:
node = stack.pop()
members.append(node)
for child in sorted(self.dependencies[node]):
if child not in owner:
owner[child] = index
stack.append(child)
groups.append(tuple(sorted(members)))
incoming = [set() for _ in groups]
outgoing = [set() for _ in groups]
for target, sources in enumerate(self.dependencies):
for source in sources:
a, b = owner[source], owner[target]
if a != b:
incoming[b].add(a)
outgoing[a].add(b)
ready = [(min(groups[i]), i) for i, inputs in enumerate(incoming) if not inputs]
heapq.heapify(ready)
blocks = []
while ready:
_, index = heapq.heappop(ready)
members = groups[index]
cyclic = len(members) > 1 or members[0] in self.dependencies[members[0]]
blocks.append(Block(members, cyclic))
for child in sorted(outgoing[index]):
incoming[child].remove(index)
if not incoming[child]:
heapq.heappush(ready, (min(groups[child]), child))
return tuple(blocks)
def ordered_subset(self, members):
"""Order a trial's computations while pressure/stream guesses are fixed."""
pending = set(members)
result = []
while pending:
ready = sorted(i for i in pending if not self.dependencies[i] & pending)
if not ready:
raise NativeCapabilityError('Unsupported cycle within native flow expressions')
result.extend(ready)
pending.difference_update(ready)
return result
def ancestors(self, inputs, allowed):
pending = [self.producers[key] for key in inputs if key in self.producers]
found = set()
while pending:
index = pending.pop()
if index in found or index not in allowed:
continue
found.add(index)
pending.extend(self.dependencies[index])
return self.ordered_subset(found)
def report(self):
result = []
sources = {key: {str(origin)} for key, origin in self.known.items()}
for block in self.blocks:
outputs = {key for i in block.members for key in self.computations[i].outputs}
inputs = {key for i in block.members for key in self.computations[i].inputs} - outputs
origins = set().union(*(sources[key] for key in inputs)) if inputs else set()
for key in outputs:
sources[key] = origins
result.append({
'cyclic': block.cyclic,
'inputs': sorted(map(self.label, inputs)),
'outputs': sorted(map(self.label, outputs)),
'origins': sorted(origins),
'operations': [self.computations[i].key for i in block.members],
'pressureUnknowns': [self.label(key) for i in block.members
if self.computations[i].kind == 'pressure'
for key in self.computations[i].outputs],
})
return {
'strategy': 'dependency-blocks',
'operationCount': len(self.computations),
'cyclicBlockCount': sum(b.cyclic for b in self.blocks),
'knownSources': {self.label(key): value for key, value in self.known.items()},
'blocks': result,
'operations': [{'key': op.key, 'kind': op.kind,
'inputs': sorted(map(self.label, op.inputs)),
'outputs': list(map(self.label, op.outputs))}
for op in self.computations],
}
def emit(self):
"""Return C helper definitions and a straight-line/local-block schedule.
Existing scalar pressure bisection and stream convergence tolerances are
retained. Only the computations in the relevant SCC participate in each
closure; a pressure trial further restricts work to its residual inputs.
"""
helpers, lines = [], []
args = 'p,h,g,w,q,fb,pipe_cache,properties'
signature = ('double *p,double *h,const NativeGas *g,double *w,double *q,'
'double *fb,NativePipeCache *pipe_cache,NativePropertyCache *properties')
def code(indices):
return [line for i in indices for line in
(f'/* schedule operation {i}: {self.computations[i].kind} */', *self.computations[i].code)]
def helper(name, indices):
helpers.extend([f'static int {name}({signature}) {{',
'(void)p;(void)h;(void)g;(void)w;(void)q;(void)fb;(void)pipe_cache;(void)properties;',
*code(indices), 'return 1;', '}'])
return f'if(!{name}({args})) return 0;'
for number, block in enumerate(self.blocks):
if not block.cyclic:
if self.computations[block.members[0]].kind == 'pressure':
raise NativeCapabilityError('Pressure balance has no pressure-dependent flow relation')
lines.extend(code(block.members))
continue
pressures = [i for i in block.members if self.computations[i].kind == 'pressure']
streams = [i for i in block.members if self.computations[i].kind in ('stream', 'alias')]
flows = set(block.members) - set(pressures) - set(streams)
if all(self.computations[i].kind == 'alias' for i in block.members):
raise NativeCapabilityError('Enthalpy reference cycle has no thermodynamic source: ' +
', '.join(self.computations[i].key for i in block.members))
refresh = helper(f'model_block_{number}_flows', self.ordered_subset(flows)) if flows else ''
lines.append(f'{{ /* local algebraic block {number} */')
hvars = [key for i in streams for key in self.computations[i].outputs]
outputs = {key for i in block.members for key in self.computations[i].outputs}
inputs = sorted({key for i in block.members for key in self.computations[i].inputs} - outputs)
if hvars:
seeds = [key for key in inputs if key.startswith('h[') or key.endswith('.h')]
if not seeds:
raise NativeCapabilityError('Local stream loop has no supplied thermodynamic state')
lines.extend([*[f'{key}={seeds[0]};' for key in hvars], 'int closure_ok=0;',
f'for(int closure=0;closure<{max(64,4*len(hvars))};closure++) {{',
'double previous[]={' + ','.join(hvars) + '};'])
if pressures:
pressure_bounds = [key for key in inputs if key.startswith('p[') or key.endswith('.p')]
if not pressure_bounds:
raise NativeCapabilityError('Local pressure block has no pressure boundary')
lines.extend(['double plo=INFINITY,phi=0;',
*[f'plo=fmin(plo,{p});phi=fmax(phi,{p});' for p in pressure_bounds],
*[f'{self.computations[i].outputs[0]}=.5*(plo+phi);' for i in pressures],
'int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {'])
for i in pressures:
op = self.computations[i]
p = op.outputs[0]
trial = helper(f'model_block_{number}_pressure_{i}', self.ancestors(op.inputs, flows))
lines.extend(['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',
f'{p}=.5*(lo+hi);', trial,
f'double balance={op.residual};if(!isfinite(balance)) return 0;',
f'if(balance>0) hi={p};else lo={p};', '}}'])
lines.extend([refresh, 'double residual=0;',
*[f'{{double balance={self.computations[i].residual};if(!isfinite(balance)) return 0;residual=fmax(residual,fabs(balance));}}' for i in pressures],
'if(residual<1e-11) {pressure_ok=1;break;}', '}',
'if(!pressure_ok) return 0;'])
elif refresh:
lines.append(refresh)
if hvars:
# Gauss-Seidel within a genuine stream loop, in stable emission order.
lines.extend([*code(streams), 'double change=0;',
*[line for j,key in enumerate(hvars) for line in
(f'if(!isfinite({key})) return 0;',
f'change=fmax(change,fabs({key}-previous[{j}])/fmax(1,fabs({key})));')],
'if(change<1e-12) {closure_ok=1;break;}', '}',
'if(!closure_ok) return 0;', refresh])
lines.append('}')
return helpers, lines
+208
View File
@@ -0,0 +1,208 @@
"""Small, uncached compile/link/run check using the production native toolchain."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
from threading import Event
from time import monotonic
from typing import Callable
from .build import CACHE, NativeCommandError, link_library_arguments, platform_build_inputs, toolchain
from .processes import command_environment, is_driver_launch_failure
STAGE_MESSAGES = {
"toolchain": "正在查找编译器与 SUNDIALS",
"preparing": "正在准备最小自检程序",
"preprocessing": "正在预处理最小自检程序",
"compiling": "正在编译最小自检程序",
"linking": "正在链接最小自检程序",
"running": "正在运行最小自检程序",
"schema": "正在检查模型 XML 解析环境",
}
OUTPUT_MARKER = "native-runtime-check-ok"
# One smooth scalar equation exercises every library used by production BDF.
# Its known answer is independent of the user's model and cannot hit model cache.
SOURCE = r'''
#include <cvode/cvode.h>
#include <cvode/cvode_ls.h>
#include <nvector/nvector_serial.h>
#include <sunmatrix/sunmatrix_dense.h>
#include <sunlinsol/sunlinsol_dense.h>
#include <math.h>
#include <stdio.h>
static int rhs(sunrealtype t, N_Vector y, N_Vector dy, void *data) {
(void)t; (void)data;
NV_Ith_S(dy,0)=-NV_Ith_S(y,0);
return 0;
}
int main(void) {
SUNContext context=NULL;
N_Vector y=NULL;
SUNMatrix matrix=NULL;
SUNLinearSolver linear=NULL;
void *solver=NULL;
int result=1;
sunrealtype time=0;
if (SUNContext_Create(SUN_COMM_NULL,&context)) goto done;
y=N_VNew_Serial(1,context);
if (!y) goto done;
NV_Ith_S(y,0)=1;
matrix=SUNDenseMatrix(1,1,context);
if (!matrix) goto done;
linear=SUNLinSol_Dense(y,matrix,context);
solver=CVodeCreate(CV_BDF,context);
if (!linear || !solver) goto done;
if (CVodeInit(solver,rhs,0,y)<0 ||
CVodeSStolerances(solver,1e-8,1e-10)<0 ||
CVodeSetLinearSolver(solver,linear,matrix)<0 ||
CVode(solver,0.1,y,&time,CV_NORMAL)<0) goto done;
if (!isfinite(NV_Ith_S(y,0)) || fabs(NV_Ith_S(y,0)-exp(-0.1))>1e-6) goto done;
puts("native-runtime-check-ok");
result=0;
done:
if (result) fputs("Minimal BDF self-check failed.\n",stderr);
if (solver) CVodeFree(&solver);
if (linear) SUNLinSolFree(linear);
if (matrix) SUNMatDestroy(matrix);
if (y) N_VDestroy(y);
if (context) SUNContext_Free(&context);
return result;
}
'''
class CheckCancelled(Exception):
pass
class CheckFailure(RuntimeError):
def __init__(self, message: str, *, command=(), cwd=None, exit_code=None,
stdout="", stderr="", error_type="RuntimeError"):
super().__init__(message)
self.details = {"command": list(command), "cwd": str(cwd) if cwd else None,
"exitCode": exit_code, "stdout": stdout[-8000:],
"stderr": stderr[-8000:], "errorType": error_type}
def _stop_process_tree(process: subprocess.Popen) -> None:
# GCC starts cc1/as/ld. Killing only its driver can leave inherited pipes open.
if os.name == "nt":
try:
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW)
except (OSError, subprocess.SubprocessError):
pass
if process.poll() is None:
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def _command(command: list[str], directory: Path, stop: Event, timeout: float = 30) -> str:
if stop.is_set():
raise CheckCancelled()
try:
with subprocess.Popen(command, cwd=directory, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env=command_environment(command[0]), close_fds=True,
start_new_session=os.name != "nt",
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) as process:
deadline = monotonic() + timeout
while True:
try:
stdout, stderr = process.communicate(timeout=0.2)
break
except subprocess.TimeoutExpired:
if stop.is_set() or monotonic() >= deadline:
_stop_process_tree(process)
stdout, stderr = process.communicate()
if stop.is_set():
raise CheckCancelled()
raise CheckFailure(f"命令执行超过 {timeout:g} 秒", command=command,
cwd=directory, exit_code=process.returncode,
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
error_type="TimeoutExpired")
except OSError as exc:
raise CheckFailure(str(exc), command=command, cwd=directory,
error_type=type(exc).__name__) from exc
output = stdout.decode("utf-8", errors="replace")
if process.returncode:
stderr_text = stderr.decode('utf-8', errors='replace')
message = ('GCC 已启动,但无法启动编译子进程;该错误不能直接判定为编译器不存在'
if is_driver_launch_failure(stderr_text) else '命令执行失败')
# Whole-check retries remain owned by warmup (initial check + 5 rounds).
raise CheckFailure(message, command=command, cwd=directory,
exit_code=process.returncode, stdout=output,
stderr=stderr_text,
error_type="CalledProcessError")
return output
def check_native_runtime(progress: Callable[..., None], stop: Event) -> None:
def stage(name: str, **details):
if stop.is_set():
raise CheckCancelled()
progress(stage=name, message=STAGE_MESSAGES[name], **details)
candidate = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
stage("toolchain", compiler=candidate)
try:
compiler, sundials, version = toolchain()
except NativeCommandError as exc:
details = exc.details
failure = CheckFailure(str(exc), command=details['command'], cwd=details['cwd'],
exit_code=details['exitCode'], stdout=details['stdout'], stderr=details['stderr'],
error_type=details['errorType'])
failure.details['attempts'] = details['attempts']
raise failure from exc
except subprocess.SubprocessError as exc:
def decoded(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else (value or "")
raise CheckFailure(str(exc), command=getattr(exc, "cmd", ()), cwd=Path.cwd(),
exit_code=getattr(exc, "returncode", None),
stdout=decoded(getattr(exc, "stdout", "")),
stderr=decoded(getattr(exc, "stderr", "")),
error_type=type(exc).__name__) from exc
except OSError as exc:
raise CheckFailure(str(exc), command=[candidate, "--version"] if candidate else [],
cwd=Path.cwd(), error_type=type(exc).__name__) from exc
compiler = str(Path(shutil.which(compiler) or compiler).resolve())
stage("preparing", compiler=compiler, compilerVersion=version, sundialsRoot=str(sundials))
flags, libraries, dlls, executable_name = platform_build_inputs(sundials)
# Keep the probe on the build filesystem: Linux /tmp may be mounted noexec.
# Disposable probes never populate or evict the model/object caches.
directory_root = CACHE.parent / "native-runtime-checks"
directory_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="check-", dir=directory_root) as temporary:
directory = Path(temporary).resolve()
source, preprocessed, obj = directory / "check.c", directory / "check.i", directory / "check.o"
executable = directory / executable_name
source.write_text(SOURCE, encoding="utf-8", newline="\n")
stage("preprocessing")
_command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={directory}=/generated",
"-I", str(sundials / "include"), str(source), "-o", str(preprocessed)], directory, stop)
stage("compiling")
_command([compiler, *flags, "-x", "cpp-output", "-c", str(preprocessed), "-o", str(obj)], directory, stop)
stage("linking")
_command([compiler, *flags, str(obj), *link_library_arguments(libraries),
"-lm", "-o", str(executable)], directory, stop)
stage("running")
for dll in dlls:
shutil.copyfile(dll, directory / dll.name)
output = _command([str(executable)], directory, stop, timeout=10)
if output.strip() != OUTPUT_MARKER:
raise CheckFailure("最小程序未返回预期的自检成功标记", command=[str(executable)],
cwd=directory, exit_code=0, stdout=output, error_type="UnexpectedOutput")
stage("schema")
from app.system_xml import validate_system_xml_document
validate_system_xml_document(b"<System/>")
@@ -0,0 +1,18 @@
"""SI absolute error floors shared by both native code generators.
Mass must not inherit the energy floor: 1e-8 kg overwhelmed relative error
control in small pipe volumes. These conservative defaults were validated on
the pipe and chamber regressions; model-scale/user tolerance settings remain
a separate protocol change.
"""
def state_absolute_tolerance(key: str) -> str:
field = key.rsplit('.', 1)[-1]
if field in ('m', 'm1', 'm2'):
return '1e-14' # kg
if field in ('v', 'x'):
return '1e-12' # m/s or m
if field == '_volume_displacement':
return '1e-14' # m3, prescribed external volume change
return '1e-8' # J (or the internal constant state of an algebraic model)
@@ -0,0 +1,63 @@
"""Carry trusted C-generated series JSON without a Python float-array round trip.
Only native output plus its byte index may construct this transport object. Public
JSON still has the ordinary object/array schema; synchronous callers materialize
it explicitly. Bytes own their lifetime independently of the worker directory.
"""
from dataclasses import dataclass
import json
from pathlib import Path
@dataclass(frozen=True)
class NativeSeriesJson:
data: bytes
sample_count: int
def materialize(self) -> dict[str, list[float]]:
return json.loads(self.data)
def read_indexed_result(output: Path, index_path: Path) -> dict:
index = json.loads(index_path.read_bytes())
if not isinstance(index, dict) or type(index.get('version')) is not int or index['version'] != 1:
raise ValueError('Unsupported native result index.')
names = ('seriesStart', 'seriesEnd', 'resultBytes', 'sampleCount')
if any(type(index.get(key)) is not int for key in names):
raise ValueError('Invalid native result index integers.')
start, end, length, count = (index[key] for key in names)
if not (0 < start < end < length and count >= 0):
raise ValueError('Invalid native result index bounds.')
if output.stat().st_size != length:
raise ValueError('Incomplete native result file.')
raw = output.read_bytes()
if (len(raw) != length or not raw[:start].endswith(b'"series":')
or raw[start:start+1] != b'{' or raw[end-1:end] != b'}'
or not raw[end:].startswith(b',"final":')):
raise ValueError('Native result index does not match the output layout.')
# The C writer supplies exact boundaries. No search through strings or numeric
# arrays, and no large json.loads call: only diagnostics/final values are read.
metadata = json.loads(raw[:start] + b'{}' + raw[end:])
if not isinstance(metadata, dict) or metadata.get('series') != {}:
raise ValueError('Invalid native result metadata.')
metadata['series'] = NativeSeriesJson(raw[start:end], count)
return metadata
def serialize_result_parts(payload: dict) -> tuple[bytes, ...]:
"""Return one JSON object in three byte segments, without copying its series.
The only raw position is payload.result.series, produced by our C writer;
all model-provided labels, messages and keys use the standard JSON encoder.
"""
result = payload.get('result')
series = result.get('series') if isinstance(result, dict) else None
if not isinstance(series, NativeSeriesJson):
return (json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8'),)
metadata = {key: value for key, value in result.items() if key != 'series'}
outer = {key: value for key, value in payload.items() if key != 'result'}
encoded = json.dumps(metadata, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
remainder = json.dumps(outer, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
prefix = b'{"result":' + encoded[:-1] + (b',' if metadata else b'') + b'"series":'
suffix = b'}' + (b',' + remainder[1:] if outer else b'}')
return prefix, series.data, suffix
+10 -2
View File
@@ -15,8 +15,16 @@ DATA_DIR = (
) )
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs" SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "data" SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines"
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame" # Current browser/solver input and the matching AME drawing (September 2026).
CURRENT_MQL8_PROJECT_PATH = PROJECT_ROOT / "tests" / "data" / "test-mql-8-corrected.json"
CURRENT_MQL8_AME_PATH = PROJECT_ROOT / "tests" / "data" / "test_mql.ame"
# Frozen August reference: its AME bytes differ from the current drawing.
HISTORICAL_MQL8_AME_PATH = PROJECT_ROOT / "tests" / "data" / "AmesimModels" / "test_mql.ame"
HISTORICAL_MQL8_PROJECT_PATH = SIMULATION_BASELINES_DIR / "simulation" / "test_mql_8" / "sources" / "test-mql-8.json"
HISTORICAL_MQL8_XML_PATH = HISTORICAL_MQL8_PROJECT_PATH.with_suffix(".xml")
AMESIM_TEST_MQL_ARCHIVE_PATH = HISTORICAL_MQL8_AME_PATH
MODELICA_TESTMODEL_RESULT_PATH = ( MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv" PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
) )
+11
View File
@@ -24,6 +24,7 @@ from app.simulation.core.metadata import (
) )
from app.simulation.core.medium import GasMedium, IdealGasMedium from app.simulation.core.medium import GasMedium, IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortVariableDefinition from app.simulation.core.ports import PortDefinition, PortVariableDefinition
from app.simulation.core.port_computation import PortComputation
ParameterSpec = ParameterDefinition ParameterSpec = ParameterDefinition
@@ -877,8 +878,18 @@ def validate_component_model_class(
f"Component '{model_type}' RESULT_VARIABLES must be a tuple." f"Component '{model_type}' RESULT_VARIABLES must be a tuple."
) )
port_by_name = {port.name: port for port in ports if isinstance(port, PortDefinition)}
for port in ports: for port in ports:
_validate_port(port, model_type=model_type) _validate_port(port, model_type=model_type)
contract = port.computation
if contract is not None:
if port.domain != 'pneumatic' or not isinstance(contract, PortComputation):
raise ValueError(f"Component '{model_type}' has an invalid pneumatic computation contract.")
if contract.reference_port:
reference = port_by_name.get(contract.reference_port)
if (reference is None or reference.computation is None
or not {'p', 'T'}.issubset(reference.computation.inputs)):
raise ValueError(f"Component '{model_type}.{port.name}' must alias a pressure/temperature input port.")
for parameter in parameters: for parameter in parameters:
_validate_parameter(parameter, model_type=model_type) _validate_parameter(parameter, model_type=model_type)
for variable in result_variables: for variable in result_variables:
+11 -4
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from app.simulation.native_codegen.transport import NativeSeriesJson
from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.core.metadata import ResultVariableMetadata
SimulationRunStatus = Literal["completed", "cancelled", "failed"] SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@@ -30,11 +33,15 @@ class GenericSimulationResult:
simulated_until: float simulated_until: float
requested_stop_time: float requested_stop_time: float
variables: tuple[ResultVariableMetadata, ...] variables: tuple[ResultVariableMetadata, ...]
series: dict[str, list[float]] series: dict[str, list[float]] | NativeSeriesJson
final: dict[str, float] final: dict[str, float]
diagnostics: dict[str, object] diagnostics: dict[str, object]
def as_dict(self) -> dict[str, object]: def as_dict(self, *, raw_series: bool = False) -> dict[str, object]:
from app.simulation.native_codegen.transport import NativeSeriesJson
series = self.series
if isinstance(series, NativeSeriesJson) and not raw_series:
series = series.materialize()
return { return {
"success": self.success, "success": self.success,
"status": self.status, "status": self.status,
@@ -43,7 +50,7 @@ class GenericSimulationResult:
"simulatedUntil": self.simulated_until, "simulatedUntil": self.simulated_until,
"requestedStopTime": self.requested_stop_time, "requestedStopTime": self.requested_stop_time,
"variables": [variable.as_dict() for variable in self.variables], "variables": [variable.as_dict() for variable in self.variables],
"series": self.series, "series": series,
"final": self.final, "final": self.final,
"diagnostics": self.diagnostics, "diagnostics": self.diagnostics,
} }
+23
View File
@@ -6,6 +6,9 @@ from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.equations import EquationDefinition from app.simulation.core.equations import EquationDefinition
from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.core.ports import PortState from app.simulation.core.ports import PortState
from app.simulation.core.port_computation import (
PortSupplyError, port_supply_issue, reference_supply_issues,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -110,6 +113,10 @@ class SimulationNetwork:
raise ValueError( raise ValueError(
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}." f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
) )
supply_issue = port_supply_issue(first_definition, second_definition,
str(endpoint_a), str(endpoint_b))
if supply_issue:
raise PortSupplyError(supply_issue)
if first_definition.kind == "signal" and { if first_definition.kind == "signal" and {
first_definition.nominal_role, first_definition.nominal_role,
second_definition.nominal_role, second_definition.nominal_role,
@@ -161,6 +168,22 @@ class SimulationNetwork:
self.connections.append(connection) self.connections.append(connection)
return connection return connection
def validate_port_supplies(self) -> None:
"""Validate again at compile time, including manually assembled networks."""
ports = {(c.name, p.name): p for c in self.components.values()
for p in c.active_port_definitions}
adjacency = {}
for edge in self.connections:
a, b = (endpoint.key for endpoint in edge.endpoints)
issue = port_supply_issue(ports[a], ports[b], '.'.join(a), '.'.join(b))
if issue:
raise PortSupplyError(issue)
if edge.kind == 'physical':
adjacency[a], adjacency[b] = b, a
issues = reference_supply_issues(ports, adjacency)
if issues:
raise PortSupplyError(issues[0])
def _port_for(self, endpoint: Endpoint) -> PortState: def _port_for(self, endpoint: Endpoint) -> PortState:
try: try:
component = self.components[endpoint.component] component = self.components[endpoint.component]
+115 -93
View File
@@ -1,117 +1,139 @@
"""Process-local warm-up for the numerical simulation runtime.""" """Nonblocking startup diagnostics; editor availability is independent of GCC."""
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass from copy import deepcopy
from datetime import datetime, timezone
import logging import logging
import os import os
from threading import Lock from threading import Event, Lock, Thread
from time import perf_counter from time import perf_counter
from typing import Literal from uuid import uuid4
from .native_codegen.self_test import CheckCancelled, CheckFailure, check_native_runtime
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
WarmupStatus = Literal["completed", "failed", "disabled"] MAX_RETRIES = 5
RETRY_DELAY_SECONDS = 0.5
@dataclass(frozen=True)
class SimulationWarmupReport:
status: WarmupStatus
duration_ms: float
error: str | None = None
def as_dict(self) -> dict[str, object]:
return asdict(self)
_WARMUP_LOCK = Lock()
_WARMUP_REPORT: SimulationWarmupReport | None = None
def simulation_warmup_enabled() -> bool: def simulation_warmup_enabled() -> bool:
raw_value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower() value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower()
if raw_value in {"", "1", "true", "yes", "on"}: if value in {"", "1", "true", "yes", "on"}:
return True return True
if raw_value in {"0", "false", "no", "off"}: if value in {"0", "false", "no", "off"}:
return False return False
raise ValueError( raise ValueError("SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0.")
"SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0."
)
class SimulationRuntimeCheck:
"""One bounded background check per application lifespan, with readable stages."""
def __init__(self):
self._lock = Lock()
self._stop = Event()
self._thread: Thread | None = None
self._history: list[dict] = []
self._started = perf_counter()
self._report = {"checkId": uuid4().hex, "status": "pending", "stage": "queued",
"message": "等待仿真环境启动自检", "durationMs": 0.0,
"attempt": 0, "maxRetries": MAX_RETRIES, "maxAttempts": MAX_RETRIES + 1,
"retryDelaySeconds": RETRY_DELAY_SECONDS, "startedAt": None, "finishedAt": None,
"compiler": None, "compilerVersion": None, "sundialsRoot": None,
"error": None, "failure": None}
def _run_native_warmup() -> None: def snapshot(self) -> dict:
"""Check the native toolchain and XML schema without running SciPy solvers. with self._lock:
report = deepcopy(self._report)
if report["status"] in {"running", "retrying"}:
report["durationMs"] = (perf_counter() - self._started) * 1000
elif report["status"] in {"completed", "failed", "disabled", "cancelled"}:
# Preserve first-failure evidence without surfacing details mid-retry.
report["attemptHistory"] = deepcopy(self._history)
return report
The model-specific executable is generated when a model is submitted. def _update(self, **values):
""" with self._lock:
from app.simulation.native_codegen.build import toolchain if values.get("status") in {"completed", "failed", "disabled", "cancelled"}:
from app.system_xml import validate_system_xml_document values["durationMs"] = (perf_counter() - self._started) * 1000
values["finishedAt"] = datetime.now(timezone.utc).isoformat()
self._report.update(values)
if os.name != "nt": def start(self):
raise RuntimeError("Native v1 currently supports Windows x64 build packaging only.") with self._lock:
toolchain() if self._thread is not None:
validate_system_xml_document(b"<System/>") return
self._started = perf_counter()
self._report.update(status="running", message="正在进行仿真环境首次启动自检",
startedAt=datetime.now(timezone.utc).isoformat())
self._thread = Thread(target=self._run, name="simulation-runtime-check", daemon=True)
self._thread.start()
def _run(self):
for attempt in range(1, MAX_RETRIES + 2):
if self._stop.is_set():
self._cancel()
return
attempt_started = perf_counter()
self._update(status="running", attempt=attempt, stage="queued", error=None, failure=None,
compiler=None, compilerVersion=None, sundialsRoot=None)
def warm_up_simulation_runtime() -> SimulationWarmupReport: def progress(**values):
"""Warm one worker exactly once, returning a startup diagnostic report. if attempt > 1 and "message" in values:
values["message"] = f"正在重试(第 {attempt - 1}/{MAX_RETRIES} 轮):{values['message']}"
self._update(**values)
Ordinary warm-up failures are reported but do not prevent the editor and try:
non-simulation APIs from starting. ``MemoryError`` remains fatal because if not simulation_warmup_enabled():
continuing a worker under memory exhaustion is unsafe. self._update(status="disabled", message="仿真环境启动自检已由服务器配置关闭")
""" return
check_native_runtime(progress, self._stop)
if self._stop.is_set():
raise CheckCancelled()
except CheckCancelled:
self._cancel()
return
except Exception as exc:
failure = deepcopy(exc.details) if isinstance(exc, CheckFailure) else {
"command": [], "cwd": None, "exitCode": None, "stdout": "", "stderr": "",
"errorType": type(exc).__name__,
}
with self._lock:
self._history.append({"attempt": attempt, "status": "failed", "stage": self._report["stage"],
"durationMs": (perf_counter() - attempt_started) * 1000,
"error": str(exc), "failure": failure})
if self._stop.is_set():
self._cancel()
return
if attempt <= MAX_RETRIES:
previous = "首次检测" if attempt == 1 else f"第 {attempt - 1} 轮重试"
message = (f"{previous}未通过,正在准备第 {attempt}/{MAX_RETRIES} 轮重试"
f"(间隔 {RETRY_DELAY_SECONDS:g} 秒);编辑器可正常使用")
self._update(status="retrying", stage="retry-wait", message=message)
LOGGER.warning("%s", message)
# Interruptible on both Windows and Linux; never sleep on the API thread.
if self._stop.wait(RETRY_DELAY_SECONDS):
self._cancel()
return
continue
self._update(status="failed", error=str(exc), failure=failure,
message=f"本次启动自检未通过:首次检测及 {MAX_RETRIES} 轮重试均失败;编辑器仍可使用")
LOGGER.error("Simulation startup self-check exhausted retries at %s; attempt history: %s",
self.snapshot()["stage"], self._history, exc_info=True)
return
else:
with self._lock:
self._history.append({"attempt": attempt, "status": "completed", "stage": "complete",
"durationMs": (perf_counter() - attempt_started) * 1000,
"error": None, "failure": None})
recovery = f"(第 {attempt - 1} 轮重试成功)" if attempt > 1 else ""
self._update(status="completed", stage="complete", error=None, failure=None,
message=f"本次启动自检通过,仿真环境可用{recovery}")
return
global _WARMUP_REPORT def _cancel(self):
self._update(status="cancelled", message="后端正在关闭,启动自检及重试已取消")
from app.simulation.backends import numeric_engine_name def close(self):
self._stop.set()
engine = numeric_engine_name() if self._thread is not None:
self._thread.join(timeout=1)
with _WARMUP_LOCK:
if _WARMUP_REPORT is not None:
return _WARMUP_REPORT
if not simulation_warmup_enabled():
_WARMUP_REPORT = SimulationWarmupReport(
status="disabled",
duration_ms=0.0,
)
return _WARMUP_REPORT
started = perf_counter()
try:
_run_native_warmup()
except MemoryError:
raise
except Exception as exc:
_WARMUP_REPORT = SimulationWarmupReport(
status="failed",
duration_ms=(perf_counter() - started) * 1000.0,
error=f"{type(exc).__name__}: {exc}",
)
LOGGER.exception("Simulation runtime warm-up failed; startup will continue.")
else:
_WARMUP_REPORT = SimulationWarmupReport(
status="completed",
duration_ms=(perf_counter() - started) * 1000.0,
)
LOGGER.info(
"Simulation runtime warm-up (%s) completed in %.1f ms.",
engine,
_WARMUP_REPORT.duration_ms,
)
return _WARMUP_REPORT
def _reset_simulation_warmup_for_tests() -> None:
global _WARMUP_REPORT
with _WARMUP_LOCK:
_WARMUP_REPORT = None
__all__ = [
"SimulationWarmupReport",
"simulation_warmup_enabled",
"warm_up_simulation_runtime",
]
+23
View File
@@ -10,6 +10,7 @@ from typing import Literal
from lxml import etree from lxml import etree
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import port_supply_issue, reference_supply_issues
from app.simulation.performance import profile_phase from app.simulation.performance import profile_phase
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec
from app.simulation.config import SolveIVPConfig from app.simulation.config import SolveIVPConfig
@@ -566,6 +567,8 @@ def _validate_connections(
occupied_physical_ports: dict[tuple[str, str], str] = {} occupied_physical_ports: dict[tuple[str, str], str] = {}
driven_signal_inputs: dict[tuple[str, str], str] = {} driven_signal_inputs: dict[tuple[str, str], str] = {}
referenced_ports: set[tuple[str, str]] = set() referenced_ports: set[tuple[str, str]] = set()
supply_ports: dict[tuple[str, str], PortDefinition] = {}
supply_adjacency: dict[tuple[str, str], tuple[str, str]] = {}
for index, connection in enumerate(document.connections, start=1): for index, connection in enumerate(document.connections, start=1):
path = f"/System/Connections/Connection[{index}]" path = f"/System/Connections/Connection[{index}]"
@@ -707,6 +710,19 @@ def _validate_connections(
connection.line, connection.line,
) )
) )
first_endpoint, second_endpoint = (item[0] for item in resolved_endpoints)
supply_issue = port_supply_issue(
first_port, second_port,
f"{first_endpoint.component}.{first_endpoint.port}",
f"{second_endpoint.component}.{second_endpoint.port}",
)
if supply_issue:
issues.append(_semantic_issue(supply_issue.code, supply_issue.message,
path, connection.line))
if first_port.kind == 'physical':
a, b = first_endpoint.key, second_endpoint.key
supply_ports[a], supply_ports[b] = first_port, second_port
supply_adjacency[a], supply_adjacency[b] = b, a
if first_port.kind == "signal" and { if first_port.kind == "signal" and {
first_port.nominal_role, first_port.nominal_role,
second_port.nominal_role, second_port.nominal_role,
@@ -725,6 +741,7 @@ def _validate_connections(
if spec is None: if spec is None:
continue continue
for port in _active_registered_ports(component): for port in _active_registered_ports(component):
supply_ports[component.id, port.name] = port
if (component.id, port.name) not in referenced_ports: if (component.id, port.name) not in referenced_ports:
issues.append( issues.append(
_semantic_issue( _semantic_issue(
@@ -736,6 +753,12 @@ def _validate_connections(
) )
) )
for issue in reference_supply_issues(supply_ports, supply_adjacency):
component_id = issue.endpoint[0] if issue.endpoint else ''
issues.append(_semantic_issue(issue.code, issue.message,
f"/System/Components/Component[@id='{component_id}']",
None))
def _validate_amesim_medium_references( def _validate_amesim_medium_references(
document: SystemXmlDocument, document: SystemXmlDocument,
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build the pinned native dependency beside the Python virtual environment.
set -euo pipefail
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
NATIVE_ENV="${SYSTEM_SIMULATION_NATIVE_ENV:-$REPO_ROOT/.venv/native}"
VERSION=7.4.0
ARCHIVE_SHA256=679ddacdd77610110e613164e8297d6d0cd35bae8e9c3afc8e8ff6f99a1c2a7b
SOURCE_ROOT="$NATIVE_ENV/build/sundials-$VERSION"
PREFIX="$NATIVE_ENV/sundials-$VERSION"
for tool in curl cmake gcc tar sha256sum; do
command -v "$tool" >/dev/null || { echo "Missing build tool: $tool" >&2; exit 1; }
done
mkdir -p "$SOURCE_ROOT"
ARCHIVE="$SOURCE_ROOT/sundials-$VERSION.tar.gz"
if [[ ! -f "$ARCHIVE" ]]; then
curl --fail --location "https://github.com/LLNL/sundials/releases/download/v$VERSION/sundials-$VERSION.tar.gz" --output "$ARCHIVE"
fi
printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE" | sha256sum --check -
if [[ ! -d "$SOURCE_ROOT/sundials-$VERSION" ]]; then
tar -xzf "$ARCHIVE" -C "$SOURCE_ROOT"
fi
cmake -S "$SOURCE_ROOT/sundials-$VERSION" -B "$SOURCE_ROOT/build" \
-DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_BUILD_TYPE=Release \
-DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=OFF \
-DBUILD_ARKODE=OFF -DBUILD_CVODES=OFF -DBUILD_IDA=OFF \
-DBUILD_IDAS=OFF -DBUILD_KINSOL=OFF \
-DEXAMPLES_ENABLE_C=OFF -DEXAMPLES_INSTALL=OFF
cmake --build "$SOURCE_ROOT/build" --parallel "${CMAKE_BUILD_PARALLEL_LEVEL:-4}"
cmake --install "$SOURCE_ROOT/build"
echo "Native dependency installed in $PREFIX"
echo "The repository .venv Python discovers this default location automatically."
-4
View File
@@ -12,10 +12,6 @@
新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。 新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
## 重点实施计划
- [C 语言数值内核实施计划与可行性评估](other/C语言数值内核实施计划与可行性评估.md):后端数值内核的分阶段迁移顺序、验收门、风险和可行性评估。
## `update-log` 书写规范 ## `update-log` 书写规范
以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。 以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。
@@ -0,0 +1,76 @@
# 工程版本警告与统一参数输入验收
报告版本:1.0.1;日期:2026-09-12;基线:`22579e5` 加本次工作区改动。
本次落实旧组件版本警告和参数输入语义统一。验收案例为 `tests/data/test-mql-8-corrected.json`,157 个组件、178 条连接、1092 个已保存参数。测试没有改动四路/八路基准文件、C 方程、雅可比算法或求解精度。
## 实现与使用规则
- 导入时集中列出不同/缺失版本;运行或生成 XML 时再次警告,兼容的数据使用当前模型。类型、端口、未知参数、范围和非法表达式仍校验,版本警告不会掩盖实际错误。
- 导出工程 JSON 时,兼容节点写当前模型版本;无法转换的草稿保留旧格式/原版本并提示。当前编辑草稿保留来源版本,重新导入新文件后使用导出版本。
- 外部工程格式升级到 `projectSchemaVersion: 2`:数值、数字字符串、表达式均使用 `parameterUnits`,缺省为目录 SI 单位。网页、HTTP、CLI 在进入数值计算前完成求值与单位转换。XML/内部数值构造器严格接收有限 SI 数字和当前版本。
- v1 兼容保持原数值的 SI 含义;不能手工只把版本号改为 2。用网页导出完成转换。内部编辑数据、浏览器草稿及结果快照继续保留 v1/SI 数字表示。
- 单位换算表由网页与 Python 共用。表达式支持受限算术、幂、常量和白名单函数;两端共用测试数据,禁止任意代码、非有限结果和离散参数表达式。
导出时检查数值能否经显示单位精确往返。不能时,该参数改用 SI 单位导出,并在控制台说明,避免物理输入漂移。本八路工程的 30 个压力参数因此从 bar 改为 Pa 显示;数值大小与单位共同保持同一个精确 SI 输入。未额外保存一份隐藏的数值副本。
## 功能验收
真实构建后的网页由临时 `127.0.0.1:8036` 提供,Chromium 151.0.7922.34;未模拟 API 或 C 结果。顺序运行原工程、将全部组件版本改成 `0.0.1` 的工程、浏览器重新导出的 v2 工程。三次均以原始配置运行到 10 s,1002 个采样点。1785 列、共 1,788,570 个结果值逐值相同,编译缓存键也相同。
| 工程 | 导入完成 ms | 点击至 HTTP 响应收齐 ms | 点击至 IndexedDB 保存完成 ms | C 求解 s |
| --- | ---: | ---: | ---: | ---: |
| current | 364.45 | 3759.60 | 3882.24 | 2.6147 |
| old | 255.86 | 3725.61 | 3869.42 | 2.6327 |
| exported-v2 | 237.20 | 3710.86 | 3807.77 | 2.5902 |
这是本机回环、缓存命中的验收记录,各工程各一次;不用于推断远程端口转发速度或长期性能提升。导入时间包含浏览器事件和界面工作,不等于版本检查耗时。
真实网页与 HTTP 分别输入 `2.5`、`"2.5"`、`"=2.5"`、`"2+0.5"`、`"sqrt(6.25)"`,单位为 bar,均生成 250000 Pa;`"=10+10"` degC 均生成 293.15 K。浏览器导出后保留数字或表达式的正确单位语义。浏览器 `pageerror` 为 0。
| 验收层次 | 结果 |
| --- | --- |
| 后台相关回归 | 49 项通过;包含 HTTP XML/网络入口、CLI、SI 数值边界、版本/端口错误、单位、表达式、原生执行与结果传输 |
| 前端相关回归 | 分批覆盖 54 个不同用例,最终通过;包含原参数表、科学计数法、压力、存档、旧版 LMECHN1/FORC、非法参数与精确导出;失败项修正后定向 9 项复测通过 |
| TypeScript/Vite | 生产构建通过;仍有既有的大包体提示 |
| 原八路 CLI 输入 | 新旧适配代码生成 XML 逐字节一致,106719 字节 |
| 全量后台扩展检查 | 运行 362 项;3 处旧夹具缺失错误,1 项跳过,未宣称全量通过 |
全量检查的缺失项为两组测试仍指向不存在的 `AmesimModels/test_mql.ame`,以及缺少 `tests/fixtures/high_stiffness_explicit_rk45.xml`。这些测试/夹具不在本次实现改动内,没有伪造数据或改成通过。原始日志保留。没有重新执行 Amesim 对照。Linux 实测完成;Windows 使用通用 Python/浏览器实现,没有新增平台专用调用,但本次没有 Windows 实机验收。
## 缺失输入后续修复 / 2026-09-12
用户确认移动/删除了夹具后,已按实际文件哈希修复历史 AME 路径;高刚度 XML 未找到,按用户授权改为当前八路完整 BDF 测试。全量后台重新执行 375 项,374 通过、1 条原条件跳过,0 失败/错误。原 RK45 专项未恢复,不将本次替代用例算成原覆盖。另修复前端/审计对已删除 JSON/XML 的依赖,画布复测 11 项通过。上述表格保留前次原始执行结果;文件映射、报告依据及本次测试日志见 [路径修复报告](2026-09-12-test-input-path-refresh.md)。
## 版本检查的独立耗时
在 Chromium 内执行正式 `projectCompatibility.ts` 中的目录索引、版本比较及提示文字生成函数。目录仅建立一次内存 Map,通过 WeakMap 按目录数组复用;每次扫描节点,无逐组件网络请求或文件扫描。预热后每组 200 个样本、每样本 10 次调用;时间分辨率约 0.1 ms,通过批量计时减小量化误差。首次索引建立约 0.10 ms。
| 组件数 | 当前版本:中位/P95 ms | 全部旧版:中位/P95 ms | 旧版检查+提示文字:中位/P95 ms |
| ---: | ---: | ---: | ---: |
| 157 | 0.010 / 0.020 | 0.010 / 0.020 | 0.030 / 0.040 |
| 1000 | 0.030 / 0.040 | 0.060 / 0.070 | 0.160 / 0.230 |
| 10000 | 0.280 / 0.310 | 0.550 / 0.620 | 1.720 / 1.820 |
该表不包含 React 绘制控制台文字、JSON 解析或整模型合法性检查。对实际八路案例,版本扫描和生成提示的中位耗时约 0.03 ms;不构成可感知的等待。
Python 输入适配的完整成本另外测量,包含防御性复制、版本对照、1092 个已保存参数的求值/换算及仿真时间转换,并非版本扫描本身:
| 阶段 | 中位 ms | P95 ms |
| --- | ---: | ---: |
| Pydantic 结构读取 | 2.084 | 2.299 |
| 当前版本输入归一化 | 11.684 | 13.276 |
| 全部旧版输入归一化 | 11.754 | 30.189 |
| 归一化后严格构造 XML | 22.358 | 43.884 |
以上为预热后各 200 次测量,尾部受调度与垃圾回收波动影响。不能把当前/旧版的微小中位数差当成稳定性能变化。
## 文件与复现
- 现行规范:[接口规范 1.2.0](../standard/backend-interface-version-spec-v1.md#61-工程存储与执行入口)、[规范索引](../standard/README.md)。
- 回归代码:`tests/test_project_input_contract.py`、`frontend/tests/e2e/project-input-contract.spec.ts`;共享表达式用例为 `tests/fixtures/parameter-expressions.json`。
- 网页验收:`tests/manual/browser_project_contract.mjs`;后台阶段计时:`tests/manual/benchmark_project_input.py`。
- 原始记录在被 Git 忽略的 `test/project-contract-20260912/`。最终网页记录为 `browser-accepted/summary.json`、`version-check.json`、三份 XML/结果文件、`upgraded-eight.json`、五份单位用例 JSON 和 `acceptance.png`。其他 browser 子目录是诊断过程,不能当作最终验收。
- `backend-input-timing.json`、`backend-targeted.log`、`backend-all.log`、`frontend-regression.log`、`frontend-final-targets.log` 保留对应计时/测试;前端第一次 26 项参数相关通过记录见本报告与执行记录。
本次没有安装新环境或上传 Git;环境和运行产物仍保持忽略。测试结束后停止临时 8036 服务,5173/8000 始终未启动。
@@ -0,0 +1,77 @@
# 测试输入路径修复与八路替代验证
报告版本:1.0.0;日期:2026-09-12;基线:`44b6ea7` 加本次改动。
用户确认曾移动 AME/JSON、删除 fixture,并要求找回实际路径;找不到专项 XML 时先改用当前八路系统。此次修复了测试入口和文档引用,四路/八路工程及 AME 内容均未修改。全量后台检查恢复为 OK;原高刚度 RK45 专项没有被伪装成已恢复。
## 当前文件与身份
后续浏览器建模和优化仍用 [八路 corrected JSON](../../tests/data/test-mql-8-corrected.json) 与 [当前八路 AME](../../tests/data/test_mql.ame)。备用为 [四路 corrected JSON](../../tests/data/test-mql-4-corrected.json) 与 [四路 AME](../../tests/data/test_mql_4.ame)。方便后续删改的目录索引见 [tests/data/README.md](../../tests/data/README.md)。
| 文件(仓库相对路径) | 字节数 | SHA-256 |
| --- | ---: | --- |
| `tests/data/test-mql-8-corrected.json` | 271259 | `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288` |
| `tests/data/test_mql.ame` | 21698560 | `1ff0ea4284b9248260eeceb8b27cd0bc14dbccb43554ab8c0b49900d0b944c3d` |
| `tests/data/test-mql-4-corrected.json` | 457639 | `542494eed8e235cb36eae5f80a0776b812e547d9215fa370949b0882834ebc8b` |
| `tests/data/test_mql_4.ame` | 13578240 | `99a071896671d634d651a7b21f487662dd7c122af8fd3d60ead404a5afa53ed4` |
| `tests/data/AmesimModels/test_mql.ame` | 21708800 | `cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb` |
| `tests/baselines/simulation/test_mql_8/sources/test-mql-8.json` | 272592 | `b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18` |
| `tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml` | 104110 | `0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b` |
历史 AME 原路径为 `AmesimModels/test_mql.ame`,现为 `tests/data/AmesimModels/test_mql.ame`,其身份与原测试要求完全一致。对应历史 JSON/XML 已在 `tests/baselines/simulation/test_mql_8/sources/`。本次只修复 manifest 中的 AME 路径,没有改动历史哈希、golden、采样曲线或耗时。
被删除的 legacy 八路 JSON 的历史 SHA 为 `4f1bf698519ba21429a11a83653adc7e52191f2d2b51fca943ee0b84f62feebd`;现存 sources JSON 是 `b44bf540…`。两者不是同一字节文件。普通画布测试因此转到当前 corrected 工程,历史基准读取则保持自己的 sources/AME 配对。
## 测试及报告来源
| 测试 / 工具 | 原报告或规范中的要求、用途 | 本次处理 |
| --- | --- | --- |
| `tests/test_test_mql_ame_contract.py` | [2026-08-17 日志 22:35](../update-log/更新日志-2026-08-17.md):锁定 XML/JSON 双哈希和配置;[2026-08-18 日志 07:58、23:10](../update-log/更新日志-2026-08-18.md):独立 AME 防漂移、单位/参数/精确端口、manifest 来源绑定 | 指向历史 AME 和历史 sources,保留原物理、哈希和结果断言 |
| `tests/test_amesim_results.py` | [2026-08-18 日志](../update-log/更新日志-2026-08-18.md):AME 二进制结果及变量、时间轴读取,支撑外部物理基准 | 只修复历史 AME 路径,已保存结果继续原样核对 |
| 八路/四路 AME 审计脚本 | [八路模型修正](test-mql-8模型对照与修正-2026-09-11.md)、[四路模型复核](test-mql-4模型复核与输入修正-2026-09-11.md)、[管流求根与四路网页验证](牛顿管流求根与四路网页验证-2026-09-11.md):逐组件、参数和参考端口核对 | 当前 JSON 作为工程输入,AME 图纸独立作为参照;不再依赖已删除 legacy JSON 或独立四路 XML |
| `frontend/tests/e2e/fit-view.spec.ts` | [2026-08-17 日志 20:01、22:48](../update-log/更新日志-2026-08-17.md):大型工程导入、视口、端点、结果系统图线桥 | 改用当前 corrected 八路;按其 P4 布局选取实际交叉边;兼容新版本提示,仍检查展开日志里的加载/恢复成功 |
| `frontend/tests/e2e/simulation-live-mql8.spec.ts` | [八路修正报告的测试范围说明](test-mql-8模型对照与修正-2026-09-11.md):这是 August Python 内部活动心跳流程,不是当前原生验收入口 | 改为读取历史 sources JSON;新证据写入忽略目录,避免覆盖历史报告。普通 4173 配置明确跳过;独立 live 配置依然保留,未重新验收旧遥测流程 |
| 原 `tests/test_high_stiffness_explicit_rk45.py` | [前次输入合同验收](2026-09-12-project-contract-acceptance.md) 披露该夹具缺失;旧测试自身要求刚度 XML 的短时 RK45 事件/重启。未找到另一个单独规定该文件的现行报告;历史运行源码清单出现其测试名不等于恢复了夹具 | XML 未找到,按本次用户授权改为 `tests/test_current_mql8_simulation.py`,用当前八路完整 BDF 运行验证 |
| 后续优化案例 | [现行优化验证约定](../standard/optimization-benchmark-model.md) | 维持 corrected 八路优先;四路只作明确的退路,历史冻结基准不能混入当前性能比较 |
前次所谓“三处旧夹具缺失”是三个报错位置,并不代表三个不同文件。两处依赖同一份被移动的历史 AME,另一处依赖已删除的高刚度 XML。补查还发现前端/审计依赖的 legacy JSON 和独立四路 XML 已删除,一并消除了活动路径依赖。
相关历史日志、模型报告、物性资料路径、native README、数据索引和现行规范已加当前位置或迁移说明。历史 `goldens/`、`runs/`、已归档审计 JSON 中的旧路径和源码状态保留为当时证据,当前位置由 manifest、数据索引和新增历史基准 README 说明。
## 实现及验收边界
- 共享路径集中在 `app/simulation/paths.py`,区分当前八路与 August 历史八路;前端用相对模块 URL 定位工程;审计脚本显式使用 UTF-8 读写文本,并固定写出 LF,避免 Windows 系统默认编码影响中文 JSON。
- 八路 `--check` 逐项检查 AME 的 157 个元件、1092 个公开参数、178 条连接以及时间配置,参数/端口差异均为 0。证据写入 `test/model-audit/mql8/`,不改工程文件。
- 四路 `--check` 从 JSON 经正式 CLI 输入转换生成内存 XML,再对照独立 AME 图纸,81 个元件、582 个参数、90 条连接通过;原 XML 缺失不再阻塞审计。无需另保存一份需同步维护的 fixture XML。
- 新八路运行测试沿正式 JSON → SI XML → 原生 C 路径执行完整 0~10 s,使用工程保存的 BDF、step 0.01、max_step 1e30 和后端 rtol 1e-8。检查终态、事件/重启、时间单调、规则采样点完整性、各列长度和所有值有限,并检查源文件未变。这不等于原 RK45 高刚度覆盖,也不单凭运行成功判定 Amesim 曲线一致。
- 更新 `.gitattributes` 与配套测试,使用现有历史 sources 和当前 JSON 的 LF 规则,避免 Windows checkout 改变受哈希保护的文本字节。
| 验证 | 结果 |
| --- | --- |
| AME 读取、历史契约、当前八路定向运行 | 11 项通过,4.988 s |
| 全量后台 `unittest discover` | 375 项:374 通过、1 条原条件跳过,57.360 s;0 失败/错误 |
| 跳过的后台项目 | Linux 完整发布锁版本实机对照,按原有 `SYSTEM_SIMULATION_VERIFY_LOCKED_ENV=1` 开关控制;不是夹具缺失 |
| 当前四路/八路 AME 审计 | 均通过;另外在临时副本故意修改参数、端口,四个错误输入均被拒绝 |
| 前端定向复测 | 11 项通过,31.4 s;历史真实后端活动回放 1 项因普通配置跳过 |
| 文件身份 | 上表 7 个输入均逐字节等于本次改动前 HEAD,模型未被审计覆盖 |
前端首次扩展检查中,两项仍期待加载信息占据最后一条摘要;新合同会追加旧工程单位提示。现在同时检查最新提示和展开日志中的加载/恢复成功。另一个线桥断言使用旧 P4 布局中已经不相交的边,已换成 corrected 工程实际相交的 P4 #5 边,仍要求生成线桥。第一次还误用普通无后端配置运行了历史 live 测试;独立服务要求现已显式标记,不能将该跳过记为真实浏览器求解通过。
本次没有重跑 Amesim 官方程序,没有新增八路网页求解耗时结论;前端是模拟 API 的画布回归,实际八路完整求解由后台测试完成。全量后台包含现有四路冻结 Amesim 曲线比较。Linux 实测完成,Windows 路径/换行兼容保持,但没有 Windows 实机环境验收。
## 复现与产物
从仓库根执行(Windows 将解释器替换为 `.venv-win\Scripts\python.exe`):
```bash
.venv/bin/python tools/audit_test_mql8_model.py --check
.venv/bin/python tests/model_audit/audit_mql4_model.py --check
.venv/bin/python -m unittest tests.test_current_mql8_simulation -v
.venv/bin/python -m unittest discover -s tests -v
```
在 `frontend/` 中用项目 Node 环境执行 `npm run test:e2e -- fit-view.spec.ts simulation-live-mql8.spec.ts`;Linux 浏览器依赖沿用 `.venv/native/browser-libs/`。需要当前真实网页/HTTP/C 验收时,使用 `tests/manual/browser_native_simulation.mjs` 和 corrected JSON;不以历史 Python 心跳脚本代替。
本次原始日志和身份摘要位于被 Git 忽略的 `test/model-path-refresh-20260912/`:`summary.json`、`targeted.log`、`backend-all.log`、`audit-mql4.log`、`audit-mql8.log`、`audit-negative.log`、`frontend.log`(首次诊断)、`frontend-final.log`(最终复测)。临时 XML/AME 审计证据位于 `test/model-audit/`。环境、缓存、测试产物均未纳入 Git;本次未提交或推送。
临时前端服务已退出;收尾检查 4173、5173、8000 均无监听。`git diff --check` 与本轮新增文档链接检查通过。
@@ -0,0 +1,137 @@
# Amesim 与 C 计算顺序差异核查
> 路径复核 / 2026-09-12:本文引用的旧 `tests/data/test-mql-8.json` 已删除,保留当时实验描述;后续实验输入统一为 `tests/data/test-mql-8-corrected.json`,对应 `tests/data/test_mql.ame`。 详见 [路径修复记录](2026-09-12-test-input-path-refresh.md)。
日期:2026-09-10。分支:`system-optimization`;核查时提交:`0dcb465`。
本次对照当前 `tests/data/test-mql-8.json`、Amesim 2404 的生成代码和本机组件源码,并在 `test/amesim-order-study/` 中进行独立实验。正式模型、C 生成器、数值内核、积分器和雅可比策略均未修改。
## 结论与先前判断的修正
1. **这个 JSON 没有执行节点压力二分循环。** 实际编译得到 48 个压力组、48 个储气状态压力锚点,待求节点压力为 0。此前从通用生成器看到的“256 轮压力协调 × 每节点 48 次二分”分支不适用于本例。
2. **四通节点的参考口连接语义与 Amesim 存在偏差。** 8 个四通节点中只有 1 个直接接到相应储气状态端,另外 7 个的参考口接法不同,其中 3 个会引入流量与比焓的相互依赖。单纯把这些循环算快,不能解决与 Amesim 模型语义不一致的问题。
3. **当前生成器缺少热力变量的依赖排序。** 即使参考口修正,原生成器仍然运行全网流量/焓传播循环。实验中可以在编译时排好顺序,将全网流量计算降至每次状态求值 1 次,并移除该副本的全网焓迭代。
4. **管道内部的数值迭代是另一层问题。** 诊断中常见的是 PNL0001/2 流量函数的 16 次迭代耗尽;全网焓迭代并未耗尽上限。
## Amesim 为什么可以顺序计算
生成文件 `F:/CO2Project/Amesim/test_maql/test_mql_.c` 声明 132 个显式状态,声明隐式状态和生成隐式状态均为 0。文件第 10427 行说明部件按照满足输入依赖的顺序调用;三通、四通节点没有运行时计算函数调用,它们通过变量复用和加法展开。
其气动部件主要交换两类量:
- 储气部件提供当前压力、温度;管道和阀门据此计算质量流率、能量流率。
- 节点从参考口取得压力、温度并传给其他端口,把其他端口的质量流率和能量流率相加后送回参考口。
PN3NODE2/P4NODE2 的参考口是 **port 2**。这是一项固定的计算接口关系,与气体当时向哪个方向流动是两回事;交换参考口与其他口可能改变模型。
在该 Amesim 模型中,8 个四通节点的参考温度、压力全部直接复用 PNL0001 的储气状态。已将 `.var` 中的共享变量位置与生成代码 `GcontStateVarNum` 核对,确认对应项属于连续状态,而非仅根据图形位置推测。
| Amesim 节点 | 参考温度/压力所属管道 |
| --- | --- |
| pnnode4_16 | pneumatic_69 的 t2/p2 |
| pnnode4_17 | pneumatic_68 的 t2/p2 |
| pnnode4_18 | pneumatic_66 的 t2/p2 |
| pnnode4_19 | pneumatic_65 的 t2/p2 |
| pnnode4_20 | pneumatic_72 的 t2/p2 |
| pnnode4_21 | pneumatic_73 的 t2/p2 |
| pnnode4_22 | pneumatic_74 的 t2/p2 |
| pnnode4_23 | pneumatic_71 的 t2/p2 |
此时,温度/压力向支路传递,质量/能量流率向储气部件汇总,最后计算状态变化率。通过储气状态隔开了不同计算阶段,不需要先迭代求出所有连接量。
这里的显式系统仍可使用 BDF 等隐式积分方法;零系统隐式状态也不表示物性函数、管流函数或积分器内部绝无迭代。
## 当前 JSON 的具体偏差
当前网络有 156 个运行部件、178 条连接、132 个状态、1784 个输出、232 个气动端口。Amesim 部件与管线展开后,各数值组件类型数量与该 JSON 一致;数量一致不足以保证参考口关系一致。
| JSON 四通节点 | 当前 port_2 接到 | 独立实验中接到的对应储气端 |
| --- | --- | --- |
| amesim_p4node2_1 | amesim_pnl0001_13.port_2 | 原连接已满足 |
| amesim_p4node2_2 | amesim_pnvo001_2.port_2 | amesim_pnl0001_14.port_2 |
| amesim_p4node2_3 | amesim_pnvo001_3.port_2 | amesim_pnl0001_15.port_2 |
| amesim_p4node2_4 | amesim_pnvo001_4.port_2 | amesim_pnl0001_16.port_2 |
| amesim_p4node2_5 | amesim_pnl0001_21.port_1 | amesim_pnl0001_17.port_2 |
| amesim_p4node2_6 | amesim_pnl0001_25.port_1 | amesim_pnl0001_18.port_2 |
| amesim_p4node2_7 | amesim_pnl0001_26.port_1 | amesim_pnl0001_19.port_2 |
| amesim_p4node2_8 | amesim_pnl0001_27.port_1 | amesim_pnl0001_20.port_2 |
实验只在工程副本中交换后 7 个节点的 port_2/port_3 连线,让参考口连接到同一压力组中的储气状态端。物理支路数量、参数和动态状态没有增加。
对于 `_2/_3/_4`,当前 C 计算存在以下潜在反馈:
```text
阀门流量需要入口比焓
↓
入口比焓来自四通节点 port_2 的能量平衡
↓
该能量平衡又用到阀门流量
```
是否实际激活反馈还与流向有关。给低压侧供气时,阀门可使用另一侧已知比焓;反向流时则可能需要节点返回的比焓。本次反向流诊断使当前全网焓迭代从通常的 3 轮升至 10 轮。
编译时追踪显示:当前流量表达式读取 48 个端口比焓,其中 3 个无法仅由储气状态和简单赋值提前确定,正是 `_2/_3/_4.port_2`。调整参考口后的副本中,这 48 个输入全部可提前确定。
Python 元数据把这些气动端口都声明为 `bidirectional`。目前连接校验主要检查领域、端口占用和物理连接合同,没有针对该 Amesim 子模型检查“参考温度应由谁提供”的因果关系,因此能够接受这种与原 Amesim 接法不同的工程。
## 即使连接修正,为什么旧循环仍然存在
`extended.py` 先把无储气状态端口的比焓初始化为默认气体比焓,再按部件在网络中的顺序执行:
```text
计算全网流量 → 更新全部端口比焓 → 检查变化 → 必要时重复
```
它没有先区分:哪些比焓只需要复制已知状态,哪些流量依赖这些复制结果,哪些能量输出应在流量确定后再计算。全网焓循环结束后还额外计算一次全网流量。
在独立副本中,提前排序这些关系即可生成:
```text
恢复储气状态的物性
→ 传递流量计算所需的参考比焓
→ 计算一次全网流量
→ 按依赖顺序计算其余比焓和节点能量输出
→ 计算导数与展示输出
```
这说明质量/内能状态和比焓端口形式本身并不必然要求全网迭代。它们会增加局部物性反解的成本,但正确的连接和计算顺序仍可以消除本例的全网闭合循环。
## 独立实验结果
三个版本均为独立编译的 C 诊断程序:原模型及原生成器、仅调整参考口的模型及原生成器、调整参考口并按依赖顺序生成的模型。管道公式、误差阈值与物性函数保持相同。
使用初始状态、12 组质量/内能扰动状态,以及 1 组反向阀流状态,共 14 组。它们是用于检验依赖关系的状态点,不能作为完整 10 s 轨迹或 Amesim 曲线对齐的替代品。
| 每次状态求值的工作量 | 原模型 | 仅调整参考口 | 调整参考口并顺序计算 |
| --- | ---: | ---: | ---: |
| 常规诊断状态:全网焓循环 | 3 | 3 | 0 |
| 常规诊断状态:全网流量调用 | 4 | 4 | 1 |
| 反向流诊断:全网焓循环 | 10 | 3 | 0 |
| 反向流诊断:全网流量调用 | 11 | 4 | 1 |
后两个版本在全部 14 组状态的 **132 个导数与 1784 个输出上逐值完全一致**。这项对照隔离了计算顺序变化的影响。它没有比较“修正前后应该相同”:参考口修正确实会改变多个状态点的数值结果,必须按模型修正处理。
本次未进行新的 Amesim/EXE 全程速度或曲线比较。诊断文件中的单次微秒计时含计数开销,不能外推为完整仿真的加速倍数。
## 真正达到迭代上限的地方
本例生成的全网焓循环上限是 928 轮。本次 14 组状态中,原模型最多执行 10 轮,触顶次数为 0;节点压力二分没有生成。
在 12 组普通扰动状态中,每次原模型求值调用 PNL0001/2 支路流量 144 次,144 次均执行满内部 16 轮。顺序版本将该调用数降至 36 次,但这 36 次仍执行满 16 轮。当前函数耗尽循环后返回最后的近似流量,没有将其标记为不收敛错误。
PNL0003 两个储气状态之间的 Darcy 流量则使用固定 48 次二分。本例每次状态求值有 8 次此类管流计算,压力差非零时执行这些局部二分;这与“未知节点压力二分”不同。
因此,优化顺序应是:先对齐节点参考口语义并增加相应检查,再生成正确的静态计算顺序,随后单独检查管道内部的收敛判据和求根算法。管流近似对 BDF 数值差分和小步推进的影响仍需进一步量化;本次不对其贡献比例作结论。
## 证据与复现
- 结构快照和源文件哈希:`test/amesim-order-study/structure.json`。
- Amesim 状态索引与四通节点参考来源:`test/amesim-order-study/amesim-evidence.json`。
- 独立模型副本:`test/amesim-order-study/reference-port-experiment.json`;正式测试输入未覆盖。
- 14 组状态、计数和逐值差异:`probe-cases.json`、`probe-comparison.json`、`*-probes.jsonl`。
- 复现脚本:`.venv-win/Scripts/python.exe test/amesim-order-study/study.py`。
- 当前生成器:`app/simulation/native_codegen/extended.py:261`(物性)、`:362`(未知压力)、`:386`(焓传播)、`:409`(全网循环)。
- 当前管流:`native/components/kernels.c:246` 起;固定 48 次二分和 16/64 轮固定点迭代分别处理不同管流模型。
- Amesim 组件依据:`F:/amesim2404/amesim/libpn/submodels/PN3NODE2.c`、`P4NODE2.c`、`PNL0001.c`、`PNL0002.c`、`PNL0003.c`、`PNVO001.c`。
实验输出保存在 Git 忽略的 `/test/` 下。报告不复制 Siemens 组件实现;相关文件需使用本机已有 Amesim 安装读取。
@@ -0,0 +1,69 @@
# Amesim 元件语义核查与修复
日期:2026-09-13;分支:`system-optimization`。本轮核查公开目录中的 22 个 Amesim 条目,重点是参数编码、选项是否实际参与计算、单位、力方向、初值和分段信号。对照本机 Amesim 2404 子模型 C 文件及其随附说明;没有复制其实现源码进入生产代码。
**同日复核补充:不能将下表理解为对应元件已全面对齐。后续发现 PNL0003 分端换热仍有计算错误,UD00 零时长及部分机械参数边界也有差异;详见 [已修复元件语义复核](Amesim已修复元件语义复核-2026-09-13.md)。本报告保留原修复记录,新增差异尚未修复。**
本轮没有修改 `native/runtime/` 中的积分器、停滞判断或事件恢复策略,也没有开展稀疏矩阵优化。公开参数名、端口名和 0/1 布尔编码不变;修复的是已有选项的实现及外部转换,故保持当前精确 `modelVersion`,避免已有 XML 被无关版本升级拒绝。
## 已修复的问题
| 元件 | 旧问题 | 修复后的行为 |
|---|---|---|
| UD00 | AME 的 `iscyclic=1` 被直接复制为本系统的 `1`,把不循环误作循环 | 外部转换明确为 **AME 1→公共 0,AME 2→公共 1**;浏览器 JSON/XML 不再次转换 |
| UD00 | 不循环时,末段斜坡在全部分段结束后仍向后延伸;未安排最后终点 | 最后终点以后保持末值;开始前保持首值;最后终点纳入分段通知。零时长分段跳过是本系统现有扩展,Amesim 拒绝有效段时长为零,不能称为语义对齐 |
| LSTP00A | 选择几何刚度后仍使用保存的 `kcont` | 使用 `G × wdiam⁴ / (8 × sdiam³ × na)`,几何参数须为正;接触力仍在 C 中计算 |
| MECMAS21 | `theta` 未参与计算 | 按度转换倾角,计入 `mass × 9.80665 × sin(theta)`;正负 90° 的加速度方向分别验证 |
| MECMAS21 | 弹性限位使用线性阻尼增长,与 LSTP00A 不同 | 二者共用指数接触公式;穿透量等于 Pdis/Pdmin/Pdmax 时达到约 63.2% 阻尼,并保留允许/禁止负接触力的选项 |
| MECMAS21 | 恢复碰撞模式仍计入隐藏的库仑摩擦 | 按原子模型,恢复碰撞只保留启用时的黏性摩擦、风阻;干摩擦不参与 |
| PNL0001/2/3 | `mode=1` 只是关闭环境换热,`k` 未生效 | 实现多方过程;`k=1` 等温,其他 k 按质量变化计算温度与压力;带换热模式保留质量/能量方程 |
| PNCH012 + PNRP17 | 初始储气量只按死容积/固定容积计算,随后加入活塞容积时初始 p/T 被改变 | 按活塞及相连质量块初始位置计算完整容积,再初始化储气量;紧凑和扩展 C 生成路径均已修复 |
多方模式对照 `pn2vol1` 说明:固定容积中 `dm/dt=Σ流量`、`dT/dt=(k−1)T/m × dm/dt`,且 `p=mRT/V`。实现使用质量/温度作为内部状态;U 作为结果由状态导出。和 Amesim 的这一简化模式一致,储气质量采用理想气体关系,流阻所用密度及端口焓仍来自所选介质;不能把这种模式解释成完整的真实气体能量守恒模型。直接相连的管路储气单元须采用相同热力学模式和多方指数。
### 尚未实现的能力改为明确拒绝
静摩擦保持、脱离和高级干摩擦/Stribeck 尚未实现,本轮不加入需要进一步讨论的摩擦切换机制。现在执行编译会拒绝这些激活的配置,不再忽略参数后给出看似正常的结果;关闭摩擦、恢复碰撞中的隐藏干摩擦参数仍按其不生效的语义处理,工程仍可编辑和保存。
同时补齐 MECMAS21 的活动参数约束:非恢复碰撞且启用摩擦时 `fcoul ≤ fstick`,`dvel/astrib > 0`;恢复碰撞要求 `restdvel > 0`。因此现阶段可完整执行黏性摩擦、风阻及无干摩擦的模式,不能宣称完整支持所有摩擦选项。
冻结 Python 基准有三组 `fcoul=2、fstick=0` 的活动干摩擦输入,Amesim 本身也拒绝这种组合。历史文件保持原样,回归改为确认两个 C 入口明确拒绝;恢复碰撞那组则用独立牛顿方程验证去掉干摩擦后的加速度。
## 其余条目的核查边界
| 条目 | 本轮核查结论 |
|---|---|
| 理想空气介质、氦气介质 | 是限定介质/物性方法的公开条目;`property_model=0` 是各自的公共编码,不是 PNGD00 所有气体/EOS 选项的原始编号 |
| STEP0、F000 | 已检查阶跃前后值与时刻、零力输出,未发现新的编码问题 |
| FORC | 公开 `direction=±1` 与原模型几何方向转换已有明确处理;图标旋转不代替方向参数 |
| LMECHN1 | `sum=1`、活动端口数量、公共力平衡规则已检查;公开最多 20 个支路端口是范围限制 |
| PNPL01 | 封闭口零流量语义已检查 |
| PNCH023 | 固定容积、初始绝对 p/T、换热系数与面积的组合已检查 |
| PNOR001、PNVO001、固定开度 PNVO001 | `flowset=1/2/3` 含义相同;Cv/Kv 采用各自单位定义换算;有效开度限制在 0–1。固定开度条目是明确的公开变体 |
| PNL00R | 阻性管段没有储气状态;管径、长度、相对粗糙度和反向流符号沿用既有已验证实现 |
| PN3NODE2、P4NODE2 | 参考量传递与支路汇总遵循公共供需合同,没有把原始有向变量符号直接视为公共端口符号 |
结合前表,覆盖全部 22 个公开条目。这里的“未发现新问题”限于本轮检查的编码、参数选择和接口约定,**不是逐点证明所有物性、所有工况都与 Amesim 相同**。公共端口统一采用连接守恒符号;例如本系统 Fvisc 是带方向的力贡献,而原模型展示量采用阻力约定,曲线比对仍需显式映射。PNCH012 的固定外部容积参数、固定开度阀也属于公开扩展,不应冒充原模型全部接口的一比一复制。
## 当前模型文件
四路、八路 corrected JSON 各仅修改两个 `iscyclic`,仿真时长仍为 10 秒,未修改 AME。网页里的已有存档不自动改写,请重新导入修正文件;不能把所有历史 JSON 中的 `1` 批量改为 `0`,其他工程可能确实需要循环。
| 文件 | 修复前 SHA-256 | 修复后 SHA-256 |
|---|---|---|
| `tests/data/test-mql-4-corrected.json` | `542494eed8e235cb36eae5f80a0776b812e547d9215fa370949b0882834ebc8b` | `c8ed9efa83d111e58aa9e20e158448a7a5f45d4bd1e2a6360dd929e34919b9eb` |
| `tests/data/test-mql-8-corrected.json` | `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288` | `60ed16c71b72be4ae71ca7980f4f8cdcdf6b4c4c4c8950863c2390960b71ced4` |
默认八路 AME 仍为 `tests/data/test_mql.ame`,本次 SHA-256 为 `319b27ac1b8ceb7e960cc05592efbfc19664e70479fe9bf36000b823c3c928f8`。它保存 50 秒、0.05 秒输出间隔、rtol=1e-7;JSON 保存 10 秒、0.01 秒输出间隔,后台 rtol=1e-8。参数审计与时间配置差异分别报告,不再把时间配置不一致冒称为元件参数错误,也不把 AME 算法枚举等同于 CVODE BDF。
## 验证结果
- 两份 AME 图纸独立审计通过:八路 157 个元件、1092 个参数、178 条连接;四路 81 个元件、582 个参数、90 条连接。当前参数与精确端口连接均无待修正项。
- 八路只将修正 JSON 的时长延长为 50 秒,原 BDF 求解器完整结束:**纯求解 10.746 秒**,9576 个接受步、669 个拒绝步、1 次状态切换;5002 个输出时刻全部有限。56 个储气质量量总和约 5.566893015 kg,最大漂移 `1.69e-14 kg`。
- 四路原 10 秒配置完整结束:**纯求解 0.888 秒**,1003 个输出时刻全部有限。
- 多方管路实际 BDF 积分 0.02 秒,验证 `T/(m/m₀)^(k−1)` 不变量,相对误差 `3.35e-8` 以下;专项也覆盖三种管路、等温/多方、直接相连的储气单元和初始状态。
- 元件、XML、原生目录及启动自检综合回归完整通过 43 项;最终元件与原生目录回归 **14 项通过**,记录在 `final-component-tests-recheck.log`。上一轮有一个独立 `--probe` 进程返回空 stdout,原失败日志保留,重跑全部通过;没有修改原生探针来忽略空输出。`final-launch-and-contract-tests.log` 中 25 项通过,包含编译进程恢复、启动自检和冻结历史合同。最后循环事件去重与进程检查另有 9 项通过。
上述耗时是本轮功能回归的观察值,不是隔离负载的性能评测。本轮没有重跑 Amesim,也没有重新宣布完整曲线一致;原积分器停滞问题仍等用户审阅后另行处理。
证据在被 Git 忽略的 `test/component-semantics-20260913/`:输入变更、工具安装说明提取、专项测试日志、四路与多方运行结果。八路原始输出在 `test/mql8-50s-20260913/semantic-fixes-production-retry/`。所有正式实现继续使用 C 数值内核;未恢复 Python 积分器。C 修改使用跨平台 C11,构建继续兼容 Windows/Linux;本次真实运行平台为 Windows,未在 Linux 服务器实跑。
@@ -0,0 +1,92 @@
# Amesim 已修复元件语义复核
日期:2026-09-13;分支:`system-optimization`。
本次针对上一份报告“已修复的问题”涉及的 UD00、LSTP00A、MECMAS21、PNL0001/2/3、PNCH012 和 PNRP17 复核,排除已单独说明的静摩擦保持和高级干摩擦。依据本机 Amesim 2404 子模型源文件、随附帮助及当前生产 C 程序的最小探针结果。没有修改生产元件、积分器、工程参数或运行中的后端;没有重跑完整 Amesim 模型。
**结论:不能确认这些元件已经全面对齐。原先修改的若干核心公式已对齐,但还发现了 PNL0003 换热计算错误、若干活动参数边界不一致,以及环境参数和适用范围限制。上一份报告中把零时长分段跳过也列作对齐行为,不准确。**
## 逐项结论
| 元件 | 已核对一致的部分 | 仍有差异或验证边界 |
|---|---|---|
| UD00 | 外部循环编码转换;正时长分段的线性插值、开始前首值保持、非循环结束后末值保持 | 我们接受有效段时长为 0,Amesim 初始化拒绝;未使用段时长为 0 不属于这个问题。事件内部的试算调用约定不同,不能据函数值核对就宣布积分器事件处理等价 |
| LSTP00A | 几何刚度公式;按公共端口方向转换后的间隙/相对速度;指数接触阻尼;负接触力保留或截零 | 数值模式 kcont=0 我们接受而 Amesim 拒绝;几何模式 G=0 我们拒绝而 Amesim 的子模型初始化检查允许 |
| MECMAS21,排除未实现干摩擦 | 默认重力下的倾角分量;上下弹性限位的指数阻尼和方向;恢复碰撞模式不计算干摩擦,保留启用的黏性摩擦和风阻 | 弹性限位参数边界尚未对齐;重力固定为 9.80665,未实现 Amesim 的 GRAV0 环境覆盖;碰撞事件处理机制仍不能宣称全面等价 |
| PNL0001、PNL0002 | 固定容积、mode=1 多方储气方程;mode=2 本地温度换热项及质量/能量守恒形式 | 公开 k、rr 范围比原子模型检查更窄;本次未重新验证全部介质物性、流阻曲线和极端状态 |
| PNL0003 | 两个半容积、各自的多方储气方程、中心质量/焓流守恒方向 | **mode=2 的两个换热项错误使用平均温度,尚未对齐**;同样存在 k、rr 范围限制 |
| PNCH012 + PNRP17 | 直接相连活塞的初始完整容积;在该容积下满足指定初始绝对 p/T;气腔能量中的环境换热和压力做功项;有效活塞面积、相对运动对应的容积及变化率 | 气压力计算固定采用 101300 Pa 参考大气压,没有对应 PNATP00 的覆盖;固定外部容积参数是公共扩展,当前 C 生成器按直接相邻 PNRP17 收集动态容积,不能据此声称任意供容积元件/连接均已支持;极小容积/负状态时的求解恢复策略未对齐证明 |
“方程形式一致”以采用相同介质状态关系和统一单位、端口方向为前提。不同内部状态选择可以表达同一物理方程,不能把状态变量名称不同直接当成语义错误;同样,形式一致也不能替代完整曲线验证。
## 已复现的计算问题:PNL0003 分别换热
Amesim 对两个半容积分别使用自己的温度:
```text
Q1 = kth × A/2 × (Text − T1)
Q2 = kth × A/2 × (Text − T2)
```
当前实现先计算 `(T1+T2)/2`,再用这一温度计算两个相同的换热项。对两个半容积的总换热功率,这个写法可能恰好相同;但分配到每个半容积的能量不同,后续温度、压力及流量会受到影响。
最小算例使用理想气体,两端封闭、初始压力均为 200000 Pa,避免中心流量干扰;管径 0.02 m、管长 1 m、kth=10 W/(m²·K),T1=400 K、T2=200 K、Text=300 K。
| 量 | 按 Amesim 方程独立计算 | 当前生产 EXE 探针 |
|---|---:|---:|
| 中心质量流量 | 0 kg/s | 0 kg/s |
| 半容积 1 的 dU/dt | −31.4159265359 W | 0 W |
| 半容积 2 的 dU/dt | +31.4159265359 W | 0 W |
这是实际方程差异,不是积分容差、求解速度或输出采样造成的。这里的 Amesim 预期值来自已核对的方程,不冒称本次运行了 Amesim EXE。
定位:本系统 `app/simulation/native_codegen/extended.py:577`;Amesim `F:/AMESim2404/Amesim/libpn/submodels/PNL0003.c:377` 和 `:382`。
建议后续使用当前半容积自己的温度,并补充两端温度不同、环境温度位于两者之间的回归。仅验证初始温度相同或 kth=0,无法发现该问题。
## 已复现的参数边界差异
以下本系统结果经过实际构建网络和执行编译校验确认。Amesim 一栏依据子模型初始化检查;不是逐个启动 Amesim 后得到的结果。
| 配置 | 本系统 | Amesim 初始化检查 |
|---|---|---|
| UD00 的有效段 t1=0、nstages=2 | 接受,跳过该段 | 拒绝,要求每个有效段终点严格晚于起点 |
| LSTP00A 数值模式 kcont=0 | 接受 | 拒绝,要求 >0 |
| LSTP00A 几何模式 G=0 | 拒绝 | 只拒绝 G<0 |
| MECMAS21 弹性限位 Kbmin=0 | 接受 | 拒绝,要求上下限位刚度均 >0 |
| MECMAS21 弹性限位 Dbmin=0 | 接受 | 拒绝,要求上下限位阻尼均 >0 |
| MECMAS21 弹性限位 xmin=1、xmax=−1 | 接受 | 拒绝,要求 xmin≤xmax |
| PNL 多方模式 k=2.5 | 拒绝,公开上限为 2 | 子模型只要求 k>0 |
| PNL 相对粗糙度 rr=0.2 | 拒绝,公开上限为 0.1 | 子模型只要求 rr≥0 |
前六项属于元件活动参数边界的差异;后两项属于已公开的支持范围限制,不表示共同支持范围内的方程一定错误。未启用参数的检查也需要单独规定,不应为对齐有效段限制而禁止 UD00 未使用段保存默认 0。
源码证据:UD00.c:318;LSTP00A.c:136、:152;MECMAS21.c:225、:234;PNL0001.c:159、:165,PNL0002/3 有对应检查。本系统位置:`signals/sources.py:12`、`semantics.py:43`、`mechanical/translational.py:61`、`flow/pipes.py:59`。
建议后续统一执行时的活动参数检查:既拒绝 Amesim 拒绝的组合,也避免错误拒绝原模型允许的活动参数;编辑、保存与求解校验仍应按现有协议各自承担责任。
## 两个默认环境条件
MECMAS21 的 Amesim 原模型从 `getgravity_()` 取重力;其随附帮助说明可由 GRAV0 改变,默认值为 9.80665 m/s²。目前本系统使用的就是这个默认值。因此倾角公式已经补上,但尚未支持自定义重力。
PNRP17 在 Amesim 中接收表压,本系统使用绝对压力后固定减去 101300 Pa。`pn2getatp` 随附帮助确认这是 Amesim 默认大气压,同时 PNATP00 可以改变该值。本系统尚未支持这种环境覆盖。不能把默认环境下对齐推广为任意环境配置下都对齐。
## 对当前四路、八路测试文件的影响
再次读取 `tests/data/test-mql-4-corrected.json` 和 `tests/data/test-mql-8-corrected.json`:
- UD00 的有效段时长均为 0.8 s、10 s,不存在有效零时长段。
- PNL0003 均使用 mode=2,但 kth=0,因此新发现的分端换热错误不会在这两份当前配置中生效。
- LSTP00A 使用数值刚度且 kcont>0;MECMAS21 使用理想限位或无限位,theta=0,不触发上述弹性限位参数问题。
- 当前目录中没有实现 GRAV0、PNATP00;默认对标模型的已审计元件清单也不包含它们。
这些判断只排除本次新发现的触发条件,**不表示完整曲线已经验证一致,也不否认尚待审阅的积分停滞问题。** PNCH012 在当前模型中有非零环境换热,该气腔项使用自身温度,不能把它与 PNL0003 的平均温度错误混为一谈。
## 本次验证与证据
- 原有 `tests.test_amesim_semantics` 共 10 项全部通过,耗时 31.444 s。说明原修复的专项检查仍通过;旧测试没有覆盖这次的非对称换热与若干参数边界。
- 新的独立审计探针确认上述 PNL0003 差异及 8 组参数边界行为。
- 证据脚本和输出:`test/component-semantics-20260913/followup_semantic_audit.py`、`followup-semantic-audit.json`、`followup-regression.log`。test 目录依仓库约定不纳入 Git。
- 真实运行平台为 Windows,未在 Linux 服务器运行;本次没有生产代码修复。
下一步应优先修复 PNL0003 分端换热,然后对齐活动参数校验;自定义重力、大气压和通用容积传递是需明确范围的能力补齐。完成相应元件级验证后,再对当前四路、八路做完整 Amesim 曲线对比。
@@ -0,0 +1,143 @@
# Amesim 摩擦与管路功能补齐验证
日期:2026-09-13。分支:`system-optimization`。参照本机 Simcenter Amesim 2404。
## 结论与范围
已补齐 MECMAS21 静摩擦保持、高级干摩擦和 Stribeck 过渡,修正其弹性限位参数检查;调整 LSTP00A 的零刚度/零剪切模量边界、PNL0001/2 的多方指数范围,并修正 PNL0003 两段各自的换热温度。
本次新建并实际运行了 **13 个 Amesim 算例**,每个运行 3 秒;对应平台 C 程序均运行到 3 秒。另外用平台的 **JSON→XML→正式后端仿真函数**完整运行两个联合小系统,确认参数解析、连接编译、结果生成与组件单独执行一致。本次没有在浏览器界面点击运行,后端入口为 `app.main.run_system_xml_simulation`。
常规采样网格上的连续状态和力/热力学曲线吻合。简单摩擦恰好脱离时有一个左右取值不同的采样点,详见下文,不能将结果表述成逐点完全相等。
重力保持 9.80665 m/s²,大气压保持 101300 Pa。根据用户要求,本轮不增加环境重力、大气压的可调组件。没有修改 CVODE 积分公式、稠密分解/条件着色算法,也没有处理此前另行审阅的积分时间停滞问题。
## 已完成的功能
| 元件 | 本次行为 |
|---|---|
| MECMAS21 简单摩擦 | 静止时干摩擦抵消外力;超过静摩擦阈值进入滑动;滑动时使用库仑摩擦;减速停住后重新检查保持或反向启动。 |
| MECMAS21 高级摩擦 | 增加 ±dvel 低速保持区间;支持 Stribeck 过渡、黏性摩擦和风阻共同作用。Amesim 2404 在高级模式采用约 0.1% 的脱离滞环,5 N、7 N 两组实际运行分别验证了 5.005 N、7.007 N 附近脱离。 |
| MECMAS21 理想限位与摩擦 | 限位挡住质量时,由限位承担外力,干摩擦输出为零;反向外力足以脱离后恢复运动。碰撞后的摩擦状态随接受的事件一起更新。 |
| MECMAS21 弹性限位 | 活动模式要求 Kbmin、Kbmax、Dbmin、Dbmax 全部大于零,Pdmin/Pdmax 允许零,xmin≤xmax。参数检查在执行编译阶段进行。 |
| MECMAS21 恢复碰撞 | 延续 Amesim 该模式只计算黏性摩擦和风阻、忽略干摩擦的行为。原有两种积分方法的碰撞回归通过。 |
| LSTP00A | 数值模式 kcont 必须大于零;几何模式允许 G=0,sdiam、wdiam、na 必须大于零。G=0 时弹簧力为零,接触阻尼仍然有效。 |
| PNL0001/2 | k 必须大于零,取消原先的 k≤2 限制;实际以 k=2.5 验证。PNL0003 共用的 k 元数据同步采用相同边界。 |
| PNL0003 | 两半管分别使用自身温度计算对外换热,而不是共同使用两段平均温度。 |
高级摩擦滑动分支的阻力幅值为:
`Fcoul + (Fstick − Fcoul) × exp(−3 × |v| / astrib)`。
PNL0003 两段换热功率分别为:
`Q1 = kth × π × diam × le / 2 × (Text − T1)`,
`Q2 = kth × π × diam × le / 2 × (Text − T2)`。
以两段 400 K、200 K,外界 300 K,管径 0.02 m、管长 1 m、kth=10 为例,独立理想气体封闭初态的换热功率为 −31.4159265359 W 和 +31.4159265359 W。旧的平均温度写法会错误地把两段都算成零。
## 数值实现与状态保存
每个有干摩擦的刚性运动组增加一个内部状态 `_friction_mode`,取保持、正向滑动、反向滑动三个值。在连续积分期间其导数为零,只在接受的脱离/停止/反向/限位事件处修改并重新初始化积分历史。
每次计算先按现有机械连接求出不含干摩擦的驱动力,再计算干摩擦并代入同一组线性力平衡。这是两次已有的线性表达式求值,未引入压力、流量、焓的额外非线性迭代。低速事件检查会增加方程求值次数,本轮验收目标是功能与结果一致性,没有据此作性能收益承诺。
保持状态随采样状态一起保存,函数不会根据试算或绘制历史曲线改变全局摩擦标志。回归额外验证了乱序查询同一状态的输出一致。模型编译的依赖登记增加了新函数和内部状态;雅可比算法本身保持现状。
多质量刚性同速组中的静摩擦反力按各质量静摩擦容量分配。多个静摩擦约束同时保持时,单个元件反力可能存在不唯一性;本次 Amesim 联合验证中的两个质量通过弹性接触连接,并非两个质量直接刚性串联。不能把本次结果扩展成任意欠定静摩擦反力分配都已与 Amesim 逐元件验证。
## 对照条件与单位
- 所有实际 Amesim 算例:3 s,变步长隐式设置,误差限 1e-9,最大积分步长 0.002 s,输出间隔 0.001 s。
- 独立平台 C 程序:BDF,rtol=1e-9,最大积分步长 0.002 s,输出间隔 0.001 s。另有简单摩擦和大低速区间算例的 RK45 回归。
- 联合系统后端:保持平台默认误差设置,JSON 中选择 BDF、3 s、0.002 s 最大步长和 0.001 s 输出间隔。
- 气动介质:显式使用气体索引 1 的氦气 Peng–Robinson 设置;平台 JSON 已指定匹配的介质索引。导出时不得把默认介质索引 0 与显式氦气索引 1 混用。
- 表中误差:将平台输出插值到 Amesim 的 0.001 s 常规采样时刻,计算整个 0–3 s 区间最大绝对差。平台另存事件点,Amesim 固定间隔数据未必包含这些瞬时时刻。
- Amesim 管路压力变量本次使用 Pa 表压,比较时加 101300 Pa 转为绝压;不是再乘 1e5。MECMAS21 限位刚度的 N/mm、阻尼的 N/(mm/s)、穿透量的 mm,分别换算到平台 SI 单位。
- 机械联合系统的 Amesim v1/x1 与平台全局坐标相反;比较时反号,且交换上下限位力。LSTP00A 的 gap 从 mm 转为 m。单质量驱动算例则通过 FORC 方向设置使用相同 v1/x1 方向。
## 单元件实际运行结果
下列 11 个单元件算例全部完整运行 3 s;源 `.ame`、对应 JSON/XML、双方 CSV 已收入交付包。
| 算例 | 主要最大绝对差 |
|---|---|
| 简单摩擦 | 速度 3.79e-9 m/s;位移 5.47e-9 m;脱离单点例外见下文。 |
| 高级摩擦、无 Stribeck | 速度 3.79e-9 m/s;位移 8.68e-9 m。 |
| 高级 Stribeck | 速度 5.31e-8 m/s;位移 3.34e-8 m;干摩擦 3.01e-6 N。 |
| 高级摩擦叠加弹性限位 | 速度 6.89e-8 m/s;位移 2.42e-9 m;加速度 1.82e-5 m/s²。 |
| 高级摩擦叠加理想限位 | 速度 1.72e-8 m/s;位移 2.96e-9 m;干摩擦 4.85e-7 N。 |
| 扩大低速区间、非零初速、风阻 | dvel=0.005 m/s、初速 0.001 m/s;速度差 8.74e-9 m/s,位移差 2.10e-9 m;验证初始低速保持与后续停止。 |
| LSTP00A 几何刚度 | 接触力 6.70e-8 N,间隙 8.87e-10 m。 |
| LSTP00A 零剪切模量 | 接触力 2.89e-8 N;阻尼仍有非零力,间隙差 8.39e-10 m。 |
| PNL0001,k=2.5 | 管内压力 0.0205 Pa,温度 1.85e-5 K。 |
| PNL0002,k=2.5 | 管内压力 0.142 Pa,温度 1.28e-4 K;相连储气腔最大压力差 0.216 Pa。 |
| PNL0003,非对称温度换热 | 两半最大压力差 0.000169 Pa,最大温度差 8.42e-7 K。 |
管路对照并非按位相等;本次全区间最大压力差不足 0.22 Pa,相对于约 200000 Pa 的压力约为百万分之一。温度与多方关系、换热方向一致。现有管阻根求解实现及积分器误差控制不同仍可带来这种小量差异,本轮未继续修改其求解算法。
### 简单摩擦的单个切换采样点
外力在 t=0.15 s 达到 5 N。Amesim 的常规输出点保存切换前的静止分支,平台该事件点保存切换后的滑动分支,因此该点干摩擦差 2 N,加速度差 2 m/s²。
原始数据和 `comparison.json` 均保留这个最大差。单独注明并排除恰好该时刻后,干摩擦最大差 4.46e-10 N,加速度最大差 8.24e-10 m/s²;整个区间的速度、位移仍分别只有 3.79e-9 m/s、5.47e-9 m 的差异。回归同时保留 0.149 s 和 0.151 s 的前后分支检查,而不把瞬时跳变强行平滑成相同数据。
## 两个联合小系统
### 机械系统
连接:分段力信号 → FORC → 左质量(高级 Stribeck+弹性限位)→ LSTP00A → 右质量(简单干摩擦+弹性限位)→ 零力源。
三秒内发生启动、接触、脱离、换向、限位压缩和再次静止。独立 C 程序记录 12 次离散状态切换。
平台正式后端相对 Amesim 的最大差:
| 输出 | 最大绝对差 |
|---|---:|
| 左质量位移 | 3.26e-8 m |
| 右质量位移 | 3.75e-8 m |
| 左质量速度 | 1.09e-6 m/s |
| 右质量速度 | 6.03e-7 m/s |
| 接触力 | 8.20e-5 N |
| 左质量干摩擦 | 8.20e-5 N |
![机械联合系统](../../test/component-semantics-20260913/delivery/joint_mechanical.png)
### 气动系统
连接:封闭储气腔 → PNL0001(k=2.5)→ PNL0002(k=2.5)→ PNL0003(两半初始 320/280 K,与 300 K 环境换热)→ 封闭端。
平台正式后端相对 Amesim,四个管内压力输出最大差 **0.133 Pa**;管内温度输出最大差 **1.12e-4 K**。两个系统的后端均完整返回 3 s 结果。
![气动联合系统](../../test/component-semantics-20260913/delivery/joint_pneumatic.png)
## 回归与限制记录
- 元件语义测试:11 项通过,覆盖活动参数、零剪切模量、多方指数 2.5、两段独立换热等。
- Amesim 实测冻结数据回归:1 项测试内含 8 个算例、10 次积分运行,全部通过;覆盖 BDF 和 RK45、静止/启动/反向/限位和联合系统。
- 原生目录与机械 XML 回归:16 项通过,包含原有恢复碰撞和代数模型两种积分方法。
- 结构依赖回归:6 项通过。旧目录有 3 个 fcoul>fstick 的非法历史输入,已改为验证明确拒绝,未修改冻结输入。
- 以上相关测试合计 **34 项通过**。扩展运行的旧 `NativeJacobianProbeTests` 仍在预检失败:`native-jacobian-cache-state.json` 绑定的八路输入摘要与当前 corrected JSON 不符。该固定状态夹具、模型输入及其摘要均未在本轮改写;需要后续在求解器审阅中重新建立一致基准。不能将完整雅可比测试套件标记为全绿。
- `NativeJacobianRuntimeTests` 的独立私有 ABI 检查依赖 Linux SUNDIALS 静态库,本机 Windows 按其既有规则跳过。
- Windows 上实际通过原生 C 编译、链接、运行及 Amesim 对照。生产代码只使用 C11、现有构建器和跨平台状态/事件接口,无新增 Windows 专用工具链路径。本轮没有 Linux 实机结果。
- Amesim API 用于新建模型、生成代码及参数文件;本机 JobManager 无可用运行资源时,关闭 API 后独立启动其生成的本机可执行文件,均正常完成。原始运行日志和 `.results` 保留在 `test/component-semantics-20260913/feature-reference/*-run/`。
## 文件与复现
交付包:[amesim-component-validation-20260913.zip](../../test/component-semantics-20260913/amesim-component-validation-20260913.zip)。含 13 个新算例的 JSON/XML/AME、单位和坐标已统一的双方 CSV、两份后端 CSV、对照统计和曲线图。`.ame` 保存模型与设置,本次仿真测量值另存于 CSV。
平台可直接导入:
- [机械小系统 JSON](../../test/component-semantics-20260913/delivery/joint_mechanical.json)
- [气动小系统 JSON](../../test/component-semantics-20260913/delivery/joint_pneumatic.json)
运行中的后端需要重新启动以加载 Python 编译器及元件参数定义的修改;新生成的 C 模型通过已有构建指纹使用更新后的内核。
无需 Amesim 安装即可运行已冻结的实测回归:
```text
python -m unittest tests.test_amesim_semantics tests.test_native_friction tests.test_native_catalog tests.test_amesim_mechanical_xml tests.test_native_jacobian_structure.StructuralDependencyTests -v
```
本机 Amesim 构建脚本和对照脚本保留在 `test/component-semantics-20260913/`;实测冻结数据在 `tests/fixtures/amesim-component-semantics-2404.json`,带原始 Amesim 结果 SHA-256。交付内容不包含 Siemens 元件库实现源码。
@@ -0,0 +1,128 @@
# Amesim 非零相对误差与近零原值核验
**结论:按下列近零分界处理后,仍不能判定所有曲线均在 ±5% 内。** 压力、温度、容积、体积功率、质量及机械量的明显非零采样均满足 ±5%;节点焓流、节点质量流和信号在部分采样点超限。近零点只列原值和绝对差,不用极大相对百分数评价。
本次重新分析上一轮同一 AME、同参数和初始状态的原始结果,没有重新运行或改变模型。四组采样区间互相重叠,统计点数是“曲线×采样点”,不能解释成独立实验次数或连续时间覆盖率。两版主比较为 Amesim 与修复后平台;附表同时保留修复前数值。
## 判据和近零分界
明显非零:`|Amesim| > ε`,计算 `100 × (平台 − Amesim) / Amesim`,绝对值大于 5% 为超限。近零:`|Amesim| ≤ ε`(包含严格零),列出 Amesim、平台原值及绝对差。如果参考近零而平台明显非零,也保留异常,绝不自动算通过。
用户没有指定工程分辨率;以下 ε 是本次明确采用的**暂定报告分界,不是 Amesim 官方公差或用户批准的验收标准**。使用带单位的绝对分界,使不同运行窗口和异常大峰值不会改变同一个物理数值的分类。另计算 ε 缩小/放大 10 倍的敏感性。
| 物理量 | ε | 单位 |
| --- | ---: | --- |
| 绝对压力 | 1 | Pa |
| 温度 | 1e-06 | K |
| 节点参考端焓流 | 1 | W |
| 节点参考端质量流 | 1e-06 | kg/s |
| 速度 | 1e-06 | m/s |
| 位移 | 1e-09 | m |
| 间隙 | 1e-09 | m |
| 气体质量 | 1e-12 | kg |
| 气室容积 | 1e-12 | m3 |
| 活塞容积 | 1e-12 | m3 |
| 活塞容积变化率 | 1e-12 | m3/s |
| 气室有效容积变化率 | 1e-12 | m3/s |
| 气室体积功率 | 1 | W |
| 接触力 | 1e-06 | N |
| 信号 | 1e-12 | 1 |
压力沿用曲线对比的绝对压力(Amesim 表压加 101300 Pa),温度为 K。相对误差依赖物理量的零点,不能把这两个统计直接当成表压或摄氏温度的相对误差。
## 明显非零区域:修复后最大逐点相对误差
| 物理量 | 最大绝对相对误差 (%) | 是否超过 5% |
| --- | ---: | --- |
| 绝对压力 | 0.065922354 | 否 |
| 温度 | 0.023452192 | 否 |
| 节点参考端焓流 | 67.617447 | 是 |
| 节点参考端质量流 | 10553.206 | 是 |
| 速度 | 0.15139547 | 否 |
| 位移 | 0.0023719562 | 否 |
| 间隙 | 0.14853129 | 否 |
| 气体质量 | 0.0068687288 | 否 |
| 气室容积 | 1.2940894e-08 | 否 |
| 活塞容积 | 6.166529e-06 | 否 |
| 活塞容积变化率 | 2.7035985e-12 | 否 |
| 气室有效容积变化率 | 2.346948e-13 | 否 |
| 气室体积功率 | 4.7856974e-06 | 否 |
| 接触力 | 0.012194529 | 否 |
| 信号 | 100 | 是 |
上表取四个已有实验网格的最大值,不代表未采样时刻的极值。
| 比较区间 | 明显非零点 | 修复前超限点 | 修复后超限点 | 修复后超限曲线 |
| --- | ---: | ---: | ---: | ---: |
| 0–50 s,10 ms,容差 1e-8 | 902334 | 31 | 36 | 14 |
| 0–0.15 s,0.1 ms,容差 1e-8 | 320526 | 1136 | 34 | 6 |
| 0–0.15 s,0.1 ms,容差 1e-10 | 320534 | 1124 | 26 | 2 |
| 0–2 μs,2 ns,容差 1e-10 | 194822 | 0 | 0 | 0 |
局部指标并非修复后全部改善:全程按本分界的超限采样由 31 增至 36,而启动细采样由 1136 降至 34。下文也保留修复后更差的尾段原值,不能用整体峰值误差改善代替逐点验收。
## 超限点和原因核查
| 区间 | 变量 | t (s) | Amesim | 修复后平台 | 相对误差 (%) | 单位 |
| --- | --- | ---: | ---: | ---: | ---: | --- |
| full | p4node2_8.焓流 | 0.36 | 2.2033573 | 0.71350335 | -67.617447 | W |
| full | p4node2_8.质量流 | 0.34 | 4.3835447e-06 | 1.2817725e-05 | 192.40547 | kg/s |
| startup | p4node2_1.焓流 | 0.1261 | -1.8519973 | -0.88438516 | -52.246951 | W |
| startup | p4node2_3.质量流 | 0.04 | 0.0036984632 | 0.39400491 | 10553.206 | kg/s |
| startup-refined | p4node2_1.焓流 | 0.1261 | -1.855029 | -0.90430536 | -51.251148 | W |
| full | ud00_2.out.signal | 0.8 | 1e+12 | 0 | -100 | 1 |
- **0.04 s 阶跃点**:标准容差启动结果中,Amesim STEP0 原始保存值为 0,平台为 1;同一采样点 P4NODE2_3 质量流分别为 0.003698463218 与 0.394004911810 kg/s。四个 P4NODE2 的质量流超限都只在该点。下一个 0.0401 s 样本两边信号均为 1。收紧容差的独立启动运行中,该点两边信号均为 1,质量流最大相对差为 3.91268446%。证据支持“事件点输出侧别不同”,不能据此推断整个运行存在 100 倍流量误差,也未证明事件时刻之外完全无差异。
- **0.8 s 信号点**:全程原始输出中,UD00_1 为 Amesim 1e17、平台 49000,UD00_2 为 Amesim 1e12、平台 0;在 0.81 s 已取相同后续值。两个事件点仍计为超限,没有剔除或移动时间轴。
- **焓流过零与尾段**:0.126 s 附近焓流有约 1 W 的差异,收紧容差后仍存在;0.27–0.36 s 的少量焓流/质量流也有超限。原值如下。只凭本次统计不能将这些差异全部归为浮点噪声,根因尚未进一步确定。
## 近零及过零样本:原值对照
以下包括非常小的尾段值、焓流过零附近的小量、严格零,以及参考为零但平台不为零的事件点。是否可接受由原值和工程要求决定;本表不使用相对百分数。
| 区间 | 变量 | t (s) | Amesim | 修复前平台 | 修复后平台 | 修复后绝对差 | 单位 |
| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |
| full | p4node2_2.焓流 | 1.63 | -0.0016188897 | -0.0021637398 | -0.8096496 | 0.80803071 | W |
| full | pn3node2_3.质量流 | 1.93 | 1.9984767e-09 | 2.3960576e-09 | 2.5191052e-05 | 2.5189054e-05 | kg/s |
| full | mecmas21_6.v | 3.69 | 1.2049528e-07 | 1.1364239e-07 | 1.1371358e-07 | 6.7816977e-09 | m/s |
| full | pn3node2_9.焓流 | 5.75 | 5.2962888e-24 | -1.0745511e-06 | 3.106839e-07 | 3.106839e-07 | W |
| full | pn3node2_9.质量流 | 5.75 | -3.2799132e-28 | 5.9755647e-11 | -1.9240571e-11 | 1.9240571e-11 | kg/s |
| full | mecmas21_4.v | 29.6 | -2.5334477e-20 | -3.4949478e-15 | -4.0371247e-15 | 4.0370994e-15 | m/s |
| startup | p4node2_1.焓流 | 0.126 | 0.81690182 | -12.915603 | 1.8036912 | 0.98678937 | W |
| startup-refined | p4node2_1.焓流 | 0.126 | 0.81995653 | -12.878673 | 1.7699446 | 0.9499881 | W |
| startup | step0_6.out.signal | 0.04 | 0 | 1 | 1 | 1 | 1 |
| volume-startup | pnch012_15.volume_work | 3.2e-08 | -0 | -0 | -0 | 0 | W |
前三行分别选择全程近零区焓流、质量流、速度的最大绝对差,避免只展示误差最小的尾段。特别是 PN3NODE2_3 在 1.93 s:Amesim 约 0.001998 mg/s,修复前约 0.002396 mg/s,修复后约 25.19105 mg/s;修复后在此点明显比修复前偏差大。这不能用“分母接近零”解释掉,需要单独查明流量残差的原因。
近零分界仅针对参考值。例如 0.126 s 的 Amesim 焓流小于 1 W,而平台大于 1 W;这个差异仍明确展示,并未视为两边都等于零。每条曲线近零区的参考/平台最小值、最大值和最大绝对差对应的两版原值均保存于各区间的 `classified-relative-error.json`。
## 分界敏感性
| 区间 | ε×0.1 超限点 | ε 超限点 | ε×10 超限点 |
| --- | ---: | ---: | ---: |
| full | 232 | 36 | 2 |
| startup | 38 | 34 | 20 |
| startup-refined | 30 | 26 | 12 |
| volume-startup | 0 | 0 | 0 |
该敏感性检查只改变呈现区域,没有改动数据或 ±5% 判据。不应为了获得通过结论而事后选择更宽的近零区。
## 时间对齐复核
上一轮曲线文件在共同网格上线性插值。复核发现,原始 Amesim 采样时间有浮点累积差;在 32 ns 容积限幅跳变点,原始 Amesim 与平台功率都为 0,但插值会从前一点约 1.109e13 W 混入约 0.402 W。因此本次直接匹配同一名义采样时刻的原始输出,要求时间偏差不超过输出间隔的 1e-8,无插值、无数值截零。
四组 Amesim 网格最大匹配时间差分别约 2.288e-12、2.554e-15、2.554e-15、2.033e-20 s,均通过匹配断言。真实事件侧别差异仍保留。原始结果及上轮曲线文件未被覆盖;本次匹配后的数据另存 `relative-aligned-curves.npz`。
## 数据和复现
- [汇总及阈值敏感性](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/classified-relative-error-summary.json)
- [分类统计脚本](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/classify_relative_error.py)
- [原始输出时间匹配和严格非零审计脚本](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/relative_error.py)
- [原始数据哈希及严格非零审计](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/relative-error-summary.json)
- [full 每曲线统计及近零原值](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/full/classified-relative-error.json)
- [startup 每曲线统计及近零原值](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/startup/classified-relative-error.json)
- [startup-refined 每曲线统计及近零原值](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/startup-refined/classified-relative-error.json)
- [volume-startup 每曲线统计及近零原值](F:/Master/SystemSimulationApp/test/node-fixes-amesim-20260914/volume-startup/classified-relative-error.json)
复现顺序:先用项目 Python 执行 `relative_error.py`,再执行 `classify_relative_error.py`。原始文件、同模型参数审计、求解器设置与此前专项缺陷的覆盖边界,见[上一轮完整报告](F:/Master/SystemSimulationApp/docs/other/节点修复后同一Amesim文件曲线对比-2026-09-14.md)。该 AME 没有非零预设 dvol 或活塞经节点传容积的工况,本次统计不会扩大它的验证范围。
@@ -2,6 +2,8 @@
> 本文记录迁移当时的阶段结果;Python 参考实现已于 2026-09-10 退役,当前入口与保留范围见 [退役记录](Python数值实现退役记录.md)。 > 本文记录迁移当时的阶段结果;Python 参考实现已于 2026-09-10 退役,当前入口与保留范围见 [退役记录](Python数值实现退役记录.md)。
> 后续更新:本文下述高级摩擦、静摩擦、几何接触等限制已有后续实现,见 [2026-09-13 功能补齐与 Amesim 验证](Amesim摩擦与管路功能补齐验证-2026-09-13.md);分块存储后的完整曲线复核见 [2026-09-15 验收项复核](分块存储未完成验收项复核-2026-09-15.md)。不要将本文历史限制作为当前组件能力判断。
日期:2026-09-10。范围是当前注册表中的全部模型:Amesim 库 22 类,实验库 5 类。这里的“覆盖”指项目现有模型方程有对应的 C 实现,不代表复刻完整 Siemens 元件库,也不代表任意拓扑、任意刚性模型均能快速求解。 日期:2026-09-10。范围是当前注册表中的全部模型:Amesim 库 22 类,实验库 5 类。这里的“覆盖”指项目现有模型方程有对应的 C 实现,不代表复刻完整 Siemens 元件库,也不代表任意拓扑、任意刚性模型均能快速求解。
## 实施步骤 ## 实施步骤
@@ -0,0 +1,156 @@
# C 端结果编码与写出优化(2026-09-11)
针对上一轮 [八路全流程成本评估](八路网页求解全流程成本评估-2026-09-11.md) 中约1.18 s的C结果写出阶段,先比较编码和缓冲方案,再实现并验证真实网页路径。案例固定为 [test-mql-8-corrected.json](../../tests/data/test-mql-8-corrected.json),未修改方程、局部管流求根、积分器、误差限、采样或前端生产代码。
已采用 **Ryu精确回读编码 + 64 KiB批量写出**。真实八路模型中,C编码写出墙钟中位数 **1.1808 → 0.1638 s,减少86.13%**;网页点击到结果可查看 **8.0100 → 6.9756 s,减少12.91%**,点击到浏览器缓存保存完成 **8.1280 → 7.0841 s,减少12.84%**。CSV下载保存没有观察到改善(1.1127 → 1.1506 s)。数值精度和求解路径保留,完整原生结果逐位一致,16次网页结果/CSV/刷新恢复核验通过。
上述网页收益是构建缓存命中的三次正式运行组中位数比较。首次编译另列:新依赖会增加冷编译成本,不能把缓存命中收益直接套用到首次运行。
## 调研与方案选择
原路径对约179万个结果数字逐值调用 `fprintf("%s%.17g", …)`。CPU时间与墙钟接近,只说明这一段主要在执行代码,仍需实验区分转换和写入成本。保留JSON合同可以继续使用已有Python原始片段传输、浏览器解析、缓存、CSV和结果文件流程。
候选比较基于作者源码与官方文档,未采用第三方性能宣传作为本项目的加速证据:
| 候选 | 本轮判断 | 一手依据 |
|---|---|---|
| 加大stdio缓存,保留 `fprintf %.17g` | 改动小;必须实测是否能减少主要成本 | 当前 `native/runtime/main.c` 与下方重放实验 |
| `snprintf %.17g` 到固定块,再 `fwrite` | 仍使用相同浮点转换;可隔离stdio调用方式的收益 | 下方重放实验 |
| Ryu binary64 shortest | 采用;C接口、小型固定依赖、无分配转换,能精确回读原浮点值 | [固定版本源码](https://github.com/ulfjack/ryu/blob/4c0618b0e44f7ef027ebae05d2cc7812048f7c8f/ryu/d2s.c)、[作者说明](https://github.com/ulfjack/ryu/tree/4c0618b0e44f7ef027ebae05d2cc7812048f7c8f)、[边界测试](https://github.com/ulfjack/ryu/blob/4c0618b0e44f7ef027ebae05d2cc7812048f7c8f/ryu/tests/d2s_test.cc) |
| yyjson 的Schubfach路径 | 可用作后续对照,但完整 `.c/.h` 约756 kB,本轮不引入完整JSON库 | [0.13.0接口](https://github.com/ibireme/yyjson/blob/6447536015f3d600f3d65323b10976103b337ca7/src/yyjson.h#L1715-L1733) |
| 独立yy_double / Dragonbox | 前者属于作者基准仓库抽出版;后者官方实现要求C++11,本轮保留C11构建链 | [yy_double](https://github.com/ibireme/c_numconv_benchmark/tree/bdacf3330e202d7ec3ae552419ea5772bae95dac/vendor/yy_double)、[Dragonbox](https://github.com/jk-jeon/dragonbox/blob/beeeef91cf6fef89a4d4ba5e95d47ca64ccb3a44/README.md) |
Ryu 的最短转换指足以恢复原始binary64的有效数字,不代表完整JSON字符数一定最少。其裸接口也会生成 `NaN`/`Infinity`;这些不是标准JSON数字,因此包装层必须明确拒绝,而非直接输出。相关规则见 [Ryu源码](https://github.com/ulfjack/ryu/blob/4c0618b0e44f7ef027ebae05d2cc7812048f7c8f/ryu/d2s.c)、[RFC 8259 §6](https://www.rfc-editor.org/rfc/rfc8259.html#section-6)。
本轮固定Ryu提交 `4c0618b0e44f7ef027ebae05d2cc7812048f7c8f`,原样保留相关C/头文件,选择Boost-1.0许可。源码及来源清单位于 [native/encoding/ryu](../../native/encoding/ryu/),头文件位于 [native/include/ryu](../../native/include/ryu/),许可同时随原生构建的 `THIRD_PARTY_NOTICES.txt` 分发。它是项目源代码依赖,不是本地运行环境;未安装新环境或新增Python/前端依赖。
## 独立编码与写入实验
将真实八路原生结果的全部series、final和finalState,共 **1,790,486个binary64**,在计时外转为预加载的连续double数组。每个候选预热一次、正式三次,顺序交替,先写真实文件并逐值按64位模式核对,再单独做 `/dev/null` 输出对照。
计时从 `fopen` 前到 `fclose` 后,包含编码、缓冲设置和写出;不含输入加载、输出投影、JSON静态键名准备或数值复核,也未调用 `fsync`。连续数据重放不包含生产代码的跨行矩阵读取,不能直接将微基准加速当作网页提速。
| 编码/写入候选 | 真实文件墙钟 / s | 真实文件CPU / s | `/dev/null`墙钟 / s | 输出字节 |
|---|---:|---:|---:|---:|
| 原 `fprintf %.17g` | 1.121005 | 1.120823 | 1.108461 | 32,725,281 |
| `fprintf` + 1 MiB stdio缓存 | 1.121927 | 1.121777 | 1.107086 | 32,725,281 |
| `snprintf %.17g` + 64 KiB批量 | 1.148625 | 1.148510 | 1.087661 | 32,725,281 |
| 裸Ryu + 64 KiB批量 | 0.100643 | 0.100637 | 0.071845 | 34,387,794 |
加大stdio缓存没有改善;保留相同浮点转换的 `snprintf` 批量方案反而稍慢。裸Ryu重放墙钟减少91.02%,且 `/dev/null` 中同样大幅加速,证据支持主要成本在浮点转换,而非仅文件写入等待。CPU/墙钟之差不是独立磁盘耗时。
裸Ryu总是采用科学计数法,重放文件反而增大约5.08%。因此生产包装层进一步比较普通与科学表示长度;下方生产C写出包含该重排和真实stride读取,不能与裸Ryu的0.1006 s混作同一测量。
原始依据:[replay/summary.json](../../test/c-result-encoding-20260911/replay/summary.json),每次真实文件与计时记录均保留在 `replay/file/`。工具:[benchmark_native_result_encoding.py](../../tests/manual/benchmark_native_result_encoding.py)。
## 实现
- [json_numbers.c](../../native/runtime/json_numbers.c) 调用 `d2s_buffered_n`,用固定64 KiB栈缓冲批量写出数组,支持原输出矩阵的stride;每次调用返回前将自身缓冲交给FILE,保留调用者已有的stdio顺序和 `ftell` 边界。
- Ryu输出后只进行十进制token重排:普通表示更短时采用普通表示,否则保留科学计数法。例如 `1.2E1 → 12`、`1E-1 → 0.1`;等长时不改。重排没有浮点运算或再次舍入,也不会展开巨大指数。负零固定输出 `-0.0`,普通Python JSON读取也能保留符号。
- [main.c](../../native/runtime/main.c) 中 `series/final/finalState` 接入新编码。状态/整数计数/索引元数据、字符串转义、`--probe/--init` 的既有stdio路径保持原方式;不是所有C数字出口都改成Ryu。
- 非有限结果数字、短写、`ferror` 或 `fclose` 失败都会阻止新结果索引发布并返回失败。数值数组未写完整时,不能把已写出的文件前缀视作成功结果。活动runner仍要求每次使用新的输出目录。
- [build.py](../../app/simulation/native_codegen/build.py) 递归纳入嵌套头文件哈希,Ryu源码、查找表和许可记录都进入构建身份或随构建分发;不会误用优化前缓存。
JSON的字段、变量键名、列序、采样数和数值精度保留。数字拼写和文件SHA允许变化;不要求与旧 `%.17g` 的文本逐字相同。没有采用降低精度、减少采样、删列或有损压缩。
## 真实八路网页验证
输入SHA256为 `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288`;157元件、178条连接、132状态、1784变量及时间列、1002个采样。0~10 s、0.01 s输出、CVODE BDF、`rtol=1e-8`、`max_step=1e30`及各状态atol下限保持原值。
旧版是本轮修改前从工作区冻结的代码(含此前结果传输/IDB/CSV优化),不是退回Git的旧后处理。旧版与新版各有无插桩组和阶段诊断组;每组预热一次、正式三次,串行运行。没有在正式计时期间安排其他大型构建、仿真或全量数值比对。前端全部使用相同生产资产,不改源码或构建。
用户端到端加速取同机无插桩两组;C写出细分取阶段诊断两组。页面“结果可查看”为成功完成DOM且按钮恢复可用,“保存完成”为IndexedDB事务提交后恢复指针发布的观察点。下载计时包括Playwright通知和 `saveAs`;本机回环HTTP不能代表远程网络。
三次正式运行的**中位数(最小~最大)**,单位s。端到端来自无插桩组,C阶段来自独立诊断组;各组依次采集,未声称同序号是交替配对试验。降幅统一为两组中位数之比,小样本、系统调度与温度波动仍影响结果。
| 指标 | 优化前 | 优化后 | 用时变化 |
|---|---:|---:|---:|
| C编码写出(墙钟,诊断组) | 1.1808(1.1529~1.2007) | 0.1638(0.1625~0.1671) | -86.13% |
| C编码写出(CPU,诊断组) | 1.1804(1.1528~1.1991) | 0.1638(0.1625~0.1671) | -86.12% |
| 点击运行 → 结果可查看 | 8.0100(7.9657~8.0463) | 6.9756(6.9228~6.9863) | -12.91% |
| 点击运行 → 缓存保存完成(观察点) | 8.1280(8.0499~8.1684) | 7.0841(7.0053~7.0849) | -12.84% |
| CSV点击 → 下载保存 | 1.1127(0.9959~1.3711) | 1.1506(0.8617~1.3136) | +3.41% |
| 结果文件点击 → 下载保存 | 1.2254(1.1259~1.3546) | 1.2662(0.9707~1.3405) | +3.33% |
| 积分求解(无插桩组) | 6.0926(6.0571~6.0992) | 6.0617(6.0598~6.0752) | -0.51% |
CSV和结果文件导出依然由已有浏览器路径生成,本轮未改。CSV用时波动范围重叠,不能判定加速;文件大小及SHA在全部16次运行间完全一致(31,820,845字节)。积分耗时的少量变化也不归因于编码:求解代码、计数和输出数值保持一致。
![C写出与网页等待时间对比](assets/2026-09-11/c-result-encoding-20260911-overview.png)
生产 `series` 字节数 **32,640,796 → 31,609,208(减少3.16%)**,诊断组HTTP响应体中位数 **34,475,145 → 33,442,734字节(减少2.99%)**。HTTP体另含元数据与进度,随时间文本略有波动;完整浏览器导出结果约33.85 MB,因浏览器重新编码而基本不变。
阶段诊断组的其他主要区间如下(单位ms,中位数):
| 阶段 | 优化前 | 优化后 | 边界说明 |
|---|---:|---:|---|
| 前端提交前预处理 | 18.800 | 22.600 | 模型检查、快照/XML生成及提交准备 |
| 后端XML校验 | 23.855 | 22.529 | 请求输入验证 |
| 网络编译 | 45.417 | 41.611 | 连接/方程编译 |
| 生成C | 59.194 | 60.283 | 生成模型代码 |
| 构建缓存校验 | 45.505 | 34.094 | 正式运行全部命中 |
| 积分求解 | 6113.293 | 5957.849 | 含积分器、RHS/Jacobian、事件和采样 |
| 输出投影 | 85.804 | 79.819 | 重算采样点输出,位于编码前 |
| C结果编码写出 | 1180.792 | 163.829 | 含fopen、编码、stdio、fclose及索引 |
| Python索引结果读取 | 46.351 | 40.434 | 父区间,含字节读取/小元数据解析 |
| HTTP结果事件组装编码 | 32.844 | 33.361 | 元数据JSON与原始series片段拼接 |
| 后端HTTP全程 | 7720.603 | 6553.295 | 包含上述后端子区间及ASGI发送等待 |
| 浏览器流文本解码 | 27.700 | 27.000 | 同步TextDecoder调用累计 |
| 浏览器结果JSON解析 | 99.700 | 104.300 | 单次原始JSON.parse |
C写出占原生 `main` 时间的比例从 **16.00%降至2.64%**;新版积分占 **96.05%**,输出投影约 **1.29%**。这里先计算每次运行的阶段/父区间比例,再取中位数。浏览器解析未见改善;下一步更大的速度空间仍在求解计算,结果侧剩余CPU时间已明显缩小。
首次运行单列(无插桩组,各一次,构建缓存未命中):
| 观察值 / s | 优化前 | 优化后 |
|---|---:|---:|
| 原生构建 | 3.6580 | 4.3720 |
| 点击到结果可查看 | 11.6815 | 11.3810 |
| 点击到缓存保存完成 | 11.8062 | 11.5025 |
本次新构建增加约0.714 s,抵消了大部分编码收益;首次页面运行仅缩短约0.30 s。这是单次冷构建观察,未做重复冷编译统计,不能推广为稳定冷启动提速。旧/新无插桩与诊断程序构建身份不同,均单独预热,不混入正式三次。
## 正确性、边界与限制
- 数字编码最终 **10项专项测试通过**。覆盖34,254个有限binary64位模式、正负零、极大极小/次正规数、十进制边界与随机值;默认及 `RYU_ONLY_64_BIT_OPS` 两种路径均逐位回读一致。覆盖64 KiB边界、stride、非有限值拒绝、短写/零写、`/dev/full` 与关闭失败。见 [最终测试日志](../../test/c-result-encoding-20260911/writer-final-tests.log)。
- 相关后端传输/取消、代码生成、HTTP、CSV与纯C后端 **29项回归通过**。该次同时运行当时9项数字编码测试,共38项;之后补充普通/科学token选择测试并重新运行最终10项编码测试。见 [回归日志](../../test/c-result-encoding-20260911/backend-tests.log)。
- 8次诊断原生结果以一份旧版为基准,其余7份全部 **1,790,486个数值逐位一致**,每份含1,044个负零;series、final和finalState均覆盖,不使用容差或抽样。状态和求解计数也相同,仅排除求解墙钟/CPU元数据。见 [native-bit-parity.json](../../test/c-result-encoding-20260911/native-bit-parity.json)。
- 16次真实网页完整series/final、CSV全部单元格、下载结果文件和刷新恢复通过;CSV比较 **28,617,120个单元格**,全部CSV SHA一致。网页比较是解析后的数值严格相等,原生64位检查另行补足负零验证。见 [equality.json](../../test/c-result-encoding-20260911/equality.json)。
- 原生CLI额外向 `/dev/full` 写出,返回退出码3且未发布结果索引。见 [写失败验证](../../test/c-result-encoding-20260911/write-failure-check/summary.json)。
- 所有网页运行均为1002采样、1784变量,nfev=74,265、接受步6,974、拒绝步454、njev=475、nlu=1,656、事件1、启动4。输入、导入导出参数/连接与前端资产哈希一致。
- 与本轮冻结源码比对,已有文件只改变C输出main、原生构建头文件扫描、README和许可;物理内核、积分器、代码生成方程、runner及前端生产资产未变。Ryu引入的C/头文件与Boost许可逐文件哈希等于固定上游快照。见 [source-manifest.json](../../test/c-result-encoding-20260911/source-manifest.json)。
本轮未改变数值求解算法,因此此前Amesim曲线差异结论保持不变。没有新的Amesim同工况CPU/墙钟数据,不作Amesim速度比较。源码兼容性考虑了GCC/MinGW,已在Linux GCC13.3实测默认及纯64位Ryu路径;本轮没有Windows运行实测。
后端输出期间仍包含输出投影;Python片段整理、传输、浏览器解析和保存也各有成本。父子区间及并行区间不能相加,独立阶段中位数不保证相加等于总耗时中位数。CSV生产实现本轮未修改,其下载用时差异仅记录为观察,不归因于C编码优化。
## 文件与复现
- 本报告:[C端结果编码与写出优化-2026-09-11.md](C端结果编码与写出优化-2026-09-11.md)。
- 所有原始产物:[test/c-result-encoding-20260911/](../../test/c-result-encoding-20260911/),Git忽略。
- 调研上游快照与SHA:[ryu-upstream/manifest.json](../../test/c-result-encoding-20260911/ryu-upstream/manifest.json);优化前冻结源码:[baseline-source/manifest.json](../../test/c-result-encoding-20260911/baseline-source/manifest.json)。
- 网页与后端汇总:[summary.json](../../test/c-result-encoding-20260911/summary.json)、[timings.csv](../../test/c-result-encoding-20260911/timings.csv)。
- 数字编码测试:[test_native_json_writer.py](../../tests/test_native_json_writer.py);传输与取消:[test_native_result_transport.py](../../tests/test_native_result_transport.py)。
当前优化版无插桩网页保留在 **http://127.0.0.1:8027/**,可导入同一八路JSON复查。计时数据、下载、截图、临时构建和上游调研快照都在被Git忽略的 `test/` 下;未安装新环境、提交或推送Git。源码Ryu依赖及许可应作为项目实现保留,不属于应忽略的本地运行环境。
仓库根目录运行。服务与浏览器应分两个终端启动,输出目录选新路径;下方只是新版复测例子,勿与其他仿真/编译并行。旧版重放使用 `baseline-source/tests/manual/backend_stage_profile.py`,显式 `--frontend-dist frontend/dist`;旧版诊断输出必须置于 `baseline-source` 内,以满足构建器的源码相对路径要求。
```bash
.venv/bin/python -m unittest tests.test_native_json_writer tests.test_native_result_transport tests.test_native_codegen tests.test_generic_system_xml_simulation tests.test_result_csv_export tests.test_native_only_backend -v
# 终端1:无插桩新版,新的端口和产物目录
.venv/bin/python tests/manual/backend_stage_profile.py --plain --port 8029 --output-dir test/c-encoding-recheck/backend
# 终端2:浏览器沿用已存在的本地运行条件
LD_LIBRARY_PATH="$PWD/.venv/native/browser-libs/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" .tools/node-v24.18.0-linux-x64/bin/node tests/manual/browser_stage_profile.mjs --url http://127.0.0.1:8029 --input tests/data/test-mql-8-corrected.json --mode control --runs 3 --output test/c-encoding-recheck/browser
```
诊断组去掉服务的 `--plain`,浏览器使用 `--mode profiled`;请另选新产物目录、端口并串行测试。独立编码实验、计时聚合和完整核对使用:
```bash
.venv/bin/python tests/manual/benchmark_native_result_encoding.py --result-json test/web-cost-20260911/native-compute-profile/control/run-1/result.json --output-dir test/c-encoding-recheck/replay --ryu-root test/c-result-encoding-20260911/ryu-upstream --run --warmups 1 --repeats 3 --dev-null
.venv/bin/python tests/manual/summarize_native_encoding.py --root test/c-result-encoding-20260911
.venv/bin/python tests/manual/compare_browser_stage_outputs.py test/c-result-encoding-20260911 --native test/web-cost-20260911/native-compute-profile/control/run-1/result.json --group baseline=test/c-result-encoding-20260911/browser-baseline --group baseline-profiled=test/c-result-encoding-20260911/browser-baseline-profiled --group optimized=test/c-result-encoding-20260911/browser-optimized --group optimized-profiled=test/c-result-encoding-20260911/browser-optimized-profiled --output test/c-result-encoding-20260911/equality.json
```
原生逐位工具 [compare_native_result_bits.py](../../tests/manual/compare_native_result_bits.py) 使用 `--baseline 旧结果.json --candidate 新结果.json --output 报告.json`,`--candidate` 可重复。准确输入路径记录于现有 `native-bit-parity.json`。图表由 [plot_encoding.py](../../test/c-result-encoding-20260911/plot_encoding.py) 用系统Python/matplotlib生成,可缩放图为 [overview.svg](assets/2026-09-11/c-result-encoding-20260911-overview.svg)。大结果数值核对安排在全部性能计时结束之后。
@@ -0,0 +1,90 @@
# C 计算依赖排序验证(2026-09-10)
> 路径复核 / 2026-09-12:本文引用的旧 `tests/data/test-mql-8.json` 已删除,保留当时实验与耗时;后续验证使用 `tests/data/test-mql-8-corrected.json`,不得把换了输入后的耗时直接与本文计算优化比例。 详见 [路径修复记录](2026-09-12-test-input-path-refresh.md)。
在 `system-optimization` 分支完成已知来源识别、依赖排序和局部循环划分,接入默认 C 编译入口使用的扩展生成路径。未修改管路内部求根、积分器、雅可比策略或用户模型接线。
## 本轮实现
| 原有处理 | 当前处理 |
| --- | --- |
| 对非储气端口先填默认焓,靠全网循环传递已知量 | 记录当前状态/已准备信号和物性的来源,参考量按依赖传递;初始猜测不作为已知来源 |
| 全网流量与端口焓反复更新 | 将流量、连接方程和焓传递拆为计算条目,按实际输入输出关系排序 |
| 一个未知量试算会刷新全网流量 | 自动划分局部循环;压力试算只刷新本块残差所需的关系 |
| 压力范围和焓初值使用全网信息 | 局部块使用自身已有压力边界和外部已知焓来源 |
图算法不依赖模型名称、组件实例顺序、画布方向或固定气体流向。节点参考别名与能量返回计算分离;常系数流量约束消元得到的表达式也参加排序。构建清单新增 `evaluationSchedule`,列出来源、计算条目和循环块,便于核查。
当前机械、信号、容积与气体物性准备阶段继续执行原有确定顺序;新图处理的是扩展生成器原先需要全网闭合的气动计算。紧凑储气拓扑原本已顺序计算,继续保留。没有恢复旧 Python 数值实现或旧 IR。
## 修正参考口模型
输入使用已有独立副本 `test/amesim-order-study/reference-port-experiment.json`。正式 `tests/data/test-mql-8.json` 和浏览器工程均未覆盖。对照 EXE 是上一轮已经包含管流精确输入缓存与粗糙度常量预计算的版本,因此本轮比较隔离了计算排序的影响。
- 132 个状态、1784 个输出;编译后形成 484 条气动计算关系,系统代数循环块为 0。
- 使用原有 14 组初始/扰动/反向状态,再逆序重访并重复前两组,共 30 次求值。两版全部成功,每次的全部导数与输出逐值一致。
- 独立插入执行计数后,30 次求值中每条计算关系均执行一次;计数不进入生产代码或计时版本。
### 相同时段的 BDF 对照
两版均使用 CVODE BDF、相对误差限 1e-7、最大积分步长 1e30 s。保持已有绝对误差策略,不绘图、不调用网页端。
| 指标 | 排序前 | 排序后 |
| --- | ---: | ---: |
| 完成的仿真时段 | 0~0.02 s | 0~0.02 s |
| 采样间隔 | 0.002 s | 0.002 s |
| 纯求解耗时(单次验证) | 2.368098 s | 1.645752 s |
| 求值次数 | 5123 | 5123 |
| 接受步数 | 169 | 169 |
| 拒绝步数 | 5 | 5 |
两版最终状态和完整采样序列逐值一致,单次耗时减少约 30.5%。这是相同时段的功能与耗时对照;当时另有回归验证任务运行,因此另用下述独立微基准检查提速幅度。
更短的 0~0.002 s 对照同样逐值一致,均为 304 次求值、25 个接受步和 1 个拒绝步。
### 独立 C 状态求值计时
使用同一批 14 组状态,在 C 内重复 200 次,共 2800 次系统求值;输入读取、进程启动、编译与输出写出不在计时内。两版各预热一次,交替计时 5 轮,取中位数:
| 排序前 | 排序后 | 加速倍数 |
| ---: | ---: | ---: |
| 1.686816 s | 1.156371 s | 1.459 |
计时版本不含诊断计数,校验和一致。这是状态求值的局部基准,不能直接代表任何模型的完整仿真倍数。
### 长时段验证的边界
尝试仿真到 10 s,每版限制执行 30 s:排序前推进到 0.0741618 s,排序后推进到 1.8645395 s,两版均返回执行超时。共同的 0、0.02、0.04、0.06 s 采样点逐值一致,但本轮没有取得完整 10 s 曲线对照。
另一次仿真终点为 0.06 s、采样间隔 0.002 s 的检查中,排序前在 30 s 限额内推进到 0.0487527 s,排序后用 25.022361 s 完成。共同的 25 个采样点逐值一致;将排序后该段全部 31 个采样状态分别送入两版 EXE,全部导数与输出也逐值一致。该次两版完成范围不同,不作为整段加速倍数。
上述不同采样/终点设置会影响积分运行,不能把两次计时直接混用,也不能用相同执行时间推进的仿真时长之比宣称提速。管流内部迭代及刚性积分的成本仍在,本轮未处理这两项。
## 通用性验证
- 2000 层逆序输入的依赖链正确排序并追溯到状态来源,不使用 Python 递归。
- 缺失来源、重复提供者、无来源的纯别名环均拒绝编译。
- 两个独立串联阻力压力循环与一个直算支路组合后,循环内各流量计算次数与独立运行相同;直算支路每次只执行一次。正反流及状态重访的结果均逐值一致。
- 打乱四通节点模型的组件和连接顺序后,仍无系统循环,各条目仅执行一次。常系数消元的求和顺序变化只产生浮点舍入级差异,使用远严于冻结基准的容差验证。
- 三支路的压力与混合焓耦合模型保留一个局部块;正反流、零压差与状态重访均通过质量/能量守恒检查。
- 现有 50 个冻结网络通过 C 数值对照;管流缓存、两种积分器、接口供需及编译入口的定向回归通过。
- `tests/data/native-skill-test.xml` 的 `skill-test` 回归完整完成 10 s,RK45 最大步长 0.001 s、rtol 1e-7,预热后单次纯求解 0.450283 s。它走原有紧凑路径,作为兼容性检查,不用于声称排序收益。
定向回归共 29 项通过。后端全量运行 276 项:首次 274 项通过、1 项锁定环境检查跳过、1 项因编译器版本查询返回空输出出错。重跑冻结网络时又有一次 EXE 初始化返回空输出;这两次均未触发数值不一致断言。受影响的网络 5 和 40 随后使用原冻结期望值单独复核通过。没有为此改动生产进程处理、冻结基准或数值容差;本次全量运行不能表述为一次全部通过,原始限制在此保留。
源文件与模型接入要求见 [C 求值排序规范](../standard/native-evaluation-schedule.md)。本轮条件表达式按所有可能分支保守记录依赖,尚未进行运行时流向特化;局部压力/焓求解保留既有方法,未宣称支持任意新非线性方程。
## 复现材料
`test/dependency-schedule-study/` 按仓库约定被 Git 忽略,包含:
- `baseline-build.json`、`scheduled-build.json`:两版 EXE 与构建清单。
- `schedule.json`、`model.c`:正式生成器输出的依赖清单与 C 源码。
- `verification.json`、`*-probes.jsonl`:30 次逐值验证。
- `benchmark.json`:5 轮交替计时及校验和。
- `integration-comparison*.json`、`bdf-*/result.json`:积分统计与原始序列,包括未完成的超时运行。
- `trajectory-probe-check.json`:长段共同采样及采样状态的求值对照。
- `skill-test/summary.json`:完整 `skill-test` 回归结果。
- `study.py`:`verify`、`benchmark`、`integrate`(0.002 s)、`short`(0.02 s)、`matched`(0.06 s)、`full`(10 s)模式;积分复现需选择新的输出目录,不能覆盖已有运行结果。
自动回归位于 `tests/test_native_schedule.py`,不依赖上述临时材料;纯依赖图测试已加入 Linux 合同检查,完整 C 执行测试由 Windows 数值回归自动发现。
@@ -1,544 +0,0 @@
# C 语言数值内核实施计划与可行性评估
> 文档状态:历史阶段方案;当前默认 C 且已退役 Python 数值实现,见 [退役记录](Python数值实现退役记录.md)。
> 建立日期:2026-09-02
> 补充日期:2026-09-09;第 10 节结合最新实测,细化 XML → 系统专用 C → 原生 EXE 路线。前九节保留原阶段方案与历史评估;本次仅补充设计,未完成生产内核替换。
> 适用分支:`model-development`
> 关联文档:[求解器性能优化任务清单](求解器性能优化任务清单.md)、[后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
> 范围:后端数值执行内核;不包含前端重写,也不主张把整个 Python 后端改写为 C
## 1. 结论
这条路线值得实施,但正确的目标不是“把现有 Python 代码逐行翻译成 C”,而是:
1. 保留 Python 作为模型解析、网络编译、任务管理、结果服务和正确性参考;
2. 先把完整的一次 RHS 计算编译成无 Python 回调的扁平数值 IR;
3. 再让 C 内核一次完成整次 RHS、stream、物性、Jacobian 和事件计算;
4. Python 旧引擎长期保留为不支持模型的兼容路径和故障回退路径。
总体技术可行性为**中高**。仓库已经具备因果计划、参考 IR、稀疏 Jacobian 结构、性能埋点和 AMESim 回归基础,因此不是从零开始。主要难点不是 C 语法,而是完整冻结当前模型的计算顺序、副作用、事件和回滚语义。
“实现稳定的 C 数值后端”具有较高可能性;“仅靠换成 C 就达到接近 AMESim 的速度”目前不能承诺。高刚度 LSTP 接触会让积分器产生大量纳秒级小步,有限差分 Jacobian 又会重复调用 RHS。C 能降低每次计算的成本,但不会自动减少这些计算次数。真正的大幅收益需要同时完成:
- 完整扁平 IR;
- 整体 C RHS;
- 稀疏解析或半解析 Jacobian;
- 正确的接触事件和模式切换;
- 必要时再评估原生积分器。
## 2. 推荐的最终边界
```text
XML / JSON
↓
Python:解析、校验、组件注册、网络编译
↓
完整扁平 IR:槽位、阶段、依赖、模式、事件、Jacobian 结构
↓ 一次跨语言调用完成整次计算
C 数值内核:RHS / stream / 物性 / Jacobian / Event
↓
SciPy BDF(首阶段保留)或后续通过验证的原生积分器
↓
Python:采样、结果编码、API、任务状态和诊断
```
以下边界必须坚持:
- 不在每个组件上来回调用 Python 和 C;一次 RHS 最多进行一次主要跨语言调用。
- C 兼容模型的热路径内不得调用 Python callback。
- C 内核不直接解析 XML,不管理 HTTP,也不承担组件编辑和数据库职责。
- 自定义 Python 组件不能静默降级为跨语言逐组件调用;只要整模型不满足原生能力合同,就明确走 Python 引擎。
- 迁移期间始终保留 `python`、`native`、`shadow` 三种模式;通过长时发布门后才能增加 `auto` 默认选择。
建议统一配置为:
```text
SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto
```
其中 `shadow` 由 Python 控制正式积分,C 只接收相同输入并逐阶段比较,不允许影响 Python 工作区。
## 3. 当前基础与首个阻断
### 3.1 已有基础
- `causal_ir.py` 已有 schema v1、结构签名、预分配工作区、阶段观察和事务回滚原型。
- 当前因果执行器已经消去一部分重复逻辑坐标,并证明了数组化和预编译执行计划的方向有效。
- 已有 Jacobian 稀疏图、局部切向原语和显式实验回退路径。
- 已有 AMESim 结果读取、物理投影、递进时域 runner、性能统计、取消和故障注入测试。
- 当前主模型约有 156 个运行组件、20 种组件类型和 132 个连续状态,适合用“能力矩阵 + 分阶段覆盖”推进。
### 3.2 当前 IR 的缺口
现有 schema v1 只覆盖全局因果代数计划,绑定仍依赖 Python 的 reader、writer 和 evaluator 回调。以下内容尚未进入同一个无回调执行计划:
- 状态写入和信号传播;
- dynamic volume 和物性状态包;
- secondary 压力/流量块;
- stream SCC/DAG 和热流体外层闭合;
- 状态导数;
- 离散 mode、事件检测、reset 和重启;
- Jacobian 数值填充;
- 结果投影。
因此,当前 IR 不能直接包一层 C 接口后就获得预期收益。
### 3.3 P0 跨平台换行阻断(已解决)
当前 Windows 工作树中的权威输入按原始字节计算时与 manifest 不一致;进一步核对确认,差异全部来自 Git 检出后的 `CRLF` 换行,而不是模型内容变化:
| 文件 | Windows 工作树原始字节 | 将 `CRLF` 还原为 `LF` 后 | manifest 记录值 |
| --- | --- | --- | --- |
| `tests/data/test-mql-8.xml` | 106,188 B;2,078 个 `CRLF`;SHA-256 `e7eef641...b801` | 104,110 B;`0a2d9331...7b0b` | 104,110 B;`0a2d9331...7b0b` |
| `tests/data/test-mql-8.json` | 282,796 B;10,204 个 `CRLF`;SHA-256 `51e1acb3...11e2` | 272,592 B;`b44bf540...fbe0` | 272,592 B;`b44bf540...fbe0` |
| `AmesimModels/test_mql.ame` | 21,708,800 B;`cbc3aadd...c20fbb` | 不适用 | 一致 |
因此没有证据表明 fixture 语义发生漂移,也不应仅因这一差异重建 golden。真正的问题是:manifest 按原始字节锁定输入,而 `.gitattributes` 没有固定这两个文本 fixture 的换行;当前加载器又直接校验工作树原始字节,所以同一提交在 Windows 上可能被拒绝、在 Linux 上通过。
该阻断已于 2026-09-02 完成本地修复:`.gitattributes` 已将两个主模型输入、历史 0.81 s 输入和 `tests/baselines/simulation` 下的 JSON 证据固定为 `LF`,当前 Windows 工作树也已从 Git 索引重新检出为 `LF`。自动测试会同时检查属性规则、禁止原始 `CR` 字节,并按 manifest 复核输入大小和 SHA-256;CI 已配置为在 Ubuntu 和 Windows 上分别执行这一合同,远端运行证据待提交后取得。
本地验收显示这些文件均为 `index=LF / worktree=LF / eol=lf`,两个 regression manifest 可在 Windows 正常完整加载,现有 manifest 和 golden 无需更新。未来只有在规范化后的内容确实变化时,才进入“模型变更、重建 manifest/golden”的流程。历史性能数字仍需按其代码版本看待,但不因换行差异失效。
## 4. 按重要性排序的实施计划
优先级定义:
- `P0`:正确性和架构前置,不完成就不能安全编写生产 C 内核;
- `P1`:形成可用且有明显收益的 C 后端;
- `P2`:P1 证明有效后再实施的增强项。
表中的工期是单名熟悉现有求解器的开发者的粗略有效工作量,不是交付承诺,也不包含长时仿真排队时间。
| 顺序 | ID | 优先级 | 工作项 | 可行性 | 粗略工作量 | 主要依赖 |
| ---: | --- | --- | --- | --- | --- | --- |
| 1 | C-00 | P0 | 固定权威基准的跨平台合同,建立双引擎与回滚合同 | 高 | 1–2 人周 | 现有 OPT-00 |
| 2 | C-01 | P0 | 定义完整数值 IR schema v2 | 高 | 3–5 人周 | C-00、OPT-01/02 |
| 3 | C-02 | P0 | 建立纯数值组件 kernel 合同和能力矩阵 | 中高 | 3–6 人周 | C-01 |
| 4 | C-03 | P0 | 完成 Python 扁平参考执行器与 Shadow 差分 | 中高 | 3–5 人周 | C-01、C-02 |
| 5 | C-04 | P1 | 稳定 C ABI、构建链和隔离 worker | 高 | 3–5 人周 | C-01,可并行 |
| 6 | C-05 | P1 | 完成一个闭环代表子系统的 C 纵向切片 | 高 | 2–4 人周 | C-03、C-04 |
| 7 | C-06 | P1 | 扩展到完整内置 RHS、stream 和物性 | 中高 | 5–9 人周 | C-05、OPT-04 |
| 8 | C-07 | P1 | 稀疏解析/半解析 Jacobian 与局部回退 | 中 | 6–12 人周 | C-02、C-06、OPT-03 |
| 9 | C-08 | P1 | LSTP/MECMAS 事件、模式和默认灰度 | 中 | 4–8 人周 | C-06、C-07、OPT-05/06 |
| 10 | C-09 | P2 | 模型专用 C 代码生成和编译缓存 | 中 | 4–8 人周 | C-06~C-08 |
| 11 | C-10 | P2 | 评估 CVODE;有真实 DAE 需求时再评估 IDA | 中/当前低 | 4–10 人周 | C-06~C-08 |
| 12 | C-11 | P2 | 输出、部署、并发和发布收口 | 中高 | 3–6 人周 | 原生默认候选 |
### 4.1 P0:先固定语义和参考答案
#### C-00 固定权威基准、双引擎和回滚合同
工作内容:
- [x] 固定 `test-mql-8.xml/json`、历史输入和回归证据的跨平台 `LF` 字节合同,实际重新规范化当前 Windows 工作树,并用 Git EOL 状态、原始 SHA-256 和双平台 CI 保护该合同;未重建语义未变的 golden。
- 重新加载 manifest 并执行 Python `0.01 s` smoke;只有发现规范化后的内容或物理结果确实变化时,才重新生成结构清单和 golden。
- 冻结当前 Python 引擎为参考实现,定义 `python/native/shadow/auto` 的启用条件。
- 原生不支持的模型只允许在编译或加载阶段整模型回退;正式验收时禁止静默回退。
- 每个阶段使用独立小提交,记录输入哈希、环境、二进制哈希和回滚开关。
完成标准:相同输入能够稳定强制走 Python;关闭原生后行为与当前参考提交一致;模型、环境和结果来源都能追溯。
#### C-01 完整数值 IR schema v2
状态:**已完成 v2.0 schema、规范、系统编译器、fail-closed 语义校验和复杂模型结构回归。** 当前结果明确为 `reference_only`,C-02/C-03 尚未完成,默认 Python 求解路径没有切换。
完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为:
```text
state scatter
→ signal
→ mechanical equivalence / dynamic volume
→ property bundle
→ first pressure-flow closure
→ stream SCC/DAG
→ temperature reference update
→ sensitive-island closure / thermofluid fixed point
→ mechanical acceleration
```
四类入口分别编译自己的依赖切片:
```text
eval_rhs = required primal stages → derivative
eval_events = required primal stages → event values
eval_jacobian = RHS primal / local derivative or JVP → CSR values
eval_outputs = required primal stages → output projection
```
IR 至少描述:
- 连续状态、代数坐标、参数、常量、离散模式和工作区槽位;
- 每个阶段和 opcode 的读写集合、单位、缩放、上下界和错误来源;
- stream 图、SCC、物性状态包、外层固定点和事务恢复集合;
- event ID、左/右状态、reset、Jacobian 失效和模式计划;
- 固定的 CSR Jacobian 结构和 output projection;
- 完整 IR 内容(含组件模型/实现、介质、dtype、参数和执行计划)组成跨平台内容签名;
- native artifact key 再把内容签名与 native ABI、目标平台、编译器、构建选项、浮点策略和 kernel 库签名组合。
原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。
完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;结构可完整描述但缺少原生能力时明确标为 `reference_only`,原生加载器必须拒绝接管。
#### C-02 纯数值组件合同
每个内置组件需声明:
- `kernel_id` 和实现版本;
- 输入、输出、参数、状态和 mode 槽位;
- primal、局部导数/JVP、event 和 reset 能力;
- 支持的正反流、接触和物性范围;
- 类型化错误码和局部数值差分回退边界。
组件 kernel 必须满足“相同输入得到相同输出”,不能依赖隐藏的 Python 对象写入。旧组件类继续作为参考实现。
首批纵向切片建议选择一条完整的 `MECMAS21 → PNRP17 → PNCH012 → PNL0001/LSTP00A` 支路及其介质/stream 闭合,因为它同时覆盖机械、气动、物性、流量和高刚度接触。
#### C-03 Python 扁平参考执行器
先用 Python/NumPy 完整执行 schema v2,目标是验证语义,而不是追求最终速度。它应摆脱 `PortState` 作为主语义载体,能在每个阶段与当前对象引擎比较首个差异。
完成标准:代表模型在普通点、反向流点、事件左右和 Jacobian 扰动点逐槽一致;`0.01/0.2/0.81/2.10 s` 的状态、事件、守恒和物理投影满足现有合同。
### 4.2 P1:形成真正有价值的 C 后端
#### C-04 稳定 C ABI、构建链和隔离 worker
底层使用稳定纯 C ABI,Python 绑定保持很薄。最小接口应包含:
```text
model_create
eval_rhs
eval_jacobian
eval_events
apply_event
eval_outputs
model_destroy
```
接口使用不透明 `ModelContext`、固定宽度整数、连续 `float64` 数组和显式长度;CSR 结构在编译时固定,运行时只填 values。每个任务独占可变 context/workspace/cache/mode,不使用可变全局缓存,热路径预分配且不进行常规堆分配。
首阶段仍由 SciPy BDF 积分,只替换整个 RHS/Jacobian 计算。构建链需覆盖 Windows x64 和 Linux x86_64,固定构建依赖,并禁止 `/fp:fast`、`-ffast-math`、`-march=native` 等会改变事件边界或破坏可移植性的默认选项。
原生访问违规、崩溃或死循环不能在同一 Python 进程中安全恢复。因此原生默认启用前,仿真必须运行在可硬终止的隔离 worker 中;C 同时接收取消标志并在组件阶段、stream SCC、Jacobian 和迭代处有界检查。
#### C-05 闭环纵向切片和首次 Go/No-Go
一次跨语言调用应完成代表子系统的整次 RHS,禁止按组件往返。先为该闭环子系统建立独立代表 fixture,执行 Shadow 和短时积分,不立即扩大组件覆盖。由于此阶段尚未覆盖主模型的全部组件,`test-mql-8` 整模型按能力合同回退 Python 是预期行为,不能拿它衡量 C-05 的端到端收益。
继续扩面的最低门槛:
- RHS 微基准至少达到 Python 的 `2×`;目标值为 `3×` 以上;
- 独立代表 fixture 的短时完整仿真中位时间至少降低 25%;
- 代表 fixture 的状态、事件、模式和守恒门全部通过;存在对应 AMESim 投影时也必须通过;
- `nfev/njev/nlu` 和接受步等工作量没有无法解释的变化;
- 无 Python callback、无静默回退、无内存错误。
如果 RHS 已快很多而完整仿真几乎不变,应先检查 Jacobian、微步数和跨语言边界,而不是直接继续扩大 C 代码。
#### C-06 完整内置 RHS、stream 和物性
- 把所有受支持的内置组件纳入能力矩阵;一个组件不支持时整模型明确走 Python。
- 将 stream 图编译成 SCC 和缩点 DAG:无环部分一次传播,只在循环 SCC 中迭代。
- 将 `p/T/rho/u/h` 及其导数组成同一物性状态包,按精确输入和阶段统一复用。
- 移除每轮临时字典/列表,使用预分配数组和原地误差统计。
- 保留试探状态事务回滚、反向流、温度参考更新和类型化可恢复失败语义。
完成标准:主目标使用的全部内置组件可原生执行;stream 迭代、压力流量残差和物性结果不恶化;跨平台字节合同修复后的同一权威输入通过短程与中程回归,且 `0.2 s` 完整仿真中位时间至少降低 30%。
#### C-07 稀疏解析/半解析 Jacobian
这是获得大幅端到端收益的关键步骤。当前历史报告中,有限差分 Jacobian 会贡献大量额外 RHS 调用;仅把 primal RHS 改成 C 仍会重复执行它。
- 组件局部导数沿完整 IR 传播,直接填充固定 CSR values。
- 不支持或非光滑点只对局部组件、状态列或 SCC 做数值差分,不能让一个局部问题恢复全网有限差分。
- 每次构建记录解析列、局部差分列、回退位置、JVP 审计和装配时间。
- LSTP 接触、流向切换、饱和及临界流动使用分段导数,并在边界上明确选择事件分段或局部回退。
完成标准:有限差分附加 RHS 至少减少 50%,相对完整 C RHS 阶段再降低至少 20% 总时间;事件顺序、模式和物理投影不变。
#### C-08 事件、模式和默认灰度
- 将 LSTP/MECMAS 的 event、mode、reset、Jacobian 失效和 solver restart 纳入正式 IR/C 合同。
- 对接触进入、保持、释放,上下端挡、回弹、同时事件、擦边事件和防抖分别测试。
- 按 `shadow → 显式 native → 模型签名白名单 → auto` 逐级启用。
- 只有当前权威 `10 s` 连续三次通过后,才允许受支持模型默认走 C。
需要特别注意:C-08 不只是把同一接触公式写成 C,还要减少因接触表达方式造成的不必要纳秒级微步。任何软化、容差或事件改动都属于数值算法变化,必须与纯执行优化分开提交和验收。
### 4.3 P2:证明 P1 有效后再做
#### C-09 模型专用 C 代码生成
通用 C IR 解释器稳定后,若 opcode 分派仍是明确热点,再为固定模型生成专用 C。XML 文本不得直接拼入源码;生成器只消费校验后的数值 IR。编译缓存键必须包含完整结构签名、编译器和 flags。
只有相对通用 C 执行器额外获得至少约 `1.5×` 的稳定收益,并且编译成本能在重复运行中摊销时才继续。否则保留通用执行器,放弃这一层复杂度。
#### C-10 原生积分器
- 当前系统是“RHS 内完成代数闭合”的半显式 ODE,先评估 CVODE,不直接切换为 DASSL/IDA。
- 只有 C RHS、Jacobian、事件、可恢复缩步、partial result、activity 和 cancel 合同稳定后,才对 CVODE 做独立 A/B。
- CVODE 在相同正确性门下额外降低至少 25% 总时间才值得替换 SciPy。
- IDA/DASSL 需要新的 `F(t,y,ydot)=0`、代数变量布局、一致初始化、指数和质量矩阵合同。只有真实模型证明无法稳健因果化或 ODE 化时再立项,当前不把它视为提速开关。
#### C-11 输出、部署和发布收口
吸收按需结果变量、列式输出、结果分块、编译缓存、并发限流、worker 硬终止和原生崩溃恢复。确保 native 崩溃只影响当前任务,服务仍能接收新任务。
## 5. 分阶段验证与 Go/No-Go 门
| 验证阶段 | 主要内容 | 通过条件 | 未通过时的处理 |
| --- | --- | --- | --- |
| V0 | fixture、环境、Python 和 AMESim 基线 | 来源一致;Python 连续 3 次稳定;现有测试通过 | 基线仍漂移时停止 C 默认路径开发 |
| V1 | 完整 Python IR | 槽位、阶段、读写集合、事件和回滚差分通过 | 先修隐藏副作用和 IR 合同 |
| V2 | C 纵向切片 | 单组件/逐阶段通过;RHS 至少 `2×` | 未达门槛则检查 IR/FFI,必要时停止扩面 |
| V3 | 完整 C RHS + SciPy | `0.01/0.2 s` 通过;总时间至少降低 30%;工作量变化不超过可解释范围 | 不作为纯执行优化合入默认路径 |
| V4 | C Jacobian/Event | FD 附加 RHS 至少减少 50%;相对 V3 再降低 20%;事件/模式一致 | 保留局部差分,禁止整网静默回退 |
| V5 | 长时与鲁棒性 | `1/5/10 s` 递进;10 s 连续 3 次;无泄漏、无静默回退 | 任一短时前驱失败即停止后续长跑 |
| V6 | 默认启用 | Windows/Linux 发布通过;支持模型走 C,不支持模型明确走 Python;可一键关闭 | 保持显式 opt-in |
正式性能比较统一要求:预热 1 次、完整仿真至少测量 3 次并比较中位数、固定机器与电源模式,冷编译和热缓存分开报告。至少记录:
- 总时间、积分、RHS、闭合、stream、物性、Jacobian 和输出时间;
- `nfev/njev/nlu`、接受/拒绝步、solver 启动和事件次数;
- Jacobian 附加 RHS、局部数值差分和回退原因;
- Python/C 边界调用次数和耗时;
- 峰值 RSS、C 工作区、IR/ABI 版本、编译器、flags 和二进制哈希。
附加硬门:关闭 C 时 Python 路径额外开销不超过 2%;纯执行层替换若使 `nfev/njev/nlu` 或接受步数变化超过 2%,必须按数值语义变化单独调查,不能直接归为性能优化;峰值 RSS 不超过 Python 基线的 110%;10 s 中 C 固定工作区不随步数增长,只有结果数组可随采样数增长。
## 6. 正确性验证范围
### 6.1 Shadow 阶段观察点
两个引擎必须接收完全相同的 `time/state/mode/parameters`,按第 4.1 节的 RHS 阶段逐项比较。差异报告至少包含:阶段、槽位、组件、输入、Python 值、C 值、绝对/相对误差和当前模式。
- 槽位布局、模式码、事件 ID、执行顺序和迭代次数要求一致。
- 连续量使用“每种物理量绝对容差 + 相对容差”,不能用一个绝对容差覆盖压力、流量、温度和位移。
- NaN/Inf、压力越界、符号错误和模式不同立即失败。
- 压力流量最大缩放残差继续使用现有 `1e-7` 门。
- 事件点分别比较左极限、事件处理结果和右极限,不扩大容差掩盖不连续。
差分语料至少覆盖:初始状态、接受状态、Jacobian 扰动状态、固定随机种子扰动、压力近似相等、流量换向、接触启停、端挡释放、物性边界,以及历史 `0.69/0.81/2.05 s` 区域。
### 6.2 AMESim 物理门
Python golden 只用于发现实现漂移,AMESim 结果仍是外部物理基线。正式默认前,应在现有 physical-state-v2.1 基础上至少覆盖:
- 代表性气室压力、温度和质量;
- PNL 管路压力、流量及换向;
- PNRP 活塞力;
- MECMAS 位移、速度和模式;
- LSTP 间隙、接触力和接触切换;
- 每个主要支路至少一个代表量。
检查点至少包含 `0`、`0.04 s` 左右、`0.2`、`0.8 s` 左右、`1`、`2`、`5` 和 `10 s`。AMESim 未保存的内部守恒量继续执行独立绝对残差门。
### 6.3 错误、取消和并发
必须覆盖 C 返回非有限值、物性域错误、闭合不收敛、未知 opcode、ABI 不匹配、损坏二进制、失败后事务回滚、编译失败、并发 context 隔离、单任务取消、重复创建/销毁无内存增长和 native 崩溃后的服务存活。
回退分为三层:
1. **启动前自动选择**:编译或加载时发现不支持能力,整次任务在开始积分前明确选择 Python;这是正式模式唯一允许的自动换引擎路径。
2. **受控运行时错误**:native 已开始积分后返回错误时,当前 native 任务直接失败并保留诊断。灰度验证可以从不可变原始请求的 `tStart` 另起一个 Python 重放任务,但必须标为独立重放,不能算作 native 成功。没有完整保存 BDF 历史、事件上下文和已有输出前,禁止从某个 `(t,y,mode)` 假装无缝续跑。
3. **进程级故障**:访问违规或无法返回时,由父进程硬终止 worker,禁止在受损进程中继续;如需 Python 对照,同样从原始请求重新执行。
正式 C 验收要求异常 `fallbackCount=0`。声明过的局部 Jacobian 数值差分不是异常回退,但必须单独计数。
## 7. 对现有优化清单的取舍
现有优化清单不能整体放弃,应按新路线重组:
| 现有任务 | 决策 | 在新路线中的位置 |
| --- | --- | --- |
| OPT-00 基线与回归 | 保留并加强 | C-00 和全部发布门 |
| OPT-01 因果代数内核 | 冻结成果 | 作为 IR 编译输入和 Python 回退,不再继续零散微调 |
| OPT-02 扁平 IR | 升为主线 | C-01、C-03、C-05 |
| OPT-03 Jacobian | 升为主线 | C-07 |
| OPT-04 stream/物性 | 升为主线 | C-06 |
| OPT-05 步长、接触和鲁棒性 | 必须保留 | C-08;C 不能消除微步根因 |
| OPT-06 dense output | 部分前置、其余后置 | 事件合同进入 C-01/C-08,插值微调后置 |
| OPT-07 输出与内存 | 暂缓但不删除 | C-11 |
| OPT-08 取消与并发 | 必须保留并提前 | C-04/C-11;原生代码更需要进程隔离 |
| OPT-09 10 s 验收 | 必须保留 | V5/V6 发布门 |
| OPT-10 高指数 DAE | 有条件暂缓 | 只有真实 DAE 需求时进入 C-10 |
可以停止继续投入的方向:
- 在现有对象热路径上继续做零散字典和属性访问微优化;
- 继续叠加缺少统一失效合同的通用缓存;
- 优化目标模型中未触发的 `least_squares` 回退;
- 不断增加少量特例半解析列,而不先建立通用组件导数合同;
- 直接对当前对象图使用 Numba;
- 通过修改容差、软化物理或寻找“幸运 maxStep”伪装性能收益。
Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为长期生产依赖。若完整数组 RHS 在 Numba 原型中仍没有明显改善,应先修正 IR 和算法,而不是立即开始大规模 C 重写。
## 8. 可行性评估
| 目标 | 当前判断 | 原因 |
| --- | --- | --- |
| 验证框架、双引擎和基准恢复 | 高 | 现有 runner、golden、AMESim 读取和埋点可复用 |
| 完整 Python 扁平 IR | 中高 | 结构基础已有,主要工作是显式化隐藏副作用和事件语义 |
| 代表子系统通用 C RHS | 高 | 数值边界清楚,一次调用可避免 FFI 碎片化 |
| 当前全部内置组件的 C RHS | 中高 | 约 20 种类型可逐类迁移,但 stream/物性/模式较复杂 |
| 可维护的稀疏解析/半解析 Jacobian | 中 | 收益大,但非光滑接触、流向切换和导数覆盖难度最高 |
| 模型专用 C 代码生成 | 中 | 收益上限高,但构建、缓存、安全和诊断成本明显增加 |
| CVODE 替换 SciPy | 中,且后置 | 可能减少调度开销,但不会自动解决错误方程或接触微步 |
| IDA/DASSL 直接提速 | 当前低 | 目前缺少真正 DAE 的残差、一致初始化和质量矩阵合同 |
| 接近 AMESim 的总速度 | 中低、待实测 | 缺少同机 AMESim 墙钟基线,且当前主要瓶颈同时包含 Jacobian 和接触微步数 |
从项目节奏看,建议把目标分成三档;这里与第 4 节一样使用“人周”,多人并行时日历时间可短于人周总量:
1. **约 15–27 人周:技术决策闭环。** 完成 C-00~C-05,回答完整 IR 是否正确、C RHS 是否有足够收益。
2. **累计约 20–36 人周:可选原生后端。** 完成 C-06,使主要内置 RHS、stream/物性、双平台构建和受控回退可由用户显式启用。
3. **累计约 30–56 人周:默认候选。** 完成 C-07/C-08、10 s 长时、并发隔离和发布门。
模型专用代码生成和原生积分器属于额外阶段,不应计入首个可用 C 后端的承诺。多人并行可以缩短日历时间,但 IR、组件合同、Jacobian 和事件语义存在强依赖,不能按人数等比例压缩。
## 9. 建议立即开展的第一批工作
第一批只做 P0,不直接开始大规模 C 编码:
1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。
2. **已完成(C-01 结构层):** 生成当前模型的组件类型、槽位、阶段、副作用和事件能力描述;纯数值 kernel 能力矩阵的实现细节继续归入 C-02。
3. **已完成:** 将 schema v2 写成独立规范,并冻结 RHS/Event/Jacobian/Outputs 入口、错误能力、事务、事件、Jacobian 和内容签名。
4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。
5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。
6. 评审通过后,再建立最小 C ABI 和纵向切片。
第一批的退出条件不是“已经写了多少 C”,而是:当前基准可信、同一计算能被无回调 IR 完整表达、差异能定位到具体阶段和槽位。只有达到这个条件,后续 C 工作才具有可预测的收益和可控的回滚成本。
## 10. 2026-09-09 补充:XML 自动生成系统专用 C / EXE
### 10.1 新证据与本次目标
用户本次希望解析 XML 后,自动生成对应系统的 C 求解程序。建议把“模型专用代码生成 + 原生积分运行”作为目标架构;开发时仍保留“相同输入逐阶段比较”和“相同 SciPy 积分器仅替换完整 RHS/Jacobian”的诊断阶段,用来分别测量执行语言与积分算法的影响。后者是验证手段,不是最终交付边界。
最新证据不能把速度差异全部归因于 Python:
- 先前特定模型的完整 C EXE 求解仍约 44.82 s;C 剖析中模型求值占 97.07%,雅可比构造占 87.17%。雅可比包含模型求值,两项不能相加。
- 该 C 版本采用 132 状态的逐列差分,每次雅可比约需 133 次完整 RHS,累计 124,089 次。这个结论针对测试 C 实现;当前 Python 引擎已经有稀疏结构、着色和部分半解析路径,不能把它也描述成始终采用全稠密差分。
- Amesim 仅关闭 optimized solver,进程 CPU 从 2.890625 s 增至 13.890625 s,结果文件字节、函数/雅可比计数、成功步数均相同。这证明执行优化层本身很重要,但没有揭示其专有优化实现。
- C 管流中的固定次数二分及频繁未收敛退出是已知热点,不能作为成熟组件内核直接复制到生产生成器。
依据:[C 剖析报告](../../test/native-mql8-profile/performance-analysis.md)、[Amesim 优化开关对照](../../test/native-mql8-profile/amesim-optimizer-comparison.json)。这是特定模型对照,不是受控的生产 Python/C 语言收益测量,也不保证新引擎达到 Amesim 的速度。
### 10.2 最终执行边界
```text
网页导出 System XML
→ Python:协议校验、组件版本/参数/连接校验
→ ModelIR:方程/可信组件原语、连续状态、代数变量、事件、依赖
→ 结构编译:合并等价变量、确定计算顺序、划分代数环、生成稀疏结构
→ C emitter:model.c + model.h + manifest.json
→ 编译并链接:组件 C 库 + 原生积分运行库
→ 独立 native worker / 模型 EXE:完成初始化、积分、事件、数值采样
→ Python:读取结果、任务状态、现有 API 编码
```
编译和 EXE 均在后端机器执行。Python 可以继续完成低频管理工作;一次积分中的 RHS、代数闭合、物性、Jacobian、Newton 和事件处理全部留在原生进程,不能在每次求值时回调 Python。
首个交付可生成模型专用 EXE,便于独立运行、复现和隔离崩溃。之后如实测编译或启动成本成为问题,再用通用 worker 加载缓存模型 DLL;DLL 同样加载在隔离进程中。原生运行时与组件库预先编译,每个新拓扑只生成和编译模型装配部分。
### 10.3 XML 不包含完整方程,必须建立 C 组件库
现行 XML 保存组件类型、版本、参数和连接,不包含各组件的物理公式。自动编译依赖一份可信映射:
```text
(model_type, model_version, 支持的结构选项)
→ 参数校验 + 状态/端口布局 + 方程或 C kernel + 局部导数 + 事件/reset
```
`app/simulation/registry.py` 的注册定义可以继续作为编译前端的数据来源,逐个增加原生能力描述。普通公式可用受限表达式 IR 表达,同时生成数值计算和导数;复杂物性与非线性管流使用经过验证的手写 C 原语,并声明完整输入输出及导数合同。不要依赖自动翻译任意 Python 类。
例如“气室 A—管道—气室 B”编译后固定为状态和参数数组下标:求两边物性、算这根管道的流量、按端口方向累加质量/能量导数。运行时不再寻找组件对象、端口名称或连接边。系统专用代码可以直接调用 `pipe_kernel(...)`,无需把物性库和积分器源码重复展开进每一个模型。
现有 `EquationResidual` 保存变量名称及已经计算出的 `value`,并没有保存完整公式表达式;`causal_ir.py` 提供部分结构基础,但其执行绑定仍有 Python 回调。因此需要补齐完整数值 IR。当前 `app/simulation/ir/` 仅发现缓存文件,不能视为已有可维护的完整 IR 源码。
`test/native-mql8-bdf/generate_model.py` 是验证原型:仍限定特定阀动作、机械组件编号、部分参数和连接形式,并拒绝某些循环闭合。正式编译器应由拓扑和能力合同推导这些内容,不能直接把这份脚本改成 XML 读入便宣称通用。
### 10.4 编译器要提前完成的计算
| 编译工作 | 运行时收益或正确性要求 |
| --- | --- |
| 合并等压、等速度等别名变量;组装流量/力平衡 | 消除多余未知量,统一端口方向与 SI 单位 |
| 对方程和未知量做匹配,将相互依赖部分划成小块 | 能顺序计算的直接计算,仅对真正代数环迭代;无法配平时定位到组件 |
| 固定状态、代数变量、参数、工作区和输出下标 | 每次求值使用连续数组,避免字典、字符串查找和临时分配 |
| 编译 stream 图及受温度影响的物理子网 | 保留现有无环传播和局部闭合能力,避免退回每轮全网重算 |
| 计算各入口的依赖集合与所有支持模式的稀疏结构 | RHS、事件、Jacobian 和输出按需计算;求 RHS 不自动附带所有输出 |
| 提取几何常量、公共表达式和物性状态包 | 参数不变的部分只初始化一次;相同精确输入下共享结果 |
| 声明事件、离散模式、缓存失效和状态复位 | 阀门跳变、流向切换、接触不依赖碰巧足够小的积分步长 |
跨积分调用的缓存必须按输入和模式有效性管理,不能仅以时间为键;同一时间可能存在多个 Newton 试探状态或雅可比扰动。试探工作区与已接受状态分开,拒绝步与事件回退不得提交试探产生的模式或缓存。
### 10.5 积分库与代数环的选择
当前引擎在 RHS 内闭合代数变量,对外给出 ODE。第一版原生全流程建议使用 **SUNDIALS CVODE 的 BDF 模式**。CVODE 提供 C 接口、1–5 阶变阶变步长 BDF,以及稠密、带状、稀疏线性求解接口。它的 Adams/BDF 由调用方选择,不能将其描述成 Amesim/LSODA 那样自动切换两种方法。[CVODE 数学方法](https://sundials.readthedocs.io/en/latest/cvode/Mathematics_link.html)
生成器负责模型方程、结构和导数,成熟积分库负责步长/阶数、误差控制和求解器历史。不要为每份 XML 重新生成一套 BDF 算法。先锁定可复现的 SUNDIALS 版本、C 接口、构建工具和依赖;Windows 用原生 C 构建链产出 EXE,Linux 对应 ELF 程序。
稀疏矩阵可配合 KLU,但先提供正确稀疏 Jacobian,再根据模型规模比较稀疏与稠密线性代数;132 状态案例的 LU 仅占约 2.19%,只换 LU 库难以改变总体耗时。[CVODE 线性求解选择](https://sundials.readthedocs.io/en/latest/cvode/Mathematics_link.html)
如果某类代数环难以可靠消去,可另行建立 `F(t, y, ydot) = 0` 的 DAE 后端,评估 C 接口的 IDA。此时必须处理代数变量、一致初始化和系统指数;高指数约束不能直接交给 IDA 期待自动解决。[IDA 官方介绍](https://sundials.readthedocs.io/en/latest/ida/Introduction_link.html)
消去代数变量后,Jacobian 不能只拼接相邻组件的直接偏导。对
```text
ydot = f(t, y, z)
0 = g(t, y, z)
```
在当前光滑分支、`g_z` 可逆时,需通过线性求解得到 `g_z * dz/dy = -g_y`,再组装 `J = f_y + f_z * dz/dy`,无需显式计算逆矩阵。这样才包含“状态变化 → 代数环重新平衡 → 导数变化”的影响。结构编译也要考虑此过程产生的新依赖。
第一版可用保守结构着色差分建立正确基线,再逐类增加局部解析导数或自动微分。旧实测的非零图和 27 色只是某次运行观察,不能直接当作全部流向/接触模式的可靠结构。迭代求解器的导数应针对其收敛方程,不宜把固定次数迭代轨迹直接当作物理方程导数。
### 10.6 项目接入点和生成产物
| 位置 | 具体接入方式 |
| --- | --- |
| `app/system_xml.py` | 复用现有校验和 `SystemXmlDocument`;补充明确的误差设置传递合同 |
| `app/main.py:compile_system_xml_network` | 抽取共同的版本、参数、介质与连接规范化前端;避免 Python/C 分别解释模型 |
| `app/simulation/solvers/causal_ir.py` 与现有分块/stream 计划 | 复用结构分析结果,转换为无 Python 回调的数值计划 |
| 建议新增 `app/simulation/native_codegen/` | 完整数值 IR、组件原生能力注册、结构编译、C emitter、编译缓存 |
| 建议新增 `native/runtime/`、`native/components/`、`native/include/` | CVODE 适配、代数求解、组件与物性原语、版本化 C ABI |
| 建议新增 `app/simulation/backends/` | Python 与原生 worker 的统一运行接口、结果适配和诊断 |
| `app/main.py:_run_system_xml_simulation_profiled` | 在现有任务/进度/取消接口内选择后端,保持结果变量标识和单位一致 |
每个模型的缓存目录建议包含:
```text
<build-key>/
model.c / model.h # 状态、端口布局和模型专用计算入口
manifest.json # 来源、版本、布局、组件映射、支持能力、构建哈希
model.exe # 链接原生运行库的可执行模型
```
每次运行另建目录,保存数值参数、运行选项、结果和诊断。模型 ABI 至少区分 `initialize`、`rhs/residual`、`jacobian`、`roots`、`apply_event`、`outputs`,通用运行库提供 `run`。RHS 返回导数,事件函数返回零点函数值,输出函数只在需要物理投影时调用。每个运行拥有独立 context,禁止共享可变全局状态。
缓存键包括规范化拓扑、组件/介质实现版本、结构参数、IR/ABI 版本、精度、平台、编译器和编译选项。普通数值参数以数组传入,时长、容差、最大步长与输出间隔由运行选项传入;这些修改通常无需重新编译。改变端口数量、状态数量或方程分支结构的参数必须触发重新编译。若把某个普通参数特化进源码,也必须将其值纳入缓存键。
生成器只输出可信原语与合法数值,用户组件 ID 通过映射表定位,不作为任意 C 源码或编译命令片段。未支持的组件、模式或算法在编译/启动阶段明确报告;强制原生测试不得静默改用 Python。
### 10.7 精度、采样与性能口径
本次检查发现两个实际差异:
- 网页入口 `app/main.py` 的 `rtol` 当前写死为 `1e-6`;此前测试 C 为 `1e-7`。XML v3 尚无 `rtol/atol` 字段,应通过明确的协议演进同时更新 XSD、解析、导出和执行设置,不可只修改某一后端。
- 当前 `tests/data/test-mql-8.xml` 仍为 `maxStep=0.001`,而此前 JSON 测试副本使用过 `1e30` 和 `0.02`。后续应由同一份规范化模型与独立运行配置驱动比较,不能用文件同名推断设置相同。
`rtol` 相同也不等于总体精度相同:需同时固定每类状态的 `atol`、单位/缩放、代数闭合容差、初始化及事件处理。首阶段保持状态定义和方程一致;改变质量/内能为压力/温度等状态形式属于单独的数值方案。
`sampleStep` 与内部积分步长分开:使用积分库的插值能力取得输出采样点,不为每个输出点重新启动求解器。已知不连续时刻仍要显式分段,状态事件要定位零点并执行复位/重启。`maxStep=1e30` 只放宽上限,不会使求解器忽略精度或事件自动迈大步。
主指标单列求解墙钟与求解 CPU;XML 解析、代码生成、冷编译、初始化、输出投影、文件写入和 HTTP 序列化分别计时。性能模式只计推进所需的求值与事件定位,不计绘图及额外结果投影;正确性模式另行采样曲线验证。积分器要求的初始 RHS/Jacobian 也计入求解工作量,避免从计时中漏掉。
### 10.8 推荐实施顺序
1. **冻结一份共同输入和运行配置。** 以 `test-mql-8.xml` 为首个完整目标,列出其全部组件类型/模式、公式与原生能力;对齐当前 Python、旧 C 原型和 Amesim 的参数、单位、状态与事件差异。
2. **完成一条闭环支路的完整 IR 和 C kernel。** 以同一 `t/y/mode/parameters` 比较初始化、物性、闭合、RHS、Jacobian 与事件左右值;覆盖正常、反向流、近零压差和接触状态。
3. **生成该支路的 C 并链接 CVODE,跑通独立 EXE。** 同时以“相同 SciPy + Python/C RHS”作为执行开销对照,分开判断 C 迁移收益与更换积分器的影响。
4. **覆盖首个完整模型,并接入现有 API 的显式原生选项。** 将当前 Python 的结构消元、stream 局部执行、事务回滚和稀疏能力迁入;优先修复管流收敛与雅可比重复求值,再考虑低占比线性代数优化。
5. **验证通用性和再逐步默认启用。** 用组件重命名、连线变化、不同支路数量、反向流和其他受支持参数模式证明生成器不依赖原测试拓扑;按现有物理验收合同检查 Amesim 投影、事件时刻和守恒残差。
首个交付应能由任意命名、符合已声明支持范围的 XML 稳定生成可运行 EXE,同时报告实际后端、求解器版本、模型/二进制哈希、计时及求值/迭代计数。本次文档没有执行这项迁移,也没有新增性能测试;提速倍率需待上述共同输入对照完成后报告。
@@ -0,0 +1,45 @@
# GCC 偶发子进程启动失败:定位与恢复
日期:2026-09-13;分支:`system-optimization`。用户要求在元件语义修复过程中继续定位并处理偶发 GCC 错误。
## 已定位的事实
本机 GCC 8.1 本体能运行,但在启动 `cc1.exe` 的过程中偶发失败。详细 `-v` 记录已经打印了实际存在的绝对路径;添加编译器目录到 PATH 后,192 次串行/并发对照中的一组仍有一次失败,因此不能把 PATH 补全或并发关闭当成已证明的根因修复。
对本轮自行启动的 GCC 诊断进程设置临时 Windows 调试断点,捕获到一次同类失败:
```text
CreateProcessA return = 0
GetLastError = 5 (ERROR_ACCESS_DENIED)
application = F:\Projects\mingw64\bin\..\libexec\gcc\x86_64-w64-mingw32\8.1.0\cc1.exe
gcc exit = 1
```
该样本是“拒绝访问”,不是“找不到 GCC 本体”。GCC 的 Windows 实现会把多种创建进程错误映射成 ENOENT,使终端只显示 `CreateProcess: No such file or directory`;这一信息损失也见 [GCC 官方讨论](https://gcc.gnu.org/pipermail/gcc-help/2025-May/144184.html)。
只读核查发现:cc1.exe 存在,普通用户有读取/执行权限,没有 Zone.Identifier 下载标记,未查到相关 Defender/Code Integrity 拦截事件。诊断进程不在作业对象中。**这些证据尚不能锁定 Windows 内部为何临时拒绝此次创建进程,也不能证明所有同类文本都对应错误 5。没有修改 ACL、安全配置、已安装 GCC 或系统 PATH。**
同时捕获到独立异常:`gcc --version` 有时退出码为 0,但 stdout 为空;旧代码直接取首行,产生 `IndexError`,进一步把工具链问题变成了不明确的后端异常。
## 后台修复
1. 保持 `SIMULATION_NATIVE_CC`/PATH 和 `SUNDIALS_ROOT` 的原查找规则,不写入任何本机绝对路径。Windows 将选中的编译器目录放入该子进程自己的 PATH;不修改后端全局环境。Linux 保留所配置命令及环境,包括依赖 argv[0] 的编译器包装器/符号链接。
2. 正式构建与启动自检统一使用有效的空标准输入、受控的标准输出/错误管道、关闭无关句柄继承;Windows 隐藏编译器窗口。
3. 正式构建只对已识别的 GCC 子进程启动错误、特定 Windows 临时进程错误,以及空编译器身份输出进行恢复。最多重试 5 次,间隔 0.05、0.1、0.2、0.35、0.5 秒。成功立即返回;失败日志保留,每次重试可追踪。
4. C 语法错误、未定义符号等链接错误、缺失编译器和超时不走上述重试。版本/目标查询必须有有效输出,空版本不会再触发 IndexError。
5. 启动自检的整轮重试仍由既有后台状态机管理:首次检测加最多 5 轮,编辑器可用,最终失败才展示详细错误。自检预处理/编译/链接命令不再额外叠加一层整轮重试;编译器身份查询可使用命令级恢复。
6. 正式构建记录 `compilerLaunchRetries`(构建命令的次数,独立的版本查询恢复在服务器日志中记录);最终编译失败保留命令、退出码、stdout/stderr、尝试次数等详情。启动自检明确描述为“GCC 已启动,但无法启动编译子进程”,避免再让用户误以为只需重配 gcc 路径。
这修复了应用把一次可恢复启动失败直接判为仿真失败的行为;**没有声称消除了 Windows 层所有拒绝访问的根源**。若六次启动仍失败,仍会如实报错,不更换编译器、不绕过系统安全限制。
## 验证
- 修复后 **128 次、并发度 4 的真实预处理调用全部完成**。期间再次捕捉到 1 次相同 CreateProcess 失败,自动重试一次成功,无需手动重启后端。
- **连续 10 次完整启动自检全部通过**,每次均执行预处理、编译、SUNDIALS 链接和最小 BDF 程序运行,未使用模型缓存。
- 进程专项测试覆盖瞬时错误恢复、重试上限、日志保留、真实语法/链接错误不重试、空版本、超时/缺失文件、环境隔离、Linux 分支,以及最终身份查询失败的完整自检详情;最后检查见 `final-process-detail-tests.log`。
- 元件与启动自检综合 43 项回归通过;最终编译进程/启动自检/历史合同组合 25 项通过;最终元件/目录检查 14 项通过。后续四路真实构建过程中再出现两次启动抖动,也都自动恢复并完成仿真。独立数值探针曾有一次空输出导致测试失败,保留记录并完整重跑通过;没有据此扩大为任意仿真进程的自动重跑。
- Linux 适配由跨平台分支测试覆盖;本次未登录 Linux 服务器做实机验证。
证据目录:`test/component-semantics-20260913/`。原始对照为 `gcc-probe.json`;原始 Windows 错误码为 `gcc-debug4.log`;恢复压力测试为 `launch-verification.json`;专项为 `compiler-process-tests.log`、`final-launch-and-contract-tests.log`。调试程序仅留在被忽略的 test 目录,不加入生产程序,也未修改磁盘上的编译器。
修改后需要后端进程加载新代码。已有运行进程不会因磁盘文件变更自动得到新逻辑(除非本身启用了 reload)。
@@ -0,0 +1,105 @@
# Jacobian 确定性复用与 UD00 验收
日期:2026-09-15。
## 结论与版本
已将方向一的工作区改动(含未跟踪源文件)保存到 Git stash,再从干净基线独立实施方向二。方向二在当前八路模型上将求解 CPU 时间减少 **19.27%**,完整原生工作进程耗时减少 **17.56%**。循环和不循环的完整原始采样均与基线逐字节一致。
- 方向一备份:`stash@{0}`,固定对象 `06b161c309644bb01aa402ff79f34749ff8e889a`。
- 备份名称:`codex-direction-one-purpose-evaluation-before-jacobian-2026-09-15`。
- 恢复后的基线:`2b07d996cff295d9f401dd32edd10472238bdf2d`,分支 `system-optimization`。
- 本轮修改保留在工作区,尚未提交;未重新应用方向一。原有忽略目录中的验收数据仍保留。
- 所有“优化前”均指上述恢复基线。交付核对确认两种优化后构建的 36 个源文件哈希、输入哈希及重新生成的 `model.c` / `model.h` 与实测冻结版本一致。
## 实施范围与通用性
在一次 Jacobian 构建中记录未扰动基准的确定性结果。分组扰动后,完整输入的浮点位全部一致才复用;输入变化时调用原始计算。每次 Jacobian 请求重新清空工作区。
覆盖储气物性、压力—焓温度反算、密度,以及管路标量求根。保留原有物性缓存的查询/填充顺序、温度观察、数值有效性检查和失败行为。普通 RHS、接受步检查、输出计算继续使用原路径,原有着色、差分步长和 Dense LU 不变。
复用依据实际物理输入与完整介质参数,不依赖模型实例名称、支路数或固定拓扑。使用已有内核的新模型自动接入,无需增加注册参数或维护另一套方程;新增底层内核需要审查完整输入和副作用后才能加入复用。
工作区使用堆内存。标量缓存为 1024 槽、每次至多探测 32 槽;内存不足、表满或未命中时执行原计算。分组计算失败时沿用原有未缓存逐列差分恢复。独立矩阵核对发现差异时采用参考矩阵并关闭后续着色/复用。
本轮未引入解析 Jacobian 或自动微分。现有切换、限位及隐式求根需要先明确导数政策,再评估这些方法。实现细节见 [技术说明](../standard/native-jacobian-reuse.md)。
## 正式性能对比
环境为同一台 Windows 11 机器、CVODE 7.4 BDF。输入 `tests/data/test-mql-8-corrected.json`,132 个状态、1784 个输出,两个 UD00 均不循环。仿真 0–10 s,采样间隔 0.01 s,rtol 为 1e-8,保持原有 atol 和求解设置。
编译及重型测试结束后,每个版本预热一次,排除预热,交替串行测量三次,报告中位数。完整工作进程计时包含原始数据写盘、输出回放和 JSON 写出,排除编译、API 及浏览器。带矩阵核对的运行不参与计时统计。
| 指标 | 优化前 | 优化后 | 耗时减少 |
|---|---:|---:|---:|
| 求解 CPU | 7.296875 s | 5.890625 s | 19.27% |
| 求解实际耗时 | 7.242260 s | 5.876553 s | 18.86% |
| 完整原生工作进程 | 7.852573 s | 6.473363 s | 17.56% |
六次正式运行的完整状态文件、输出文件、最终值、物性告警及原有求解计数全部相同:1002 个采样,nfev 44467,接受步 10840,拒绝步 918,Jacobian 896,LU 3106,状态转换 1,求解器启动 4。nfev 保持不变;收益来自每次求值内部减少了昂贵计算。
该工况的 Jacobian 优化路径计数如下,范围仅为基准/分组探针,不代表整个求解器的计算量:
| 内核 | 实际计算 | 复用 |
|---|---:|---:|
| 储气物性 | 150568 | 1254360 |
| 压力—焓温度反算 | 85283 | 601820 |
| 密度 | 440295 | 2479392 |
| 管路求根 | 196345 | 884176 |
原生可执行文件由 400314 字节增至 411613 字节。构建缓存状态未配平,不据此报告冷编译性能变化。以上性能收益限于当前机器及工况,不推断所有模型的加速比例。
## UD00 循环与不循环
按实际项目 JSON 修改两个 UD00 的 `iscyclic`,通过正常生成器分别构建。每个版本、每种工况运行一次 0–21.7 s,覆盖 10.8 s 和 21.6 s 的周期边界。
| 两个 UD00 的配置 | 采样数 | 优化前 CPU | 优化后 CPU | 全部原始数据及原有计数 |
|---|---:|---:|---:|---|
| 均不循环 | 2172 | 8.093750 s | 6.453125 s | 完全一致 |
| 均循环 | 2175 | 10.796875 s | 8.750000 s | 完全一致 |
这些时间是单次工况观察,正式性能结论使用上一节的三次中位数。
- 不循环:nfev 50092,接受步 11006,拒绝步 884,Jacobian 1073,LU 3287,状态转换 1,求解器启动 5。
- 循环:nfev 66495,接受步 15731,拒绝步 1334,Jacobian 1374,LU 4700,状态转换 4,求解器启动 10。
- 完整比较包含所有状态和输出采样、最终状态/输出、完整物性告警和上述计数;并非只比较曲线外观或最终值。本轮没有改变 UD00 信号语义。
## 独立 Jacobian 验证
`--verify-jacobian` 使用未缓存的 canonical 逐列差分,核对矩阵每个元素。
| 工况 | 核对矩阵数 | 不匹配数 | 与对应普通运行的数值结果 |
|---|---:|---:|---|
| 不循环,0–10 s | 896 | 0 | 完全一致 |
| 循环,0–21.7 s | 1374 | 0 | 完全一致 |
共 **2270 次矩阵核对,零差异**。核对运行的 nfev 增量分别严格等于 `896 × 132` 与 `1374 × 132`;原步数、事件、完整采样及复用计数不变。这避免把重复调用缓存路径误当作独立验证。
## 回归与验收限制
新增内核/生成器测试覆盖精确输入键、各介质参数的 1 ULP 变化、正负零、失败、冲突、容量耗尽、记录关闭、观察与缓存副作用、反向管流和活动容积。测试理想气体和氦气、热状态和多变储气;全模型 RHS/输出在所有状态逐项扰动及非有限输入下与原路径逐位对照。
新增运行时测试使用 SUNDIALS 公共类型编译生产 `cvode_solver.c`,覆盖基准重置、原始差分步长、无可选工作区、分组失败恢复、取消、故意破坏复用结果后由独立核对捕获并停用优化。
相关回归包括物性、管路、生成器、依赖排序、守恒、告警、分块存储、摩擦及 Jacobian。unittest 汇总为 **Ran 52 tests,FAILED (errors=1, skipped=1)**,已实际执行的用例通过,但不能称全量验收通过:
1. `NativeJacobianProbeTests.setUpClass`:旧固定状态样本的输入 SHA 与当前物理输入不符。当前输入为 `60ed16c71b72be4ae71ca7980f4f8cdcdf6b4c4c4c8950863c2390960b71ced4`,样本要求 `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288`。这是基线已有问题,输入及样本均未在本轮修改,也未更新样本以绕过保护;已排除换行差异。
2. `NativeJacobianRuntimeTests`:旧测试使用 Linux SUNDIALS 7.4 私有 ABI/静态库,在 Windows 跳过。新增公共类型运行时测试在 Windows 通过,但不替代真实 Linux 验收。
当前没有可用的 Linux 容器或 WSL 运行环境,未完成 Linux 编译/运行验收;本轮也未重新执行完整 Amesim 对照或浏览器端全流程。数值一致结论限定为同平台恢复基线与方向二的前后比较。
## 复现与证据
- 比较工具:[benchmark_native_jacobian_reuse.py](../../tests/manual/benchmark_native_jacobian_reuse.py)。`prepare` 冻结对应源码构建,`run` 保存单次测量及原始数据,`report` 断言全部对比项。
- 本地证据目录:`test/jacobian-reuse-20260915/`(项目忽略的验收数据目录,不随源码提交)。
- `comparison.json`:六次正式测量、两种 UD00 工况、矩阵验证和数值一致性断言。
- `before/`、`after/`、`before-cyclic/`、`after-cyclic/`:冻结构建、输入/源文件哈希与构建清单。
- 各运行子目录:`states.bin`、`outputs.bin`、`result.json`、`measurement.json`、日志。
- `regression.log`:相关回归完整输出;`delivery-verification.json`:交付源文件与实测生成文件的一致性核对。
| 构建 | buildKey |
|---|---|
| 基线,不循环 | `80cf19d21e41cb5662f934232c7a2c5302eb7fc5b91f82da3cb23f14e3f9b885` |
| 方向二,不循环 | `7fe268c97557c3e1c5a511ea5e28cf0f0479a087031abe1a38d7007dde3c4ca0` |
| 基线,循环 | `a74d52ffe2daef7ccba5030dec75209f96d39f1bc7b47436ba5351fab08a3a92` |
| 方向二,循环 | `a50f302b77a49d1aa28a4ee305239a36e8c9281ecdc93756b729183b8f1e0a0a` |
@@ -0,0 +1,151 @@
# LSTP 主路接入与八路力差定位
日期:2026-09-17。参照:本机 Amesim 2404、当前循环八路归档及上轮已审计的非循环归档。
## 1. 结论
**LSTP 事件已接入平台默认原生求解流程。循环、非循环八路的 238 条比较曲线均与上一轮“仅 LSTP”独立实验逐值相同,生成的原 RHS、雅可比结构和求解计数保持一致。MASS 的新增释放检查未合入。**
此前循环八路约 **128.55 万 N** 的最大力差,主要来自事件附近的保存时刻差异:平台配对点尚在驱动跳变边界,Amesim 普通输出点已经向前演化约 **2.20 ps**。两边驱动值相同,但连续速度已经不同,`1e11 N/(m/s)` 的阻尼将这个速度差放大。
在 Amesim 副本中只开启 **Discontinuities printout(不连续点输出)** 后,沿用原有共同网格及同驱动阶段配对,循环八路最大力差降为 **1.312403 N**,力的相对差超过 5% 的样本归零。平台没有再次改变积分结果,Amesim 原有所有样本也逐值不变,只新增了事件保存点。
另一个约 **−2.084e14 N** 的原始力峰,在 Amesim 的事件输出中同样存在,来自理想硬限位碰撞后的速度重置与大阻尼。这是当前参照工况本身产生的瞬态量级,不能直接当成平台独有的数值误差。
## 2. 默认流程改动及验证
- 两条生成路径均发出 `NativeContact` 描述,直接引用两端质量组的速度/位移状态,涵盖数值/几何刚度模式。
- 默认定位 LSTP 间隙过零;非负力模式还定位接触区内原始力过零。允许负力的模式不新增力截断事件。
- 沿用已测试的接受步密集插值、二分定位及相对速度换向分段检查。接触事件不重置位置或速度,不额外调用整模型 RHS。
- 防重复记录只在事件接受时更新,不参与力、雅可比或输出重放。保留已有输出语义 v2;结果增加 `contactEvents` 诊断计数。
- 两条生成路径原有间隙表达式的浮点运算顺序分别保留。移除新增常量描述表后,生成的 C 源码 SHA-256 与上轮原模型相同;雅可比仍为 132 状态、27 色和原物性复用方案。
实现:[事件描述](../../app/simulation/native_codegen/contacts.py)、[公共事件处理](../../native/runtime/common.c)、[接触定位](../../native/runtime/contact_events.h)、[组件接入规范 12.3](../standard/component-model-authoring-spec-v1.md#123-lstp-接触状态事件)。已有 LSTP 实例自动生效,无需工程 JSON 另加事件开关。
| 主路复测 | 循环 | 非循环 |
| --- | ---: | ---: |
| 与独立 LSTP 实验的全部比较数组 | 完全相同 | 完全相同 |
| 接受步 | 16,934 | 9,980 |
| RHS 求值 | 81,067 | 54,412 |
| 求解器启动 | 21 | 6 |
| 新增 LSTP 接触事件 | 8,同一时刻合并重启一次 | 8,同一时刻合并重启一次 |
| 接触力截断事件 | 0 | 0 |
| 同阶段配对 | 5,001/5,001 | 5,001/5,001 |
本轮未重新做五次性能测量,不将单次秒数解释为新性能变化;积分计数与上一轮独立实验一致。上轮性能结论见[MASS/LSTP 独立报告](MASS与LSTP事件独立实验及八路对比-2026-09-17.md)。
验证覆盖解析碰撞、守恒、两个最大步长、BDF/RK45、几何刚度、指数阻尼、力截断、同号间隙的中间接触/脱离、切触、无事件轨迹不变、事件输出及纯求解一致性,以及原有摩擦、硬限位和雅可比结构。
8 项 LSTP 专项及相关输出/信号、摩擦/结构回归合计 **27 个不同测试方法通过**。最初 Amesim 参考回路测试误用 2 ms 采样,参考含 1 ms 点,产生插值误差;恢复到参考的 1 ms 采样后原公差通过,没有放宽断言。另保留旧独立实验工具的兼容处理:使用新运行库时关闭主路接触注册,避免与实验事件重复执行。
测试日志:[LSTP 首批](../../test/lstp-mainline-unit.log)、[参考回路复测及输出/信号回归](../../test/lstp-mainline-regression.log)、[摩擦与结构](../../test/lstp-mainline-friction-structure.log)、[几何刚度](../../test/lstp-mainline-geometry.log)。八路原始结果:[主路汇总](../../test/lstp-mainline-20260917/baseline-summary.json)。
另有 1 项旧独立实验入口在新运行库上的[兼容回归](../../test/lstp-mainline-experiment-compatibility.log)通过;最终源码/结果隔离核验见[检查记录](../../test/lstp-mainline-20260917/final-verification.json)。
## 3. 将 32.4 s 的力差拆开
以第 2 路为例,接触力为:
`F = K*p + R*(1-exp(-p/Pdis))*(v_branch-v_common)`。
此时穿透约 4.6866 μm,`Pdis=0.1 μm`,阻尼系数已基本达到 `R=1e11 N/(m/s)`。由两侧保存的间隙和速度分别重建接触力,均与各自输出完全一致。
| 原普通输出的配对量 | 平台 | Amesim |
| --- | ---: | ---: |
| 实际时间,s | 32.400000000000006 | 32.40000000000221 |
| 公共质量块速度,m/s | 0 | −1.2883209101e-5 |
| 第 2 路相对速度,m/s | −3.3024983e-12 | 1.2854894891e-5 |
| 弹性力,N | 468,662.995301 | 468,662.655106 |
| 阻尼力,N | −0.330250 | 1,285,489.489088 |
| 总接触力,N | 468,662.665051 | 1,754,152.144194 |
**弹性项只差 0.340 N,约 128.55 万 N 的差异几乎全部是阻尼项。** 双方均在接触区,并不存在一边接触、一边脱离的模式错误。
此刻 UD00 驱动从 0 切换为 1e12 N,经负方向力源作用于公共质量块。质量为 170,000 kg,位于 0.37 m 上限,切换后向允许方向释放,加速度约 −5.882e6 m/s²。经过约 2.2e-12 s,速度变化达到约 1.3e-5 m/s,乘以大阻尼便得到百万牛级力变化。
本机参考代码中,[UD00](F:/AMESim2404/Amesim/libsig/submodels/UD00.c:593)更新周期阶段并调用时间不连续处理;[MECMAS21](F:/AMESim2404/Amesim/libmec/submodels/MECMAS21.c:547)将理想限位交给 `endst2_`;[LSTP00A](F:/AMESim2404/Amesim/libmec/submodels/LSTP00A.c:296)由穿透及其导数计算接触力。这些代码和本次输出支持上述因果链,但本报告不据此声称两套积分器内部算法完全相同。
## 4. 三种对照验证取样影响
### A. 使用相同实际时刻的连续状态
在独立运行库副本中增加 210 个只读密集输出查询,包括 Amesim 真正保存的时间。查询不改变步长、时间事件或接受状态;完整积分计数与主路运行一致。结果重放仍调用原模型公式。
| 第 2 路名义时刻 | 原配对绝对力差,N | 同一实际时刻绝对力差,N | 减少 |
| --- | ---: | ---: | ---: |
| 21.6 s | 387,825.741 | 7,758.635 | 98.00% |
| 32.4 s | 1,285,489.479 | 5,025.566 | 99.61% |
| 43.2 s | 29,254.603 | 3.685 | 99.99% |
同一实际时刻仍有小残差,不能据此宣称皮秒瞬态完全一致。32.4 s 附近 binary64 时间间距约 7.105e-15 s;按此刻加速度和阻尼估算,一格时间变化对应约 4,180 N。主路日志在这些驱动边界也记录了积分器成功步未推进外部浮点时间的情况。
进一步用**同一已保存的边界状态**,在独立程序中将局部时间从 0 开始,只继续到上述 2.20 ps;原力学、热力学公式及驱动阶段不变。rtol=1e-8 和 1e-10 的第 2 路末力均约 **1,761,496.860 N**;默认绝对时间密集输出为 **1,749,126.578 N**,Amesim 普通输出为 **1,754,152.144 N**。该试验表明这一极短瞬态对时间原点和启动处理敏感,单纯再收紧相对容差不能解释或消除全部残差。局部时钟没有合入生产。
证据:[逐支路力分解与同时间对照](../../test/lstp-mainline-20260917/force-error-analysis.json)、[连续状态取样](../../test/lstp-mainline-20260917/trace/dense-outputs.json)、[局部时钟结果](../../test/lstp-mainline-20260917/rebase/summary.json)。
### B. 直接比较事件前后保存点
按本机 [Amesim `ameputsimopt`](F:/AMESim2404/Amesim/scripting/python/amesim.py:1093) 的定义,仅将副本 `.sim` 第二行的 `simOptions[2]` 从 0 改为 1,启用 `printDiscont`。模型、容差、时间区间、普通输出间隔及其他求解选项不变,重新运行 Amesim。
记录从 5,002 行增至 5,046 行,核验**所有原有时刻、全部原保存变量逐值不变**。32.4 s 第 2 路新增记录为:
| Amesim 实际时刻,s | 阶段 | 公共质量块速度,m/s | 接触力,N |
| --- | --- | ---: | ---: |
| 32.400000000000006 | 驱动切换前 | 0 | 468,662.652083 |
| 32.40000000000001 | 驱动切换后立即 | 0 | 468,662.652083 |
| 32.40000000000221 | 原普通输出,已演化约 2.2 ps | −1.2883209101e-5 | 1,754,152.144194 |
平台事件后的力为 **468,662.665051 N**。与 Amesim 的事件后立即值比较,差 **0.012968 N**。这直接证明“相同信号阶段”还不足以把事件边界与已经演化的普通输出当成同一个瞬态。
### C. 保持原比较算法,重新比较整组八路
平台结果不变;参照换成含事件点的 Amesim 输出,仍用相同名义 10 ms 网格、相同时间候选窗口、完整 10 个驱动信号配对,并让所有曲线共用一对行。没有根据力差选择样本,没有删除事件网格点。
Amesim 原始事件输出存在一处文件行序倒置:先写 0.8000000000000009 s 普通点,后写 0.8 s 事件点。本次诊断只按原始实际时间稳定排序,保留全部行、值和重复时间,并保存排序到原始行的映射;未改变平台导入器或原始结果文件。
| 循环八路指标 | 原普通 Amesim 输出 | Amesim 含事件点输出 |
| --- | ---: | ---: |
| 比较曲线/网格点 | 238 / 5,001 | 238 / 5,001 |
| 未配对时刻 | 0 | 0 |
| 全网格最大绝对力差,N | 1,285,489.485624 | **1.312403** |
| 最大力差时刻 | 32.4 s | 0.01 s |
| 力的相对差超过 5% 的曲线样本数 | 24 | **0** |
| 所有变量相对差超过 5% 的曲线样本数 | 32 | **6** |
| 最大压力差,Pa | 61.105785 | 61.105785 |
| 最大温度差,K | 0.389714 | 0.389714 |
剩余 6 个为质量流量 4、焓流 2;5% 仍只是原有诊断线。32.4 s 八路在事件侧配对后的力差均小于 0.015 N。**1.312403 N 是这组共同网格和事件侧对照的最大差,不能作为网格之间所有极短瞬态误差的上界。**
证据:[新对照汇总](../../test/lstp-mainline-20260917/event-output-comparison/summary.json)、[全部曲线](../../test/lstp-mainline-20260917/event-output-comparison/comparison.json)、[阶段配对](../../test/lstp-mainline-20260917/event-output-comparison/phase-pairing.json)、[原始行映射](../../test/lstp-mainline-20260917/event-output-comparison/ame-row-order.json)。
## 5. 约 2.084e14 N 的原始力峰来自哪里
该峰发生在约 32.4003551 s,即释放后约 0.355 ms,公共质量块从上限运动至下限。碰撞前,两端速度都约 −2083.9 m/s、相对速度较小;理想下限把公共质量块速度重置为 0,支路质量块仍保持约 −2083.9 m/s。因此大阻尼立即产生约 −2.084e14 N 的力。此模型允许负接触力,该值不会被截断。
| 第 2 路对应的下限碰撞后 | 平台 | Amesim |
| --- | ---: | ---: |
| 事件时刻,s | 32.40035510065042 | 32.400355100640695 |
| 公共质量块位移/速度 | 0 m / 0 m/s | 0 m / 0 m/s |
| 支路速度,m/s | −2083.913265366 | −2083.913208006 |
| 接触力,N | −2.083913259638e14 | −2.083913202278e14 |
对应碰撞时间差约 **9.73 ps**,力值相对差约 **2.75e-6%**。单看绝对差仍有约 5.74e6 N,但这是在约 2.084e14 N 的峰值上。两边都出现同一种速度重置和阻尼峰,不能把这个峰本身归因于缺少 LSTP 事件。
这验证了当前参照模型的实现一致性;是否希望工程模型具有如此大的外力、速度及理想碰撞瞬态,应由工况与接触建模目标决定,不能用调小数值结果来替代模型选择。
证据:[平台碰撞前后样本](../../test/lstp-mainline-20260917/raw-force-peak.json)、[Amesim 事件输出及碰撞样本](../../test/lstp-mainline-20260917/amesim-event-output/mechanical-events.json)。
## 6. 后续建议及复现
初步平台展示与正确性评价,优先使用**开启不连续点输出的 Amesim 结果**作为参照,并保留共同事件侧配对、原时间戳及原普通输出的审计。当前证据不支持为消除原来的 128 万 N 配对差再调整 LSTP 刚度、阻尼,或将昂贵的 MASS 释放实验合入默认路径。
若下一步要验证皮秒尺度的峰形,再研究按事件相对时间表示/求解短瞬态、保存精度以及硬限位重置后的连续状态;本次只完成了独立局部时钟诊断,没有改生产时间处理。
[诊断脚本](../../tests/manual/diagnose_mql8_force_events.py)支持分阶段复现,完整执行使用新目录:
```powershell
.\.venv-win\Scripts\python.exe -X utf8 -m unittest tests.test_native_contact_events -v
.\.venv-win\Scripts\python.exe -X utf8 tests/manual/diagnose_mql8_force_events.py --output test/lstp-mainline-new-run --stage all
```
该诊断依赖本工作站已对齐的工程、上一轮独立实验数据和 Amesim 2404。源码、测试、规范及本文为可跟踪交付;原始结果在 Git 忽略的 `test/lstp-mainline-20260917/`。Windows 编译器的偶发启动失败由现有重试恢复;诊断和新专项串行构建以减少干扰,生产构建调度未更改。
@@ -0,0 +1,171 @@
# MASS 与 LSTP 事件独立实验及八路对比
日期:2026-09-17。环境:Windows、本项目 Python 3.12、原生 C、CVODE BDF;组件专项同时覆盖 RK45。
## 1. 本次实验回答什么
以已经完成 UD00 边界修复、事件输出语义修改的当前平台为基线,独立比较 `off`、`mass`、`lstp`、`all` 四种配置。循环和非循环各运行 50 s;每种配置保留完整结果,再做 5 次轮换顺序的纯求解计时。
**实验实现放在 `tests/manual/`,没有把新事件启用到默认平台。已有信号修复、输出语义、组件公式、雅可比分色及物性复用保留。** 因此本文的“未修改”特指本轮新增机械事件关闭,不是回退到此前有信号边界问题的版本。
MASS 的新增事件在当前八路工况中没有带来精度改善,纯求解耗时中位数增加 **31%–34%**。LSTP 的非循环最大力差降低 **9.90%**,本次耗时没有明显增加,但循环工况 32.4 s 的主要力差没有改善,部分其他误差反而增大。两类一起开启,精度与只开 LSTP 相同,耗时增加 **22%–23%**。
## 2. 独立实现与隔离方式
当前 `native/` 冻结为 `test/mechanical-events-20260917/source-control/native/`。`off` 直接用这个副本;其他配置用另一份副本追加实验运行代码,编译缓存、可执行文件、结果存储均放在本次实验目录。
[实验程序构建](../../tests/manual/mechanical_event_variant.py)在原生成源码后追加事件描述与释放驱动力求值入口。每次构建均断言原生成源码仍是逐字相同的前缀,状态键、求值调度和雅可比稀疏结构一致。正常 RHS、Jacobian 和物性缓存路径没有替换。八路仍为 132 个状态、1,784 个输出、27 个雅可比颜色。
生产源码的前后 SHA-256 核验、原模型前缀核验与积分计数用于区分“新增事件改变积分轨迹”和“破坏已有优化”。这些检查不代表新事件本身没有计算成本。
最终核验:46 个生产源码文件前后哈希相同,控制副本与当前 `native/` 相同;所有配置仍采用 `colored-difference` 和输出语义 v2。两组 `off` 的全部比较数组与上一轮输出语义基线完全一致。见[隔离及结果核验](../../test/mechanical-events-20260917/baselines/evidence-verification.json)与[生产源码哈希](../../test/mechanical-events-20260917/baselines/production-verification.json)。
### MASS
- 弹性上下限:分别检查 `xmin - x`、`x - xmax` 过零,定位接触和脱离。
- 非负接触力模式:在接触区内额外检查未截断力 `K*p + D(p)*v_relative` 过零。允许负力的模式不添加这个截断事件。
- 理想/恢复限位保持后的释放:在已贴限位、速度接近零时,检查不施加限位约束的加速度是否转向允许运动的方向。
- 原有碰撞、恢复系数和摩擦事件继续按原逻辑处理。弹性事件不重置速度或位置;保持后的释放从约束位置、零速度继续。
为隔离已有优化,释放驱动力来自独立整模型求值,没有修改原 RHS 以共享中间量。`releaseRhsCalls` 单独记录这项成本,并计入 `nfev`。这是**本实验实现**的代价,不是所有 MASS 事件实现必然付出的代价。
### LSTP
按原公式的同一运算顺序计算 `gap = gap0 + x2 - x1`,检查 `-gap` 的过零。非负力模式再检查接触区内的原始力过零;允许负力时不检查力截断。事件不施加硬碰撞式速度重置。
### 公共定位与输出
[实验运行库](../../tests/manual/mechanical_events_runtime.c)在积分接受步的密集插值上二分定位;浮点区间不可再分时停止。同一浮点时刻发生的事件合并提交并重启一次。仅在事件真正接受后更新防重复触发记录;试算、雅可比求值和输出重放不更新物理模式,输出仍由原公式计算。
相对速度换向时,先按换向点分段找间隙根,以覆盖步首尾间隙同号但中间接触/脱离的情况;孤立切触不制造虚假来回事件。这仍依赖积分步已经解析运动,不能保证捕获一个接受步内任意多次未解析的高频振荡。根的二分精度也不等于积分状态精度。
沿用上一轮 `outputSemantics.version=2`:记录事件左邻时刻和接受后的事件时刻。没有调整比较点来挑选误差更小的力值。
## 3. 组件独立验证
验证包括解析解、物理守恒、现有 Amesim 参考回路和无事件回归,代码见[专项测试](../../tests/test_mechanical_event_experiment.py)。
| 测试 | 验证依据 |
| --- | --- |
| MASS 弹性上下限 | 阻尼振子的解析接触持续时间及出界速度;两个最大步长、BDF/RK45 |
| MASS 保持后释放 | 连续斜坡驱动,1 s 释放;上下限镜像,最终速度和位置解析值 |
| MASS 原有恢复碰撞 | 恢复系数 0.5;与事件关闭版逐样本、最终状态和启动次数对照 |
| LSTP 两质量弹性碰撞 | 解析接触/脱离时刻、速度交换、总动量和动能加弹性势能 |
| MASS/LSTP 非负力 | 接触区内力截断事件、非负输出、MASS 截断时刻解析值 |
| 指数阻尼与两种力模式 | MASS/LSTP、BDF/RK45、extended 生成路径,检查完成且无事件抖振 |
| 无接触、初始贴边静止、持续接触 | 无多余重启,原始输出与关闭版完全一致 |
| 同号间隙与切触 | 已知二次多项式密集输出:准确找到 0.4/0.6 s 两根,切触不造事件 |
| 已有 Amesim 回路 | `friction_ideal`、`friction_elastic`、`joint_mechanical`,按现有参考值核验 |
弹性接触时刻/速度允许误差 2e-6,连续释放时刻 1e-8 s,解析截断时刻 3e-6 s;守恒断言另设独立公差。Amesim 参考来自现有 `amesim-component-semantics-2404.json`,这里不声称重新测出了 Amesim 的内部根定位算法。八路则使用本轮重新运行的 Amesim 结果。
**9 个测试方法全部通过**,覆盖 38 种模型/配置/求解器组合,另含 2 个解析密集输出场景。按开发顺序分批执行,力截断条件补全后专门复跑相关测试;日志有重复方法,不能把各批数量直接相加。证据:[首批 6 项](../../test/mechanical-events-20260917/isolated-verified.log)、[截断复测及参考回归 3 项](../../test/mechanical-events-20260917/independent-reference-tests.log)、[恢复碰撞回归 1 项](../../test/mechanical-events-20260917/restitution-regression.log)。
## 4. 八路比较口径
循环参照为 `tests/data/test_mql.ame`,非循环参照为已审计的 `test/node-fixes-amesim-20260914/test_mql.ame`。输入用上轮已对齐的工程副本,原 AME 和用户工程不修改。每个工况重新执行一次 Amesim,作为四种配置共同的参考;平台 BDF、0–50 s、输出间隔 0.01 s、误差限数值 1e-8。
八路包含 10 个 MASS:9 个无限位、1 个理想限位,没有弹性限位 MASS;8 个 LSTP 均允许负力,接触刚度与阻尼均为 1e11,指数阻尼距离约 1e-7 m。因此八路不覆盖全部新增模式,必须结合前面的组件测试判断。
每个比较点先核验 8 个 STEP 和 2 个 UD00 的完整驱动组合,再让全部 238 条曲线共用同一对真实保存行,不跨信号跳变插值。另检查八个 `gap < 0` 标志。信号阶段匹配不等于两边浮点时间完全相同,也不代表验证了网格之间的全部接触事件。
全部 8 次准确性运行均正常结束,全部比较输出有限;各自 **5,001/5,001 个时刻成功配对,信号最大差为 0,接触/分离标志不匹配为 0**。没有删除未匹配点来改善指标。
## 5. 八路准确性结果
### 汇总
下表列 `off → lstp`。`mass` 在 238 条已比较曲线上与 `off` 完全一致,`all` 与 `lstp` 完全一致,因此不重复相同列。
| 指标 | 循环:off → lstp | 非循环:off → lstp |
| --- | --- | --- |
| 最大接触力绝对差,N | 1,285,489.483041 → 1,285,489.485624 | 1.456532 → **1.312403** |
| 上项发生时间,s | 32.4 → 32.4 | 0.01 → 0.01 |
| 平静段最大力差,N | 0.275890 → **0.212043** | 0.275890 → **0.154170** |
| 最大压力差,Pa | 65.686768 → **61.105785** | 65.686768 → **61.105785** |
| 最大温度差,K | 0.395395 → **0.389714** | 0.395395 → **0.389714** |
| 最大间隙差,m | 1.293005e-8 → **5.474060e-8,增大** | 1.293005e-8 → **1.581826e-8,增大** |
| 最大质量流量差,kg/s | 1.326797e-5 → **2.424649e-5,增大** | 5.663171e-6 → **2.424649e-5,增大** |
| 最大焓流差,W | 141.920203 → **18.688805** | 141.920203 → **18.688805** |
| 明确非零且相对差超过 5% 的曲线样本数 | 56 → **32** | 30 → **6** |
“平静段”沿用已有定位统计:t≥0.1 s,避开信号边界前后 0.02 s;它不能替代包含事件点的完整指标。5% 是诊断筛查线,不是工程验收公差。循环的 32 个超线样本仍含接触力 24、速度 2、质量流 4、焓流 2;非循环剩余质量流 4、焓流 2。
最大压力差降低约 6.97%,温度差降低约 1.44%;循环/非循环平静段最大力差分别降低约 23.14%/44.12%。但间隙和质量流量的最大绝对差增大,因此不能称为所有指标全面改善。新增最大流量差发生在 0.09 s、`amesim_pn3node2_1.reference_mass_flow`;该点参考流量约 0.02098 kg/s,绝对差约为参考值的 0.116%。原有小流量样本的相对差计数减少,与这一绝对误差增大可以同时发生。
### 八个支路分别看
以下均为各支路完整 0–50 s 网格的最大绝对力差,单位 N。支路编号对应 `amesim_lstp00a_1…8`。循环均发生在 32.4 s,非循环均发生在 0.01 s。
| 支路 | 循环 off | 循环 lstp | 非循环 off | 非循环 lstp |
| --- | ---: | ---: | ---: | ---: |
| 1 | 1,285,489.480 | 1,285,489.479 | 1.450464 | 1.306552 |
| 2 | 1,285,489.483 | 1,285,489.479 | 1.456532 | 1.312398 |
| 3 | 1,285,489.482 | 1,285,489.486 | 1.456530 | 1.312403 |
| 4 | 1,285,489.478 | 1,285,489.480 | 1.450460 | 1.306567 |
| 5 | 1,285,489.475 | 1,285,489.483 | 1.447450 | 1.299400 |
| 6 | 1,285,489.475 | 1,285,489.481 | 1.446239 | 1.298499 |
| 7 | 1,285,489.477 | 1,285,489.478 | 1.446238 | 1.298504 |
| 8 | 1,285,489.478 | 1,285,489.477 | 1.447445 | 1.299415 |
非循环八路均改善,循环八路的大力差均基本不变。完整精度数据见[汇总](../../test/mechanical-events-20260917/baselines/effect-summary.json)、[逐支路](../../test/mechanical-events-20260917/baselines/branch-force-summary.json)、[全部物理量分组](../../test/mechanical-events-20260917/baselines/group-summary.json)。
### 实际新增了哪些事件
两组工况都只有 8 个新增 LSTP 接触事件,全部发生在 **6.5966840909145208e-9 s**,合并为一次积分重启;之后没有检测到新的 LSTP 接触/脱离。当前采用允许负力模式,因此没有力截断事件。
两组都没有新增 MASS 弹性接触或连续释放事件。当前模型没有弹性限位 MASS;理想限位的相关驱动跳变已有时间事件负责重新启动,没有额外触发本次连续释放根。不能用“本模型未触发”推断其他驱动下不需要释放事件;连续斜坡测试已验证释放能正确发生。
循环 32.4 s 仍使用平台实际时刻 `32.400000000000006` 和 Amesim `32.40000000000221`。驱动阶段一致,但约 2.20e-12 s 的实际时间差与高刚度连续状态的影响仍未分离。新增的启动间隙事件不能替代对此的检查。
额外事件保存点的最大绝对接触力仍约为循环 **2.084e14 N**、非循环 **4.035e11 N**,与 `off` 基本同量级;这些点没有同分辨率 Amesim 参考,未计入上述准确性判断。
## 6. 求解耗时与成本来源
每组、每配置运行 **5 次纯求解**,轮换配置顺序。计时排除编译和结果重放/序列化;完整输出准确性运行另存。下表为墙钟中位数,括号为五次最小–最大值,百分比相对本次同工况 `off`,不与上轮不同时间运行的单次秒数混比。
| 配置 | 循环耗时,s | 相对 off | 非循环耗时,s | 相对 off |
| --- | ---: | ---: | ---: | ---: |
| off | 14.334(14.209–14.664) | — | 9.583(8.978–10.293) | — |
| mass | 19.220(18.035–20.428) | **+34.09%** | 12.565(12.190–14.599) | **+31.12%** |
| lstp | 13.605(13.369–15.221) | **−5.09%** | 8.871(8.661–9.044) | **−7.43%** |
| all | 17.697(17.003–19.600) | **+23.46%** | 11.653(11.246–11.852) | **+21.60%** |
LSTP 的运行范围与基线有重叠,五次试验不构成普遍提速保证;本次没有明显增加耗时,且积分步数下降。MASS 的范围与基线明显分离,额外开销同时有计算次数支持。各配置五次计时的积分计数完全重复,并与完整输出运行一致。
| 工况/配置 | 接受步 | 拒步 | RHS 求值 nfev | 求解器启动 | 额外释放驱动力求值 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 循环 off | 19,606 | 1,552 | 85,570 | 20 | 0 |
| 循环 mass | 19,606 | 1,552 | 122,240 | 20 | **36,670** |
| 循环 lstp | 16,934 | 1,137 | 81,067 | 21 | 0 |
| 循环 all | 16,934 | 1,137 | 112,331 | 21 | 31,264 |
| 非循环 off | 11,238 | 885 | 56,299 | 5 | 0 |
| 非循环 mass | 11,238 | 885 | 78,561 | 5 | **22,262** |
| 非循环 lstp | 9,980 | 742 | 54,412 | 6 | 0 |
| 非循环 all | 9,980 | 742 | 74,172 | 6 | 19,760 |
LSTP 循环/非循环分别执行 135,472/79,840 次元件步检查、2,196/963 次密集插值调用,实际只增加一次事件重启;其检查直接使用状态,不调用整模型 RHS。MASS 单独开启时,轨迹、步数和原有重启次数没变,多出的 nfev 恰好等于释放驱动力整模型求值次数。这解释了为什么“没有新事件发生”仍可能有明显耗时。
CPU 时间中位数也支持同一方向:循环 off/mass/lstp/all 为 13.891/18.266/13.062/16.984 s,非循环为 9.328/11.969/8.609/11.281 s。全部独立计时见[循环](../../test/mechanical-events-20260917/baselines/full/timings.json)、[非循环](../../test/mechanical-events-20260917/baselines/noncyclic/timings.json)。
## 7. 结论适用范围与下一步
1. **当前八路不宜直接把本实验 MASS 释放检查作为默认路径。** 其新增事件在这两组工况中没有触发,结果不变而存在额外计算。对于连续驱动力导致的限位释放,专项解析测试证明事件机制有作用;后续若集成,应优先从已有 RHS 获取释放驱动力,或仅计算必要依赖,再重新计时。
2. **LSTP 可以作为后续生产集成候选,但本次仍保留在实验中。** 它补齐了接触边界定位,部分误差降低,间隙和质量流量最大误差也有回退;不能据此声称循环八路的主要力差已解决,也不能把这一模型中的耗时结果推广到频繁接触/脱离的模型。正式集成前应根据展示关注量确认这些取舍。
3. **循环力峰仍应检查同一实际时刻的连续状态。** 32.4 s 的信号阶段已经一致,新增间隙事件仍未消除差异。下一步应核对细时间窗中的位移、速度、间隙和力,区分实际保存时刻偏移、高刚度响应及已有硬限位处理的影响。
纳秒级启动事件的定位时刻是本次平台数值轨迹上的根,没有同分辨率 Amesim 根时刻验证。额外事件保存点的巨大力峰同样保留,没有平滑或删除,也没有把缺少同分辨率参考的峰值当成准确性结论。
## 8. 复现与证据
[运行说明](../../tests/manual/mechanical_events_experiment.md)、[八路运行脚本](../../tests/manual/evaluate_mechanical_events.py)、[指标汇总脚本](../../tests/manual/summarize_mechanical_events.py)。
```powershell
.\.venv-win\Scripts\python.exe -X utf8 -m unittest tests.test_mechanical_event_experiment -v
.\.venv-win\Scripts\python.exe -X utf8 tests/manual/evaluate_mechanical_events.py --output test/mechanical-events-new-run --repeats 5
.\.venv-win\Scripts\python.exe -X utf8 tests/manual/summarize_mechanical_events.py test/mechanical-events-new-run
```
八路脚本依赖本工作站 Amesim 2404 及已有对齐工程;主输出目录必须是新目录。构建阶段出现过 Windows 缓存发布拒绝与 GCC 子进程启动/停滞,保留已完成运行后续跑,最后一次恢复使用 `--resume --serial-build`。串行选项只改变实验构建调度,不改变编译参数、模型、事件实现或求解器设置;构建及其等待均不计入上述纯求解时间。
本轮完整数据位于 Git 忽略的 `test/mechanical-events-20260917/`;代码、测试和本文可跟踪保存。每个配置目录有 `comparison.json`、`curves.npz`、`phase-pairing.json`、完整原始结果、事件日志及五次计时目录。
+7 -5
View File
@@ -1,5 +1,7 @@
# Python 数值实现退役记录(2026-09-10) # Python 数值实现退役记录(2026-09-10)
> 路径复核 / 2026-09-12:本文当时使用的 `tests/data/test-mql-8.json` 已删除;当前活动输入为 `tests/data/test-mql-8-corrected.json`,按其实际保存配置运行。August 冻结基准仍在 `tests/baselines/simulation/test_mql_8/sources/`,对应 AME 为 `tests/data/AmesimModels/test_mql.ame`。 详见 [路径修复记录](2026-09-12-test-input-path-refresh.md)。
网页与 CLI 统一采用 C 数值内核。删除旧 Python 积分器及模型数值实现;Python 保留输入校验、模型元数据、C 生成、构建缓存、任务与结果管理。 网页与 CLI 统一采用 C 数值内核。删除旧 Python 积分器及模型数值实现;Python 保留输入校验、模型元数据、C 生成、构建缓存、任务与结果管理。
## 删除与保留 ## 删除与保留
@@ -25,15 +27,15 @@
本地生成的 EXE、DLL、计时日志在 `/test/`,不提交编译产物。源码和冻结基准进入 Git,可重新生成。 本地生成的 EXE、DLL、计时日志在 `/test/`,不提交编译产物。源码和冻结基准进入 Git,可重新生成。
## 远端 IR 分支集成 ## 远端 IR 分支集成(历史阶段)
合并保留 `system-optimization` 的 IR v2 schema、Python 数据结构、内容签名和静态校验器。旧 `compile_system_ir(GenericFluidSystem)` 导出器直接依赖已删除的压力流量、机械和闭合执行计划,因此停用并返回明确的迁移提示。当前 C 生成接口是 `native_codegen.compiler.compile_native_program(network)`,尚不输出独立的 System IR v2 执行计划。 合并 `system-optimization` 时曾保留 IR v2 schema、Python 数据结构、内容签名和静态校验器,停用了依赖旧 Python 系统对象的导出器。这些 IR 文件已在随后清理中删除,未接入当前 C 生成或求解路径。
从远端提交 `ce353d2` 固化机械、132 状态 MQL、74 状态历史模型的 reference-only IR,保存在 `tests/data/system-ir-v2-reference.json.gz`。保留并迁移了 20 项 IR 结构、签名、索引、阶段和错误校验测试,全部通过。用于捕获参考结果的临时旧代码副本已删除。 当时从远端提交 `ce353d2` 固化了三个 IR 示例并通过 20 项校验测试;专属示例和测试已随 IR 一起删除,历史内容可从 Git 找回。
CI 同步移除旧 Python 基准命令:Linux 验证元数据和 IR 合同,Windows 配置 C 工具链后运行原生回归。CI 配置已更新,本地验证与远端 CI 的实际运行结果分别记录,不把本地通过当成远端 CI 已通过。 当前 CI 在 Linux 验证元数据和 XML 合同,在 Windows 配置 C 工具链后运行原生回归。本地验证与远端 CI 的实际运行结果分别记录,不把本地通过当成远端 CI 已通过。
## 最终本地验证 ## 旧数值实现退役时的本地验证
- 后端完整发现 280 项测试,1 项 Linux 锁定环境版本校验按配置跳过。数值、XML、IR 等检查通过;心跳测试改为等待消费确认后,对应 11 项 API 回归通过,消除依赖几毫秒固定等待的时序假设。 - 后端完整发现 280 项测试,1 项 Linux 锁定环境版本校验按配置跳过。数值、XML、IR 等检查通过;心跳测试改为等待消费确认后,对应 11 项 API 回归通过,消除依赖几毫秒固定等待的时序假设。
- 前端 TypeScript 检查与 Vite 生产构建通过。 - 前端 TypeScript 检查与 Vite 生产构建通过。
@@ -2,7 +2,7 @@
状态:22 个模型已在临时 AMESim 库完成第一版公开,持续校准中 状态:22 个模型已在临时 AMESim 库完成第一版公开,持续校准中
适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system` 适用模型:`tests/data/AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system`
配套规范:[`component-model-authoring-spec-v1.md`](../standard/component-model-authoring-spec-v1.md) 配套规范:[`component-model-authoring-spec-v1.md`](../standard/component-model-authoring-spec-v1.md)
+20 -64
View File
@@ -1,76 +1,32 @@
# AMESim 氦气 Peng-Robinson 介质模型 # Amesim 氦气 Peng–Robinson 介质模型
## 本地资料依据 平台公开组件 `amesim_helium_medium` 的 `property_model=0` 对应 Amesim `fluidType=12`、`eosType=6`。`gi` 为气体引用索引。实际物性在 [native/components/modules/properties.c](../../native/components/modules/properties.c) 中计算;Python 介质类仅保存编译期身份和参考常数,不是另一个物性求值器。
仓库中已经包含该模型所需的说明和算例数据: ## 状态方程与热量学
- `AmesimModels/help/pneumatic_library_manual/03_gas_properties.pdf` 第 3.3 节 使用摩尔体积形式的 Peng–Robinson EOS,气相取最大物理解。常数来自 Amesim 2404 `libpn/data/nasa_properties/helium_eos.data`:`R_u=8.31446261815324 J/(mol·K)`、`M=0.004002602 kg/mol`、`Tc=5.1953 K`、`Pc=227460 Pa`、偏心因子 `−0.382`。
给出 AMESim Pneumatic Library 的气体物性层级和 Peng-Robinson 状态方程。
- `AmesimModels/help/pneumatic_library_manual/lib_pneumatic.pdf` 第 87--88 页
给出 293.15 K、1 barA 下的氦气参考物性。
- `AmesimModels/test_mql.ame` 中的 `PNGD00` 实例明确选择
`fluidType=12`(helium)和 `eosType=6`(Peng-Robinson)。
- `app/simulation/core/peng_robinson.py` 已实现纯物质 Peng-Robinson 方程,
并定义了 `HELIUM_PR`。
AMESim 手册中的 Peng-Robinson 形式为: 热量学包含理想项和 PR 剩余项:`h=h0(T)+h_departure(p,T)`,`u=u0(T)+u_departure(T,rho)`,`u0=h0−R*T`。储气状态反解、p/h 温度反解及等熵系数使用同一套热物性。
```text 2026-09-15 补齐的温度分段:
p = r T / (v - b) - a alpha(T) / (v^2 + 2 b v - b^2)
a = 0.457235583 r^2 Tc^2 / Pc
b = 0.07779607 r Tc / Pc
alpha(T) = [1 + m (1 - sqrt(T/Tc))]^2
m = 0.37464 + 1.54226 omega - 0.26992 omega^2
```
当前 Python EOS 使用等价的摩尔体积形式,并通过最大物理解选择气相根。 | 项目 | Amesim 数据温区 | 当前实现 |
| --- | --- | --- |
| 理想 Cp、h | 200–1000、1000–6000、6000–20000 K | 前两段 Cp/R=2.5;第三段 NASA 2002 多项式,衔接处 Cp 和 h 连续 |
| 动力黏度 | 200–1000、1000–5000、5000–15000 K | 三段 NASA 96 公式,1000/5000 K 处连续修正 |
| 表外热量学 | 超过 20000 K | 按 Amesim 的二次 Cp 延拓及其焓积分处理,不能直接无限延长高阶多项式 |
| 表外黏度 | 超过 15000 K | 延用末段并保持边界修正,与 Amesim 对照一致 |
## 首版参数 低温参考量 `cp_ref` 和 `nasa_cp_over_R` 只描述低温段;它们不是全温区比热。黏度已不采用 Sutherland 近似。
| 参数 | 数值 | 本地依据 | ## 有效域与验证边界
| --- | ---: | --- |
| 摩尔质量 | `0.004002602 kg/mol` | `HELIUM_PR` |
| 临界温度 | `5.1953 K` | `HELIUM_PR` |
| 临界压力 | `227460 Pa` | `HELIUM_PR` |
| 偏心因子 | `-0.385` | `HELIUM_PR` |
| 定压比热 | `5193 J/(kg*K)` | AMESim 手册表 11.1 |
| 定容比热 | `3116 J/(kg*K)` | AMESim 手册表 11.1 |
| 比气体常数 | 由 `R/M` 计算,约 `2077.26 J/(kg*K)` | EOS + 手册表 11.1 |
| 293.15 K 黏度 | `1.96e-5 Pa*s` | AMESim 手册表 11.2 |
| Sutherland 常数 | `79.4 K` | 已迁移 `test_mql` 管路近似 |
手册参考点还给出 `rho=0.164 kg/m3`、`gamma=1.667`、`Z=1.0005`。 Amesim EOS 文件标记的有效温度为 **2.2–1500 K**。高温 NASA 理想热量学/输运数据存在,并不意味着 PR EOS 在相同高温也经过物理有效性验证;Amesim 自身会给出域外警告并继续计算。本次实现复现这些数值及延拓行为,没有宣称扩大 EOS 的适用范围。
## 组件与索引映射 当前组件求解路径使用密度、焓、内能、比热、黏度与等熵系数。没有新增平台尚未使用的熵或导热系数接口。
公开介质组件使用: 直接调用 Amesim 2404 官方库获取了 108 个固定温压状态作为独立回归基准,覆盖 200–30000 K、101300–250000000 Pa,各分段边界及其两侧。测试同时检查 p/h 温度反解、质量/内能状态重构、热力学导数和缓存路径。
```text - [回归测试](../../tests/test_native_helium_high_temperature.py)
modelType = amesim_helium_medium - [Amesim 独立数值基准](../../tests/data/amesim_helium_high_temperature_properties.json)
gi = 画布自动分配的 1..99 引用索引 - [修复证据、误差与整模型对照](氦气高温物性补齐与Amesim对照-2026-09-15.md)
property_model = 0 (Peng-Robinson,本应用内的稳定编号)
fluidType = 12 (AMESim 元数据)
eosType = 6 (AMESim 元数据)
```
`property_model` 与 AMESim 的 `eosType` 有意分离:前者是单个介质组件内部的
下拉选项编号,后者用于保留 AMESim 原模型语义。以后给氦气增加其他计算方法时,
只需扩展氦气的物性模型注册表。
源归档的 `gasName` 缓存文本与结构字段存在冲突,因此介质识别以
`fluidType=12`、`PNGD_HELIUM` 和组件标签为准,不使用 `gasName` 猜测。
## 首版计算边界
首版公共组件实现以下闭合:
- `rho(p,T)`、`p(rho,T)` 和控制容积压力使用 Peng-Robinson EOS;
- `u=cv*T`、`h=cp*T`,温度反解使用同一组定比热;
- 黏度使用以 293.15 K 为参考点的 Sutherland 近似;
- `gi` 注册、气动连通域一致性、XML/JSON 保存均复用现有介质机制。
这是一版可运行的“PR 压力--密度闭合 + 定比热热量学”,不是 AMESim 真实气体
物性的完全复刻。AMESim 的 `Cp/Cv/h/mu/rho/Z` 接口均可随 `(p,T)` 变化,而当前
`GasMedium` 的焓、内能和比热接口只有温度参数。后续若要接入完整残余焓、
压力相关比热和真实气体临界流,应先扩展为状态相关物性接口,再调整气室、管路和
孔口公式。
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,104 @@
{
"acceptance": "User accepted the documented temperature difference and authorized production default; no new Amesim accuracy claim",
"urls": {
"official": "http://127.0.0.1:8000",
"editor": "http://127.0.0.1:5173"
},
"inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288",
"frontendAssetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2",
"formalRuns": 3,
"warmups": 1,
"browserErrors": [],
"regression": {
"tests": 57,
"passed": true,
"seconds": 129.667
},
"formalMediansMs": {
"importToDomMs": 88.70000076293945,
"importToPaintOpportunityMs": 236.89999961853027,
"clickToFetchMs": null,
"fetchToHeadersMs": null,
"headersToEofMs": null,
"clickToReadyDomMs": 3498.5,
"clickToReadyPaintOpportunityMs": 3521.199998855591,
"resultParseMs": null,
"resultParseEndToReadyDomMs": null,
"clickToIndexedDbCommitMs": null,
"clickToIndexedDbObservedMs": 3613.3999996185303,
"resultTabToDomMs": 132.89999961853027,
"resultTabToPaintOpportunityMs": 195.10000038146973,
"resultTabToDomStableMs": 371.5,
"curveSelectToDomMs": 21.5,
"curveSelectToPaintOpportunityMs": 29.599998474121094,
"curveSelectToDomStableMs": 170.0999984741211,
"csvClickToWorkerConstructMs": null,
"csvWorkerConstructMs": null,
"csvWorkerStartToFinishPostMs": null,
"csvWorkerFinishPostToCompleteReceivedMs": null,
"csvWorkerStartToCompleteReceivedMs": null,
"csvWorkerPostSyncTotalMs": null,
"csvClickToFetchMs": null,
"csvFetchToHeadersMs": null,
"csvClickToBlobAnchorMs": null,
"resultClickToBlobAnchorMs": null,
"csvClickToDownloadSavedMs": 1199.7000007629395,
"resultClickToDownloadSavedMs": 1105.7000007629395,
"streamOutstandingReadMs": null,
"synchronousStreamJsonParseMs": null,
"synchronousStreamDecodeMs": null,
"restoreNavigationToDomMs": 273.1000003814697,
"restoreNavigationToPaintOpportunityMs": 431.1999988555908,
"restoreNavigationToDomStableMs": 606.1000003814697
},
"nativeSolveMedianSeconds": 2.606560856103897,
"nativeCounters": {
"buildKey": "6e4d7a46571ce154c1d25dc99d6578025b44d8daefffe953a112f88846e4fb81",
"jacobianMode": "colored-difference",
"nfev": 22853,
"njev": 433,
"jacobianRhsCalls": 12124,
"jacobianColoredEvals": 433,
"jacobianFallbacks": 0,
"cvodeRhsCalls": 10729,
"cvodeLinearRhsCalls": 0,
"acceptedSteps": 6660,
"rejectedSteps": 359,
"solverStarts": 4,
"stateTransitions": 1
},
"retiredModeEnvironmentSelectorRemoved": true,
"defaultSelectedWithoutJacobianFlag": true,
"formalPayloadEqualsAcceptedCandidate": true,
"formalCsvCellsCompared": 7154280,
"editorPayloadEqualsAcceptedCandidate": true,
"editorCsvByteEqualsValidatedOfficialCsv": true,
"editorRestoreExact": true,
"csvSha256": "9bf943659ecb717ea9c198e544039828ee072f3ca4d14490071d7462ec563c65",
"sources": {
"regression.log": {
"sha256": "bd240eaf9f4b76a2144176eabf0f3e03d9a376e52554b9062cb45dd4830c7e07",
"bytes": 10282
},
"browser-official/summary.json": {
"sha256": "136171c64879b14308238d91b5bf43981c0611e6dc521b5d68c428dd42fcd505",
"bytes": 22672
},
"browser-vite/summary.json": {
"sha256": "c996640c02b7535deecf6f48868fa06bbf853e0d9225bde0ce35894dd663fc70",
"bytes": 11303
},
"equality-official.json": {
"sha256": "6c0bb3f316693bcf9e4990dee3cf258d8f67f3e578a89a3ac09c4238e27933f3",
"bytes": 4894
},
"verify-cli-summary.json": {
"sha256": "5aaa11d8b03dd22683617fa67cebdb49be429942eba7c037533f05e4b63e0551",
"bytes": 1190
},
"manual-tool-checks/checks.json": {
"sha256": "1417648d0976c9daf42b912013913621276cd4b017520c9ab2d06f0f7170b0a3",
"bytes": 539
}
}
}
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 111 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
{
"host": "Linux-6.8.0-111-generic-x86_64-with-glibc2.39",
"windowsRuntimeExecuted": false,
"case": "Corrected eight-branch 0-10 s with os.utime rejecting follow_symlinks=False, real Linux GCC/CVODE execution.",
"success": true,
"simulatedUntil": 10,
"method": "BDF",
"rtol": 1e-08,
"bitwiseValuesCompared": 1790486,
"allBitsEqual": true,
"coldCacheHit": false,
"subsequentCacheHit": true,
"timestampUpdates": 14,
"solveSeconds": 2.510730743408203,
"buildSeconds": 2.51858819834888,
"sourceSha256": "ace0843c8fe84b687c7dedce9eecae1ab2a9174a0f321f047d99089cfa15b301",
"tests": {
"buildAndStorage": {
"passed": 40,
"seconds": 8.174
},
"realToolchainIntegration": {
"passed": 1,
"seconds": 1.399,
"windowsDllBranchExecuted": false
},
"windowsCi": "Configured; not executed from this Linux session."
}
}
@@ -0,0 +1,464 @@
{
"scope": "Native worker: model initialization through result encoding and cleanup. Excludes build, process launch, API and browser. Error estimation remains inside CVODE controller. Dense LU has no symbolic factorization. Explicit Newton uses the same library implementation.",
"cases": [
{
"name": "noncyclic",
"meanTotalSeconds": 7.6562506666667405,
"phases": {
"residual_evaluation": {
"seconds": 2.0878067666756883,
"percent": 27.269310496395295
},
"jacobian_assembly": {
"seconds": 2.739998700024747,
"percent": 35.78773500656092
},
"numerical_factorization": {
"seconds": 0.23652539999102373,
"percent": 3.089311077820268
},
"linear_solve": {
"seconds": 0.17955199999232718,
"percent": 2.3451687752864254
},
"linear_system_setup_other": {
"seconds": 0.10677826666005785,
"percent": 1.3946547900389645
},
"newton_overhead": {
"seconds": 0.012538333336427362,
"percent": 0.16376597217507388
},
"cvode_controller_including_error_estimation": {
"seconds": 0.021977399989888607,
"percent": 0.28705172997499034
},
"event_detection": {
"seconds": 0.001956633334733245,
"percent": 0.025556025003882137
},
"accepted_property_checks": {
"seconds": 1.1713370666645158,
"percent": 15.29909504875804
},
"sampling_and_storage": {
"seconds": 0.0096981666655059,
"percent": 0.12666992092786503
},
"output_replay": {
"seconds": 0.2987757666667979,
"percent": 3.9023770207470787
},
"result_encoding": {
"seconds": 0.777298566666559,
"percent": 10.152470190803813
},
"poll_and_progress": {
"seconds": 0.006268433334904937,
"percent": 0.0818734078573996
},
"framework_other": {
"seconds": 0.005739166663564295,
"percent": 0.0749605376499895
}
},
"meanScopes": {
"total": {
"inclusiveSeconds": 7.6562506666667405,
"exclusiveSeconds": 0.00013766666703910838
},
"integration": {
"inclusiveSeconds": 6.578948899999887,
"exclusiveSeconds": 0.005601499996525187
},
"cvode_controller": {
"inclusiveSeconds": 5.389218199991167,
"exclusiveSeconds": 0.021977399989888607
},
"rhs": {
"inclusiveSeconds": 2.0841626000111924,
"exclusiveSeconds": 2.082771766674341
},
"nonlinear_residual": {
"inclusiveSeconds": 2.0881416333457614,
"exclusiveSeconds": 0.005035000001347119
},
"jacobian": {
"inclusiveSeconds": 2.7426492000089033,
"exclusiveSeconds": 0.02242556667806639
},
"jacobian_probe": {
"inclusiveSeconds": 2.720223633330837,
"exclusiveSeconds": 2.7175731333466806
},
"linear_setup": {
"inclusiveSeconds": 3.085952866659985,
"exclusiveSeconds": 0.10677826666005785
},
"numerical_factorization": {
"inclusiveSeconds": 0.23652539999102373,
"exclusiveSeconds": 0.23652539999102373
},
"linear_solve": {
"inclusiveSeconds": 0.17955199999232718,
"exclusiveSeconds": 0.17955199999232718
},
"newton": {
"inclusiveSeconds": 5.366184833334501,
"exclusiveSeconds": 0.012538333336427362
},
"event_detection": {
"inclusiveSeconds": 1.181901733331415,
"exclusiveSeconds": 0.001956633334733245
},
"accepted_property_checks": {
"inclusiveSeconds": 1.1713370666645158,
"exclusiveSeconds": 1.1713370666645158
},
"sampling": {
"inclusiveSeconds": 0.00887103333207051,
"exclusiveSeconds": 0.0008239000012508768
},
"append": {
"inclusiveSeconds": 0.008658133330906518,
"exclusiveSeconds": 0.000971733330970892
},
"flush": {
"inclusiveSeconds": 0.007902533333284131,
"exclusiveSeconds": 0.007902533333284131
},
"output_replay": {
"inclusiveSeconds": 0.2987757666667979,
"exclusiveSeconds": 0.2987757666667979
},
"result_encoding": {
"inclusiveSeconds": 1.076074333333357,
"exclusiveSeconds": 0.777298566666559
},
"poll": {
"inclusiveSeconds": 0.006268433334904937,
"exclusiveSeconds": 0.006268433334904937
}
},
"symbolic_factorization": {
"seconds": 0,
"status": "not applicable: dense LU"
},
"error_estimation": {
"seconds": null,
"status": "included in cvode_controller_including_error_estimation"
},
"counters": {
"accepted_steps": 11006,
"residual_evaluations": 20048,
"linear_rhs_evaluations": 0,
"jacobian_evaluations": 1073,
"linear_setups": 3287,
"error_test_failures": 884,
"newton_iterations": 20037,
"newton_failures": 978,
"nonlinear_step_failures": 179,
"counter_segments": 5,
"jacobian_reuses": 2214,
"jacobian_refresh_setups": 1073,
"linear_setup_failures": 0,
"LU_factorization_failures": 0,
"LU_solve_failures": 0,
"event_model_evaluations": 0,
"scheduled_boundary_count": 3,
"rejected_steps": 1063,
"application_accepted_steps": 11006,
"same_time_returns": 0,
"max_same_time_streak": 0,
"nonlinear_residual_calls": 20037,
"jacobian_probe_evaluations": 30044,
"LU_factorizations": 3287,
"LU_solves": 20037,
"newton_solve_calls": 12069,
"event_count": 1,
"event_detection_calls": 11006,
"solver_starts": 5,
"all_counted_model_evaluations": 50092,
"accepted_property_checks": 11007,
"sample_count": 2172,
"jacobian_kernel_reuse": {
"gasEvaluations": 180304,
"gasReuses": 1502160,
"phEvaluations": 105403,
"phReuses": 732566,
"densityEvaluations": 531697,
"densityReuses": 2979837,
"pipeEvaluations": 235272,
"pipeReuses": 1063254
}
},
"medians": {
"control": {
"solveSeconds": 6.583620599999904,
"solveCpuSeconds": 6.59375,
"processWallSeconds": 7.7777962999998635
},
"profiled": {
"solveSeconds": 6.638836199999787,
"solveCpuSeconds": 6.6875,
"processWallSeconds": 7.830034699999942
}
},
"observedOverheadPercent": {
"solveSeconds": 0.8386813784482694,
"solveCpuSeconds": 1.4218009478673022,
"processWallSeconds": 0.671634971978885
},
"allRunsFullParity": true,
"samples": 3
},
{
"name": "cyclic",
"meanTotalSeconds": 9.711948900000152,
"phases": {
"residual_evaluation": {
"seconds": 2.8323357333368526,
"percent": 29.163412642511005
},
"jacobian_assembly": {
"seconds": 3.431086966648157,
"percent": 35.32851132122517
},
"numerical_factorization": {
"seconds": 0.3293585666582051,
"percent": 3.391271618595521
},
"linear_solve": {
"seconds": 0.24083636668046893,
"percent": 2.479794417786374
},
"linear_system_setup_other": {
"seconds": 0.14310710000260465,
"percent": 1.4735157842789146
},
"newton_overhead": {
"seconds": 0.01622606665841886,
"percent": 0.1670732293331836
},
"cvode_controller_including_error_estimation": {
"seconds": 0.028731599965188554,
"percent": 0.2958376352782097
},
"event_detection": {
"seconds": 0.002641499996722511,
"percent": 0.02719845443917512
},
"accepted_property_checks": {
"seconds": 1.6209957666577186,
"percent": 16.69073615757691
},
"sampling_and_storage": {
"seconds": 0.009674700005386208,
"percent": 0.09961646323516032
},
"output_replay": {
"seconds": 0.29708673333334445,
"percent": 3.058981635841801
},
"result_encoding": {
"seconds": 0.7434664000000643,
"percent": 7.655172073650971
},
"poll_and_progress": {
"seconds": 0.008574800045759426,
"percent": 0.0882912393181897
},
"framework_other": {
"seconds": 0.007826600011261084,
"percent": 0.08058732692941743
}
},
"meanScopes": {
"total": {
"inclusiveSeconds": 9.711948900000152,
"exclusiveSeconds": 0.00014803333336506816
},
"integration": {
"inclusiveSeconds": 8.670142566666678,
"exclusiveSeconds": 0.007678566677896015
},
"cvode_controller": {
"inclusiveSeconds": 7.026683966654825,
"exclusiveSeconds": 0.028731599965188554
},
"rhs": {
"inclusiveSeconds": 2.8279145666924705,
"exclusiveSeconds": 2.825953500006411
},
"nonlinear_residual": {
"inclusiveSeconds": 2.832391033356089,
"exclusiveSeconds": 0.006382233330441522
},
"jacobian": {
"inclusiveSeconds": 3.4341274666670265,
"exclusiveSeconds": 0.027501033304664208
},
"jacobian_probe": {
"inclusiveSeconds": 3.406626433362362,
"exclusiveSeconds": 3.403585933343493
},
"linear_setup": {
"inclusiveSeconds": 3.9065931333278363,
"exclusiveSeconds": 0.14310710000260465
},
"numerical_factorization": {
"inclusiveSeconds": 0.3293585666582051,
"exclusiveSeconds": 0.3293585666582051
},
"linear_solve": {
"inclusiveSeconds": 0.24083636668046893,
"exclusiveSeconds": 0.24083636668046893
},
"newton": {
"inclusiveSeconds": 6.9960466000228125,
"exclusiveSeconds": 0.01622606665841886
},
"event_detection": {
"inclusiveSeconds": 1.632206199992955,
"exclusiveSeconds": 0.002641499996722511
},
"accepted_property_checks": {
"inclusiveSeconds": 1.6209957666577186,
"exclusiveSeconds": 1.6209957666577186
},
"sampling": {
"inclusiveSeconds": 0.008811533338606145,
"exclusiveSeconds": 0.0008969333368137692
},
"append": {
"inclusiveSeconds": 0.008516533335447699,
"exclusiveSeconds": 0.0009090666676456749
},
"flush": {
"inclusiveSeconds": 0.007868700000926765,
"exclusiveSeconds": 0.007868700000926765
},
"output_replay": {
"inclusiveSeconds": 0.29708673333334445,
"exclusiveSeconds": 0.29708673333334445
},
"result_encoding": {
"inclusiveSeconds": 1.0405531333334086,
"exclusiveSeconds": 0.7434664000000643
},
"poll": {
"inclusiveSeconds": 0.008574800045759426,
"exclusiveSeconds": 0.008574800045759426
}
},
"symbolic_factorization": {
"seconds": 0,
"status": "not applicable: dense LU"
},
"error_estimation": {
"seconds": null,
"status": "included in cvode_controller_including_error_estimation"
},
"counters": {
"accepted_steps": 15733,
"residual_evaluations": 28023,
"linear_rhs_evaluations": 0,
"jacobian_evaluations": 1374,
"linear_setups": 4700,
"error_test_failures": 1334,
"newton_iterations": 28003,
"newton_failures": 1218,
"nonlinear_step_failures": 229,
"counter_segments": 10,
"jacobian_reuses": 3326,
"jacobian_refresh_setups": 1374,
"linear_setup_failures": 0,
"LU_factorization_failures": 0,
"LU_solve_failures": 0,
"event_model_evaluations": 0,
"scheduled_boundary_count": 5,
"rejected_steps": 1563,
"application_accepted_steps": 15731,
"same_time_returns": 2,
"max_same_time_streak": 1,
"nonlinear_residual_calls": 28003,
"jacobian_probe_evaluations": 38472,
"LU_factorizations": 4700,
"LU_solves": 28003,
"newton_solve_calls": 17296,
"event_count": 4,
"event_detection_calls": 15731,
"solver_starts": 10,
"all_counted_model_evaluations": 66495,
"accepted_property_checks": 15732,
"sample_count": 2175,
"jacobian_kernel_reuse": {
"gasEvaluations": 230872,
"gasReuses": 1923560,
"phEvaluations": 136587,
"phReuses": 940359,
"densityEvaluations": 685389,
"densityReuses": 3819723,
"pipeEvaluations": 301626,
"pipeReuses": 1368464
}
},
"medians": {
"control": {
"solveSeconds": 8.631554299999607,
"solveCpuSeconds": 8.65625,
"processWallSeconds": 9.813595800000257
},
"profiled": {
"solveSeconds": 8.620837400000255,
"solveCpuSeconds": 8.640625,
"processWallSeconds": 9.760674299999664
}
},
"observedOverheadPercent": {
"solveSeconds": -0.12415956184568255,
"solveCpuSeconds": -0.18050541516245744,
"processWallSeconds": -0.5392671664813387
},
"allRunsFullParity": true,
"samples": 3
}
],
"evidence": {
"revision": "2b07d996cff295d9f401dd32edd10472238bdf2d",
"compiler": "gcc.exe (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0",
"platform": "win32",
"stop": 21.7,
"settings": {
"method": "BDF",
"start": 0,
"sample_step": 0.01,
"rtol": 1e-08,
"max_step": 1e+30
},
"directionOneStash": "06b161c309644bb01aa402ff79f34749ff8e889a",
"normalSourcesUnchanged": true,
"generatedModelIdentical": true,
"runsWithFullParity": 16,
"repeats": 3,
"warmups": 1,
"builds": [
{
"name": "noncyclic",
"ud00Count": 2,
"buildKey": "7fe268c97557c3e1c5a511ea5e28cf0f0479a087031abe1a38d7007dde3c4ca0",
"inputSha256": "f16fc251c10073a4155b56060625e622d81cbc141779dc52fa44746ae8654b5b",
"controlSha256": "ee51ac31f625391ede4457d909968dae856471e66f19f1ae6d614a0735dc20d6",
"profiledSha256": "06c56f6963e7e3ea6930b1fc1f832231b97eb9feb231d60a3b0bed5b8d28fd01"
},
{
"name": "cyclic",
"ud00Count": 2,
"buildKey": "a50f302b77a49d1aa28a4ee305239a36e8c9281ecdc93756b729183b8f1e0a0a",
"inputSha256": "38189b2d3608ca8dfc2691dd8e60b634de499b8d7c35f94ea1a3e72e9b58eae3",
"controlSha256": "02041b6b16a2aca2d26f3ef727d995e67595ff094387615f66e8b7f759626f3d",
"profiledSha256": "705948a62df23999084223e8e96269b65741de3f913c731041f42eecaaa8de27"
}
]
}
}
@@ -0,0 +1,25 @@
# 仿真结果传输优化备份(未启用)
2026-09-12:按用户要求停止测试,将本轮改动仅保存为后续备选方案。正式源码、依赖声明和浏览器构建资源已恢复到本轮试验前;此前已接受并启用的雅可比求解优化保留。未重启或替换正式后端,未提交或推送本轮备份。
## 备份位置
- [optimization.patch](optimization.patch):本轮 9 个文件的完整差异,含后端 gzip 中间件、前端结束处理、依赖下限、回归测试及测量工具。
- [manifest.json](manifest.json):各文件试验前和候选版本的 SHA-256,明确恢复基线。
- 本机原始试验资料:`test/result-finalization-20260912/`,被 Git 忽略。`baseline-source/` 是试验前源码和构建快照,`candidate-frontend-dist/` 是未启用的候选构建;其余目录保存停止前已产生的浏览器记录和下载。
补丁相对于本轮试验开始时、已经包含正式雅可比优化的工作区生成,不能用仓库较早提交直接替代该基线。当前没有需要开启或关闭的生产配置;备份不会被应用自动导入。
## 已定位的问题与候选方案
`complete` 进度在 C 计算、结果写出和 Python 索引读取完成后发出。旧提示“正在汇总仿真结果”之后仍需传送约 33.4 MB 的 HTTP 结果。用户通过远程端口转发访问 5173,约 20 秒的等待与传输瓶颈相符;受控 1,600,000 B/s 条件已复现约 21 秒等待。这是受控复现,不是用户实际隧道的抓包测量。
候选方案只压缩仿真响应:gzip level 1、256 KiB 分块、及时刷新进度;结果体约降至 12.1 MB。JSON、数值精度、采样及求解器保持不变。压缩响应仍读到 EOF,保留浏览器解码完整性校验;未压缩响应可在完整结果行后结束,取消清理不阻塞结果发布。
本机已完成组显示压缩增加约 0.45 秒等待,因此该方案主要面向带宽受限场景,不是所有连接的普遍加速。停止测试前已有轻量压缩与 HTTP 回归、前端流结束测试通过;未完成此次正式网页发布及完整八路导出数值的独立验收,不将备份视为已验收版本。
## 日后恢复
在隔离分支或工作目录中先审阅补丁,执行 `git apply --check docs/other/backups/2026-09-12-result-transfer/optimization.patch`,确认基线仍匹配后再应用。候选依赖 `starlette>=1.6,<2` 的流式刷新及线程压缩行为。恢复后需重新构建前端并完成数值、异常、浏览器和真实转发连接的验收,才决定是否启用。
本次没有新增或上传运行环境。
@@ -0,0 +1,52 @@
{
"status": "archived-not-enabled",
"date": "2026-09-12",
"basis": "Current accepted Jacobian implementation before this transfer experiment; not a rollback to git HEAD.",
"files": [
{
"path": "app/main.py",
"beforeSha256": "ba8467bd855086e60e2f7b3f1d5cf61efbb8a698560fc5a44163c59c73982c96",
"candidateSha256": "89ccbf3b1aa949dcfca8cb28dd2ccfdb867c1e90ef3adf2a0142e8408a9e50a3"
},
{
"path": "frontend/src/App.tsx",
"beforeSha256": "b43507ca9cde6e21bcfc8b057b1c15c495e02dc1c782b9c8a1f3bd3d7b29b883",
"candidateSha256": "8ef51d0483886dce459943e1e2cf27a37e205a03c3949ea227061642792ebec2"
},
{
"path": "tests/manual/browser_stage_profile.mjs",
"beforeSha256": "127936b288a612b0f7bafd7ad906afa1c9edfc8254aee8cf97e7bd4cb1c058c2",
"candidateSha256": "fdad4ab82892cce90db1e662bc48dc7a7a62c7ebea176e6ec77097448279422e"
},
{
"path": "requirements.txt",
"beforeSha256": "0580e0c33b80e6c740fffb5f61d43a26dede3ee209822a064429f35e9f68b696",
"candidateSha256": "13e99540643dbe6211e3619d3c15ceb1f56dcd61448f3cf930a3ace7df508171"
},
{
"path": "tests/test_native_result_transport.py",
"beforeSha256": "9127978933adcb29e748a4d71e6ce66d2af949750b162f62299031161176c3af",
"candidateSha256": "c0f1a68e603d34f072eed3ca82efaff446970fc48a32196e0c3c50ad098738f6"
},
{
"path": "app/result_compression.py",
"beforeSha256": null,
"candidateSha256": "82179874927292007020186af35bb9fb0314446d9fdf8951265f610899328451"
},
{
"path": "frontend/tests/e2e/simulation-stream-terminal.spec.ts",
"beforeSha256": null,
"candidateSha256": "a903571ebcf995d43bd9ff2be86d40720ac24fd7c11925b07cbc66dae9cc4582"
},
{
"path": "tests/test_simulation_result_compression.py",
"beforeSha256": null,
"candidateSha256": "77c99527ab3e5f2bf8b05f378b489cf175ff51b6b6c508040bcfda47422ab8e1"
},
{
"path": "tests/manual/result_transfer_profile.py",
"beforeSha256": null,
"candidateSha256": "f362f079540a9787f307599d0bb79b8b984adb274750edffd4b5df0fd4b15d04"
}
]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,92 @@
**test-mql-4 仿真对照与耗时分析(2026-09-11)**
后续状态:本报告所述 C 修正已于 2026-09-11 合入正式网页后端,并完成重启及 HTTP 仿真核验,详见 [合入记录](../update-log/更新日志-2026-09-11.md)。以下正文保留合入前的实验过程与结论;其中“未合入正式内核”描述的是当时状态。
新增核查:后续直接查看网页 LSTP00A 接口力时,在额外保存的碰撞事件点发现约 1.50×10¹¹ N 的纳秒级瞬态峰。它不在本报告的 0.01 s 共同采样点对照范围内;不能把下面的曲线接近结论扩展为所有事件点输出一致。完整曲线、采样差异与内部步诊断见 [网页接口力对比报告](test-mql-4网页仿真与LSTP00A接口力对比-2026-09-11.md)。
在 `system-optimization` 分支工作区开展测试。原始 JSON 不能直接运行;修正参考口后,现有 C 内核仍在后半段陷入极小步长。测试副本经过参数对齐、管路计算修正和质量绝对误差限调整后,能够完整算到 10 s。最终验证版与 Amesim 的主要曲线接近,但仍有下面列出的局部差值,不能称为逐点完全一致。
本次只在 `/test/mql4-20260910` 中生成实验程序和数据;未将实验修改并入正式 C 内核,未改网页,未改雅各比矩阵算法,也未提交或推送。原始两个输入文件的 SHA256 复核通过,均未修改。
测试来源为 `F:/Downloads/test-mql-4.json` 和 `tests/data/test_mql_4.ame`。模型均为四支路气动机械系统;按元件类型和连接拓扑匹配后,均为 81 个元件、90 条连接、64 个动态状态。其中 26 个气体容腔产生 52 个状态,6 个质量块产生 12 个状态。Amesim 采用压力、温度状态,C 采用质量、内能状态;比较时转换到相同物理量。全部分支的元件对应关系保存在 `topology-mapping.json`。
**先解决输入和测试条件。** 原 JSON 的 `amesim_p4node2_2/3/4` 分别把 2 号参考口接到了阀门,3 号支路口接到了储气管端。供需校验报告 `CONNECTION_VARIABLE_SUPPLY_MISSING`,这是实际模型接线错误。仅在副本交换这三个节点的 2、3 号口,共修正六个端点;详见 `connection-corrections.json`。
AME 包内带有旧八支路结果和 C 源文件缓存。通过本机 Amesim 官方 API 重新编译运行四支路图纸后,EXE 日志确认 `64 unknowns`,新结果包含 0~10 s。对比采用这批新结果,未使用旧结果。归档 `.c` 缓存没有随本次保存更新,不能用它证明本次运行的代码结构;本次 Amesim 侧的依据是官方重编译、EXE 运行日志和新变量/结果文件。
AME 实际保存的积分设置为固定步长。测试副本已通过官方 API 改为 Standard 变步长、误差限 1e-7、最大步长 1e30、动态运行、不接续旧状态。JSON 的 BDF/max_step=1e30 被保留,rtol 显式设为 1e-7;当前网页后端默认 rtol=1e-6,因此不能用默认值冒充已对齐设置。两边输出间隔均为 0.01 s。Amesim 实际使用的自动算法和统计仅记录,不将复现 Amesim 求解算法作为本次目标。
此外有五处参数差异,按 AME 当前实际参数/初始值修正 JSON 副本:
| JSON 元件 | 参数 | 原 JSON | AME 对应值 |
|---|---|---:|---:|
| amesim_pnl00r_4 | 直径 | 14 mm | 10 mm |
| amesim_pnl0002_10 | 直径 | 20 mm | 10 mm |
| amesim_pnl0002_10 | 长度 | 2 m | 1 m |
| amesim_pnl0002_10 | 相对粗糙度 | 0.00225 | 0.00001 |
| amesim_pnl0002_10 | 初始绝对压力 | 100000 Pa | 101301.013 Pa |
最后一项不是 1.013 bar:AME 实际保存的 `pctr` 是表压 1.013 Pa,加环境压力 101300 Pa 得到 101301.013 Pa。交付文件为 `test-mql-4-corrected.json`,与实验输入 `test-mql-4-amesim-aligned.json` 和最终打包副本 `final/input.json` 内容相同。该 JSON 包含接线及参数修正,C 公式与质量绝对误差限的修改保存在实验程序中,不属于 JSON 内容。初次只比较气罐、气缸和机械量时未发现该差异;扩大到全部 64 个状态量后识别并修正。
**运行失败与其原因。** 仅修正接线、保留原 C 算法及 JSON 参数,BDF/rtol=1e-7 运行 120 s 后停在 4.926982415 s;已接受 285780 步、调用模型 651838 次。它是超时退出,并非已完成或进程崩溃。另一次 30 s 的诊断运行显示,约 4.9 s 后步长降至 7.23e-8~1.59e-7 s,主要控制误差的状态是 PNL0003 管内气体质量/内能。超时回调使 RHS 返回失败后,CVODE 会附带线性系统/Jacobian 失败日志;不能把这种派生日志误判为另一个独立的雅各比崩溃。
把五处参数中的前四处对齐,但保留原管路算法,40 s 内也只推进到 4.720248930 s,说明参数差异与慢速问题并不是同一件事。此阶段初始压力差异尚未修正,原始记录保留在 `matched-original`。
C 的 `PNL0003` 用了独立 Darcy 压降反求、固定 48 次二分;现有其他气体管路采用可压缩流量关系。在接近平衡时,前者的流量对很小的压差过于敏感,给积分器造成很快的局部动态。只把 PNL0003 切到项目已有可压缩流量关系的实验中,同一份只修正接线的模型便在 18.03 s 墙钟内跑完 10 s,接受步数降至 9074;这验证了管路公式是主要卡顿来源之一,并不表示只减少二分次数就能获得相同效果。
最终 C 验证版还让 PNL0001 按流动方向选择上游温度,并统一氦气管路流量计算和诊断使用的黏度表达式。原实现存在“一个用途使用 Sutherland,另一个用途使用 NASA 表达式”的不一致。它们是物理输入/计算一致性修正,不是全局求解顺序调整。各项实验的源文件和统计均保留;没有据单次速度变化声称某一项具有普遍的固定加速比。
另一个已验证问题是小管腔质量状态的绝对误差限。旧 C 对所有气体质量和内能状态都给 1e-8(各自 SI 单位)。对质量只有约 1e-5~1e-3 kg 的小容腔,1e-8 kg 会盖过 rtol=1e-7,允许的相对质量误差远大于 1e-7。两边名义 rtol 相同,不代表压力/温度精度相同。
在相同已对齐模型和管路公式上,仅把质量绝对误差限调为 1e-14 kg,保留能量 1e-8 J、速度/位移 1e-12,模型调用从 61246 降到 24941,线性化触发的模型调用从 48960 降到 16768。阀流量最大差从 0.1652 g/s 降至约 0.0160 g/s,9.53~9.58 s 附近的异常小流量尖峰明显降低。更严格的容腔质量控制在此模型上改善了稳定性,反而更快;1e-14 kg 是本次试验值,不能直接作为任意尺寸新模型的统一默认值,应按容腔尺度定义误差权重。
**曲线对比。** 在共同的 1001 个采样时间点比较 64 个状态对应的物理量,并补充 8 条孔口/阀流量,共 72 条曲线。压力统一为绝对压力;AME 的 g/s 转为 kg/s,并按端口流入/流出约定处理符号。没有绘图。最终验证版的最大绝对差为:
| 量 | 最大绝对差 | 对应量 | 时间 |
|---|---:|---|---:|
| 位移 | 0.646855 μm | `amesim_mecmas21_9.x` | 1.22 s |
| 速度 | 3.41015 μm/s | `amesim_mecmas21_9.v` | 1.22 s |
| 压力 | 198.27 Pa | `amesim_pnl0001_14.p` | 0.05 s |
| 温度 | 0.00962632 K | `amesim_pnl0002_1.T` | 0.08 s |
| 质量流量 | 0.0160272 g/s | `amesim_pnvo001_3.port_2.m_flow` | 1.89 s |
主要储气罐和气缸的压力差比表中管路局部压力峰值更小。位移末值停在规定的 -0.72 m 和 0.37 m。882 个输出序列全部有限,完整输出含两个碰撞事件点共 1003 点;闭合气路总质量初值为 2.78334124184 kg,全程最大漂移 8.44e-15 kg。三次重复 C 运行的完整序列及最终状态逐值相同。
原来主要腔室约 50 kPa 的差异,在参数对齐后降到几十 Pa,主要差异可归因于输入参数。仍有约 198 Pa 的管内短时偏差;收紧质量绝对误差限后这一峰值基本没有变化,因此不能用质量容差解释所有剩余偏差。两边还有管路阻力近似、物性计算和积分/事件处理的差别;本次没有逐项证明这 198 Pa 的唯一来源,也未建立“所有输出逐点满足 1e-7 相对差”的验收结论。接近零流量时应使用绝对误差或工作范围归一化,不能只看瞬时相对百分比。
还做了一次针对管内迭代上限的独立检查:在最终版本基础上,把相应管流求解的最大迭代次数从 16 提高到 64,仍得到约 200 Pa 的最大压力差、0.00991 K 的最大温度差。增加迭代次数没有消除该局部偏差,不能把剩余差异直接归因于这个迭代上限。该诊断保存在 `matched-v4-pnl3-compressible-upstream-viscosity-nasa-mass-atol-tight-pipe`,最终交付和计时仍采用 v3。
**速度对比。** 最终版本与 Amesim 在同一台机器交错运行各三次,取中位数。下表的主要速度指标是各程序报告的求解 CPU 时间,编译和进程启动不计入;另外列出进程墙钟供核对。结果序列保存一致,未涉及网页或绘图。
| 项目 | Amesim 当前图纸重编译版 | 最终 C 验证版 |
|---|---:|---:|
| 完成仿真时间 | 10 s | 10 s |
| 求解 CPU 中位数 | 1.14062 s | 4.01562 s |
| 进程墙钟中位数 | 4.1550 s | 6.5606 s |
| 接受积分步数 | 2456 | 5137 |
| 程序报告的模型/函数计算次数 | 33143 | 24941 |
| 程序报告的 Jacobian 计算次数(仅记录) | 382 | 262 |
C 的纯求解墙钟中位数为 4.4412 s;求解 CPU 比值约为 3.52。进程墙钟包含启动、授权、结果整理与写文件,不能拿它替代求解时间。两套程序的函数统计口径并不保证一次调用执行同样多的工作,不直接用调用数相除推导单函数性能。原 C 没有完成全程,因此不报告“原 C 完整运行耗时”或从仿真推进比例外推完整速度。
**C 内部的耗时证据。** 另编译一个只加计数/计时的版本,按嵌套调用排除重复计时;以下占比的分母是 C 模型 RHS 计算时间。该诊断运行不作为上述正式计时结果。
| 计算 | 次数 | 模型 RHS 时间占比 |
|---|---:|---:|
| model_eval | 24,941 | 5.72% |
| native_pipe_flow | 548,702 | 49.39% |
| native_medium_orifice | 199,528 | 8.22% |
| native_medium_gas | 648,466 | 9.21% |
| native_temperature_ph | 608,716 | 20.13% |
| native_pipe_diagnostics | 548,702 | 7.18% |
| native_contact | 99,764 | 0.16% |
这里 `model_eval` 一行指去掉列出的子函数后的模型调度、赋值及机械平衡等剩余工作。模型 RHS 合计约占诊断求解墙钟的 96.4%。C 求解器线性化共触发 16768 次 RHS,占全部 24941 次的 67.2%;这是“为何重复计算”的另一种切分,与上表不能相加。
本模型生成的 236 项流体运算组成零循环依赖块,已经按依赖顺序一次执行;当前主要开销不能再解释为 Python 数值积分或多层全局压力/焓值循环。真正重的是一次 RHS 内反复算管流、反算温度、求氦气物性,以及积分器在试算/线性化时反复调用整套计算。
后续优先事项是:先把本次确认的物理公式和误差权重修正做成通用内核变更并补独立部件验证;再消除同一 RHS 内的重复物性换算、共享管流所需物性;把只用于显示的雷诺数、流速、摩擦系数等从每次 RHS 移到采样输出阶段;最后改进管内阻力方程的收敛策略和未收敛诊断。雅各比相关实现暂不改。
Amesim 算法信息仅留档:当前 Standard 自动运行统计为 Adams 727 步、BDF 1729 步,处理 8 次不连续事件;日志记录启用了其自适应 Jacobian 计算。按用户最新要求,不将 Amesim 的具体算法作为后续实现标准。
**可复现文件。** 全部位于 `test/mql4-20260910`:`final/` 是本次 C 验证包,包含模型 C、公共计算 C、运行时快照、EXE、DLL、输入 JSON、哈希清单和运行脚本;`final/run-bdf.ps1` 显式指定本次求解参数;`final/input.json` 导入现有网页时仍由正式旧版 C 内核运行,不等同于此实验 EXE。`run_ame.py` 使用本机 Amesim 官方 API,`experiment.py` 保留各阶段实验,`compare.py` 生成比较指标,`benchmark-v3/` 保存三次对照计时,`cost-profile-v3/` 保存函数计时,`verification-final.json` 保存基本验证,`amesim-fresh/test_mql_4.ame` 保存重新计算的 Amesim 参考结果。
@@ -0,0 +1,80 @@
# test-mql-4 模型复核与输入修正(2026-09-11)
> 路径复核 / 2026-09-12:下文独立四路 XML 已删除。当前工程仍为 `tests/data/test-mql-4-corrected.json`;审计脚本从该 JSON 临时生成 XML,并在 `--check` 中独立核对 AME 参数与端口。历史 XML 哈希和生成记录保留为当时证据。 详见 [路径修复记录](2026-09-12-test-input-path-refresh.md)。
目录整理补充:`tests/data` 的 JSON/XML 仅保留浏览器可导入的四路、八路 corrected JSON;参考曲线、旧工程和后端 XML 已迁到 `tests/baselines` / `tests/fixtures`,本文当前路径同步更新,文件内容及来源哈希不变。详见 [数据目录说明](../../tests/data/README.md)。
本次从 `tests/data/test_mql_4.ame` 的原始 `.cir` 图纸重新核对元件、参数和端口连接,生成了可导入网页的 `tests/data/test-mql-4-corrected.json`,并同步 `tests/fixtures/amesim/test-mql-4-corrected.xml`。两份输入均有 81 个元件、90 条连接、582 个公开参数,对应 64 个动态状态;生成结果的每个公开参数和连接端口均已按同一元件映射与 AME 图纸逐项比较。生成的 XML 通过后端语法和语义校验,无错误、无警告。
`native-python-reference.json` 是 50 个网络的冻结数值参考,`test-mql-4-amesim-reference.json` 是 72 条曲线、57 个时刻的 Amesim 结果参考,两者都不是可导入的工程 JSON。本次未覆盖或改写这两份参考数据。
## 新发现及修正
此前名为 corrected 的 XML 仍有一处参数不一致:`amesim_pnl00r_4` 对应 AME 的 `pneumatic`(图纸 `LINE` 索引 38、PNL00R)。AME 的相对粗糙度 `rr` 为 `1e-5`,旧 XML 为 `0.0032142857142857142`;本次改为 `1e-5`。该管径在前轮已改为 `0.01 m`,本次确认管长 `1 m` 也一致。此前报告列出的五处参数修正未覆盖这项遗留差异。
旧 XML 和 AME 的元件连接图同构,但有 25 条连线涉及等价端口编号或方向差异。本次 JSON/XML 直接采用 AME 的确切端口编号:
- 11 条连接涉及 P4NODE2 的非参考分支口重排,固定参考口 `port_2` 原本已匹配。
- 8 条连接涉及 PNCH012 的同一容腔端口重排。
- 4 条连接涉及 LMECHN1 的支路编号重排,公共机械端保持原对应关系。
- 2 条连接涉及 PNL00R 两端方向反转。它不增加容腔状态,但其带符号端口输出应按新端口对应关系解释。
PNL0001 的状态端未颠倒:AME 建模连线的 `OUTPUT_TYPE=1` 使用起点→模型端口 1、终点→端口 2;`OUTPUT_TYPE=2` 使用相反对应。组件端口索引从 0 起,PNVO001/FORC 的索引 0 映射为公开 `res`,STEP0/UD00 的索引 0 映射为 `out`,其他组件端口映射为 `port_(索引+1)`。
工程保留先前结果变量使用的组件 ID;显示名改为 AME 图纸别名。已有画布布局来自八路 JSON 中同 ID 元件的展示元数据,缺少的闭合管另行放置。变化端口的连接改用可见连线,避免把不再重合的端口继续标记为接触连接。布局、名称和绘图连线路径不作为物理一致性的依据。
后续真实浏览器导入发现,组件目录中的信号端口带有 `positiveFlowDirection: null`,而前端工程合同只接受省略此可选字段或值为 `intoComponent`。已修正交付 JSON 及生成器:没有方向值时省略该键。该修复仅涉及展示元数据,物理参数、连接端点及数值 XML 不变;审计 JSON 中的工程文件 SHA256 已同步更新。仅通过 XML 校验不足以证明工程 JSON 可被前端导入。
## 用户复核新增:P4NODE2 图形编号与朝向
用户直接打开 AME 后指出参考口看起来不同。这是实际的显示缺陷,前面的物理连接审计没有覆盖图形编号与朝向:导出工程的 80/81 个节点位置沿用了八路 JSON 中同 ID 的位置,闭合管 `amesim_pnl0002_10` 另放在 `(80, 900)`;工程布局并没有恢复为 AME 原图的坐标和连线路径。
重新直接读取 `.cir` 的 4 个 P4NODE2,均为 `COMP_GEOMETRY=1`,原始四个 `COMP_PORT/PORT_POS` 依次为 `(10,19)`、`(19,10)`、`(10,1)`、`(1,10)`。因此原图端口 1 朝下、参考口 2 朝右、端口 3 朝上、端口 4 朝左。前端原图标的 3/4 号锚点编号与该对应关系不符,而且四路 JSON 的第 2~4 节点未旋转,令参考粗线朝左。仅旋转原图标不能修正全部四口,因为对顶端口的编号也不同。
已交换通用 P4NODE2 图标的端口 3/4 锚点,并把四路工程的 4 个 P4NODE2 统一为旋转 180°、不镜像,使四口完整方位符合 AME;清除了 1 条相关连线的旧路由点。参考粗线与实际 `port_2` Handle 使用同一镜像/旋转变换,二者本身没有脱离。通用锚点修正也影响其他工程(包括八路 corrected)的 P4NODE2 显示方位,不改变其连接所引用的端口名称。
这次修复没有改变任何元件 ID、模型类型/版本、参数、连线 ID/端点或仿真设置,数值 XML 完全未改。审计 JSON 保留此前参数和连接修正记录,另记本次展示变化及最新工程哈希。
AME 自带预览图以及重新直接读取的原始图纸均显示四支路:4 个 PNRP17 活塞、4 个 PNCH012 气室、4 个 PNVO001 阀、4 个 STEP0 输入、4 个 LSTP00A 接触元件;4 个支路质量块另与 2 个公共质量块连接,因此 MECMAS21 总数为 6。图中气动管线有 18 条作为子模型的 LINE,在网页中显示为独立管路元件,这也是两种画法的计数观感不同之处。
独立图形证据保存在 `tests/model_audit/p4node2-ame-geometry.json`,直接记录原始 AME 端口坐标和归档哈希。新增前端回归以这组独立坐标检查实际 DOM Handle 的相对方位,并检查镜像后粗线仍指向 `port_2`;它不直接复制前端锚点常量作为判断依据。两项定向浏览器测试(既有 PN3/P4 图形检查及新增 AME 四口/镜像检查)均通过,共 9.4 s;测试没有请求仿真。
## 独立原始参数复核
为避免共享参数转换代码造成共同漏检,另用 `tests/model_audit/independent_mql4_parameter_check.py` 直接解析 AME 原始字段和表达式,独立求值及换算,没有调用原审计的 `_expected_parameters` 或其字段转换辅助函数。582 项分为 543 个 AME 原生字段、32 个公开容积/容积变化输入的零默认值,以及 7 个适配字段(4 个阀开度回退值、2 个由图形方向映射的力方向、1 个氦气物性选择);公开额外字段不冒充 AME 原生参数。
当前 JSON 的 582 项均与各自来源或明确的适配规则相符。独立核对包括 26 个表压转绝压、58 个 mm→m、8 个 mm²→m²、6 个 L→m³,以及各 12 个刚度和阻尼从每毫米转为每米的换算。脚本已从可提交路径实际运行通过;逐字段表达式、原值、原单位、目标单位与比较结果输出到 `test/solver-newton-20260911/mql4/visual-recheck/independent-parameters.json`。
## 仿真设置及参考边界
生成输入为 BDF、0~10 s、输出间隔 0.01 s、最大步长 `1e30 s`;本轮初始核查阶段网页后端默认 `rtol=1e-7`,后续精度核查后已调整为 `1e-8`;JSON 不保存该后端默认值,实际运行值应以结果诊断为准。原 AME `.sim` 的原始两行数值保存在审计 JSON 中。前轮官方 API 核查记录该归档保存的是固定步长设置,而用于既有参考数据的是官方重新编译后改用 Standard 变步长、误差限 `1e-7`、最大步长 `1e30` 的四路运行。本次没有重新运行 Amesim,也不把 native BDF 与 Amesim Standard 自动算法称为同一积分器。
原 AME 归档内的图纸和编译/结果缓存不一致,不能直接把包内 `.results` 当作本次四路参考:
- `.cir` 有 63 个组件、18 条建模管线、26 条直接连线和 28 对接触连接,得到 81 个公开元件、90 条连接、64 个动态状态。
- `.modelinfo` 仍记载 132 个状态,`.ameperf` 包含 LSTP00A 第 1~8 实例,属于旧八路运行。
- `.results` 声明 1116 个保存变量,而当前 `.var` 只有 640 行;这两份缓存不能作为相互一致的结果集合使用。
既有 `test-mql-4-amesim-reference.json` 的来源声明为 2026-09-11 官方重编译四路模型,包含 72 条状态物理量及阀流量曲线、57 个选定时刻。它的原始 AME SHA256 与当前归档一致;其 `xmlSha256` 绑定的是修改前 XML,故本次修正后不再与该字段相等。这个历史字段和曲线数据保持不变,不能为通过校验而改写来源哈希。
该参考可用于本次曲线差异评估,但不表示本次重新运行了 Amesim,也不覆盖接触事件力峰或全部 1001 个常规采样点。新修改的粗糙度应作为本次输入差异明确记录,不能继续沿用“此前 corrected XML 已逐参数完全对齐”的结论。
## 复现与文件
在仓库根目录执行只读核查,不生成或改写工程与审计文件:
```sh
.venv/bin/python tests/model_audit/audit_mql4_model.py --check
```
该模式检查生成端口可选字段的规则,并逐组件比较 JSON/XML 的模型类型、版本、参数、全部连接端点与仿真设置;不启动求解器。当前交付通过,内存中人为加入 `positiveFlowDirection: null`、修改参数或连接端点均被拒绝。它是针对交付输入的轻量检查,不替代完整前端解析与真实浏览器导入。
需要重新生成 corrected JSON/XML 及审计文件时执行:
```sh
.venv/bin/python tests/model_audit/audit_mql4_model.py
```
脚本只静态读取 AME 图纸和注册组件合同,生成/核对 corrected JSON/XML,并输出 `tests/model_audit/test-mql-4-audit.json`。参数和单位转换复用仓库 `tests/test_test_mql_ame_contract.py` 的明确映射,脚本不依赖 `test/` 中的程序。首次运行时将其输入 XML 备份到 `test/solver-newton-20260911/mql4/legacy-corrected-input.xml`,另在该目录保存展开后的源模型和过程审计;修改前 XML 的 SHA256 为 `7b67e61f791f33bf2a4a0045d151d7fd6a418e63a5ea7f3472d474fd4eff9c86`。
原 AME SHA256:`99a071896671d634d651a7b21f487662dd7c122af8fd3d60ead404a5afa53ed4`。完整成员哈希、元件映射、25 条连接变化和生成文件哈希保存在审计 JSON 中。本报告记录输入及图形复核,不记录求解性能或本轮数值曲线结论。
@@ -0,0 +1,70 @@
# test-mql-4 网页仿真与 LSTP00A 接口力对比
2026-09-11,在网页导入 `test-mql-4-corrected.json`,点击“运行仿真”,完整算到 10 s,再通过“下载结果文件”导出本次原始结果。网页显示 1003 个采样点,未发生仿真失败。
**结论:常规采样点的接触力很接近,但完整网页曲线与 Amesim 已保存曲线不一致。网页额外保存的一个碰撞事件点有约 1.50×10¹¹ N 的瞬时峰值。** 不能仅凭常规采样点上的小误差,宣称整条力曲线一致。
## 本次仿真时间
| 口径 | 本次结果 |
|---|---:|
| 点击运行后至结果可查看 | 约 7 s |
| C 纯求解墙钟 | 3.650152 s |
| C 求解 CPU | 3.515625 s |
| 原生子进程墙钟(含结果整理等) | 5.238736 s |
| 构建缓存检查 | 0.167409 s(命中缓存) |
| 模型计算次数 / 接受步数 | 24941 / 5137 |
页面控制台“开始仿真”为 01:53:06,“仿真完成”为 01:53:13;前端日志只显示整秒,因此约 7 s 不是毫秒精度测量,也不包含用户后来打开曲线窗口的时间。纯求解时间直接来自本次网页导出文件。网页全程与纯求解之差包含准备、子进程启动、结果生成、传输和前端接收,不能全部归于进度条。
设置为 BDF、相对误差限 1e-7、最大积分步长 1e30 s、输出间隔 0.01 s、仿真区间 0~10 s。初始 5173 开发页面未完成渲染,实际成功测试使用后端提供的 `http://127.0.0.1:8000/` 页面。一次先前标签页在任务启动后不可用,其时间不计入本次结果。
## 部件与接口对应
用户所称 `lstp001` 在当前模型中对应 `LSTP00A` 第 1 个实例,即 JSON 的 `amesim_lstp00a_1`,网页显示名为 `elasticendstop_9_副本`。根据连接拓扑,它对应 Amesim 的 `elasticendstop_8`,不是仅按显示名中的数字匹配。
| 网页变量 | 对照 Amesim 变量 | 方向处理 |
|---|---|---|
| `amesim_lstp00a_1.port_1.f` | `f1@elasticendstop_8` | 同号直接比较 |
| `amesim_lstp00a_1.port_2.f` | `f2@elasticendstop_8` | Amesim 值取负后比较 |
本系统端口力统一取流入元件为正,两个端口的力相反。Amesim 变量清单明确将此模型的 f2 标为 f1 的重复量,已保存的两个数组也完全相同。因此第二接口符号不同本身不能判定为力计算错误。
## 完整曲线与常规采样对照
![接口力曲线对比](../../test/mql4-web-force-20260911/lstp00a-force-comparison.png)
第 1 实例在 1001 个共同的 0.01 s 采样点上:网页峰值为 272620.924818 N,Amesim 已保存峰值为 272621.136490 N;最大绝对差 0.577026 N,均方根差 0.135705 N。最大差相当于 Amesim 峰值的 0.000212%,该百分比是按峰值归一化,不是接近零力时的瞬时相对误差。
顺带核对全部四个接触元件的接口 1;接口 2 统一方向后有同样的误差:
| JSON 实例 | Amesim 对应实例 | 共同采样点最大差 | 均方根差 | 最大差 / Amesim 峰值 |
|---|---|---:|---:|---:|
| 1 | `elasticendstop_8` | 0.577026 N | 0.135705 N | 0.000212% |
| 2 | `elasticendstop_9` | 0.578578 N | 0.136276 N | 0.000212% |
| 3 | `elasticendstop_10` | 1.134175 N | 0.140897 N | 0.000416% |
| 4 | `elasticendstop_11` | 0.729195 N | 0.136876 N | 0.000267% |
但是网页还保存了 1.201137556938643 s 和 1.225339087445038 s 两个事件点。第一个事件点,四个接触元件均出现约 1.49859×10¹¹ N 的力;Amesim 本次保存的曲线没有这一时刻的数据,其相邻采样为 1.20 s 和 1.21 s。上图左上保留网页原始事件点,右上只用于展示共同采样点的一致程度,两者不可混为一个比较口径。
## 尖峰来源与持续时间
在 t = 1.201137556938643 s,170000 kg 的负载到达 0.37 m 理想限位。本系统将该负载速度置零;与它相接触的 50 kg 活塞一侧仍为 1.498587803347 m/s。接触参数为刚度 1e11 N/m、阻尼 1e11 N/(m/s),当时穿透量约 1.47291297e-06 m,阻尼已经几乎全量参与。
按当前公式,弹性项为 147291.297 N,阻尼项为 149858720230.815 N,合计 149858867522.112 N。这个数值直接对应网页上的尖峰,并非力单位换算造成。
另编译一个仅记录事件附近内部接受步的诊断程序,使用同一输入、公式和容差。记录开关不改变已有 1003 个输出点,已逐值核验。碰撞后力值约 0.348 ns 降到一半、1.154 ns 降到 10%、2.336 ns 降到 1%;这与 50 kg / 1e11 N·s/m ≈ 0.5 ns 的阻尼时间尺度吻合。
因此有两个叠加因素:理想限位突然改变速度,配合极大阻尼形成纳秒级瞬态;网页结果保存了瞬态最高事件点,却没有保存紧接着的纳秒级回落点。用直线把该点连接到 1.20 s、1.21 s,会把非常窄的峰画成毫秒级宽峰。
目前只能证明 Amesim 的**已保存曲线**没有采到该峰,不能据此断言 Amesim 内部没有同类瞬态,也不能把两者完整力曲线的差异简单归因于 C 语言或积分器精度。按此前要求,本次不以 Amesim 的内部算法作为实现标准。
此前的 72 条曲线对照主要覆盖状态量和阀流量,且只比较共同采样点,没有验证这两个新增事件点处的接触力。这是此次网页检查新增发现的覆盖缺口。
下一步应先明确碰撞时刻输出采用哪一侧的状态,并保存事件前后及必要的快速衰减点,让曲线按真实时间分辨率表达瞬态;常规采样比较仍可保留,但不能靠删去峰值来宣称物理问题已经解决。如果要消除模型本身的极短冲击,应再评估理想刚性限位及接触阻尼参数的物理取值。
## 数据与复核
本次导出的原始结果为 `test/mql4-web-force-20260911/web-result.simresult`,可在网页“加载结果文件”中复现。核对了其中所有元件参数和连接端点,均与交付 JSON 相同。Amesim 数据来自上一轮对 `tests/data/test_mql_4.ame` 官方重新编译所得的 `test/mql4-20260910/amesim-fresh/test_mql_4.ame`,未使用原归档内旧八支路缓存。
同目录 `summary.json` 保存计时、逐部件误差及源文件哈希;`force-series.json` 保存原始和共同采样力数据;`event-trace/accepted-event.jsonl` 保存内部步诊断;`lstp00a-force-comparison.svg` 提供矢量图。生产求解代码及两个用户原始输入文件本次均未修改。
@@ -0,0 +1,80 @@
# test-mql-8 当前 AME 归档与完整曲线核查
> 路径复核 / 2026-09-12:本报告当前 AME/JSON 路径仍有效;文中 SHA 开头 `cbc3aadd` 的另一个历史 AME 位于 `tests/data/AmesimModels/test_mql.ame`,只与 August 冻结基准配套。 详见 [路径修复记录](2026-09-12-test-input-path-refresh.md)。
2026-09-11。只读解析当前输入、AME 归档和已完成的原生运行,不重新求解、编译或修改模型。
**当前八路归档的数据身份、公开参数和初始状态相互对应,可以作为已保存曲线的观察参考。完整曲线仍有差异,不能判为一致;Amesim 没有可信 CPU/墙钟耗时,速度比较明确 `skip`。** 归档名义容差为 1e-7,本轮原生为 1e-8,亦不构成同精度的性能比较。
## 本轮来源
| 文件 | SHA-256 |
|---|---|
| `tests/data/test_mql.ame` | `1ff0ea4284b9248260eeceb8b27cd0bc14dbccb43554ab8c0b49900d0b944c3d` |
| `tests/data/test-mql-8-corrected.json` | `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288` |
| `test/mql8-efficiency-20260911/native-production/run-1/result.json` | `12a2018119c8f231056dfd834dfc9ac096945173f3982c98ee2b385140b2c7dc` |
本轮正式原生 run-1 已完整算到 10 s。其 `series`、`final`、`finalState` 与先前 `test/solver-newton-20260911/mql8/native-run-rtol1e8/execution/result.json` 逐值完全相同,比较产物已经绑定本轮 run-1。见 [逐值相等记录](../../test/mql8-current-archive-audit-20260911/native-result-equivalence.json)。
历史 `tests/baselines/simulation/test_mql_8/manifest.json` 绑定的是另一个 AME SHA:`cbc3aadd4569a49b3a63e5d66d4143ec16126c0f950df73fb637e07673c20fbb`。该旧基准不用于本轮。另一个现有 `test/mql8-efficiency-20260911/amesim-archive-comparison/comparison.json` 确实绑定当前 1ff0… 归档,但仅覆盖 140 条状态/阀流量曲线;本次补入碰撞力、限位力、间隙及原生额外事件采样。
## 归档是否自洽
归档成员均为 `test_mql_` 前缀,子文件 SHA 和字节数完整保存在 [comparison.json](../../test/mql8-current-archive-audit-20260911/comparison.json)。
| 成员 | 本次核查 |
|---|---|
| `.cir` | 117 个 COMP、40 个有子模型 LINE,157 个公开元件;以真实端口/接触/管线方向核对 corrected JSON 的全部 178 条连接、1092 个公开参数,均对应 |
| `.param` / `.data` | 各 1170 行;重新求值图纸与数据表中的表达式,1015 个直接绑定的公开参数均一致,2 个 LMECHN1 结构字段只保存在图纸 |
| 编译 `.c` | 声明 157 个子模型引用、132 个连续状态、24 个离散状态、1278 个变量;GParamInfo 对应全部 1170 行,1154 个具名参数所属模型可核对;1100 个具名 GVarInfo 的模型实例和变量名逐项匹配 `.var` |
| `.modelinfo` | 132 个连续状态、24 个离散状态,与编译 C 相同 |
| `.state` | 全部 132 个状态 Data_Path 与编译 C 的 GcontStateVarNum 顺序一致;其文件时间较旧本身没有造成状态错配 |
| `.var` / `.results` | 1278 个变量行,1116 个保存变量,1100 条具名曲线;1002 个严格递增时间点,覆盖 0–10 s,具名保存值均有限;132 个状态首点逐项对应当前 `.data` 初值 |
| `.sim` | 首行 `0 10 0.01 1e+30 1e-07 0.001 4 0.1`;不把未解码的求解器枚举位直接当作 CVODE/BDF 设置 |
| `.ameperf` | 仅包含 SC/DISC 的仿真时刻及事件计数,没有 CPU、墙钟或完整运行耗时 |
`.param` 的第 766、768、770、776 行将介质显示为 `PNGD_HELIUM instance 1`,编译参数表对应 `PNGD00 instance 1`;四行 Data_Path 均属同一 `pn_gas_data`,在审计中明确列作显示名别名。16 个 HIDDEN 参数行、82 个 HIDDEN 编译变量项没有伪造可读名称来宣称逐名核对。
这些检查未发现当前归档发生四路图纸/八路缓存错配,提供了使用其保存曲线的依据。它仍是归档中的历史运行,未在本轮观察 Amesim 重新运行,不能提供新的控制精度或耗时验收。
## 变量与误差口径
覆盖全部 132 个连续状态对应的物理量:56 条压力、56 条温度、10 条位移、10 条速度;另含 8 条阀流量、8 条接触力、20 条质量元件限位力和 8 条接触间隙,共 176 条曲线。
变量身份通过归档编译 C 的 GVarInfo/GcontStateVarNum、`.var` Data_Path 和图纸拓扑映射确定。单位来自对应 `.var` 标签与仓库保存的元件 C:PNVO001 的 `dm2` 保存单位为 g/s,乘 `-0.001` 转成本系统 port_2 的流入 kg/s;压力为表压 Pa,加 101300 Pa 转为绝对压力;温度 K、位移 m、速度 m/s、力 N 直接比较,间隙 mm 乘 0.001。见 [PNVO001.c](../../tests/data/AmesimModels/help/source/PNVO001.c) 中端口定义及末尾转换、[PNL0001.c](../../tests/data/AmesimModels/help/source/PNL0001.c) 中 `pa = p + PATM`。这些元件源文件用于解释单位,不作为另一个 AME 归档的数据参考。
LSTP00A 的归档 f2 明确为 f1 的重复量,8 组保存数组逐值相同;本系统两个接口力逐值反号。因此本文以 port_1.f 对 f1;port_2.f 对负 f2,得到相同误差。质量元件的 20 条 Fmin/Fmax 在双方保存点均为零,此项不能代替接触力检查。
对每一方的原始保存网格,分别将另一方原始曲线线性插值到该网格;不平移时刻、不平滑、不删除事件点。每条曲线输出最大绝对差、采样均方根差、最大差/参考峰值、RMS/参考 RMS,以及参考非零时的逐点相对差;参考为零的点另外保留绝对差。RMS 是所述采样网格上的等权 RMS。原生网格和归档网格分别报告,避免将没有保存的 Amesim 事件状态冒称为实测值。
## 保存点上的实际差异
下表是归档 1002 个保存点上的结果,每类列出最大绝对差所在曲线及该曲线 RMS;“峰值归一化”是该差除以该曲线参考峰值,不是逐点相对误差。
| 量 | 最差曲线 | 最大绝对差 | RMS 差 | 峰值归一化 | 最大差时刻 |
|---|---|---:|---:|---:|---:|
| 压力 | `amesim_pnl0002_6.p` | 163395.189 Pa | 7170.441 Pa | 0.792631% | 0.01 s |
| 温度 | `amesim_pnl0001_13.T` | 105.215279 K | 5.829065 K | 2.033444% | 0.01 s |
| 阀流量 | `amesim_pnvo001_7.port_2.m_flow` | 0.002990901 kg/s | 0.000114014 kg/s | 0.766320% | 0.04 s |
| 接触力 | `amesim_lstp00a_7.port_1.f` | 5131.970918 N | 225.217793 N | 0.796525% | 0.01 s |
| 接触间隙 | `amesim_lstp00a_2.gap` | 5.215260e-8 m | 2.265633e-8 m | 1.112982% | 1.27 s |
位移的最大绝对差为 `amesim_mecmas21_9.x` 的 53,857,561 m,RMS 为 52,430,984.93 m;该曲线参考终值本身为 7.68e15 m,所以不能脱离物理量级解读。该元件在双方均出现约 8e14 m/s 速度;当前 AME 的 UD00 输入含 1e17 的初始信号,不能把这种极端输入改小后再当成同模型验收。
各曲线按自身峰值归一化后,位移最差为 `amesim_mecmas21_5.x`:最大差 2.193606e-5 m、RMS 1.406520e-6 m、峰值归一化 0.00592859%;速度最差为 `amesim_mecmas21_10.v`:最大差 2.437279e-4 m/s、RMS 1.964328e-5 m/s、峰值归一化 0.00615392%。两者最大差在 0.98 s。全部逐曲线、逐网格统计见 [curve-differences.csv](../../test/mql8-current-archive-audit-20260911/curve-differences.csv)。
## 碰撞事件使完整曲线不一致
原生额外保存 `t = 0.9833956321732664 s`,170000 kg 的 `amesim_mecmas21_10` 此时到达 0.37 m 限位并将速度置零。8 个 LSTP00A 的原始接口力同时约为 **4.034997e11 N**。不能丢弃这一点后宣称完整曲线吻合。
归档在相邻 0.98 s、0.99 s 保存数据,没有这一事件时刻;将这两个点线性插值得到的接触力约 467936 N。以全部原生保存点为网格,最差接触力最大差为 **4.034993e11 N**,采样 RMS 为 **1.274703e10 N**。同一点,负载速度与归档插值差 2.615678 m/s、位移差 0.008950590 m。这些是保存曲线之间的差异,插值跨越限位事件,不能当作 Amesim 真实事件状态的误差。
双方保存的负载数据都显示限位发生在 0.98–0.99 s 内。归档 `.ameperf` 没有提供该负载在此区间的精确限位时刻;只有采样数据不足以确定两者精确事件偏移,也无法判断 Amesim 内部是否出现同类极窄接触峰。此前 0.01 s 的压力、温度、接触力误差发生得更早,不能全部归因于 0.9834 s 这一未对齐的保存事件。
因此本轮结论是“已完成全点差异观察,尚未通过八路曲线一致性验收”。未预设八路接受阈值,也未取得足够密的 Amesim 事件输出,不将这些差异自动判成精度达标。
## 耗时与复现
本轮原生 run-1 的纯求解墙钟为 5.797191 s、CPU 为 5.796605 s;这里只标识已有运行,完整性能分析由本轮优化总报告给出。归档中没有可用 Amesim CPU/墙钟记录。当前环境的 PATH、相关环境变量及 Python 模块检查亦未发现可调用 Amesim 安装;归档生成 C 标记为 Simcenter Amesim 2404,不等于当前可调用软件版本。不得用成员修改时间差或 `.ameperf` 的 10 s 仿真终点推算实际耗时,**Amesim 速度比较 `skip`**。
复现本次只读数值后处理:`.venv/bin/python test/mql8-current-archive-audit-20260911/audit_and_compare.py`。脚本输出来源/成员哈希、参数及状态身份、两种原始网格的误差、额外事件与未验收边界,不启动任何求解器。长期模型选择、先正确性后计时、固定精度及分阶段记录遵循 [优化验证约定](../standard/optimization-benchmark-model.md)。
@@ -0,0 +1,112 @@
# test-mql-8 模型对照与修正(2026-09-11)
> 路径复核 / 2026-09-12:下文修正前 legacy JSON 已删除;现存历史基准 JSON 的哈希不同,不能代替那份原输入。当前审计改读 `tests/data/test-mql-8-corrected.json`,使用 `--check` 与 `tests/data/test_mql.ame` 复核而不覆盖工程。 详见 [路径修复记录](2026-09-12-test-input-path-refresh.md)。
2026-09-11 目录整理记录(后续位置以上方复核说明为准):`tests/data` 的 JSON/XML 仅保留浏览器可导入的四路、八路 corrected JSON;参考曲线、旧工程和后端 XML 已迁到 `tests/baselines` / `tests/fixtures`,本文当前路径同步更新,文件内容及来源哈希不变。详见 [数据目录说明](../../tests/data/README.md)。
已依据 `tests/data/test_mql.ame` 的真实图纸生成 `tests/data/test-mql-8-corrected.json`,当时保留原 `test-mql-8.json` 供审计(现已删除)。修正版包含参数、逐端口连接和仿真最大步长的对齐。静态校验通过。rtol=1e-7 的首次求解在 120 s 限额内推进到 5.144858167 s 后超时;后续按用户确认的全局 rtol=1e-8,以同一执行模型和内核完成了完整 10 s 单次运行。本轮未运行 Amesim 官方程序,也未进行八路 Amesim 曲线对比。
## 来源与核查方法
AME 是可读取的 tar 归档。以 `test_mql_.cir` 的组件、接触连接、DIRECT 连接和带子模型的管线为模型来源,解析全局表达式、SI 单位和初始状态。压力字段按图纸保存的表压加 `101300 Pa` 转为公开模型的绝对压力。原始输入指纹:
- `tests/data/test_mql.ame`:21698560 字节,SHA256 `1ff0ea4284b9248260eeceb8b27cd0bc14dbccb43554ab8c0b49900d0b944c3d`。
- `tests/fixtures/legacy/test-mql-8.json`:272523 字节,SHA256 `4f1bf698519ba21429a11a83653adc7e52191f2d2b51fca943ee0b84f62feebd`。
图纸含 117 个组件、40 条带模型管线,展开后共 157 个元件(含介质)、178 条连接,与 JSON 的类型拓扑完全同构。映射以类型和完整邻接约束建立,再选端口修改数最少的对应关系,不使用组件显示名称猜测。类型拓扑的剩余对称候选具有相同 AME 参数;完整逐元件映射保存在审计文件中。
组件图纸端口索引从 0 开始;信号端按公开合同映射为 `res/out`。管线 `OUTPUT_TYPE=1` 时起点对应计算 port_1、终点对应 port_2;`OUTPUT_TYPE=2` 时相反。这个字段决定 PNL0001 的储气端,不能仅根据线段绘制方向推断。
1092 个公开参数全部核对,其中 1015 个可直接对应参数又与归档 `.param/.data` 逐值交叉核对,全部一致;这两份表均有 1170 行。两个 LMECHN1 的结构参数 `v1=8` 只在图纸中声明,其余公开补充参数按已有合同映射。归档结果未用于声称本轮已经重新运行 Amesim 或完成曲线一致性验证。
## 参数修正
以下四项均属于拓扑唯一对应的 `amesim_mecmas21_9`,AME 实体为 `component:35 / mass_friction_endstops_19`。
| 参数 | 原 JSON | AME 图纸与修正版 | `.param/.data` 行号 |
| --- | ---: | ---: | ---: |
| mass | 90000 kg | 100 kg | 364 |
| xmin | -0.72 m | -1 m | 373 |
| xmax | 0 m | 1 m | 377 |
| stoptype | 1 | 4 | 383 |
这不是只交换参考口的同数值优化;质量与限位配置的修正确实会改变系统行为。其余 1088 个参数已一致,保留原值及编辑器单位、表达式和显示设置。比较允许正常的浮点表达式舍入差异,不把 `15299999.999999998` 与 `15300000` 当作参数错误。
## 连接和仿真设置
所有 178 条边逐端口与 AME 对齐,共 62 条边各修改一个端点,元件之间的邻接关系保持一致:
| 类型 | 修改端点数 | 原因 |
| --- | ---: | --- |
| 8 个 P4NODE2 | 30 | 修正 7 个参考口的实际来源,并对齐其余支路的精确编号 |
| 8 个 PNCH012 | 16 | 对齐共享容腔的 port_2/port_3 编号 |
| 2 个 LMECHN1 | 16 | 对齐八条机械汇总支路的编号顺序 |
后两项属于现有共享状态/汇总端口的编号对齐,不应解释成 32 处独立物理机制错误。15 条受影响边原带有几何接触标记,端点变化后清除该标记,使连接以普通边显示;不删除任何物理连接,也不移动组件。
AME `.sim` 首行保存 `0 10 0.01 1e30 1e-7 ...`。修正版保持 0~10 s、输出间隔 0.01 s,最大积分步长由原 JSON 的 0.02 s 对齐为 1e30 s。公开模型仍采用 BDF;没有将 AME 的内部求解器编号等同于 CVODE BDF。AME 归档相对误差限为 1e-7,首次原生核验采用同值。后续经用户确认,全局网页/API 默认值调整为 1e-8;JSON 协议本身不新增误差限字段,后续执行按实际后端设置记录。
## 验证与复现
修正版通过工程载荷解析、XML 导出、端口供需与网络校验,以及正式 C 代码生成:132 个动态状态、1784 个输出、484 条气动计算关系、0 个系统代数循环块。编辑器保存的参数表达式在单独的执行副本中解析为数值,与浏览器导出行为一致。此项静态验证之后进行了下述一次真实数值执行;管流求根修改的其他模型性能对照由独立验证记录说明。
复现:在仓库根目录执行 `.venv/bin/python tools/audit_test_mql8_model.py`。脚本复用现有 `tests/test_test_mql_ame_contract.py` 中的 AME 表达式、单位和公开参数映射辅助函数,不执行该历史测试类。
脚本写入修正版 JSON,以及 Git 忽略目录 `test/solver-newton-20260911/mql8/` 中的 `audit.json`(157 个实体映射、1092 项参数、178 条连接、1015 项归档参数交叉证据和来源指纹)与 `corrected.xml`。它不修改原 JSON、AME 或冻结基准。
本轮实际求解与手工网页验收均显式使用修正版 `tests/data/test-mql-8-corrected.json`,作为后续用户运行的交付文件。原 JSON 仅保留为审计来源;旧 `simulation-live-mql8.spec.ts` 记录 2026-08-18 的 Python 后端活动心跳流程并写入该日基准文件,不作为本轮原生验收入口,未覆盖其历史证据。`fit-view.spec.ts` 的旧模型视图检查和 `tests/baselines/` 也保持原始输入。
## 单次真实 C 执行
使用修正版 JSON,经正式输入加载、C 生成和本机静态 SUNDIALS 7.4.0 构建后,直接调用一次 `execute_native`;未预热、未重复计时,也未更改物理参数、精度、积分器或库。请求 BDF/CVODE、0~10 s、rtol=1e-7、最大步长 1e30 s、采样间隔 0.01 s,执行限额 120 s。质量等状态的实际绝对误差限仍由当前生成器按物理量生成。
| 指标 | 单次运行结果 |
| --- | ---: |
| 完成状态 | failed,执行超时 |
| 实际推进终点 | 5.144858167417297 s |
| 纯求解墙钟 | 120.000105 s |
| 模型求值次数 | 1530472 |
| 接受步数 / 拒绝步数 | 77649 / 2590 |
| 输出采样数 | 517(含事件和退出时刻) |
| 气体质量状态数 | 56 |
| 初始气体总质量 | 5.5668930151375235 kg |
| 已返回采样的总质量最大漂移 | 1.62537e-13 kg |
所有已返回采样数值均有限。结果明确报告 `Native solve exceeded its time limit.`;超时后 RHS 拒绝求值产生的 CVODE Jacobian/setup 错误日志不能单独作为另一个雅可比故障的证据。这次运行证明修正模型能生成、构建并实际推进,不能表述为完整 10 s 回归通过,也不支持性能加速结论。未为取得完成标记放宽设置或追加运行。
材料保存在 `test/solver-newton-20260911/mql8/native-run/`:`input.xml`、`model-manifest.json`、`summary.json`、`execution/result.json` 和 `execution/worker.log`。构建键为 `403eb3539384555d3d2eb48951d2416f4754a7b1466f5c001a12879b4928eb7a`,该次执行时修正版 JSON 的 SHA256 为 `d034938422cc35e4e49183c76e900ad09ebbca250624fe3706fed76064e25038`,对应下述 P4 显示修复之前的工程。单次执行脚本是同目录上级的 `run_native_once.py`;复现必须改用新的运行目录,不能覆盖现有结果。
## P4 端口显示对齐
后续逐口复核发现,原通用 P4 符号的 port_3/port_4 方位与 AME 编号不一致。通用符号修正为默认 port_1 上、port_2 左、port_3 下、port_4 右后,对八路图纸逐个读取 `COMP_GEOMETRY` 和 `COMP_PORTS`:8 个 P4 均为 geometry 1,口位依次为下、右、上、左。因此修正版统一设为 `rotation=180`、`mirrored=false`,同时清理关联边上 6 组过期 `routePoints`;生成脚本保存了这项处理与每个原图端口的证据。
修复前后通过正式 `load_input` 导出的 XML **逐字节完全一致**,SHA256 为 `2804b33f04cabb3c10fcc3cc0843c35de17efbe1b6ee5e5e821edd9a5515a23d`;这次仅改变显示方位和旧路径缓存,没有改变物理端点、参数或执行设置。当前修正版 JSON 的 SHA256 为 `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288`。验证记录在 `test/solver-newton-20260911/mql8/p4-display-verification.json`,此前的运行输入和结果保持原样。
## 全局 rtol=1e-8 的单次完整运行
用户确认全局网页/API 默认相对误差限改为 1e-8 后,对当前显示修正后的 JSON 再执行一次。脚本从正式 `simulation_config` 读取设置,并在运行前断言其确为 1e-8,未通过命令行或局部配置覆盖默认值。使用 BDF/CVODE 7.4.0、0~10 s、最大步长 1e30 s、采样间隔 0.01 s;执行限额仍为 120 s,无预热或重复运行。
| 指标 | 单次运行结果 |
| --- | ---: |
| 完成状态 | success=true,completed |
| 实际推进终点 | 10 s |
| 纯求解墙钟 / CPU | 5.907054 s / 5.906099 s |
| 模型求值次数 | 74265 |
| 接受步数 / 拒绝步数 | 6974 / 454 |
| 雅可比求值 / LU 次数 | 475 / 1656 |
| 状态转换 / 求解器启动次数 | 1 / 4 |
| 返回采样数 / 范围 | 1002 / 0~10 s |
| 气体质量状态数 | 56 |
| 气体总质量最大漂移 | 1.15463e-14 kg |
全部返回采样均为有限数值,132 个动态状态、1784 个输出和 0 个系统代数循环块保持一致。此次命中与 rtol=1e-7 运行相同的构建键 `403eb3539384555d3d2eb48951d2416f4754a7b1466f5c001a12879b4928eb7a`;P4 显示修复不改变执行 XML,相对误差限是运行时设置。两次运行精度条件不同,而且本轮与轻量前端检查并行,不能根据这两次时间记录宣称管流优化的加速倍数。
本轮证明当前修正版在全局 rtol=1e-8 下可以完整运行 10 s;不以此替代八路模型与 Amesim 曲线的独立一致性核验。此前 rtol=1e-7 超时记录保留,不改写为通过。
材料位于 `test/solver-newton-20260911/mql8/native-run-rtol1e8/`,包含 `input.xml`、`model-manifest.json`、`summary.json`、`execution/result.json` 和 `execution/worker.log`。执行脚本为其上级 `run_native_rtol1e8.py`,只有传入 `--execute` 才启动求解,且要求新的运行目录。输入 JSON SHA256 为 `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288`。
## 八路真实网页补验
通过本次 `127.0.0.1:8011` 正式网页,直接导入最终 `test-mql-8-corrected.json`,以全局 `rtol=1e-8` 完整运行到10 s。曲线显示、CSV和结果文件下载、刷新后恢复均成功,无浏览器脚本异常。网页原始曲线和最终输出与独立原生八路运行逐值相同;CSV的1785列、1002行逐单元与原始数据相同。最后一轮为功能补验,不计入四路速度基准。
完整证据在 `test/solver-newton-20260911/browser-mql8-final/`:`summary.json`、`verification.json`、输入快照、原始/恢复后的`.simresult`、CSV和网页截图。初轮自动化曾因写死四路独有组件ID而在选择曲线时失败,应用求解已完成且无pageerror;脚本改为从当前工程选择真实存在的PNL0002后,上述流程全部通过。
@@ -0,0 +1,95 @@
# 事件输出语义修改与八路基线复测
日期:2026-09-17。Windows,本项目 Python 3.12、原生 C、CVODE BDF;独立测试同时覆盖 RK45。
## 1. 结论
**已完成输出语义修改、专项测试,以及循环/非循环两组 50 s 八路基线重跑。两组各 5,001 个比较时刻均找到同一信号驱动阶段的样本,信号比较误差归零;循环接触力尖峰差仍然存在。**
本次修改只增加事件两侧的保存样本并明确比较规则,没有补充接触事件。前后对照均使用已经修复 UD00 的内核,避免把此前信号修复与本次输出修改混在一起归因。修改前的原生源码快照保存在 `test/output-semantics-20260917/control-source/native/`。
## 2. 输出语义怎样改变
- 规则采样时间仍按实际浮点数解释,不把网格点吸附到事件,不提前切换信号。
- 对仿真区间内部的时间事件,保存 `nextafter(boundary, -INFINITY)` 和 `boundary`。前者是旧积分段实际到达的终点;后者保留连续状态,用事件后的信号重放输出。两者是相邻的真实浮点时刻,不是两个重名时间戳。
- 对已注册的机械状态事件,在当前积分区间可用时,保存事件左邻时刻的插值状态,然后保存事件后的接受状态。这保留了碰撞前后速度及各自模式。初始即时事件没有新左侧区间,不倒填样本。
- 同一实际时间依然以后一次接受状态为准;序列严格递增。结果增加 `outputSemantics.version=2`,已有状态/输出块格式和变量集合保持兼容。
- 新增样本同样走既有有界缓冲、存储配额和输出重放流程。纯求解模式不保存这些样本。
主要实现:[公共事件采样](../../native/runtime/common.c)、[BDF](../../native/runtime/cvode_solver.c)、[RK45](../../native/runtime/rk45.c)、[结果元数据](../../native/runtime/main.c)。注册与重放要求见[组件开发规范 12.2](../standard/component-model-authoring-spec-v1.md#122-事件输出与重放)。
## 3. 如何保证比较阶段一致
当前八路有 8 个 STEP 和 2 个 UD00,UD00 各启用阶段均为常值。比较程序先取 Amesim 在名义网格附近实际保存的样本,再在平台保存样本中寻找相同的完整信号组合。
- 时间候选窗口仍为输出间隔的 `1e-7`,本次即 `1e-9 s`;它只用于匹配保存时间的舍入差,不修改求解时间或事件时间。
- 10 个信号必须同时一致;所有 238 条曲线共用同一对样本行,不按接触力、压力或误差大小选择样本。
- 不跨跳变插值;缺失对应阶段时明确记录未配对,不把错误配对纳入同阶段指标。旧的纯时间匹配指标另存为 `raw-time-comparison.json`,用于审计。
- 本规则针对本次分段常值信号;脚本遇到 UD00 斜坡会明确拒绝,避免把“输出值相近”错误地推广为所有信号的阶段标识。
- 额外核验八个接触件的带符号间隙:两组全部已配对点的 `gap<0` 标志一致。但这不是对网格之间所有接触/脱离时刻的验证,也不声称复原了 Amesim 的全部内部离散模式。
修改前循环工况缺少 4 个时刻的同阶段样本,非循环缺少 1 个;修改后两组均为 **5,001/5,001 配对,未配对 0 个**,没有删除事件点。循环改配时刻为 0.8、10.8、32.4、44 s;非循环为 0.8 s。真实时间、选中的信号值及原始行记录在各组 `phase-pairing.json`。
## 4. 第一次测试与回归
首先执行输出/相位配对、循环 UD00、存储专项共 18 项,全部通过;另补非网格时刻阶跃测试 1 项,通过。随后结果传输、HTTP 返回、取消/异常处理、恢复碰撞及摩擦保持回归 11 项通过,共 **30 项**。
关键验证包括:32.4 s 浮点边界前后分属不同阶段时正确配对;Amesim 保留事件前值时选用平台左侧样本;缺失阶段或任一驱动不同则明确拒绝;BDF/RK45 保存非网格阶跃两侧;碰撞前后速度分别为约 1 和 −0.5 m/s;有/无采样的积分最终状态及计数一致;二进制存储与 JSON 返回一致。
证据:[18 项首次通过日志](../../test/output-semantics-20260917/verified-first-tests.log)、[非网格阶跃](../../test/output-semantics-20260917/off-grid-test.log)、[11 项附加回归](../../test/output-semantics-20260917/additional-regression.log)。测试过程中一次解析碰撞时间断言过严:原设 1e-9 s,实际偏差约 2.61e-8 s;按该数值积分测试的目的改为 1e-6 s。事件两侧相邻浮点时间及速度断言未放宽。编译器偶发启动失败由既有重试恢复。
## 5. 八路基线结果
使用原有 AME 作为参照,在副本中审计并对齐参数、连接和运行设置。两组均重新执行 Amesim 归档中的程序及平台程序,没有用旧曲线代替本次运行。循环采用当前 `tests/data/test_mql.ame`;非循环采用此前已审计的 `test/node-fixes-amesim-20260914/test_mql.ame`。时间 0–50 s,输出间隔 0.01 s,两边误差限数值 1e-8。原始工程文件未修改。
下表“修改前”是保留了 UD00 修复的本次对照运行,采用旧的纯时间配对;“修改后”使用新增事件样本和同阶段配对。每项均包含事件网格点。
| 指标 | 循环:修改前 → 修改后 | 非循环:修改前 → 修改后 |
| --- | --- | --- |
| 信号最大绝对差 | 约 1e17 → **0** | 约 1e17 → **0** |
| 接触力最大绝对差 | 1,285,489.483 → **1,285,489.483 N** | 1.456532 → **1.456532 N** |
| 压力最大绝对差 | 65.686768 → **65.686768 Pa** | 65.686768 → **65.686768 Pa** |
| 温度最大绝对差 | 0.395395 → **0.395395 K** | 0.395395 → **0.395395 K** |
| 明确非零且相对差超过 5% 的曲线样本数 | 63 → **56** | 32 → **30** |
| 修改后信号阶段不匹配的比较点 | **0** | **0** |
| 修改后八路接触/分离标志不匹配的比较点 | **0** | **0** |
5% 仍是诊断筛查线,不是工程验收公差。循环剩余 56 个超线曲线样本为接触力 24、速度 2、质量流 23、焓流 7;非循环剩余 30 个为质量流 23、焓流 7。事件之外的小流量/焓流差异未因本次修改消失。历史初评中的 61 个不能直接当作本次修改前计数,因为本次对照的 UD00 已修复。
### 32.4 s 为何仍需继续查
选中的平台时刻为 `32.400000000000006`,Amesim 为 `32.40000000000221`。两边 UD00 均为事件后的值,第 2 路间隙均为负、处于接触区,但力仍分别为 **468,662.661 N** 与 **1,754,152.144 N**。
本次证据只排除了“信号前值对信号后值”的误配,不能排除高刚度过程对约 `2.20e-12 s` 实际时间差的敏感性,也不能证明缺少接触事件就是该误差的唯一原因。弹性接触力取决于连续位移/速度;切换信号输出不会直接把这些连续状态改成 Amesim 的值。
共同 10 ms 网格仍不是原始瞬态的上界:循环额外事件点中约 **2.084e14 N** 的绝对力峰仍存在;非循环原始峰值约 **4.035e11 N**。这些原始峰未被平滑、删除或纳入不具备同分辨率参考的精度结论。循环在 t≥0.1 s 且避开信号边界前后 0.02 s 的定位统计中,最大力差约 **0.276 N**,不能替代完整事件段验收。
## 6. 是否改变求解与耗时
对两组修改前后原始结果逐项核验:全部旧样本仍保留原始时间,1,784 条原输出在这些时刻数值完全一致,最终状态和全部下列积分计数也一致。这支持本次为输出/对照语义修改,没有改变八路积分轨迹。
| 项目 | 循环:修改前 → 修改后 | 非循环:修改前 → 修改后 |
| --- | --- | --- |
| 实际保存点数 | 5,010 → 5,028 | 5,002 → 5,006 |
| 接受步数 | 19,606 → 19,606 | 11,238 → 11,238 |
| 拒步计数 | 1,552 → 1,552 | 885 → 885 |
| 方程求值 | 85,570 → 85,570 | 56,299 → 56,299 |
| 求解器启动次数 | 20 → 20 | 5 → 5 |
| 雅可比刷新 | 1,835 → 1,835 | 1,270 → 1,270 |
| 线性 setup 次数 | 6,028 → 6,028 | 3,603 → 3,603 |
| 求解耗时,单次 | 11.945 → 11.572 s | 7.357 → 7.469 s |
| 平台进程耗时,单次 | 18.339 → 17.851 s | 13.638 → 13.869 s |
这些单次耗时有运行波动,不能据此声称性能提升或给出可靠开销百分比;可以确认没有新增积分重启与 RHS 求值。新增保存和输出重放仍有实际工作量。
## 7. 后续判断与复现
输出阶段误配已消除,可将这两组作为接下来修改的基线。继续判断循环接触力峰前,应在同一实际时刻或足够细的共同时间窗口核对位移、速度、间隙和接触力,区分保存时间偏移、动态响应及接触事件定位的影响;现有结果不支持直接把全部力峰差归因于接触事件缺失。
汇总证据:[前后效果对照](../../test/output-semantics-20260917/effect-summary.json)、[修改后运行汇总](../../test/output-semantics-20260917/after/summary.json)、[循环阶段配对](../../test/output-semantics-20260917/after/full/phase-pairing.json)、[非循环阶段配对](../../test/output-semantics-20260917/after/noncyclic/phase-pairing.json)。
```powershell
.\.venv-win\Scripts\python.exe -X utf8 tests/manual/evaluate_mql8_correctness.py --output test/output-semantics-rerun --ame-home F:/AMESim2404/Amesim --align-cyclic-from-ame --noncyclic-ame test/node-fixes-amesim-20260914/test_mql.ame --noncyclic-project tests/data/test-mql-8-corrected.json --profiles full noncyclic
```
输出目录必须是新目录。`--native-source` 可指定本次冻结源码作修改前对照;`tests/manual/summarize_output_semantics.py` 核验 before/after 两组结果。原始结果位于 Git 忽略的 `test/output-semantics-20260917/`;代码、测试与本报告为可跟踪交付。
-285
View File
@@ -1,285 +0,0 @@
# SystemSimulationApp 仿真性能评估(2026-08-15)
> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。
> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。
> 2026-08-16 已按本报告建议实现“仿真内独立物性缓存”“worker 启动暖机”、高刚度试探压力边界修复、方程关联块闭合、代数稀疏回退、外部 volume 跨域 ODE Jacobian 修正和 dense output 惰性构造;原始基线数据保留用于对照,当前大型 XML 复验见第 11 节。
## 1. 结论
1. **压力—流量闭合是原始基线的首要热点。** 2026-08-15 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加;2026-08-16 已完成方程块与稀疏首轮,当前现状见第 11 节。
2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。
3. **评估基线已有的两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些数据描述 2026-08-15 的原始基线,后续实现见第 9 节。
4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。
5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。
6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。
7. **用户提供的高刚度 XML 已能完成 10 s 仿真。** 原始基线在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;原因是压力优化下界为 1 Pa,排除了 RK45 合法产生的、仍严格大于 0 Pa 的亚帕试探值。2026-08-16 将优化器压力下界放宽到 0 Pa 后,构成方程仍要求压力严格为正,完整 RK45 仿真通过且没有触发可恢复重试。
8. **闭合不再固定执行第二次全网压力求解,也不再把一个大物理岛等同于一个求解块。** 每次闭合仍先保证全网成立;stream 更新后,只重算声明为 stream-sensitive 的方程—未知量关联块。物理连通岛只是安全范围,当前 `secondaryBlockCount` 是真实方程块数;无法安全分类的自定义模型会保守回退原全网路径。
9. **历史物理岛版收益取决于模型拓扑。** `off` 模式配对测试中,空气链、空气分支、氦气阶跃、机械接触和高刚度短算例分别改善 7.9%、6.5%、0.6%、21.4% 和 14.6%。这些数据保留作纵向基线,但该版已由方程关联块实现取代。
10. **大型分支 XML 的 `0.69 s` 现象已定位并完整跑通。** 输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量、74 个 ODE 状态。根因是外部 volume 跨域耦合在 ODE Jacobian 依赖图中漏 12 个实测显著项,而不是线程死锁;修正后结构由 1092 非零/27 色变为 1284 非零/31 色。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009。
11. **代数非线性回退已有可信声明图上的稀疏保护链。** 先求本轮未闭合方程块的 union sparse;失败恢复原始 `x0` 后做 global sparse,再失败才做 dense。受控扰动微基准在相同 `max_nfev=20` 下把真实残差回调由 3796 降至 164、墙钟约 7.357 s 降至 0.634 s(约 11.6 倍);活动接触或不可信声明仍走兼容 dense 路径。
12. **首轮其他优化均按适用范围解释。** worker 暖机已覆盖 sparse `least_squares` 的 LSMR 路径;dense output 仅在跨采样点或需要状态事件时构造,但本次大型 XML 含状态事件,因此没有本案收益。机械 `atol` 的 `1e-12→1e-10` A/B 约快 16%,但会改变机械误差合同,未采用;外层 thermofluid 流量固定点相对容差的 `1e-12→1e-9` A/B 反而增加 BDF 步数并改变轨迹,也未采用。
## 2. 埋点实现与污染控制
性能开关由进程启动环境变量 `SIMULATIONAPP_PROFILE` 决定:
| 模式 | 用途 | 记录内容 | 适合场景 |
| --- | --- | --- | --- |
| `off` | 正常运行,默认值 | 不在响应中加入性能数据;装饰器在模块加载时直接返回原函数 | 正式仿真和最终性能对比 |
| `standard` | 低开销阶段统计 | XML 校验、网络编译、系统构造、初始化、积分、后处理、结果组装 | 日常定位“大阶段” |
| `audit` | 深度审计 | 再展开 RHS、代数闭合、压力流量、stream、刷新、导数和物性内核 | 短算例诊断、调用频率与缓存评估 |
一次运行使用一个 `ContextVar` 隔离的 `PerformanceTrace`,不会把不同仿真任务的阶段计数混在一起。成功或失败的求解结果在 profiling 模式下都会把快照放入 `diagnostics.performance`。主要字段为:
- 阶段:`calls`、`inclusiveNs`、`selfNs`、`maxNs`、`errors`;
- 物性:上述时间字段,以及介质、操作、缓存查询/命中/未命中;
- audit 专有:闭合内精确输入唯一数/重复数、逆解迭代总数/最大值/收敛与未收敛次数;
- `propertyOutermostNs`:只累计最外层物性调用,避免把嵌套 PR 内核时间重复相加。
标准模式只保留低频的大阶段计时。21 次氦气阶跃配对运行中,标准模式相对关闭模式的中位开销为 1.9%;四个短算例分开校准为 0.5%~2.9%。audit 会逐次生成精确指纹并计时,短算例可慢到约 2.5~5 倍,因此 audit 数据用于定位和计数,最终优化收益必须回到 `off` 模式复测。
将当前代码的 `off` 模式与备份提交 `6a06489` 同时运行 21 次氦气阶跃,墙钟中位数差为约 0.3%,处于本机噪声范围。也就是说,默认关闭时没有观察到稳定的热路径退化。
## 3. 测试方法
环境:Windows 11、Python 3.12.3、SciPy 1.18.0、64 位 Intel 处理器。仓库没有 PyInstaller/Nuitka 等可执行文件构建链,本次直接使用项目实际启动后端的 `.venv-win` 解释器。把同一 Python 代码再包成单文件只会混入解包和启动成本,不会使这里的求解内核更接近生产路径。
基准工具入口:
```powershell
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
--mode audit --warmups 1 --runs 3 `
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
--output app/data/performance-evaluations/helium-step.json
```
工具默认传入取消检查回调,从而走与前端流式仿真相同的低层逐步积分路径。它记录墙钟、进程 CPU、最终 NDJSON 编码、输入 SHA-256 和完整性能快照。原始 JSON 写入被 Git 忽略的 `app/data/performance-evaluations/`,避免把机器相关的大量样本提交到仓库。
本次代表算例:
| 算例 | 内容 | 暖机后 `off` 墙钟中位数 | 重复次数 |
| --- | --- | ---: | ---: |
| `air_chain` | 空气气缸—节流孔—管路—储罐 | 62.4 ms | 9 |
| `air_branched` | 空气分支网络 | 130.3 ms | 9 |
| `helium_step` | 高压 PR 氦气、信号阶跃阀 | 65.2 ms | 9 |
| `mechanical_contact` | MECMAS21/LSTP00A 弹性接触 | 33.0 ms | 9 |
| `helium_long` | 10 s PR 氦气均压、501 个输出点 | 10.63 s | 1 |
短算例先暖机 2 次再测 9 次;缓存 A/B 使用两个同时启动的独立进程各暖机 5 次、测量 21 次,以尽量抵消瞬时系统负载。长算例只测 1 次,因此它只用于判断数量级与阶段占比。
## 4. 深度阶段结果
下表时间是 audit 中位数,会包含审计自身开销;调用数和相对热点比绝对时间更可靠。
| 算例 | RHS | 完整闭合 | 压力流量求解 | 压力流量包含时间占 audit 总时间 | 物性调用 | 闭合内精确重复率 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `air_chain` | 33 | 37 | 74 | 77.0% | 4,265 | 91.2% |
| `air_branched` | 23 | 26 | 56 | 84.6% | 7,378 | 96.5% |
| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% |
| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 |
这是 2026-08-15 原始基线的闭合数据:当时每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点,理论上最多 26 次;四个算例平均为 2.00、2.15、2.30 和 2.00 次/闭合。随后 2026-08-16 的第一版先把后续重算缩到 stream-sensitive 物理连通岛;该历史版本又被当前方程关联块版取代。当前做法是在安全物理范围内只重算敏感方程实际关联的块,没有敏感块时不强制第二次压力求解。这样裁剪的是无效重算,不是删除真实耦合。
## 5. 物性调用与缓存结果
氦气阶跃的冷缓存 audit 代表运行:
| 操作 | 调用 | 命中/未命中 | 命中率 | 真实逆解次数 | 平均迭代 | 最大迭代 | 未收敛 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 |
在原始基线中,暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当时的进程级精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,712 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。
## 6. 输出与失败样本
| 算例 | 最终结果大小 | NDJSON 编码中位数 |
| --- | ---: | ---: |
| `air_chain` | 29.2 KiB | 0.39 ms |
| `air_branched` | 59.1 KiB | 1.21 ms |
| `helium_step` | 55.5 KiB | 1.10 ms |
| `mechanical_contact` | 51.4 KiB | 0.62 ms |
| `helium_long` | 507.4 KiB | 18.48 ms |
用户高刚度 XML 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。原始 audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;其 1.08 s 只代表历史失败路径,不能当作完整模型性能。允许严格正的亚帕试探压力后,同一模型已完成 10 s,当前完整性能结果见第 10 节。
## 7. 后续优化顺序
1. **[2026-08-16 已落实方程块与稀疏首轮] 优化压力流量执行计划。** 压力/流量方程、显式赋值、热流依赖和方程—未知量关联图已预编译;每轮 stream 更新后只重算敏感方程块。非线性时先做可信未闭合块的 union sparse,失败从原 `x0` 做 global sparse→dense;自定义、活动接触或结构不安全的网络保守回退兼容路径。后续仍可评估 equality group 真正消元和解析 Jacobian。
2. **[2026-08-16 已落实] 减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,并缓存相同输入的密度和等熵因子;沿用精确键、有界容量和按仿真隔离原则。
3. **[2026-08-16 已落实] 处理冷启动。** worker 在 FastAPI lifespan 中完成无业务副作用的 SciPy/XSD 微型暖机后再接收请求;不要把约 0.65 s 冷启动归因到每次仿真。
4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。
5. **[2026-08-16 已落实] 修复高刚度 XML 的压力试探边界。** 优化器允许严格正的亚帕试探值,物理构成方程仍拒绝零压和负压;该模型已用 10 s RK45 回归验证,不需要把这类合法试探误报为可恢复拒步。
6. **[2026-08-16 已落实] 补全外部 volume 的跨域 ODE Jacobian 依赖。** 机械位移写入气室容积后,气动储能与机械力平衡必须在状态稀疏图中双向关联;大型分支 XML 据此完整跑通。机械 `atol` 放宽虽有约 16% 的单次改善但改变精度合同;flow 固定点容差放宽反而增加步数,均未采用。
7. **[2026-08-16 已落实] dense output 惰性构造。** 仅当已接受步跨越下一样本或需要状态事件时构造插值;含状态事件的模型每步仍需要,不将其宣传为大型分支 XML 的收益来源。
## 8. 本次评估边界
- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。
- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。
- 原始基线的 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告当时均为单任务运行。该并发统计限制已由第 9 节的仿真内独立缓存消除。
- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。
- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。
- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。
## 9. 2026-08-16 缓存与启动暖机复验
原先两个函数级 LRU 会在 Python 进程内跨仿真共享条目。现已改为每次仿真通过
`ContextVar` 创建独立缓存,并在运行结束后整体释放;并发任务不会共享缓存或
命中统计。每个“物性操作 + 介质实例”使用独立的 C 层有界 LRU,默认上限为
8192 项。当前只缓存四条有明确重复收益的氦气路径:密度、等熵密度—压力因子、
`properties_from_mU` 和 `temperature_from_pressure_enthalpy`。
同一个高压氦气阶跃算例的冷缓存 audit 结果为:
| 操作 | 调用 | 命中 / 未命中 | 命中率 |
| --- | ---: | ---: | ---: |
| `density` | 578 | 403 / 175 | 69.7% |
| `isentropic_density_pressure_factor` | 398 | 310 / 88 | 77.9% |
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% |
四项合计 2,869 次命中、435 次未命中、435 个最终条目,未发生驱逐。PR 三次根
计算从原审计的 1,712 次降到 689 次。关闭埋点、各自预热 5 次并测量 21 次时,
缓存开启/完全关闭的墙钟中位数在本机分别为 71.6 ms 和 154.0 ms。这个对比表示
“四项缓存整体”相对“完全不缓存”的收益,不能误解为相对旧版两个 LRU 又提升
53.5%。缓存开关两次运行的完整 `series` 和 `final` SHA-256 一致。
10 s、501 个输出点的氦气均压算例单次复验中,缓存开启和关闭分别用时
14.18 s 与 26.64 s;开启时命中 400,571 次、未命中 96,427 次。四个缓存均达到
各自 8192 项上限,共发生 63,659 次 LRU 驱逐,但仍完成到 10 s。该长算例每种
配置只测了一次,只能说明容量上限确实生效且仍有收益,不能作为稳定百分比承诺。
worker 暖机只执行内存中的一维 BDF、`least_squares`、`brentq`、稀疏 Jacobian
分组和 XSD 编译,不运行用户模型、不写文件、不填充氦气业务缓存。当前暖机还显式
覆盖携带 `jac_sparsity`、使用 sparse LSMR trust-region 子问题的代数路径,避免真实
用户任务第一次触发该 SciPy 分支时再承担惰性初始化。三个新进程的
中位数为:不暖机首个仿真 709.1 ms;启动暖机本身 637.1 ms;暖机后的首个仿真
69.8 ms,同进程第二次约 68~74 ms。也就是说总初始化成本没有消失,而是被
移到服务宣告就绪之前。
## 10. 2026-08-16 历史物理岛版闭合复验
> 本节保留方程关联块实现之前的物理岛版数据,用于纵向对照。它已不是当前执行计划;当前结果见第 11 节。
该轮把 signal 源/连接、stream 组件/端口/连接以及气动外部 volume 组件/连接的
静态查找移到系统构造阶段;运行时仍按每个状态执行实际传播和热力刷新。压力流量
闭合先进行一次全网求解,再根据预编译的依赖声明,只对 stream-sensitive 的物理
连通块做后续固定点重算。对未声明依赖的自定义 stream 组件、跨组件方程或非方阵
物理岛,执行计划保守回退到原全网求解,不以性能换取模型兼容性。
该历史版本的 `off` 模式配对结果如下。表中百分比是同一模型、同一数值配置下的墙钟改善,适合
判断优化方向,不是跨机器速度承诺:
| 算例 | 历史物理岛版相对原全网闭合的改善 |
| --- | ---: |
| `air_chain` | 7.9% |
| `air_branched` | 6.5% |
| `helium_step` | 0.6% |
| `mechanical_contact` | 21.4% |
| high-stiffness short | 14.6% |
`helium_step` 在该历史版本里只有一个需要在 stream 后继续求解的敏感物理岛,因此 0.6% 的改善处于
小幅范围;不能用其他无敏感岛模型的收益夸大氦气模型的效果。积分完成后的后处理
也没有跳过闭合:每个输出采样点仍重新应用状态、执行完整 `_close_current_state()`
并提取结果,只是闭合内部使用同一安全执行计划。
完整 high-stiffness 10 s 算例的历史 `off` 基线约为 28.126 s;本轮全部改动后的
多次运行中位数为 13.694 s。这个跨版本对比同时包含本轮多项改动,不能把差额全部
归因于物理岛裁剪。为了单独核对当时的闭合计划,在同版代码上做 optimized/forced-global
对照,墙钟分别为 14.676 s 和 16.929 s,完整 `series` 完全一致;这组受控对比才
直接反映该模型的执行计划收益。
当前诊断已经进一步消除旧命名歧义:`secondaryPhysicalIslandCount` 表示安全分类的
物理范围,`secondaryBlockCount` 表示方程—未知量关联图中的真实块数,
`secondaryUnknownCount` 表示这些方程块合计未知量。`solveCount` 统计实际求解器调用,
`closurePassCount` 单列发生过压力求解的闭合 pass;`last` 中还包含
`residualEvaluations`、`jacobianMode`、dense/方程块回退状态。不能再把
`secondaryBlockCount` 解释为物理岛数。
## 11. 2026-08-16 大型分支 XML 与方程块首轮复验
验证输入 `test_mql-full-branches-01-04.xml` 的 SHA-256 为
`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`。模型规模如下:
| 项目 | 数量 |
| --- | ---: |
| 组件 | 98 |
| ODE 状态 | 74 |
| 压力/流量/机械代数未知量与方程 | 472 / 472 |
| 代数声明图结构非零 | 919(约 0.413%) |
| 全部独立代数方程块 | 58 |
| stream 后续敏感方程块 | 9,合计 192 个未知量 |
### 11.1 `0.69 s` 慢区的根因与完整结果
旧代码在 `0.69 s` 左右不是线程死锁:它仍会缓慢前进,但 BDF 在刚性变化区大量
缩步、重建有限差分 Jacobian 和执行 LU。定位出的结构错误是气动外部 volume
跨域耦合没有完整进入 ODE 状态依赖图。机械位置先写入气室容积,气室压力又反馈到
机械力平衡;旧图只沿普通物理端口追踪,漏掉这条闭环中的 12 个实测显著导数项。
修正后,状态稀疏图由 1092 个非零项、27 个颜色组变为 1284 个非零项、31 个颜色组。
颜色数增加是因为补上了真实依赖,并非回退到更差算法;完整 Jacobian 让 BDF 少走
错误 Newton 方向和重复试步。功能收口过程中的较早阶段测量为 92.187 s;最终稳定
代码连续三次完整 `0~0.81 s` 分别用时 79.049 s、74.658 s 和 85.103 s,数值工作量一致:
| 指标 | 结果 |
| --- | ---: |
| `nfev` | 3393 |
| `njev` | 226 |
| `nlu` | 667 |
| 接受步 | 1009 |
| 求解器启动次数 | 3 |
| 压力流量求解 | 28008,全部 seeded |
| 方程块/dense 非线性回退 | 0 / 0 |
| 三次 full-response 规范 JSON SHA-256 | `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f` |
| `physical-solution-v1` SHA-256 | `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7` |
这里的 full-response 哈希覆盖完整响应,所以诊断字段增删也会改变它。为稳定比较物理
结果,`physical-solution-v1` 只把 `{status, simulatedUntil, requestedStopTime, series,
final}` 投影为待哈希对象;schema 名只是外部标签,不进入对象。两种口径都使用
`json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 后计算 SHA-256。
旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,响应结构不同,
不作为最终结果,也不能与当前口径直接比较。
容差 A/B 必须分开解释:机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次测试约快
16%,但会改变机械状态与事件的误差合同,当前未采用;外层 thermofluid 流量固定点
相对容差从 `1e-12` 放宽到 `1e-9` 后,BDF 内部步数反而增加并改变积分轨迹,也未
采用。完整成功来自依赖图修正和闭合优化,不是牺牲积分精度或闭合精度。
### 11.2 stream 方程块与受控 A/B
第一次压力流量求解仍承担“全网必须成立”的语义;但可信声明图允许它在非线性时只把
本轮未闭合的独立方程块合并成一个 union sparse 问题,而不是固定构造 472 变量的
dense 问题。stream 更新后的固定点进一步只处理 9 个敏感方程块、合计 192 个未知量,
不再因为机械总线把拓扑连成一个大物理岛,就重复求解全部 472 个未知量。
在相同当前代码、相同 `0~0.01 s` 区间做 optimized/forced-global 配对,墙钟分别为
16.200 s 和 18.584 s,物理解与 `series` 逐值一致。这组对照隔离的是后续 stream
闭合作用域;它不包含完整 `0.81 s` 慢区的全部收益,不能与最终完整运行直接换算百分比。
### 11.3 非线性稀疏回退与其他首轮项
全局非线性回退当前采用兼容保护链:可信声明图先求未闭合方程块的 union sparse;
若块解失败,先把所有共享端口未知量恢复到原始 `x0`,再做 global sparse;若 sparse
仍未达到既有残差合同,再次从原 `x0` 做 global dense。活动接触会改变坐标/活动集,
不可信自定义声明也可能漏依赖,这两类不冒险使用静态稀疏图,继续走 dense 兼容路径。
受控扰动微基准在相同 `max_nfev=20` 下得到:
| 路径 | 真实残差回调 | 墙钟 |
| --- | ---: | ---: |
| dense | 3796 | 7.357 s |
| sparse | 164 | 0.634 s |
同一评估预算下约为 11.6 倍的回退成本改善;两条路径在 20 次优化器评估内都没有收敛,
所以这是“数值 Jacobian 试算成本”微基准,不是整体仿真加速承诺。诊断用
`residualEvaluations` 记录真实残差回调,避免仅看 SciPy `nfev` 漏掉内部差分调用。
worker 暖机现已覆盖带 `jac_sparsity` 的 sparse LSMR `least_squares` 路径。逐步积分的
dense output 也改为只在当前步跨越下一采样点或需要状态事件定位时构造;本 XML 含
状态事件,所以每步仍需要插值,这项优化对最终 79.049 s/74.658 s/85.103 s 复验没有收益。
@@ -0,0 +1,101 @@
# 八路 50 秒仿真在 10.8 秒终止的原因核查
2026-09-13,Windows,本地 `system-optimization` 工作区。此次定位问题,在 `test/mql8-50s-20260913/` 创建独立输入、源码和日志副本,没有修改正式求解器、原始 JSON、AME 或运行中的后端。
**结论:已复现 10.8 秒终止,由两层问题叠加造成。AME 转换/审计遗漏 UD00 循环开关的枚举转换,使本应单次输出的两个信号周期重复;信号重启后,CVODE 成功完成一个小于当前时间浮点分辨率的步,外层封装因返回时间相同立即判失败。** 这不是此次运行超时,也没有观察到 CVODE 返回雅可比或线性分解错误。
## 1. 输入身份与参数核对
用户确认后续八路验证默认使用下列仓库文件,本次网页仅修改终止时间:
| 输入 | 本次 SHA-256 |
|---|---|
| `tests/data/test-mql-8-corrected.json` | `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288` |
| `tests/data/test_mql.ame` | `319b27ac1b8ceb7e960cc05592efbfc19664e70479fe9bf36000b823c3c928f8` |
AME 已由用户更新,不能继续使用历史 `1ff0ea42…` 的 10 秒归档身份。本次归档 `.results` 有 1002 个时间点,覆盖 0~50 秒,所有具名保存值有限;本轮读取此归档,没有重新运行 Amesim。
以现有审计工具核对:157 个元件、178 条连接逐端口匹配,1092 项公开参数在原工具的数值/单位规则下全部相等;1015 项直接绑定参数也核对图纸与 `.param/.data`。但下文两个枚举的语义不相等,因此“数字全部相同”不能作为参数语义一致的结论。其余参数本轮未发现数值差异,不将此等同于全部元件实现已逐项验收。
| 设置 | 本次原生复现 | 当前 AME |
|---|---:|---:|
| 终止时间 | 50 s,副本仅由原 JSON 的 10 s 延长 | 50 s |
| 输出间隔 | 0.01 s | 0.05 s |
| 最大积分步长 | 1e30 s | 1e30 s |
| 相对误差限 | 后端默认 1e-8 | 1e-7 |
| 积分算法 | CVODE/BDF 7.4.0 | 保留归档设置,不把内部枚举直接等同于 BDF |
原 JSON 仍保存 10 s、输出间隔 0.01 s,所以原审计工具最后的时间设置断言按预期失败。其物理参数与连接审计记录已经写出,不能把该断言当作仿真故障。
## 2. 两个 UD00 的循环开关被错误对齐
当前应用公共协议由 `app/simulation/components/amesim/signals/sources.py` 定义为 `0=否、1=是`。本机 Amesim 2404 的 `libsig/submodels/UD00.c` 明确只有 `iscyclic == 2` 才执行循环分支,其余合法值 1 执行单次分支。
因此正确的外部转换应为 **AME 1 → 公共协议 0,AME 2 → 公共协议 1**。不能直接修改现有 C 内核中布尔值 1 的含义,否则会破坏用户合法创建的循环信号。
AME 图纸与 `.param/.data` 中两个开关均为 1;当前 JSON 也保存 1。`tests/test_test_mql_ame_contract.py::_expected_parameters` 按单位转换后直接比较数字,没有处理这一枚举映射;八路审计工具复用该方法,因而漏报。此前仅运行 10 秒,尚未越过这个差异首次产生影响的时刻。
两个信号有效阶段均为 0.8 秒 + 10 秒,总长度为 10.8 秒:
| 信号 | 0~0.8 s | 0.8~10.8 s | AME 在 10.8 s 后 | 原 JSON 在 10.8 s |
|---|---:|---:|---:|---:|
| `amesim_ud00_1` | 1e17 | 49000 | 保持 49000 | 跳回 1e17 |
| `amesim_ud00_2` | 1e12 | 0 | 保持 0 | 跳回 1e12 |
该差异既由源代码和参数表证实,也由 AME 保存的 `output@piecewiselinear`、`output@piecewiselinear_1` 曲线与原生失败结果最后一点直接证实。AME 在 10.8 s 邻近、11 s 和 50 s 均保持末段值。
## 3. 求解器封装过早终止
原生结果:`success=false`、`simulatedUntil=10.8`、`Native integration failed to advance.`,接受步 8848、拒绝步 669、雅可比计算 682 次、无着色回退。执行限额为 300 秒,而此次纯求解仅约 9.888 秒,排除超时。
在隔离 C 副本中仅增加日志,数值结果与原版的终点和求解计数相同。10.8 秒信号重启后,CVODE 返回:
```text
flag = 0 (CV_SUCCESS)
t = next = internal = 10.800000000000001
last step = actual initial step = 2.092542956622022e-16 s
next proposed step = 1.072750168401015e-14 s
internal accepted steps = 1
```
10.8 附近两个相邻双精度时间数的间隔约为 `1.7763568394002505e-15 s`,大于这一步。因此状态更新后返回的时间数字仍相同。`native/runtime/cvode_solver.c` 中的 `if (flag<0 || next<=t) goto cleanup;` 把“负返回码”和“成功但时间暂未改变”合并为同一种立即退出条件。CVODE 的 0 确实代表成功,见 [官方返回码说明](https://sundials.readthedocs.io/en/latest/cvode/Constants_link.html)。
诊断副本在 `CV_SUCCESS && next == t` 时允许有限次继续,不人工增加时间、不改变误差限,不执行零长度采样/碰撞处理。该诊断保留了原来的循环输入,仍完整到达 50 秒:总计出现 12 次这样的返回,最长连续 6 次,均恢复前进。这证明本次外层立即退出条件过早,不代表可以无限忽略不前进或接受任意不连续输入的精度。
## 4. 两个独立对照
所有对照使用相同物理参数、BDF、rtol=1e-8、最大步长 1e30 s、输出间隔 0.01 s。下列都是单次诊断观察,不作为性能优化统计。
| 对照 | 改动 | 结果 | C 求解墙钟 |
|---|---|---|---:|
| 原模型 + 正式内核 | 仅延长到 50 s | 停在 10.8 s | 9.888 s |
| 语义对齐模型 + 正式内核 | 两个 UD00 的 iscyclic 改为 0 | 完成 50 s | 9.851 s |
| 原循环模型 + 诊断内核 | 对成功但时间未变提供有限次继续 | 完成 50 s | 11.805 s |
第二项没有修改求解器,返回 5002 个严格递增采样点,全部数据有限;56 个气体质量状态的总质量最大漂移为 `1.2434497875801753e-14 kg`。两个信号的末段值与 AME 相符。此项证明对齐信号语义即可让当前模型完整运行,不宣称已通过双方全部 50 秒曲线的等精度验收。
第三项仍具有与 AME 不同的周期激励,产生 9 次机械状态转换,不能作为 AME 对照模型或直接交付为正式修复。诊断的连续 8 次保护上限只用于验证假设,正式实现需明确停滞处理规则和诊断。
## 5. 建议修复范围
1. 修正 AME→公共协议枚举映射及审计,按语义对齐这两个模型参数;保持公共协议 0/1 不变。补上“单次保持末值”和“循环跨周期”的转换与长时间测试,不能用双方都写 1 的测试继续自证正确。
2. 区分 CVODE 失败返回、时间倒退、成功但暂时无可表示时间增量。对最后一种提供有上限且可取消的继续机制,记录内部步数、实际步长和停滞次数;仍无法推进时明确报错,避免笼统提示。不得强行用 nextafter 推动系统时间以跳过积分。
3. 正式验收包括该八路 50 秒模型、真正的周期信号、单次斜坡结束后的保持行为、事件/采样完整性与失败诊断,并在 Windows 和 Linux 上分别验证。稀疏求解优化安排在这些正确性问题之后。
本轮未修改生产代码或原始工程,仅提供已经完成 50 秒运行的诊断 JSON:`test/mql8-50s-20260913/model-50s-semantic-aligned.json`。它与原 JSON 仅有终止时间及两个循环开关的差异。
## 6. 证据与复现
证据目录 `test/mql8-50s-20260913/`:
- `sources/identity.json`、输入快照:来源身份;检查结束时原始文件 SHA 未改变。
- `parameter-audit/audit.json`、`parameter-audit.log`:逐参数、逐端口映射与时间设置差异。
- `archive-summary.json`:AME 保存结果时间范围及有限性。
- `production-retry/`:正式内核复现;`traced-boundary/`:仅增加日志的同故障验证。
- `semantic-aligned/`:对齐开关后的完整结果,`validation.json` 包含采样、有限性与质量守恒检查。
- `stationary-probe/`:停滞继续的诊断源码、实际构建信息和完整结果;`cause-evidence.json` 汇总信号差异及 CVODE 返回信息。
- `diagnose.py`:复用正式输入、代码生成、构建与执行入口。重新运行需使用新的 `--name`,避免覆盖原始证据。`--trace --allow-stationary` 只修改 test 下的源码副本。
Windows 使用 `.venv-win/Scripts/python.exe -B test/mql8-50s-20260913/diagnose.py run --name 新目录名`。语义对齐组加 `--input model-50s-semantic-aligned.json`。本轮没有 Linux 实机结果。
最初一次诊断编译在受限进程环境中遇到 GCC CreateProcess 错误,尚未开始求解;保留 `production-default/input.xml`。随后正常 Windows 进程环境构建成功并稳定复现 10.8 秒中断,两者属于不同阶段的问题,不能混为同一根因。
@@ -0,0 +1,131 @@
# 八路循环信号:参数核对、耗时与曲线对比
2026-09-14,Windows,分支 `system-optimization`。
## 结论
先核对参数,再将两个 UD00 都设为循环并统一运行设置。最终测试副本的 **157 个元件、178 条逐端口连接、1,092 项公开参数均匹配**,另有 1,015 项图纸参数与 Amesim 实际运行参数表交叉核验通过。
两边均连续三次完成 50 秒。平台纯求解墙钟时间中位数 **9.133 秒**;平台原生进程完整运行 **10.660 秒**,Amesim 原生进程完整运行 **6.816 秒**,按此进程口径平台约慢 **1.56 倍**。平台通过真实 HTTP 流式接口提交和接收结果,完整请求中位数为 **14.593 秒**。
**曲线总体变化趋势相近,但不能验收为全部一致。** 循环切换采样点存在左右分支差异;起始快速变化阶段还有压力、温度差异,而且将双方误差限收紧到 `1e-10` 后仍然存在。
## 1. 仿真前参数检查
本轮来源为用户指定的当前八路默认文件:
| 来源 | 仿真前 SHA-256 |
| --- | --- |
| `tests/data/test_mql.ame` | `251b2f17c24ea02c1ab48f9f441a0ec38e7920627ea7e6870392326424a517c5` |
| `tests/data/test-mql-8-corrected.json` | `60ed16c71b72be4ae71ca7980f4f8cdcdf6b4c4c4c8950863c2390960b71ced4` |
原模型公开物理参数和连接没有差异,两个 UD00 都为非循环。原运行设置有三项差别:JSON 为 10 秒、输出间隔 0.01 秒,Amesim 为 50 秒、0.05 秒;平台默认相对误差限 `1e-8`,Amesim 容差 `1e-7`。
在测试副本中调整如下,并在开始求解之前重新通过完整参数、连接和时间设置审计:
| 设置 | 平台 | Amesim |
| --- | --- | --- |
| 起止时间 | 0–50 s | 0–50 s |
| 输出间隔 | 0.01 s | 0.01 s |
| 最大积分步长 | 1e30 s | 1e30 s |
| 误差限数值 | rtol=1e-8 | tolerance=1e-8 |
| 运行方式 | CVODE BDF | 标准变步长积分器;API `integ_method=0` |
| 循环开关 | 两个 UD00 均为 1 | 两个 UD00 均为 2 |
循环开关按各自枚举的语义转换,而非直接比较数字。两个信号周期都是 **10.8 秒**:
- `amesim_ud00_1` / `piecewiselinear`:前 0.8 秒为 `1e17`,后 10 秒为 `49000`。
- `amesim_ud00_2` / `piecewiselinear_1`:前 0.8 秒为 `1e12`,后 10 秒为 `0`。
统一的是公开物理输入、连接和上述运行设置,不是两套求解器的全部内部行为。Amesim 保留 mixed error、standard solver、启用 optimized solver;平台保留生成的逐状态绝对误差限。相同的 `1e-8` 数字不等于完全相同的误差权重。按用户此前要求,仅记录 Amesim 运行选项,不把其内部算法直接认定为 CVODE BDF。
证据:`test/mql8-cyclic-20260914/preflight/audit.json`、`aligned-audit/audit.json`、`ame-settings.json`。原始 AME/JSON 在本轮结束时 SHA-256 均未改变。
## 2. 执行方式和耗时
Amesim 使用本机 2404 官方 API 打开副本、设置参数、生成程序并保存;随后独立运行其生成的模型程序。平台启动一个独立临时 FastAPI 实例,走 JSON→XML→`/api/system-xml/simulate-stream` 正式路径,使用本轮修复后的 C 封装。该临时服务已结束,未替换用户运行中的服务。
两边各串行运行三次,没有互相并发竞争计算资源。平台三次均命中模型编译缓存。主测均为 `1e-8`;后文 `1e-10` 仅用于精度诊断,不混入速度中位数。
| 计时字段 | 第 1 次 | 第 2 次 | 第 3 次 | 中位数 |
| --- | ---: | ---: | ---: | ---: |
| 平台 `solveSeconds`:纯求解墙钟 | 9.368 s | 9.133 s | 9.058 s | **9.133 s** |
| 平台 `solveCpuSeconds`:纯求解 CPU | 9.344 s | 9.219 s | 9.078 s | **9.219 s** |
| 平台 `processWallSeconds`:原生进程及结果读取 | 10.901 s | 10.660 s | 10.594 s | **10.660 s** |
| 平台 HTTP 请求墙钟 | 14.593 s | 14.653 s | 14.291 s | **14.593 s** |
| Amesim 原生进程墙钟 | 6.886 s | 6.816 s | 6.649 s | **6.816 s** |
| Amesim 日志 `Total CPU time` | 4.406 s | 4.438 s | 4.250 s | **4.406 s** |
CPU 字段之比约 2.09;但 Amesim 的日志总 CPU 字段与平台明确排除了初始化/最终结果投影的纯求解 CPU 字段不保证边界完全一致,因此只作辅助观察。原生进程墙钟之比约 1.56;两边结果格式和输出数量也不同,不能把全部差额认定为积分器内部开销。
平台完整请求的额外成本可直接看到:缓存命中时仍有约 **2.22 秒构建检查/预处理**;原生进程时间比纯求解约多 **1.53 秒**;另有后端组装及本地 HTTP 结果传输。此次平台返回完整 1,784 个输出,JSON 体积较大。HTTP 计时包含请求至接收完全部流式响应,不含客户端随后解析大 JSON、浏览器 JavaScript 或绘图,所以不能称为网页按钮到绘图完成时间。
平台三次接受步均为 15,037,均记录同时间成功返回 12 次、最多连续 6 次;最终到达 50 秒,没有再次出现 10.8 秒提前终止。
## 3. 曲线结果
比较 158 条关键曲线:质量位移/速度、气腔和管路压力/温度、8 路接触力/间隙及信号。由参数审计确定对应元件,按物理单位转换,例如 Amesim 表压加 101300 Pa 对应平台绝对压力,间隙 mm 转为 m;没有通过交换元件或改变符号来拟合曲线。
平台返回 5,010 个时刻,Amesim 为 5,002 个时刻。双方具名保存值均有限;对比使用 0~50 秒的 5,001 个共同 0.01 秒网格点,线性插值。额外事件点及浮点边界的处理并不相同,原始文件全部保留。
### 完整区间的最大差
| 物理量/曲线 | 最大绝对差 | 发生时刻 |
| --- | ---: | ---: |
| 第 1 路接触力 `amesim_lstp00a_1.force` | **1,285,518 N** | 32.4 s |
| 全部 8 路接触力的最差项,第 7 路 | 1,285,518 N | 32.4 s |
| 管路压力 `amesim_pnl0002_6.p` | **163,286 Pa** | 0.01 s |
| 管路温度 `amesim_pnl0001_13.T` | **105.215 K** | 0.01 s |
| 接触间隙最差项,第 2 路 | 2.703e-8 m | 44.19 s |
不能只报“趋势重合”而省略上表的大差异。
### 循环切换点为什么有尖峰差异
在共同网格 `t=32.4 s`,两边取到了不同的信号分支:平台第一个信号为 `49000`,Amesim 插值值约为 `1e17`。这里正好是第三个循环边界;浮点时间、事件点及保存采样的先后约定影响取到跳变前还是跳变后的值。离开切换点前后 0.02 秒后,所有对照信号完全相同,说明不是周期长度或幅值参数仍不一致。
接触力对此特别敏感:本模型 `kcont=rcont=1e11`,32.4 秒处两边接触间隙接近,但 Amesim 相连两质量的相对速度约为 `1.2855e-5 m/s`,平台接近零,仅此阻尼项就对应约 **1.2855e6 N** 的力。因而微小速度和切换取样侧的差别,会被很大的阻尼放大为力尖峰。这里是对保存结果的解释,不等于已证明某一方的尖峰代表真实物理脉冲。
保留全部初始阶段、只避开每个 UD00 切换点前后 0.02 秒,8 路接触力最大差约 **5,129 N**。进一步仅看 `t>=0.1 s` 且避开上述窗口,接触力最大差 **32.12 N**;第 1 路在 50 秒时的差为 **−28.79 N**。这些筛选结果用于定位差异,不能代替完整区间验收。
### 温度和压力差异并未随容差收紧消失
补做两边 `1e-10` 的 50 秒诊断:
| 项目 | 双方 1e-8 最大差 | 双方 1e-10 最大差 |
| --- | ---: | ---: |
| 上述管路压力 | 163,285.92 Pa | 163,359.89 Pa |
| 上述管路温度 | 105.2145 K | 105.2373 K |
| 第 7 路接触力 | 1,285,518.34 N | 1,288,259.73 N |
对压力最差曲线,平台自身收紧容差的变化最大约 89.87 Pa,Amesim 自身变化约 15.89 Pa;温度最差曲线分别约 0.02356 K 和 0.00106 K,远小于双方之间的 105 K。因此这部分差异主要不是普通积分误差限偏松,后续应核对相应状态/物性公式、有效范围之外的处理和初始化后的快速压缩过程。
本次 Amesim 日志实际报告:初始快速过程的氦气温度超出状态方程及残余热物性有效上限 1500 K,日志中出现约 9609 K 的查询;保存曲线也有约 5174 K 的管内温度。Amesim 还报告 8 个 PNCH012 容积被最小容积 `cvol0/100=150 cm³` 限制。这些都是当前匹配模型实际进入的状态,需要在后续公式对照中保留,不能因为双方均正常终止就认定该工况的物性精度已经得到保证。本轮没有进一步认定某个具体物性函数为唯一根因。
### 巨大位移的特殊质量块
`amesim_mecmas21_9` 是质量 100 kg、无摩擦、无限位的质量块,受到本模型量级很大的重复激励。两边都得到约 `1.12e17 m` 的最终位移。默认容差下该位移的绝对差约 `1.50e10 m`,相对该量级约 `1.34e-7`;容差收紧后绝对差降到约 `2.66e8 m`。这部分不能与普通活塞的毫米级位移混在同一绝对误差指标里,也不能忽略这项极端模型状态。
排除该质量块,在 `t>=0.1 s` 且避开切换窗口后,其他质量位移最大差约 `2.19e-5 m`,速度最大差约 `2.43e-4 m/s`。完整逐曲线指标均见 `comparison.json`。
![循环模型对比](../../test/mql8-cyclic-20260914/comparison.png)
## 4. 后续方向与本轮范围
这次已经确认:参数与连接匹配、两个循环信号配置正确、两边都完成 50 秒、外层提前终止问题没有复发。仍未完成的“一致性”包括切换点采样侧统一,以及高温快速过程的压力/温度差异解释。应先完成这两项,再把此模型作为等精度性能基准。
本轮没有修改生产求解器、组件公式或两个可调环境参数,也没有据此开展稀疏矩阵优化。Amesim 首次重新编译遇到既有 GCC CreateProcess 故障,另一次 API 会话缺少模型库搜索路径;补齐该会话使用的既有库路径后编译完成,未修改机器工具链配置。这些发生在正式计时之前。
## 5. 交付文件与证据
所有本机工件位于 `test/mql8-cyclic-20260914/`:
- **`amesim/test_mql.ame`**:循环、50 秒、0.01 秒采样、容差 1e-8 的模型副本,已通过官方 API 保存最新仿真结果;归档 `.results` 的 SHA-256 与本次实际运行结果一致。
- **`test-mql-8-cyclic.json`**:参数对应的平台工程,可直接导入;rtol 由当前平台后端默认值提供。
- `platform-curves.csv`、`amesim-curves.csv`:158 条曲线在共同网格上的单位统一数据。
- `comparison.png` / `.svg`:总体及局部曲线。
- `comparison.json`、`refinement-comparison.json`:完整指标及严格容差诊断。
- `platform-timings.json`、`ame-timings.json`:三次计时;`platform-result-event.json`、`ame-run/` 保留原始输出。
- `prepare_ame.py`、`run_comparison.py`、`analyze.py`、`refine.py`、`analyze_refinement.py`:本机复现脚本。
原始 `tests/data` 两份默认输入保持不变;循环测试使用交付副本。测试目录为 Git 忽略目录,报告保存在 `docs/other/`。
@@ -0,0 +1,132 @@
# 八路曲线剩余差异占比与全程评价
日期:2026-09-17。
## 1. 结论与适用范围
**在目前 0–50 s、10 ms 共同输出网格上,两侧主要运行过程的数值一致性较好,剩余流量和焓流差异对整条曲线的影响很小。这个结论支持当前平台的初步功能展示和基线一致性说明。**
循环工况的 238 条曲线中,只有 3 条曲线、6 个“曲线×时刻”样本超过原来的 5% 相对差筛查线;它们集中在 0.30、0.31、0.32 s 三个时刻,占 5,001 个共同输出时刻的 **0.0600%**。不过,不能据此说其余时间完全没有问题:参考值接近零时,另有零星绝对差异;把这类点也计入,涉及 **34 / 5,001 个时刻,即 0.6799%**。
比占比更有说服力的是幅值和累计指标:质量流量各曲线中最差的全程相对 L2 误差为 **0.008566%**,焓流为 **0.007368%**;逐条曲线的累计绝对差与参考累计绝对值之比,最差分别为 **0.013934%** 和 **0.013814%**。这两种指标不会仅因为增加长时间的双方零输出而变小。
本次只读取已有结果,不修改元件、求解器、配对逻辑或工况,不重新求解。统计不包含共同网格之间所有自适应步或所有机械事件点,因此不构成未采样的极短瞬态误差上界。流量和焓流的差异尚未完成根因定位;本次量化的是影响范围和量级。
## 2. 数据来源与评价口径
- 循环:上一轮 LSTP 已接入主路的结果,对比开启不连续点输出的 Amesim 结果;238 条曲线,5,001 个共同网格点,全部成功配对。
- 不循环:同一轮主路基线的已有结果,Amesim 参照仍为普通输出,采用原有信号阶段配对;主要用来交叉检查剩余流量和焓流差异是否重复出现。
- 每个网格点、所有变量沿用同一对原始记录,不根据误差大小重新选择事件侧,没有平滑、插值或删除异常点。
- 原相对差口径为 `100 × |平台−Amesim| / |Amesim|`。质量流量只有在 `|Amesim| > 1e-6 kg/s` 时计算该比值;焓流门槛为 `1 W`。这些是已有报告的诊断门槛,不是工程验收公差。
- 接近零的区间单列:`|Amesim| ≤ 门槛` 且 `|平台−Amesim| > 门槛`。避免除以极小数,也避免把这些点直接忽略。
- “全程占比”使用曲线样本数或不重复时刻数,分母分别注明。连续标记点折合时间只是 10 ms 网格上的估计,不是已经定位的超差起止时刻。
令差值为 `e`、参考值为 `r`、梯形积分权重为 `w`:
- 全程相对 L2 误差:`100 × sqrt(Σw·e² / Σw·r²)`,即相对均方根误差。
- 全程相对 L1 误差:`100 × Σw·|e| / Σw·|r|`,避免正负误差相互抵消。
- 累计输送量误差:对流量或焓流差值做梯形积分;另保存绝对差积分和任意时刻累计有符号差的最大值。
积分均为已有 10 ms 网格上的估算。每条节点通道独立计算,不把串联节点的流量相加当成系统总输送量,也不把这些积分当成完整的守恒验证。
## 3. 超过 5% 的差异有多大占比
每类流量都有 16 条对照曲线,每条 5,001 个点。
| 变量类别 | 超过 5% 的曲线数 | 超过 5% 的样本数 | 占该类全部 80,016 个样本 | 占该类高于近零门槛的样本 |
| --- | ---: | ---: | ---: | ---: |
| 质量流量 | 2 | 4 | 0.004999% | 4 / 576 = 0.6944% |
| 焓流 | 1 | 2 | 0.002500% | 2 / 426 = 0.4695% |
下面按单条受影响曲线计算,防止其他无差异曲线稀释占比:
| 曲线 | 超过 5% 的时刻,s | 占本曲线全部样本 | 占本曲线高于近零门槛的样本 | 最大相对差 |
| --- | --- | ---: | ---: | ---: |
| `amesim_pn3node2_2.reference_mass_flow` | 0.30 | 0.0200% | 1 / 32 = 3.125% | 11.111% |
| `amesim_pn3node2_3.reference_mass_flow` | 0.30、0.31、0.32 | 0.0600% | 3 / 32 = 9.375% | 32.690% |
| `amesim_p4node2_4.reference_enthalpy_flow` | 0.31、0.32 | 0.0400% | 2 / 35 = 5.714% | 11.360% |
所以,单说“占全程很小”不够:最差流量曲线高于近零门槛的样本中,仍有 9.375% 超过相对差筛查线。关键是它们全部位于衰减尾部,实际量值很小:
- 最大相对流量差位于 0.32 s:Amesim **3.66657 mg/s**,平台 **2.46797 mg/s**,差 **1.19860 mg/s**;该曲线参考峰值约 **249,299 mg/s**。
- 最大相对焓流差位于 0.31 s:Amesim **−3.08080 W**,平台 **−2.73081 W**,差 **0.34999 W**;该曲线参考绝对峰值约 **522,749 W**。
- 0.30–0.32 s 并非本基线登记的 STEP/UD00 信号切换时刻。当前不能把这些剩余差异直接归因为之前的事件侧配对问题。
若仅查看参考幅值超过各自峰值 1% 的主要幅值区间,并同时要求高于近零门槛,质量流量最大逐点相对差为 **0.11559%**,焓流为 **0.10395%**。1% 峰值是用于区分主要幅值和衰减尾部的辅助口径,不替代上面的全样本统计。
门槛敏感性也支持差异集中于小量区间:近零门槛缩小到原来的 0.1 倍,超过 5% 的样本增至流量 79、焓流 29;扩大到 10 倍,两类都为 0。本报告保留原门槛,未靠改门槛消除差异。
## 4. 接近零的区间没有被忽略
| 类别 | 接近零但绝对差超过门槛的样本 | 占该类全部样本 | 这些点上的最大绝对差 |
| --- | ---: | ---: | ---: |
| 质量流量 | 44 | 0.054989% | 7.24440 mg/s |
| 焓流 | 2 | 0.002500% | 8.01591 W |
这些异常涉及 31 个不重复时刻,网格折合约 0.31 s,占全程约 0.62%。多数是零散点,最长连续标记段为 45.80–45.84 s 的 5 个样本,网格折合约 0.05 s。焓流两个点分别在 1.41 s 和 46.18 s,并与流量异常时刻重合。
连同相对差超过 5% 的三个时刻,共涉及 34 个时刻,网格折合约 0.34 s、约 0.68%。**99.32% 的共同网格时刻未触发上述任一筛查条件**,但这不表示误差严格为零。
按运行阶段分组,流量/焓流的主要绝对差仍在初始阶段;后续循环中没有再次出现高于原近零门槛且相对差超过 5% 的点:
| 时间区间,s | 最大流量绝对差,mg/s | 最大焓流绝对差,W | 流量近零异常样本 | 焓流近零异常样本 |
| --- | ---: | ---: | ---: | ---: |
| [0, 1) | 24.2465 | 18.6888 | 2 | 0 |
| [1, 10.8) | 6.5727 | 8.0159 | 9 | 1 |
| [10.8, 21.6) | 0.2172 | 0.02551 | 0 | 0 |
| [21.6, 32.4) | 7.2444 | 0.26117 | 5 | 0 |
| [32.4, 43.2) | 4.5750 | 0.17762 | 8 | 0 |
| [43.2, 50] | 6.3927 | 5.08374 | 20 | 1 |
## 5. 全程幅值与累计影响
以下每一列均独立取该类曲线中的最差值,因此同一行不同列可能来自不同曲线。
| 变量类别 | 最大全程相对 L2 误差 | 最大全程相对 L1 误差 | 最大逐点绝对差 |
| --- | ---: | ---: | ---: |
| 接触力,8 条 | 0.000008651% | 0.000004595% | 1.31240 N |
| 质量流量,16 条 | 0.008566% | 0.013934% | 24.2465 mg/s |
| 焓流,16 条 | 0.007368% | 0.013814% | 18.6888 W |
| 压力,56 条 | 0.00001109% | 0.000004617% | 61.1058 Pa |
| 温度,56 条 | 0.002601% | 0.002407% | 0.389714 K |
| 接触间隙,8 条 | 0.133016% | 0.045482% | 54.7406 nm |
接触间隙的最大逐点相对差为 1.16799%,但对应约 55 nm 的绝对差;因此除近零流量/焓流外,也不应宣称所有变量逐点都小于 1%。其余类别的完整逐曲线结果保存在统计 JSON 中。
质量流量的绝对差积分最大为 **7.82056e−7 kg = 0.782056 mg**;同一条曲线的参考绝对流量积分为 **5.61262 g**,比值 **0.013934%**。所有流量曲线中,任意时刻累计有符号差的最大绝对值为 **0.364845 mg**。
焓流的绝对差积分最大为 **0.491310 J**;该通道参考绝对焓流积分约 **12,521.9 J**。逐条比较误差积分与各自参考积分,最差比例为 **0.013814%**,来自另一条较小幅值通道。所有焓流曲线中,任意时刻累计有符号差的最大绝对值为 **0.307860 J**。
这些结果表明,在已保存输出的尺度上,剩余差异没有表现为显著的累计质量输送或能量输送偏差。
模型中另有异常量级的机械位移/容积输出,例如某位移参考值达到约 1.12e17 m;其绝对差虽大,相对差很小。本报告评价两套实现的一致性,不把这类模型本身的异常物理量级解释为工程工况合理,也不笼统宣称所有量的绝对差都小。
## 6. 不循环工况交叉检查
不循环工况仍然是相同三个时刻、相同三条曲线、相同六个相对差超过 5% 的样本。质量流量/焓流最差全程相对 L2 误差分别为 **0.008262% / 0.007048%**,相对 L1 误差分别为 **0.010307% / 0.010197%**。
接近零的绝对异常少于循环工况:流量 11 个样本,焓流 1 个;加上相对异常,共涉及 13 个不重复时刻,占 5,001 个时刻的 **0.25995%**。这支持主要剩余相对差属于初始衰减尾部,不是循环越多便持续积累的整体偏离。
## 7. 展示用结论与复现
可以在当前基线展示中使用以下表述:
> 在与 Amesim 对齐工况和事件输出阶段后,0–50 s、10 ms 共同输出网格上的主要响应曲线具有良好的数值一致性。质量流量和焓流最差全程相对均方根误差分别约为 0.0086% 和 0.0074%;较大的逐点相对差主要集中于近零衰减尾部,对累计输送量的影响很小。该评价不覆盖共同网格之间的全部极短瞬态。
本次不需要为整体展示而优先修改流量或焓流公式。若后续目标是低流量尾部、微小泄漏或每一事件内部瞬态的精确复现,再针对这些区间增加密集采样与根因定位;现有占比不应被当作这类目标已经通过的证据。
![全程曲线、近零尾部与绝对误差包络](../../test/mql8-curve-coverage-20260917/coverage.png)
- [完整统计:两种工况、所有曲线、区间及异常点](../../test/mql8-curve-coverage-20260917/coverage.json)
- [只读分析脚本](../../tests/manual/analyze_mql8_curve_coverage.py)
- [上一步力差与事件输出定位报告](LSTP主路接入与八路力差定位-2026-09-17.md)
复现统计无需重新仿真:
```powershell
.\.venv-win\Scripts\python.exe -X utf8 tests/manual/analyze_mql8_curve_coverage.py
# 本机已有 matplotlib 的环境可同时生成图:
F:\Anaconda\python.exe -X utf8 tests/manual/analyze_mql8_curve_coverage.py --plots
```
脚本核验:全部输入有限、共同网格完整配对、时间严格递增、积分权重合计 50 s;逐条曲线重算的相对异常数和近零异常数与此前报告完全一致;读取前后的输入 SHA-256 不变。原平台代码与仿真结果未改动。
@@ -0,0 +1,165 @@
# 八路模型正确性初评与展示建议
日期:2026-09-17。代码提交:`1aac2200843009a86066271d3bfb1053bd8d6b2d`。本机 Windows / Python 3.12.3 / GCC 8.1 / SUNDIALS 7.4.0。
## 评价结论
**平台已具备初步展示核心仿真功能的条件,但尚不能表述为“全部结果与 Amesim 一致”或“已完成工程精度验证”。**
本轮重新执行当前 Amesim 模型程序和当前平台代码,共完成六组对照,每组比较 238 条关键曲线。20 项相关自动化回归全部通过。压力、温度、常规行程和质量守恒表现较好;当前仍需处理默认模型配置不一致、循环信号边界不一致,以及小流量/焓流的局部偏差。
原始模型输入和 143 个已跟踪生产文件的 SHA-256 在评估前后均未变化。本轮新增评估脚本、图片和报告,没有修改组件公式、求解器或默认工程。
## 1. 首先发现:当前两份默认模型并非相同工况
| 项目 | 当前平台 JSON | 当前 Amesim AME |
| --- | --- | --- |
| 文件 | `tests/data/test-mql-8-corrected.json` | `tests/data/test_mql.ame` |
| 两个 UD00 信号 | 非循环,`iscyclic=0` | 循环,Amesim 枚举值为 2 |
| 仿真终点 | 10 s | 50 s |
| 输出间隔 | 0.01 s | 0.05 s |
| 误差限数值 | 后端默认 rtol=1e-8 | tolerance=1e-7 |
结构审计得到 **157 个元件、178 条逐端口连接、1,092 项公开参数**;只有两个 UD00 的循环参数不匹配,连接差异为 0。当前 AME 的 SHA-256 为 `0ad9117d88c64c743fcfc41c7b1c5d10854527ab6478cc76ffa092913e52ada3`,与 9 月 14 日旧报告的 `251b2f…` 不同,不能直接复用旧报告作为当前输入的结论。
正式对照在新建 JSON 副本中,仅将这两个循环参数按 Amesim 语义设为 1;再次审计后 **1,092 项参数及全部连接一致**,并完成 1,015 项图纸参数与归档运行参数交叉核验。运行时间、输出间隔、误差限在两边的副本中统一。原文件保持原样。
证据:[原始审计](../../test/evaluation-mql8-20260917/audit-original/audit.json)、[两个参数的对齐记录](../../test/evaluation-mql8-20260917/alignment-changes.json)、[对齐后审计](../../test/evaluation-mql8-20260917/audit-aligned/audit.json)。
## 2. 本轮实际运行与比较口径
| 工况 | 信号 | 时长 | 输出间隔 | 两边误差限数值 | 结果 |
| --- | --- | ---: | ---: | ---: | --- |
| 对齐后的短演示 `default` | 循环 | 10 s | 0.01 s | 1e-8 | 两边完成 |
| 当前模型全程 `full` | 循环 | 50 s | 0.01 s | 1e-8 | 两边完成 |
| 启动细采样 `startup` | 循环 | 0.15 s | 0.0001 s | 1e-8 | 两边完成 |
| 启动容差收紧 `startup-refined` | 循环 | 0.15 s | 0.0001 s | 1e-10 | 两边完成 |
| 最初压缩过程 `volume-startup` | 循环 | 2 μs | 2 ns | 1e-10 | 两边完成 |
| 非循环对照 `noncyclic` | 非循环 | 50 s | 0.01 s | 1e-8 | 两边完成 |
非循环对照使用已保存、重新审计的旧版非循环 AME 与当前默认 JSON,只在副本中统一运行设置;同样重新执行程序,没有读取旧运行曲线代替新结果。其余五组使用当前 AME。
Amesim 端提取源归档内的可执行程序与运行输入后重新运行;没有重新编译 Amesim。除 `.sim` 前五个运行设置外,提取文件逐字节校验不变。平台走 JSON→System XML→组件网络→当前原生 C 编译/运行。平台使用 CVODE BDF,最大步长为 1e30;Amesim 保留其余原运行选项。相同误差限数字不代表两套求解器的误差权重和算法完全相同。
每组映射压力、温度、位移、速度、8 路接触力与间隙、节点焓流/质量流、气室质量/容积/容积功、活塞容积及信号,共 **238 条曲线**。平台完整输出为 **1,784 条**;其余输出检查了有限性,未逐条声明与 Amesim 相符。节点焓流由保存的各支路流量和流入焓按符号重构;Amesim 气室体积功按其保存的压力、连接容积变化率及容积下限条件重构。
单位统一为 SI:Amesim 表压加 101300 Pa,g 转 kg,cm³ 转 m³,L/min 转 m³/s,间隙 mm 转 m。映射来自元件拓扑和端口审计,没有交换元件、拟合时间轴或任意改符号。
**时间比较方法:**在共同规则网格上,若原始保存时刻距名义采样时刻不超过输出间隔的 1e-7,直接读取最近保存值;其他点线性插值。保留原记录的事件侧别,不平均、不平滑尖峰。直接对 `1e17→49000` 的巨大跳变做插值,会因输出时间浮点漂移在 0.81 s 伪造 57888 等尾值。本轮已用原始样本消除这一比较器伪差;原始结果、原始时刻及朴素插值诊断仍保留。
所有“最大差”均指所述采样网格,不是连续时间极值。事件点不从主统计中删除;另外的事件外指标仅用于定位问题。
## 3. 主要结果
| 指标 | 50 s 循环全程,10 ms 采样 | 启动 0.15 s,0.1 ms 采样 |
| --- | ---: | ---: |
| 最大绝对压力差 | **65.687 Pa** | **4,641.503 Pa** |
| 最大温差 | **0.3954 K** | **0.4575 K** |
| 压力最大逐点相对差 | 0.000319% | 0.02022% |
| 温度最大逐点相对差 | 0.004197% | 0.007854% |
| 节点焓流最大绝对差 | 141.920 W | 27,064.123 W |
| 节点质量流最大绝对差 | 1.3268e-5 kg/s | 0.390305 kg/s,位于 0.04 s 阶跃点 |
| 8 路接触力最大绝对差 | **1,285,489.483 N**,32.4 s | 105.579 N |
| 普通质量块最大位移差,排除特殊 #9 | 1.2930e-8 m | 7.4362e-13 m |
表中各指标取该类全部映射曲线中的最大值,最大绝对差与最大相对差可能属于不同曲线和时刻。
![主要曲线和误差](../../test/evaluation-mql8-20260917/comparison.png)
### 完成状态与基本物理检查
- 六组均完成指定终点;所有读取的结果数值有限,输出时间有序。50 s 循环运行到达 50 s,没有再出现早期的 10.8 s 提前退出。
- 56 个气体质量状态合计初值约 **5.566893 kg**。六组中最大总质量漂移为 **4.3521e-14 kg**,相对漂移约 **7.82e-15**。
- 所检查的保存结果中气体质量、绝对压力和温度均为正。质量守恒通过不等于能量及所有本构关系均已验证。
- 20 项回归全部通过,覆盖默认非循环八路 10 s 完整运行、节点能量与容积传递、容积功和下限、氦气物性与反解、UD00 信号链路、压力单位、CSV 导出。首次运行中的部分 GCC 启动失败经现有重试机制恢复;最终没有测试失败。
### 容积下限的快速过程
普通 10 ms 采样看不到最初快速压缩,不能只拿恒定容积说明实现正确。2 ns 采样下,双方第 15 个气室都在 32 ns 的首个下限采样点达到 **0.15 L**,此前初值为 15 L。八个气室最大容积差约 **1.716e-13 m³**;体积功率最大绝对差约 **5.306e5 W**,最大逐点相对差约 **4.786e-6%**,参考功率本身约为 1e13 W 量级。
![容积与体积功率](../../test/evaluation-mql8-20260917/volume-startup.png)
该八路文件中的活塞直接连接气室;经节点传递容积与非零预设容积变化率的覆盖来自本轮通过的专项测试,不能仅由这张图推断。
## 4. 仍不能验收为全部一致的部分
### 4.1 循环事件与采样边界:已确认存在实现缺陷
当前原生信号函数分别通过 `fmod` 判断周期位置、通过周期乘法定位事件。本轮直接编译调用当前源文件,得到:
| 事件函数返回时刻 | 该时刻信号求值 | 应进入的阶段值 | 下一个 double 时刻的值 |
| --- | ---: | ---: | ---: |
| 44 s | 1e17 | 49000 | 49000 |
| 54 s,补充探针 | 49000 | 1e17 | 1e17 |
这证明同一数值时刻上的事件定位和信号求值不一致;不需要积分器即可复现。定位文件:[signal.c](../../native/components/modules/signal.c)。证据:[探针输出](../../test/evaluation-mql8-20260917/signal-probe/signal-boundaries.csv)。
32.4 s 的大接触力差还包含另一层问题:平台保存时间为 `32.4`,此时 UD00 为 49000;Amesim 保存时间为 `32.40000000000221`,UD00 为 1e17。第 2 路接触力分别为 **468662.661 N** 和 **1754152.144 N**。显示相同的小数时间并不保证处于跳变的同一侧,不能把该差额直接认定为接触力本构公式错误。
仅作定位:保留 t≥0.1 s 并避开各信号边界前后 0.02 s 后,8 路接触力最大差为 **0.276 N**,普通质量块速度最大差约 **1.35e-7 m/s**。这不能替代包括事件点的整段验收。
此外,平台原始额外事件样本在约 `32.40035510065 s` 保存了约 **−2.084e14 N** 的接触力;共同 10 ms 网格未包含这个样本,Amesim 本轮保存点也未覆盖同一时刻。因此本轮无法对该窄峰完成同侧、同分辨率定量验收,不能把共同网格最大差当作全部原始事件样本的上界。
### 4.2 小流量与焓流:绝对差小,但局部相对差仍明显
沿用此前报告的诊断分界:焓流参考绝对值超过 1 W、质量流超过 1e-6 kg/s 时计算逐点相对误差;参考更接近零时单列原值和绝对差。**±5% 是本轮筛查线,不是用户批准的工程公差。** 其他物理量分界及分界乘/除 10 的敏感性保存在指标文件中。
| 50 s 工况中的样本 | Amesim | 当前平台 | 绝对差 | 相对差 |
| --- | ---: | ---: | ---: | ---: |
| P4NODE2_1 参考焓流,0.32 s | −2.375850 W | −1.641047 W | 0.734803 W | −30.928% |
| P4NODE2_2 参考质量流,0.32 s | 3.260972e-6 kg/s | 8.351735e-6 kg/s | 5.090763e-6 kg/s | +156.112% |
50 s 循环共同网格中,明确非零的超 5% 样本为 **61 个**:接触力 24、速度 2、信号 5、焓流 7、质量流 23。其中 **30 个位于信号事件点之外**,全部属于上述焓流/质量流;不能将所有差异解释成事件侧别。参考近零的异常另计,没有因为不算相对误差而自动通过。
启动段 0.04 s 的质量流约 0.390305 kg/s 差异位于阶跃点;收紧双方误差限后,该点差异消失,启动质量流最大差降为 0.0009686 kg/s。另一方面,启动焓流过零附近仍有约 0.32 W 差异。收紧容差后最大压差仍约 **4.551 kPa**,最大温差为 **0.5214 K**,说明剩余偏差没有简单随误差限收紧消失。
本轮没有证明管阻公式、近零流量闭合或积分误差中的哪一项是剩余偏差的唯一原因。应在相同状态输入下逐项比较,再决定修改位置。
![事件与小量差异](../../test/evaluation-mql8-20260917/diagnostics.png)
### 4.3 输入本身属于极端工况,数值对齐不等于物理有效
模型包含 1e17 量级激励;无限位、无摩擦的特殊质量块 #9 在循环 50 s 时位移约 **1.12e17 m**。其最大绝对位移差约 **3.056e10 m**,最大逐点相对差约 **9.78e-5%**。该量及相连活塞的巨大几何容积必须单独列出,不能与普通行程的绝对误差混合评价。
启动细采样的气体温度达到约 **11759 K**;当前物性诊断也报告状态方程有效上限 1500 K 被越过,并报告内部理想热量学查询约 39.44 K 低于其 200 K 下限。39.44 K 是内部物性查询温度,不是初始气室温度。这些提示来自本次实际运行。
因此该输入适合展示复杂网络、刚性系统、事件和诊断能力;若用于展示某个真实设备的预测精度,需要另行明确合理的载荷、约束和物性适用工况,并以实验或可靠工程基准验证。
## 5. 建议修改优先级与验收方式
| 优先级 | 修改对象 | 依据 | 修改后应如何验收 |
| --- | --- | --- | --- |
| P1,展示前 | 默认 AME/JSON 的工况管理 | 两个循环开关、终点、采样间隔不同 | 明确主展示工况;保存成对的 AME/JSON 与哈希;参数、端口和运行设置审计一致 |
| P1,正确性 | UD00 的阶段判定、事件定位和输出侧别 | 当前 44 s/54 s 探针直接失败;循环接触力事件点差异大 | 为所有边界检查前一个 double、边界、本身之后一个 double;约定事件前/后输出;比较事件时刻、同侧力与速度及窄窗口原始样本 |
| P2,精度 | 启动残差与小流量/焓流 | 事件之外仍有偏差,收紧容差后未完全消失 | 先做同状态物性、管流、节点输入输出探针;再做分辨率/容差收敛;同时报告绝对误差和非零相对误差 |
| P2,展示模型 | 极端载荷、自由质量块与物性适用域 | 巨大位移、超温、纳秒级压缩 | 保留当前模型作为压力测试;另建有合理量级和约束的展示工况,重新对齐两平台,不能仅缩坐标轴隐藏异常 |
现有结果不足以支持“先更换积分器”或“继续优化求解器即可修复曲线差异”。已证明的边界缺陷和输入一致性应优先处理。
## 6. 可用于初步展示的内容
建议展示顺序为:加载八路工程 → 查看元件、连接与信号设置 → 运行 → 叠加两平台压力/温度/普通行程 → 展示误差曲线 → 查看物性诊断与事件差异 → 导出结果。
可直接导入的评估副本:[10 s 对齐工程](../../test/evaluation-mql8-20260917/default/platform.json)、[50 s 循环工程](../../test/evaluation-mql8-20260917/full/platform.json)。两者已与当前 AME 的物理输入对齐。
可用表述:“已完成 157 元件、132 状态八路网络的原生求解及结果输出,对 238 条关键曲线进行了 Amesim 对照;主要连续状态吻合较好,事件边界和小流量精度仍在完善。”
本轮没有执行真实浏览器中的拖拽、导入按钮、持久化恢复、绘图及取消全流程,不将后端测试通过等同于前端展示流程已经验收。单次 50 s 循环平台求解约 12.41 s,Amesim 完整进程约 7.59 s;计时边界不同且未重复测量,**不据此计算性能优劣比**。
## 7. 交付与复现
全部本机证据在 `test/evaluation-mql8-20260917/`,该目录按仓库规则被 Git 忽略;报告及四个脚本为新增可跟踪文件。
- [总指标与运行状态](../../test/evaluation-mql8-20260917/summary.json)、[输入/代码哈希](../../test/evaluation-mql8-20260917/manifest.json)、[评估结束哈希核验](../../test/evaluation-mql8-20260917/source-verification.json)。
- [1,428 条分工况曲线指标 CSV](../../test/evaluation-mql8-20260917/curve-errors.csv)、[18 条展示变量的双平台曲线 CSV](../../test/evaluation-mql8-20260917/showcase-curves.csv)。每个工况另有完整 `curves.npz`、`comparison.json`、映射、原始结果、日志和事件邻近样本。
- [20 项回归日志](../../test/evaluation-mql8-20260917/regression.log)、[信号边界探针](../../test/evaluation-mql8-20260917/signal-probe/signal-boundaries.csv)、[采样匹配检查](../../test/evaluation-mql8-20260917/sampling-verification.json)。
- [主评估脚本](../../tests/manual/evaluate_mql8_correctness.py)、[曲线映射](../../tests/manual/mql8_comparison.py)、[边界探针脚本](../../tests/manual/probe_mql8_signal_boundaries.py)、[绘图脚本](../../tests/manual/plot_mql8_evaluation.py)。
从仓库根目录使用新的输出目录重跑当前循环模型五组对照:
```powershell
.\.venv-win\Scripts\python.exe tests/manual/evaluate_mql8_correctness.py --output test/evaluation-mql8-rerun --ame-home F:/AMESim2404/Amesim --align-cyclic-from-ame
.\.venv-win\Scripts\python.exe tests/manual/probe_mql8_signal_boundaries.py --output test/evaluation-mql8-rerun/signal-probe
```
可选追加 `--noncyclic-ame test/node-fixes-amesim-20260914/test_mql.ame --noncyclic-project tests/data/test-mql-8-corrected.json`,重跑第六组非循环对照;该历史副本只存在于当前工作站证据目录。绘图脚本需要 Matplotlib,本轮使用已安装该库的 `F:/Anaconda/python.exe`。主评估脚本仅需项目既有运行依赖与 NumPy。
@@ -0,0 +1,210 @@
# 八路模型计算效率与网页阶段计时(2026-09-11)
本轮以 `808c484` 的正式代码和 [test-mql-8-corrected.json](../../tests/data/test-mql-8-corrected.json) 为主案例。八路在当前默认精度下完整运行,未退回四路。本轮增加独立诊断脚本和运行记录,没有修改生产算法、工程参数或输出采样。后续遵循 [八路优先的优化验证约定](../standard/optimization-benchmark-model.md)。
## 运行范围与可复现条件
- 工程 SHA256:`670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288`;CLI XML SHA256:`2804b33f04cabb3c10fcc3cc0843c35de17efbe1b6ee5e5e821edd9a5515a23d`。对应 AME 为 `tests/data/test_mql.ame`,SHA256 `1ff0ea4284b9248260eeceb8b27cd0bc14dbccb43554ab8c0b49900d0b944c3d`。
- 157 元件、178 条连接、132 连续状态;返回 1784 个变量、1002 个原始采样点(含事件点),仿真 0~10 s,输出间隔 0.01 s。
- CVODE BDF / SUNDIALS 7.4.0,`rtol=1e-8`,`max_step=1e30`;自动首步。实际绝对误差限按状态生成:质量 `1e-14 kg`、内能 `1e-8 J`、速度 `1e-12 m/s`、位移 `1e-12 m`,并非对所有状态统一使用配置对象中的 `atol=1e-8`。数值雅可比、稠密线性求解和事件处理策略保持生产设置。
- Linux x86_64 虚拟机,Intel Xeon Silver 4210R @ 2.40 GHz,16 逻辑 CPU、约 30 GiB 内存;GCC 13.3.0、Python 3.12、Node 24.18.0、Chromium 151.0.7922.34。原生进程主要为单核计算,环境与构建哈希保存在原始记录。
- 各组预热一次、正式运行三次;中位数与范围单列。三算法组交错执行;网页对照组与分阶段插桩组串行执行,不同时运行诊断求解、绘图或大编译。样本数较少,组间百分之几的差异不作为显著优化结论。
## 管流算法效率与触顶情况
三算法使用同一 corrected 八路、物性复用实现和全系统积分精度。固定点版本仅在明确的 `5d5a2e1` 基线上恢复原半步松弛管流循环,其余生产数值逻辑保持一致;不是运行旧版整个应用。前次牛顿取自该提交,当前保护牛顿取自本轮正式内核。此组没有逐次调用计数插桩。
| 算法 | 纯求解中位数(范围)/ s | 进程全程中位数 / s | RHS 次数 | 接受 / 拒绝步 |
|---|---:|---:|---:|---:|
| 旧固定点,16 / 64 轮上限 | 12.0976(11.9926~12.1511) | 14.2514 | 119058 | 9647 / 679 |
| 前次牛顿 | 5.8183(5.7829~5.8944) | 7.9144 | 74265 | 6974 / 454 |
| 当前保护牛顿+二分回退 | 5.9138(5.9115~5.9323) | 7.9698 | 74265 | 6974 / 454 |
相对旧固定点,当前纯求解时间减少 **51.12%**、速度约 **2.05 倍**,进程全程减少 **44.08%**,RHS 次数减少 **37.62%**。相对前次牛顿,本组耗时增加约 1.64%,不能宣称保护机制带来额外提速;两版牛顿的完整采样、最终输出和状态逐值相同。
另用未经本轮插桩的正式构建独立运行一次预热、三次正式检查:纯求解中位数 **5.7972 s**(5.7897~5.8214),进程全程 **7.8504 s**(7.8437~7.8685),均完成 10 s;每次 RHS 74265、接受 6974、拒绝 454、雅可比 475、线性分解 1656、状态转换 1、求解器启动 4。该组构建缓存命中,输入准备 0.1493 s、构建/缓存核验 0.03294 s;这些准备时间在三个进程运行前发生一次,不包含在纯求解时间内。单独组的 5.80 s 不与上一组旧算法拼接计算加速比。
为区分算法本身和积分轨迹变化,另对三版内核进行独立计数诊断:先各自跑完整八路,再将当前轨迹的 **3,081,320 组实际管阻方程输入全部原样重放**给三个版本,不抽样、不去重。诊断耗时不用于上述性能比较。
| 同一组管阻输入的计数 | 旧固定点 | 前次牛顿 | 当前保护牛顿 |
|---|---:|---:|---:|
| 解析返回 / 迭代求解 | 0 / 3081320 | 2370685 / 710635 | 相同 |
| 累计循环轮数 | 49431317 | 3081555 | 相同 |
| 单次最多轮数 | 64 | 6 | 6 |
| 进入最后允许轮次 | 3037792(98.587%) | 0 | 0 |
| 耗尽额度且未满足各自停止条件 | 3010570(97.704%) | 0 | 0 |
| 实际返回值的统一相对残差 > 1e-9 | 3072455 | 0 | 0 |
| 二分回退 / 非有限返回 | 0 / 0 | 0 / 0 | 0 / 0 |
牛顿累计轮数减少 **93.766%**。710635 次迭代调用分别用 3 轮 74290 次、4 轮 361601 次、5 轮 236183 次、6 轮 38561 次。当前案例没有触发二分或失败路径,所以它验证了正常输入的效率和一致性,不能代替极端状态下的保护测试,也不能证明任意系统必然收敛。
“进入最后一轮”与“耗尽仍未满足条件”分开统计:固定点 PNL00R 的 64 轮分支有 1638 次进入最后一轮、但没有耗尽失败;真正耗尽的都来自其他管路的 16 轮分支。旧循环满足自身停止条件的 70750 次中,61885 次仍不满足当前统一方程残差门槛;相对残差超标不直接代表同等数量的大绝对流量误差。
各自完整积分轨迹上,旧固定点实际管阻调用 5041645 次、耗尽 4937544 次;两版牛顿均为 3081320 次、耗尽 0 次。零压差 247634 次和 PNL00R 直接层流 235766 次另计,未混入混合摩擦方程残差。当前插桩结果与独立正式构建的 `series/final/finalState` 逐值相同。
该八路在积分 RHS 中请求管流缓存 2970600 次,命中 **0**;另有 594120 次直接调用。物性复用与这项管流缓存不同;此结果只说明本例可进一步评估缓存查找的成本,不能直接推广到其他拓扑或据此删除缓存。
## 真实网页主流程与额外操作成本
使用正式 Vite 静态构建,经 Playwright 驱动 Chromium;无模拟 API、预制结果注入或重复读取响应体。独立对照服务 `127.0.0.1:8013` 不加载诊断插桩;分阶段服务 `127.0.0.1:8012` 仅在隔离 C 主程序中增加稀疏时钟,并包装实际后台函数。两个服务的求解器和元件文件逐字节相同,前端构建集合 SHA256 同为 `2faa6ce32ee9997187870d64af2bc235a830eb7b8def7749ddcb0eaeeed4831e`;计时脚本 SHA 也相同。
每组预热一次、正式三次。每轮从公开 JSON 导入入口加载同一 corrected 文件,公开导出工程后逐项验证全部参数、端点和仿真设置,然后点击运行;导入与核查不包含在点击运行计时中。每轮继续打开结果页、选择指定管路温度、下载 CSV 与结果文件,再刷新并核对恢复结果。结果页和温度曲线立即打开,允许浏览器 IndexedDB 异步保存与查看过程自然重叠。
下表单位为秒,格式为 **中位数(最小~最大)**。对照组是常规网页成本的主记录;“首次绘制机会”指可见 DOM 后经过两次 requestAnimationFrame,未测量 GPU 完成时间。
| 阶段 | 对照组 / s | 分阶段组 / s |
|---|---:|---:|
| 工程导入 change → 画布首次绘制机会 | 0.2527(0.2273~0.2862) | 0.2312(0.2251~0.2411) |
| 点击运行 → 完成状态 DOM | 9.8716(9.7697~9.8826) | 9.7777(9.7358~9.8408) |
| 点击运行 → 完成状态首次绘制机会 | 9.8989(9.7840~9.9104) | 9.8005(9.7596~9.8660) |
| 点击运行 → 结果页首次绘制机会(自动切页) | 10.1516(10.0600~10.1834) | 10.0589(10.0305~10.1056) |
| 点击运行 → 温度曲线首次绘制机会(自动选曲线) | 10.6899(10.5762~10.7431) | 10.6160(10.5916~10.6433) |
| 点击运行 → 观察到 IndexedDB 保存完成指针 | 12.2922(11.8303~12.4076) | 12.4344(11.5322~12.4967) |
| 进入结果页 → 首次绘制机会 | 0.1910(0.1893~0.1980) | 0.1927(0.1832~0.1946) |
| 选择温度 → 曲线首次绘制机会 | 0.0269(0.0262~0.0297) | 0.0276(0.0233~0.0293) |
| CSV 点击 → 下载并保存 | 5.6198(5.5717~5.6381) | 5.5732(5.4253~5.6625) |
| 结果文件点击 → 下载并保存 | 1.2308(1.2265~1.3213) | 1.2921(1.1552~1.5145) |
| 刷新导航 → 已恢复结果首次绘制机会 | 0.5885(0.5406~0.6592) | 0.5487(0.5074~0.5630) |
无插桩对照组的 C 纯求解中位数为 **6.0653 s**、进程全程 **7.9499 s**;独立 CLI 的 5.7972 s 属另一运行环境下的分组结果,不把差值直接归因于网页某项 CPU 工作。分阶段组点击到完成绘制为 9.8005 s,对照为 9.8989 s,相差约 -0.99%;本组三次样本未见明显端到端插桩增量,也不能把该负差当作加速。持久化和导出波动更大,逐次原始值均保留。
初次导航只记录每组一次:HTML `loadEventEnd` 对照 0.0971 s、分阶段 0.1086 s;Playwright 观察到工程导入控件时分别为 0.5628 / 0.6064 s,包含自动化观察与调度延迟,不是严格的首次可交互时间。首次工程导入到两帧观察点,对照预热为 0.3716 s;三次重复导入中位为 0.2527 s。页面加载、导入、仿真、查看与导出分别记录,未用自动化操作间隙拼成一个“纯网页耗时”。
“DOM 稳定”另取 120 ms 无变动再等两帧:结果页中位 0.3710 s、温度曲线 0.1736 s、刷新恢复 0.7575 s(对照)。这包含人为安静窗口,不能把它全称为渲染工作。下载完成包含 Playwright 通知、`saveAs` 与文件系统开销,不等于浏览器内序列化 CPU 时间。
## 后台内部各阶段
用每个请求的 `X-Simulation-Id` 将网页与后台记录一一关联。下表为分阶段组正式三次的秒数;所列主要阶段在单个请求内依次发生。各阶段分别取中位数,故其相加不保证等于总耗时中位数。
| 阶段 | 中位数(最小~最大)/ s |
|---|---:|
| XML 校验 | 0.0231(0.0222~0.0246) |
| 网络编译 | 0.0364(0.0361~0.0465) |
| 系统 C 代码生成 | 0.0542(0.0528~0.0548) |
| 构建缓存核验(3 次均命中) | 0.0321(0.0308~0.0331) |
| C 参数准备 | 0.0000503 |
| C 初始化与初始采样 | 0.0001(0.0001~0.0001) |
| C 纯积分 | 6.0855(6.0414~6.0994) |
| C 最终采样/状态处理 | 0.0000029 |
| C 最终及全采样输出投影 | 0.0855(0.0837~0.0861) |
| C 结果 JSON 格式化并写文件 | 1.1707(1.1656~1.1945) |
| Python 读文件并 UTF-8 解码 | 0.0360(0.0321~0.0429) |
| Python 解析原生 JSON | 0.5513(0.5429~0.5588) |
| 进程启动/等待/退出等未细分余量 | 0.0029(0.0025~0.0029) |
| 结果接口元数据组装 | 0.0330(0.0245~0.1175) |
| 最终 NDJSON 结果事件序列化 | 1.1928(1.1862~1.2106) |
| 序列化结束 → ASGI 最后响应体发送完成 | 0.1715(0.1489~0.1808) |
| HTTP 其余编排/调度余量 | 0.0124(0.0114~0.0155) |
后台 HTTP 全程中位 **9.5123 s**(9.4458~9.5845)。内部 C main 全程 7.3380 s,Python 原生执行与结果读取包装 7.9194 s,仿真 worker 全程 8.1380 s;这些是包含上表子阶段的父区间,**不能再次相加**。ASGI `send` 的累计 await 为 0.0701 s,可与生成结果的后台任务重叠,亦不代表客户端网络总时间。诊断产物保留另耗约 0.00066 s,已包含在原生执行包装中。
约 **2.95 s** 用于 C 的 JSON 写出、Python 读取解析及 HTTP 结果 JSON 序列化,约为后台总程的 31%;相比之下输出物理量投影仅约 0.086 s。这里先定位到文本结果处理成本,并未将整个差额笼统归为物性或求根。
正式网页八次运行均复用已构建的程序。唯一冷构建来自功能预检:编译/核验 **3.4154 s**,构建键 `bcb85b5dce729fe9b7a503919b720be03950ff31839877edd344377d4ee9cfc0` 与后续完整八路分阶段组相同;预检将运行终点设为 0.02 s,只改变运行时选项,未改变编译出的系统程序。这个单次冷编译时间可供首次运行成本参考,但本轮没有测得完整 0~10 s 的冷启动网页总时间,不把预检总时间冒用为正式结果。
## 浏览器内部、持久化和导出细分
分阶段组保留了原始 `fetch`、流读取、解码、JSON.parse、IndexedDB 事务和下载锚点的时间戳,没有克隆响应或重复解析。
| 观察区间 | 中位数 / s | 含义与边界 |
|---|---:|---|
| 点击运行 → 调用 fetch | 0.0234 | 含前端模型检查、快照/XML 和提交准备,未单独计 XML CPU |
| fetch → 收到响应头 | 0.0298 | 收到流式响应头,不表示仿真已经算完 |
| 响应头 → 读到流 EOF | 9.6765 | 包含后台计算、传输、消费者处理与调度 |
| 累计未完成的流 read 等待 | 9.4636 | 含后台结果尚未生成的等待,不能称为纯网络时间 |
| 最终结果的原始 JSON.parse | 0.1125 | 同步解析仅执行一次 |
| 全部流消息 JSON.parse | 0.1139 | 已含上一行 |
| UTF-8 decode 累计 | 0.0280 | 原始解码调用,不包含分片扫描/拼接 |
| 最终 JSON.parse 结束 → 完成 DOM | 0.0430 | 含应用处理和 React 调度,未独立测 React CPU |
| 结果解析结束 → IndexedDB 完成指针发布 | 2.6279 | 含批处理、事务等待与调度,可与看图重叠 |
| 首次保存事务 → 完成指针发布 | 2.5952 | 仍是异步区间;不是磁盘或主线程独占时间 |
| CSV 点击 → fetch | 0.2641 | 包含将整份结果再次 JSON.stringify 的提交准备 |
| CSV fetch → 响应头 | 4.6968 | 含浏览器请求准备、后台处理及网络/调度 |
| CSV 点击 → Blob 下载锚点 | 5.0143 | 浏览器已经取得并准备好下载数据 |
| 结果文件点击 → Blob 下载锚点 | 0.6113 | 包含结果文件序列化及 Blob 准备 |
最终流式响应约 **33.73 MB**;原生结果 JSON 为 32,725,293 字节,CSV 为 32,346,795 字节,结果文件约 33.85 MB(十进制 MB)。本次是本机回环 HTTP,未模拟远端带宽、TLS 或网络延迟;不能把这些网络相关耗时直接推广到远端部署。
CSV 后台另有独立请求,按请求顺序和时间戳关联,正式三次分段如下:
| CSV 后台阶段 | 中位数(最小~最大)/ s |
|---|---:|
| ASGI 开始 → 收完请求体 | 0.0262(0.0247~0.0285) |
| 收完请求体 → CSV 函数开始 | 0.7072(0.7010~0.7138) |
| CSV 校验、逐单元格式化与文本组装 | 2.3684(2.3616~2.3885) |
| CSV 函数结束 → HTTP 完成 | 0.1252(0.1221~0.1264) |
| CSV HTTP 全程(父区间) | 3.2264(3.2177~3.2494) |
“收完请求体→CSV 函数”约 0.707 s,涵盖路由层 JSON 解码、模型校验及调度,未再将每项 CPU 时间拆开。CSV 浏览器 fetch 到响应头比 ASGI 全程更长;跨进程请求准备、发送及调度的余量未进一步归因。CSV 的完整结果往返及文本组装成本已经实测,后续可据此单独优化。
## 本轮验证与下一步优先级
两组共 **8 次**网页运行均完成 10 s,且无 `pageerror`。每轮公开导出的工程参数与连接全部匹配固定输入;结果页、精确单位 K 的温度曲线、CSV、`.simresult` 下载和刷新恢复均通过。8 份 CSV 均为 1785 列 × 1002 数据行,合计 **14,308,560 个数值单元**与独立原生结果精确相等;全部 `series/final` 也精确相等,刷新前后完整结果一致。4 份后台稀疏插桩原生文件的 `series/final/finalState` 均与生产文件逐值相同。56 个唯一气体质量状态用 `math.fsum` 汇总,初始总质量 5.566893015 kg,最大漂移 **1.15463e-14 kg**;所有保存数值有限。
初次自动化预检曾误用旧控制台 CSS,另一次重复组在刷新后假定建模工程自动恢复而未提交新请求。这两类脚本失败保存在 `browser-stage-smoke/` 与 `browser-control-failed/`,明确排除正式统计。最终脚本使用当前 DockedSimulationConsole、每轮重新导入并核对工程,两个完整正式组均通过。0.02 s 的功能预检只证明操作流程,不混入八路完整计算效率。
依据本轮数据,后续优先考虑:
1. **先追踪八路早期压力/温度差和碰撞事件输出。** 参数对齐与局部求根成功不能代替整条曲线正确性;保留八路主案例,继续固定当前输入与精度。
2. **减少结果反复文本化。** 当前 C 写 JSON、Python 再解析、HTTP 再序列化合计约 2.95 s;优化这条路径时仍需保留全部变量、采样、浮点数值与输出合同。
3. **单独优化 CSV 与浏览器持久化。** CSV 全量回传后台并组装文本明显影响使用时间;持久化约 2 s 以上、且与查看曲线重叠,适合分别评估批大小、复制与调度,不能把保存等待计为绘图耗时。
4. **继续剖析 RHS/雅可比等求解成本。** 管流触顶已经为零,当前再削减迭代上限没有实测依据。可先评估本例零命中的管流缓存和 74265 次 RHS 的组成;更改雅可比或缓存策略后仍按同一八路、同精度和完整输出复核。
![八路算法与网页阶段耗时](assets/2026-09-11/mql8-efficiency-20260911-stage-costs.png)
图中三算法与浏览器操作取三次中位数;后台堆叠图选 HTTP 总耗时居中的单个请求,所有片段可相加。浏览器各操作区间不能相加;完整可缩放图为 [SVG](assets/2026-09-11/mql8-efficiency-20260911-stage-costs.svg)。
## 与当前 Amesim 归档的对照
本次找到可用的八路保存曲线。独立核对当前 AME 内的图纸、参数数据、编译 C、状态与变量索引及初值,未发现此前四路归档中那种图纸/缓存错配;完整说明见 [当前八路归档与曲线核查](test-mql-8当前AME归档与完整曲线核查-2026-09-11.md)。使用的是当前 AME SHA,未套用绑定另一归档的历史八路冻结基准。
选取全部 132 个连续状态对应的压力、温度、位移与速度,另加阀流量、接触力、限位力和间隙,共 **176 条曲线**;明确映射表压/绝压、SI 单位与接口方向,分别在双方原始保存网格上线性插值对照,不删去原生事件点。下表为 AME 保存网格上各类最差曲线的最大差及该曲线 RMS,不是把全类所有点混算的 RMS。
| 量 | 最大绝对差 | 对应曲线 RMS |
|---|---:|---:|
| 压力 | 163395.189 Pa | 7170.441 Pa |
| 温度 | 105.215279 K | 5.829065 K |
| 阀质量流量 | 0.002990901 kg/s | 0.000114014 kg/s |
| 接触力(AME 保存网格) | 5131.971 N | 225.218 N |
最大压力、温度和此表接触力差在 0.01 s,不能全归因于后续碰撞采样时刻不一致。原生另有 `t=0.9833956321732664 s` 的原始事件点,8 个接触力约 **4.035×10¹¹ N**;AME 只保存相邻 0.98 / 0.99 s 点,无法据其线性插值判断 Amesim 精确事件时刻或是否也有极窄峰。当前 AME 的 UD00 输入含 `1e17` 初始信号,双方公共质量元件都出现极大位移/速度,也不能把“数值有限”当成物理合理性证明。本轮未修改这些 AME 参数。
因此,**八路运行与计时记录完成,不等于八路曲线一致性验收通过**。目前没有八路专用接受阈值,也缺少足够密的 AME 事件输出;保留差异供后续核查,不为通过而放宽门槛或换四路。
Amesim **耗时对比跳过**:当前环境没有可调用的 Amesim,归档也没有可信 CPU/墙钟记录;`.ameperf` 中的时间是仿真事件时刻。归档名义容差为 `1e-7`,本轮为 `1e-8`,也不能冒称同精度速度比较。没有重跑 Amesim,没有从 10 s 仿真终点或文件时间戳推算耗时。
## 记录、脚本与复现
所有输入快照、编译程序、完整曲线、下载文件和浏览器截图都放在 Git 忽略目录 `test/`,环境仍在 `.venv/native/`,未纳入 Git。报告引用的相对路径在仓库本地可打开;这些大型运行产物不随 Git 克隆传递。
- [三算法原始基准](../../test/mql8-efficiency-20260911/native-benchmark/summary.json)、[三算法汇总](../../test/mql8-efficiency-20260911/native-summary.json)、[正式构建独立基准](../../test/mql8-efficiency-20260911/native-production/summary.json)。
- [完整管阻计数与同输入重放](../../test/solver-newton-20260911/pipe-iteration-profile-8/summary.json)、[插桩与生产逐值核对](../../test/solver-newton-20260911/pipe-iteration-profile-8/production-parity.json)。
- [阶段汇总 JSON](../../test/mql8-efficiency-20260911/stage-summary.json)、[阶段汇总 CSV](../../test/mql8-efficiency-20260911/stage-summary.csv)、[网页对照组](../../test/mql8-efficiency-20260911/browser-control/summary.json)、[网页分阶段组](../../test/mql8-efficiency-20260911/browser-profiled/summary.json)。
- [网页与 CSV 全量相等核查](../../test/mql8-efficiency-20260911/equality.json)、[后台插桩逐值核查](../../test/mql8-efficiency-20260911/backend-profile-production-parity.json)、[质量守恒](../../test/mql8-efficiency-20260911/mass-conservation.json);每个后台请求在 `test/mql8-efficiency-20260911/backend-profile/requests/<simulationId>/` 保存执行 XML、原生 JSON、日志和阶段时间。
- [后台分阶段诊断脚本](../../tests/manual/backend_stage_profile.py)、[真实浏览器计时脚本](../../tests/manual/browser_stage_profile.mjs)、[管阻计数脚本](../../tests/manual/profile_pipe_iterations.py)。[网页结果复核脚本](../../tests/manual/compare_browser_stage_outputs.py)。这些都是手动诊断入口,生产服务无需加载。
```bash
# 八路三算法基准;目录必须为新目录
.venv/bin/python tests/manual/benchmark_native_pipe_solver.py \
tests/data/test-mql-8-corrected.json --output-dir test/new-mql8-variants --runs 3
# 正式构建独立基准
.venv/bin/python -m app.simulation.native_codegen tests/data/test-mql-8-corrected.json \
--output-dir test/new-mql8-production --runs 3 --timeout 120
# 后台阶段记录服务;需要已有 frontend/dist,选空闲端口
.venv/bin/python tests/manual/backend_stage_profile.py \
--output-dir test/new-mql8-backend --port 8012
# 另一个终端:--plain 为无诊断插桩的对照服务
.venv/bin/python tests/manual/backend_stage_profile.py --plain \
--output-dir test/new-mql8-control --port 8013 \
--frontend-dist test/new-mql8-backend/frontend
# Chromium 所需依赖仅使用仓库现有忽略环境
export LD_LIBRARY_PATH="$PWD/.venv/native/browser-libs/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
.tools/node-v24.18.0-linux-x64/bin/node tests/manual/browser_stage_profile.mjs \
--output test/new-mql8-browser-control --url http://127.0.0.1:8013 --mode control --runs 3
.tools/node-v24.18.0-linux-x64/bin/node tests/manual/browser_stage_profile.mjs \
--output test/new-mql8-browser-profiled --url http://127.0.0.1:8012 --mode profiled --runs 3
```
Loaded 100 of 486 files, more files were not shown because too many files have changed in this diff. Show more