Compare commits
2
Commits
4e0b9fd8cc
...
5332a788f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5332a788f3 | ||
|
|
57b459bc72 |
No files matched your search
+60
-9
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Iterator, Mapping
|
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
import csv
|
import csv
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -21,6 +22,8 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
|||||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
|
from app.simulation.performance import performance_span, profile_phase, profile_run
|
||||||
|
from app.simulation.property_cache import property_cache_run
|
||||||
from app.system_xml import (
|
from app.system_xml import (
|
||||||
SystemXmlDocument,
|
SystemXmlDocument,
|
||||||
SystemXmlValidationReport,
|
SystemXmlValidationReport,
|
||||||
@@ -34,7 +37,19 @@ if TYPE_CHECKING:
|
|||||||
from app.simulation.systems.network import SimulationNetwork
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="System Simulation ReactFlow App")
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||||
|
from app.simulation.warmup import warm_up_simulation_runtime
|
||||||
|
|
||||||
|
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="System Simulation ReactFlow App",
|
||||||
|
lifespan=_app_lifespan,
|
||||||
|
)
|
||||||
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||||
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
||||||
SYSTEM_XML_SCHEMA_VERSION = "3"
|
SYSTEM_XML_SCHEMA_VERSION = "3"
|
||||||
@@ -666,6 +681,37 @@ def run_system_xml_simulation(
|
|||||||
xml_bytes: bytes,
|
xml_bytes: bytes,
|
||||||
progress_callback: SimulationProgressEmitter | None = None,
|
progress_callback: SimulationProgressEmitter | None = None,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
with property_cache_run() as property_cache:
|
||||||
|
with profile_run() as trace:
|
||||||
|
result = _run_system_xml_simulation_profiled(
|
||||||
|
xml_bytes,
|
||||||
|
progress_callback,
|
||||||
|
cancel_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
performance = trace.snapshot()
|
||||||
|
if performance.get("mode") == "audit" and property_cache is not None:
|
||||||
|
cache_info = property_cache.info()
|
||||||
|
performance["propertyCache"] = {
|
||||||
|
"hits": cache_info.hits,
|
||||||
|
"misses": cache_info.misses,
|
||||||
|
"maxEntriesPerCache": cache_info.max_entries_per_cache,
|
||||||
|
"cacheCount": cache_info.cache_count,
|
||||||
|
"currentEntries": cache_info.current_entries,
|
||||||
|
"evictions": cache_info.evictions,
|
||||||
|
}
|
||||||
|
if performance.get("mode") != "off":
|
||||||
|
diagnostics = dict(result.get("diagnostics", {}))
|
||||||
|
diagnostics["performance"] = performance
|
||||||
|
result["diagnostics"] = diagnostics
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_system_xml_simulation_profiled(
|
||||||
|
xml_bytes: bytes,
|
||||||
|
progress_callback: SimulationProgressEmitter | None = None,
|
||||||
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
||||||
from app.simulation.solvers.solver import SolveIVPConfig
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
@@ -745,6 +791,9 @@ def run_system_xml_simulation(
|
|||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except AlgebraicSolveError as exc:
|
except AlgebraicSolveError as exc:
|
||||||
|
algebraic_diagnostics = exc.diagnostics.as_dict()
|
||||||
|
algebraic_diagnostics["scopeKind"] = exc.scope_kind
|
||||||
|
algebraic_diagnostics["scopeComponents"] = list(exc.scope_components)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
@@ -757,7 +806,7 @@ def run_system_xml_simulation(
|
|||||||
"message": str(exc),
|
"message": str(exc),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"diagnostics": exc.diagnostics.as_dict(),
|
"diagnostics": algebraic_diagnostics,
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except StreamSolveError as exc:
|
except StreamSolveError as exc:
|
||||||
@@ -792,12 +841,13 @@ def run_system_xml_simulation(
|
|||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return {
|
with performance_span("simulation.response_assembly"):
|
||||||
"validation": report.as_dict(),
|
return {
|
||||||
"simulation": document.as_model_data()["simulation"],
|
"validation": report.as_dict(),
|
||||||
"model": network.as_interface_dict(),
|
"simulation": document.as_model_data()["simulation"],
|
||||||
**result.as_dict(),
|
"model": network.as_interface_dict(),
|
||||||
}
|
**result.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def simulation_event_stream(
|
def simulation_event_stream(
|
||||||
@@ -1200,6 +1250,7 @@ def compile_reactflow_network(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@profile_phase("simulation.network_compilation")
|
||||||
def compile_system_xml_network(
|
def compile_system_xml_network(
|
||||||
document: SystemXmlDocument,
|
document: SystemXmlDocument,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
|||||||
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
|
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
|
||||||
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
|
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
|
||||||
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
|
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
|
||||||
|
- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点
|
||||||
|
- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具
|
||||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||||
@@ -58,6 +60,36 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
|||||||
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
|
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
|
||||||
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
||||||
|
|
||||||
|
## 可选性能诊断
|
||||||
|
|
||||||
|
`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard`
|
||||||
|
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
|
||||||
|
明显更高。最终优化收益必须在 `off` 下复测。
|
||||||
|
|
||||||
|
Peng–Robinson 氦气的高开销物性默认使用一次仿真内独立的精确 LRU 缓存;不同
|
||||||
|
仿真任务不会共享条目,仿真结束后自动释放。可在启动进程前设置
|
||||||
|
`SIMULATIONAPP_PROPERTY_CACHE=off` 做数值和性能 A/B,正常运行保持默认 `on`。
|
||||||
|
缓存只复用完全相同的输入,不做四舍五入或容差匹配。
|
||||||
|
|
||||||
|
FastAPI worker 默认在 lifespan 启动阶段预热 SciPy 积分、非线性求解、稀疏
|
||||||
|
Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行业务模型,也不
|
||||||
|
写入文件;如需诊断冷启动,可设置 `SIMULATIONAPP_WARMUP=off`。每个 worker 都会
|
||||||
|
独立暖机一次。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
|
||||||
|
--mode audit --warmups 1 --runs 3 `
|
||||||
|
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
|
||||||
|
--output app/data/performance-evaluations/helium-step.json
|
||||||
|
```
|
||||||
|
|
||||||
|
缓存关闭对照可在同一命令中增加 `--disable-property-cache`。缓存容量、命中、
|
||||||
|
未命中和驱逐数会在 audit 响应的
|
||||||
|
`diagnostics.performance.propertyCache` 中返回。
|
||||||
|
|
||||||
|
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
|
||||||
|
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。
|
||||||
|
|
||||||
## 当前阶段进度
|
## 当前阶段进度
|
||||||
|
|
||||||
这一阶段原先有 4 件重点工作,现在的状态如下:
|
这一阶段原先有 4 件重点工作,现在的状态如下:
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from math import ceil
|
||||||
|
from pathlib import Path
|
||||||
|
from time import perf_counter_ns, process_time_ns
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _named_value(value: str, *, option: str) -> tuple[str, str]:
|
||||||
|
name, separator, target = value.partition("=")
|
||||||
|
if not separator or not name.strip() or not target.strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"{option} must use NAME=VALUE syntax, received {value!r}."
|
||||||
|
)
|
||||||
|
return name.strip(), target.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: list[float], percentile: float) -> float:
|
||||||
|
ordered = sorted(values)
|
||||||
|
index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1))
|
||||||
|
return ordered[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_summary(values: list[float]) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"samplesMs": values,
|
||||||
|
"minimumMs": min(values),
|
||||||
|
"medianMs": statistics.median(values),
|
||||||
|
"p95Ms": _percentile(values, 0.95),
|
||||||
|
"maximumMs": max(values),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_factory_xml(specification: str) -> bytes:
|
||||||
|
module_name, separator, member_name = specification.partition(":")
|
||||||
|
if not separator or not module_name or not member_name:
|
||||||
|
raise ValueError(
|
||||||
|
"Factory specifications must use module.path:callable syntax."
|
||||||
|
)
|
||||||
|
factory = getattr(importlib.import_module(module_name), member_name)
|
||||||
|
value = factory()
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.encode("utf-8")
|
||||||
|
|
||||||
|
from app.main import build_reactflow_system_xml
|
||||||
|
|
||||||
|
return build_reactflow_system_xml(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_result_event(result: dict[str, object]) -> bytes:
|
||||||
|
"""Render the final NDJSON payload shape used by the streaming endpoint."""
|
||||||
|
|
||||||
|
status = str(result.get("status", "completed"))
|
||||||
|
event = {
|
||||||
|
"event": "result",
|
||||||
|
"progress": 100 if status == "completed" else 0,
|
||||||
|
"phase": status,
|
||||||
|
"message": "仿真完成" if status == "completed" else "仿真任务结束",
|
||||||
|
"simulatedTime": result.get("simulatedUntil"),
|
||||||
|
"totalTime": result.get("requestedStopTime"),
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_case(
|
||||||
|
name: str,
|
||||||
|
xml_bytes: bytes,
|
||||||
|
*,
|
||||||
|
warmups: int,
|
||||||
|
runs: int,
|
||||||
|
cancellable_path: bool,
|
||||||
|
allow_failures: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.main import run_system_xml_simulation
|
||||||
|
|
||||||
|
cancel_check = (lambda: False) if cancellable_path else None
|
||||||
|
for _ in range(warmups):
|
||||||
|
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
|
||||||
|
if not bool(result.get("success")) and not allow_failures:
|
||||||
|
raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}")
|
||||||
|
|
||||||
|
wall_samples_ms: list[float] = []
|
||||||
|
cpu_samples_ms: list[float] = []
|
||||||
|
serialization_samples_ms: list[float] = []
|
||||||
|
serialized_sizes: list[int] = []
|
||||||
|
profiles: list[dict[str, object]] = []
|
||||||
|
final_result: dict[str, object] | None = None
|
||||||
|
for _ in range(runs):
|
||||||
|
wall_start = perf_counter_ns()
|
||||||
|
cpu_start = process_time_ns()
|
||||||
|
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
|
||||||
|
cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0)
|
||||||
|
wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0)
|
||||||
|
if not bool(result.get("success")) and not allow_failures:
|
||||||
|
raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}")
|
||||||
|
diagnostics = result.get("diagnostics")
|
||||||
|
if isinstance(diagnostics, dict):
|
||||||
|
performance = diagnostics.get("performance")
|
||||||
|
if isinstance(performance, dict):
|
||||||
|
profiles.append(performance)
|
||||||
|
serialization_start = perf_counter_ns()
|
||||||
|
serialized_event = _serialize_result_event(result)
|
||||||
|
serialization_samples_ms.append(
|
||||||
|
(perf_counter_ns() - serialization_start) / 1_000_000.0
|
||||||
|
)
|
||||||
|
serialized_sizes.append(len(serialized_event))
|
||||||
|
final_result = result
|
||||||
|
|
||||||
|
assert final_result is not None
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"success": bool(final_result.get("success")),
|
||||||
|
"message": final_result.get("message"),
|
||||||
|
"inputBytes": len(xml_bytes),
|
||||||
|
"inputSha256": hashlib.sha256(xml_bytes).hexdigest(),
|
||||||
|
"status": final_result.get("status"),
|
||||||
|
"simulatedUntil": final_result.get("simulatedUntil"),
|
||||||
|
"requestedStopTime": final_result.get("requestedStopTime"),
|
||||||
|
"wall": _duration_summary(wall_samples_ms),
|
||||||
|
"cpu": _duration_summary(cpu_samples_ms),
|
||||||
|
"resultSerialization": _duration_summary(serialization_samples_ms),
|
||||||
|
"resultEventBytes": serialized_sizes,
|
||||||
|
"performanceRuns": profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Benchmark the real System XML simulation path with optional profiling."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=("off", "standard", "audit"),
|
||||||
|
default="audit",
|
||||||
|
help="Instrumentation depth selected before importing the simulation modules.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--warmups", type=int, default=1)
|
||||||
|
parser.add_argument("--runs", type=int, default=5)
|
||||||
|
parser.add_argument(
|
||||||
|
"--xml",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="NAME=PATH",
|
||||||
|
help="Add an XML file benchmark case.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--factory",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="NAME=MODULE:CALLABLE",
|
||||||
|
help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--direct-path",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--disable-property-cache",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable the run-local exact property cache for an A/B comparison.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-failures",
|
||||||
|
action="store_true",
|
||||||
|
help="Record failed simulation runs instead of aborting the benchmark.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=Path)
|
||||||
|
arguments = parser.parse_args(argv)
|
||||||
|
if arguments.warmups < 0:
|
||||||
|
parser.error("--warmups must not be negative.")
|
||||||
|
if arguments.runs <= 0:
|
||||||
|
parser.error("--runs must be positive.")
|
||||||
|
if not arguments.xml and not arguments.factory:
|
||||||
|
parser.error("At least one --xml or --factory case is required.")
|
||||||
|
return arguments
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = _parse_arguments(argv)
|
||||||
|
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
|
||||||
|
os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = (
|
||||||
|
"off" if arguments.disable_property_cache else "on"
|
||||||
|
)
|
||||||
|
|
||||||
|
cases: list[tuple[str, bytes]] = []
|
||||||
|
for raw_case in arguments.xml:
|
||||||
|
name, raw_path = _named_value(raw_case, option="--xml")
|
||||||
|
cases.append((name, Path(raw_path).read_bytes()))
|
||||||
|
for raw_case in arguments.factory:
|
||||||
|
name, specification = _named_value(raw_case, option="--factory")
|
||||||
|
cases.append((name, _load_factory_xml(specification)))
|
||||||
|
|
||||||
|
report: dict[str, Any] = {
|
||||||
|
"generatedAt": datetime.now(UTC).isoformat(),
|
||||||
|
"profileMode": arguments.mode,
|
||||||
|
"cancellableSolverPath": not arguments.direct_path,
|
||||||
|
"propertyCacheEnabled": not arguments.disable_property_cache,
|
||||||
|
"allowFailures": bool(arguments.allow_failures),
|
||||||
|
"warmups": arguments.warmups,
|
||||||
|
"runs": arguments.runs,
|
||||||
|
"runtime": {
|
||||||
|
"python": sys.version,
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"processor": platform.processor(),
|
||||||
|
},
|
||||||
|
"cases": [
|
||||||
|
_run_case(
|
||||||
|
name,
|
||||||
|
xml_bytes,
|
||||||
|
warmups=arguments.warmups,
|
||||||
|
runs=arguments.runs,
|
||||||
|
cancellable_path=not arguments.direct_path,
|
||||||
|
allow_failures=arguments.allow_failures,
|
||||||
|
)
|
||||||
|
for name, xml_bytes in cases
|
||||||
|
],
|
||||||
|
}
|
||||||
|
text = json.dumps(report, ensure_ascii=False, indent=2)
|
||||||
|
if arguments.output is not None:
|
||||||
|
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
arguments.output.write_text(text + "\n", encoding="utf-8")
|
||||||
|
print(f"Performance report written to {arguments.output.resolve()}")
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -19,6 +19,7 @@ class AmesimPnpl01(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnpl01"
|
MODEL_TYPE = "amesim_pnpl01"
|
||||||
MODEL_VERSION = "0.1.0"
|
MODEL_VERSION = "0.1.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
||||||
PARAMETERS = ()
|
PARAMETERS = ()
|
||||||
RESULT_VARIABLES = ()
|
RESULT_VARIABLES = ()
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnor001"
|
MODEL_TYPE = "amesim_pnor001"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -462,6 +463,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnvo001_fixed"
|
MODEL_TYPE = "amesim_pnvo001_fixed"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||||
@@ -698,7 +700,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
|
|
||||||
def mass_flow(self, p_2: float, p_3: float) -> float:
|
def mass_flow(self, p_2: float, p_3: float) -> float:
|
||||||
if (
|
if (
|
||||||
isclose(p_2, p_3, rel_tol=1.0e-7, abs_tol=1.0e-9)
|
isclose(p_2, p_3, rel_tol=0.0, abs_tol=1.0e-8)
|
||||||
or self.effective_area == 0.0
|
or self.effective_area == 0.0
|
||||||
):
|
):
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -885,6 +887,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnvo001"
|
MODEL_TYPE = "amesim_pnvo001"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.signal("res", nominal_role="input"),
|
PortDefinition.signal("res", nominal_role="input"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnl00r"
|
MODEL_TYPE = "amesim_pnl00r"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -283,7 +284,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
return 0.5 * (lower + upper)
|
return 0.5 * (lower + upper)
|
||||||
|
|
||||||
def mass_flow(self, p_1: float, p_2: float) -> float:
|
def mass_flow(self, p_1: float, p_2: float) -> float:
|
||||||
if isclose(p_1, p_2, rel_tol=1.0e-7, abs_tol=1.0e-9):
|
if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8):
|
||||||
return 0.0
|
return 0.0
|
||||||
pressure_difference = p_1 - p_2
|
pressure_difference = p_1 - p_2
|
||||||
upstream_pressure = max(p_1, p_2, 1.0)
|
upstream_pressure = max(p_1, p_2, 1.0)
|
||||||
@@ -910,6 +911,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnl0002"
|
MODEL_TYPE = "amesim_pnl0002"
|
||||||
MODEL_VERSION = "0.6.0"
|
MODEL_VERSION = "0.6.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -1396,7 +1398,7 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
port_1 = self._properties(self.state_1)
|
port_1 = self._properties(self.state_1)
|
||||||
port_2 = self._properties(self.state_2)
|
port_2 = self._properties(self.state_2)
|
||||||
pressure_difference = port_1.p - port_2.p
|
pressure_difference = port_1.p - port_2.p
|
||||||
if isclose(port_1.p, port_2.p, rel_tol=1.0e-7, abs_tol=1.0e-9):
|
if isclose(port_1.p, port_2.p, rel_tol=0.0, abs_tol=1.0e-8):
|
||||||
return 0.0
|
return 0.0
|
||||||
upstream = port_1 if pressure_difference > 0.0 else port_2
|
upstream = port_1 if pressure_difference > 0.0 else port_2
|
||||||
magnitude = self._mass_flow_for_pressure_drop(
|
magnitude = self._mass_flow_for_pressure_drop(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class _AmesimPneumaticNode(AlgebraicComponent):
|
|||||||
balance, matching the AMESim dh2 causality.
|
balance, matching the AMESim dh2 causality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
REFERENCE_PORT = "port_2"
|
REFERENCE_PORT = "port_2"
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
@@ -122,6 +123,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pn3node2"
|
MODEL_TYPE = "amesim_pn3node2"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -158,6 +160,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_p4node2"
|
MODEL_TYPE = "amesim_p4node2"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AmesimPnrp17(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_pnrp17"
|
MODEL_TYPE = "amesim_pnrp17"
|
||||||
MODEL_VERSION = "0.1.0"
|
MODEL_VERSION = "0.1.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.mechanical_translational("port_2"),
|
PortDefinition.mechanical_translational("port_2"),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
@@ -12,6 +11,8 @@ from app.simulation.core.medium import (
|
|||||||
ThermodynamicProperties,
|
ThermodynamicProperties,
|
||||||
)
|
)
|
||||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||||
|
from app.simulation.performance import profile_property, record_property_iterations
|
||||||
|
from app.simulation.property_cache import cache_property_calculation
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -69,6 +70,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
del T
|
del T
|
||||||
return self.cv
|
return self.cv
|
||||||
|
|
||||||
|
@profile_property("density")
|
||||||
|
@cache_property_calculation("density")
|
||||||
def density(self, p: float, T: float) -> float:
|
def density(self, p: float, T: float) -> float:
|
||||||
return self.fluid.density(p, T)
|
return self.fluid.density(p, T)
|
||||||
|
|
||||||
@@ -134,6 +137,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
)
|
)
|
||||||
return factor, exponent
|
return factor, exponent
|
||||||
|
|
||||||
|
@profile_property("isentropic_density_pressure_factor")
|
||||||
|
@cache_property_calculation("isentropic_density_pressure_factor")
|
||||||
def isentropic_density_pressure_factor(
|
def isentropic_density_pressure_factor(
|
||||||
self,
|
self,
|
||||||
p: float,
|
p: float,
|
||||||
@@ -166,12 +171,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
raise ValueError("Volume must stay positive.")
|
raise ValueError("Volume must stay positive.")
|
||||||
return self.fluid.pressure_from_density(T, m / V)
|
return self.fluid.pressure_from_density(T, m / V)
|
||||||
|
|
||||||
|
@profile_property("specific_internal_energy")
|
||||||
def specific_internal_energy(self, T: float) -> float:
|
def specific_internal_energy(self, T: float) -> float:
|
||||||
return self.R_gas * (
|
return self.R_gas * (
|
||||||
(self.nasa_cp_over_R - 1.0) * T
|
(self.nasa_cp_over_R - 1.0) * T
|
||||||
+ self.nasa_enthalpy_constant_K
|
+ self.nasa_enthalpy_constant_K
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property("specific_internal_energy_at_pressure")
|
||||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
||||||
density = self.density(p, T)
|
density = self.density(p, T)
|
||||||
return (
|
return (
|
||||||
@@ -179,12 +186,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
|
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property("specific_enthalpy")
|
||||||
def specific_enthalpy(self, T: float) -> float:
|
def specific_enthalpy(self, T: float) -> float:
|
||||||
return self.R_gas * (
|
return self.R_gas * (
|
||||||
self.nasa_cp_over_R * T
|
self.nasa_cp_over_R * T
|
||||||
+ self.nasa_enthalpy_constant_K
|
+ self.nasa_enthalpy_constant_K
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property("specific_enthalpy_at_pressure")
|
||||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
||||||
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
|
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
|
||||||
|
|
||||||
@@ -198,7 +207,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
h / self.R_gas - self.nasa_enthalpy_constant_K
|
h / self.R_gas - self.nasa_enthalpy_constant_K
|
||||||
) / self.nasa_cp_over_R
|
) / self.nasa_cp_over_R
|
||||||
|
|
||||||
@lru_cache(maxsize=8192)
|
@profile_property("temperature_from_pressure_enthalpy")
|
||||||
|
@cache_property_calculation("temperature_from_pressure_enthalpy")
|
||||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||||
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
||||||
for _iteration in range(16):
|
for _iteration in range(16):
|
||||||
@@ -211,8 +221,18 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
temperature,
|
temperature,
|
||||||
1.0,
|
1.0,
|
||||||
):
|
):
|
||||||
|
record_property_iterations(
|
||||||
|
"temperature_from_pressure_enthalpy",
|
||||||
|
_iteration + 1,
|
||||||
|
True,
|
||||||
|
)
|
||||||
return next_temperature
|
return next_temperature
|
||||||
temperature = next_temperature
|
temperature = next_temperature
|
||||||
|
record_property_iterations(
|
||||||
|
"temperature_from_pressure_enthalpy",
|
||||||
|
16,
|
||||||
|
False,
|
||||||
|
)
|
||||||
return temperature
|
return temperature
|
||||||
|
|
||||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
||||||
@@ -222,7 +242,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
)
|
)
|
||||||
return self.temperature_from_internal_energy(U / m)
|
return self.temperature_from_internal_energy(U / m)
|
||||||
|
|
||||||
@lru_cache(maxsize=8192)
|
@profile_property("properties_from_mU")
|
||||||
|
@cache_property_calculation("properties_from_mU")
|
||||||
def properties_from_mU(
|
def properties_from_mU(
|
||||||
self,
|
self,
|
||||||
m: float,
|
m: float,
|
||||||
@@ -249,6 +270,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
self.temperature_from_internal_energy(target_internal_energy),
|
self.temperature_from_internal_energy(target_internal_energy),
|
||||||
2.2,
|
2.2,
|
||||||
)
|
)
|
||||||
|
converged = False
|
||||||
for _iteration in range(16):
|
for _iteration in range(16):
|
||||||
residual_internal_energy = (
|
residual_internal_energy = (
|
||||||
self.fluid.residual_specific_internal_energy_at_density(
|
self.fluid.residual_specific_internal_energy_at_density(
|
||||||
@@ -267,8 +289,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
1.0,
|
1.0,
|
||||||
):
|
):
|
||||||
temperature = next_temperature
|
temperature = next_temperature
|
||||||
|
converged = True
|
||||||
break
|
break
|
||||||
temperature = next_temperature
|
temperature = next_temperature
|
||||||
|
record_property_iterations(
|
||||||
|
"properties_from_mU",
|
||||||
|
_iteration + 1,
|
||||||
|
converged,
|
||||||
|
)
|
||||||
pressure = self.fluid.pressure_from_density(temperature, density)
|
pressure = self.fluid.pressure_from_density(temperature, density)
|
||||||
return ThermodynamicProperties(
|
return ThermodynamicProperties(
|
||||||
p=pressure,
|
p=pressure,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Orifice(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "orifice"
|
MODEL_TYPE = "orifice"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ResistivePipe(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "pipe"
|
MODEL_TYPE = "pipe"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class Tee(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "tee"
|
MODEL_TYPE = "tee"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ if TYPE_CHECKING:
|
|||||||
class Component(ABC):
|
class Component(ABC):
|
||||||
MODEL_TYPE: ClassVar[str | None] = None
|
MODEL_TYPE: ClassVar[str | None] = None
|
||||||
MODEL_VERSION: ClassVar[str | None] = None
|
MODEL_VERSION: ClassVar[str | None] = None
|
||||||
|
# ``True`` means that pressure/flow residuals read values written by
|
||||||
|
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
|
||||||
|
# ``False`` is an explicit promise that those residuals are independent of
|
||||||
|
# stream propagation. ``None`` keeps custom components conservative: when
|
||||||
|
# they override either stream hook, the closure planner retains the legacy
|
||||||
|
# full-network thermofluid fixed point.
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
|
||||||
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
|
from app.simulation.performance import profile_property
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -108,9 +109,11 @@ class IdealGasMedium:
|
|||||||
def cv_at_temperature(self, T: float) -> float:
|
def cv_at_temperature(self, T: float) -> float:
|
||||||
return self.cp_at_temperature(T) - self.R_gas
|
return self.cp_at_temperature(T) - self.R_gas
|
||||||
|
|
||||||
|
@profile_property("density")
|
||||||
def density(self, p: float, T: float) -> float:
|
def density(self, p: float, T: float) -> float:
|
||||||
return p / (self.R_gas * T)
|
return p / (self.R_gas * T)
|
||||||
|
|
||||||
|
@profile_property("isentropic_density_pressure_factor")
|
||||||
def isentropic_density_pressure_factor(
|
def isentropic_density_pressure_factor(
|
||||||
self,
|
self,
|
||||||
p: float,
|
p: float,
|
||||||
@@ -123,6 +126,7 @@ class IdealGasMedium:
|
|||||||
cv = self.cv_at_temperature(T)
|
cv = self.cv_at_temperature(T)
|
||||||
return cv / cp
|
return cv / cp
|
||||||
|
|
||||||
|
@profile_property("dynamic_viscosity")
|
||||||
def dynamic_viscosity(self, T: float) -> float:
|
def dynamic_viscosity(self, T: float) -> float:
|
||||||
"""Return dynamic viscosity using the default air Sutherland law."""
|
"""Return dynamic viscosity using the default air Sutherland law."""
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ class IdealGasMedium:
|
|||||||
/ (T + self.sutherland_constant)
|
/ (T + self.sutherland_constant)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property("specific_internal_energy")
|
||||||
def specific_internal_energy(self, T: float) -> float:
|
def specific_internal_energy(self, T: float) -> float:
|
||||||
delta_T = T - self.T_ref
|
delta_T = T - self.T_ref
|
||||||
return (
|
return (
|
||||||
@@ -143,10 +148,12 @@ class IdealGasMedium:
|
|||||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
+ 0.5 * self.cp_slope * delta_T * delta_T
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property("specific_internal_energy_at_pressure")
|
||||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
||||||
del p
|
del p
|
||||||
return self.specific_internal_energy(T)
|
return self.specific_internal_energy(T)
|
||||||
|
|
||||||
|
@profile_property("specific_enthalpy")
|
||||||
def specific_enthalpy(self, T: float) -> float:
|
def specific_enthalpy(self, T: float) -> float:
|
||||||
delta_T = T - self.T_ref
|
delta_T = T - self.T_ref
|
||||||
return (
|
return (
|
||||||
@@ -155,6 +162,7 @@ class IdealGasMedium:
|
|||||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
+ 0.5 * self.cp_slope * delta_T * delta_T
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property("specific_enthalpy_at_pressure")
|
||||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
||||||
del p
|
del p
|
||||||
return self.specific_enthalpy(T)
|
return self.specific_enthalpy(T)
|
||||||
@@ -191,6 +199,7 @@ class IdealGasMedium:
|
|||||||
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
||||||
return self.T_ref + delta_T
|
return self.T_ref + delta_T
|
||||||
|
|
||||||
|
@profile_property("temperature_from_pressure_enthalpy")
|
||||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||||
del p
|
del p
|
||||||
return self.temperature_from_enthalpy(h)
|
return self.temperature_from_enthalpy(h)
|
||||||
@@ -207,6 +216,7 @@ class IdealGasMedium:
|
|||||||
raise ValueError("Volume must stay positive.")
|
raise ValueError("Volume must stay positive.")
|
||||||
return m * self.R_gas * T / V
|
return m * self.R_gas * T / V
|
||||||
|
|
||||||
|
@profile_property("properties_from_mU")
|
||||||
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
||||||
T = self.temperature_from_mass_internal_energy(m, U)
|
T = self.temperature_from_mass_internal_energy(m, U)
|
||||||
p = self.pressure(m, T, V)
|
p = self.pressure(m, T, V)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from app.simulation.core.errors import RecoverableTrialStateError
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import acos, cos, isfinite, log, pi, sqrt
|
from math import acos, cos, isfinite, log, pi, sqrt
|
||||||
|
|
||||||
|
from app.simulation.performance import profile_property
|
||||||
|
|
||||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
||||||
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
|
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
|
||||||
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
|
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
|
||||||
@@ -97,6 +99,11 @@ class PengRobinsonFluid:
|
|||||||
) -> float:
|
) -> float:
|
||||||
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
|
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"pressure_from_molar_volume",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
||||||
self._validate_temperature(temperature)
|
self._validate_temperature(temperature)
|
||||||
if molar_volume <= self.b_parameter:
|
if molar_volume <= self.b_parameter:
|
||||||
@@ -107,11 +114,21 @@ class PengRobinsonFluid:
|
|||||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
||||||
return repulsive - attractive
|
return repulsive - attractive
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"pressure_from_density",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
def pressure_from_density(self, temperature: float, density: float) -> float:
|
||||||
if density <= 0.0:
|
if density <= 0.0:
|
||||||
raise ValueError("Density must be positive.")
|
raise ValueError("Density must be positive.")
|
||||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"pressure_temperature_derivative_at_density",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def pressure_temperature_derivative_at_density(
|
def pressure_temperature_derivative_at_density(
|
||||||
self,
|
self,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
@@ -132,6 +149,11 @@ class PengRobinsonFluid:
|
|||||||
- self.attractive_parameter_temperature_derivative(temperature) / denominator
|
- self.attractive_parameter_temperature_derivative(temperature) / denominator
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"pressure_density_derivative_at_temperature",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def pressure_density_derivative_at_temperature(
|
def pressure_density_derivative_at_temperature(
|
||||||
self,
|
self,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
@@ -165,6 +187,11 @@ class PengRobinsonFluid:
|
|||||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
||||||
return A, B
|
return A, B
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"compressibility_roots",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
||||||
A, B = self.reduced_parameters(pressure, temperature)
|
A, B = self.reduced_parameters(pressure, temperature)
|
||||||
coefficients = (
|
coefficients = (
|
||||||
@@ -178,6 +205,11 @@ class PengRobinsonFluid:
|
|||||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
||||||
return physical_roots
|
return physical_roots
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"compressibility_factor",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def compressibility_factor(
|
def compressibility_factor(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -193,6 +225,11 @@ class PengRobinsonFluid:
|
|||||||
return roots[-1]
|
return roots[-1]
|
||||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"molar_volume",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def molar_volume(
|
def molar_volume(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -202,6 +239,7 @@ class PengRobinsonFluid:
|
|||||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
||||||
|
|
||||||
|
@profile_property("density", layer="kernel", minimum_mode="audit")
|
||||||
def density(
|
def density(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -210,6 +248,11 @@ class PengRobinsonFluid:
|
|||||||
) -> float:
|
) -> float:
|
||||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"residual_specific_enthalpy",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def residual_specific_enthalpy(
|
def residual_specific_enthalpy(
|
||||||
self,
|
self,
|
||||||
pressure: float,
|
pressure: float,
|
||||||
@@ -239,6 +282,11 @@ class PengRobinsonFluid:
|
|||||||
)
|
)
|
||||||
return residual_molar_enthalpy / self.molar_mass
|
return residual_molar_enthalpy / self.molar_mass
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"residual_specific_internal_energy_at_density",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def residual_specific_internal_energy_at_density(
|
def residual_specific_internal_energy_at_density(
|
||||||
self,
|
self,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
@@ -268,6 +316,11 @@ class PengRobinsonFluid:
|
|||||||
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
|
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
|
||||||
return residual_molar_internal_energy / self.molar_mass
|
return residual_molar_internal_energy / self.molar_mass
|
||||||
|
|
||||||
|
@profile_property(
|
||||||
|
"residual_isochoric_heat_capacity_at_density",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)
|
||||||
def residual_isochoric_heat_capacity_at_density(
|
def residual_isochoric_heat_capacity_at_density(
|
||||||
self,
|
self,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
|
|||||||
@@ -0,0 +1,689 @@
|
|||||||
|
"""Low-overhead, run-local performance instrumentation for simulations.
|
||||||
|
|
||||||
|
The profiling mode is intentionally read once when this module is imported.
|
||||||
|
``standard`` records low-frequency pipeline stages, while ``audit`` also wraps
|
||||||
|
hot RHS/property operations and computes exact-input reuse metrics. With
|
||||||
|
profiling disabled, decorators return the original callable while classes are
|
||||||
|
being defined, so ordinary simulation calls do not pass through a wrapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Generator, Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import wraps
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from time import perf_counter_ns
|
||||||
|
from typing import Any, Literal, ParamSpec, TypeVar, cast
|
||||||
|
|
||||||
|
|
||||||
|
ProfileMode = Literal["off", "standard", "audit"]
|
||||||
|
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
_R = TypeVar("_R")
|
||||||
|
_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_startup_mode() -> ProfileMode:
|
||||||
|
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
|
||||||
|
aliases: dict[str, ProfileMode] = {
|
||||||
|
"": "off",
|
||||||
|
"0": "off",
|
||||||
|
"false": "off",
|
||||||
|
"no": "off",
|
||||||
|
"off": "off",
|
||||||
|
"1": "standard",
|
||||||
|
"true": "standard",
|
||||||
|
"yes": "standard",
|
||||||
|
"on": "standard",
|
||||||
|
"standard": "standard",
|
||||||
|
"audit": "audit",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return aliases[raw_mode]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_MODE: ProfileMode = _read_startup_mode()
|
||||||
|
|
||||||
|
|
||||||
|
def _minimum_mode(value: str) -> ProfileMode:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in _MODE_RANK:
|
||||||
|
raise ValueError("minimum_mode must be one of: off, standard, audit.")
|
||||||
|
return cast(ProfileMode, normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
|
||||||
|
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
|
||||||
|
# ``minimum_mode="off"`` by mistake.
|
||||||
|
return (
|
||||||
|
PROFILE_MODE != "off"
|
||||||
|
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TimingStats:
|
||||||
|
calls: int = 0
|
||||||
|
inclusive_ns: int = 0
|
||||||
|
self_ns: int = 0
|
||||||
|
max_ns: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
|
||||||
|
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
|
||||||
|
self.calls += 1
|
||||||
|
self.inclusive_ns += inclusive_ns
|
||||||
|
self.self_ns += self_ns
|
||||||
|
self.max_ns = max(self.max_ns, inclusive_ns)
|
||||||
|
if error:
|
||||||
|
self.errors += 1
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"calls": self.calls,
|
||||||
|
"inclusiveNs": self.inclusive_ns,
|
||||||
|
"selfNs": self.self_ns,
|
||||||
|
"maxNs": self.max_ns,
|
||||||
|
"errors": self.errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _PropertyStats(_TimingStats):
|
||||||
|
operation: str = ""
|
||||||
|
layer: str = "semantic"
|
||||||
|
medium: str = "unknown"
|
||||||
|
exact_input_unique: int = 0
|
||||||
|
exact_input_repeats: int = 0
|
||||||
|
iteration_calls: int = 0
|
||||||
|
iteration_total: int = 0
|
||||||
|
iteration_max: int = 0
|
||||||
|
iteration_converged: int = 0
|
||||||
|
iteration_nonconverged: int = 0
|
||||||
|
cache_lookups: int = 0
|
||||||
|
cache_hits: int = 0
|
||||||
|
cache_misses: int = 0
|
||||||
|
|
||||||
|
def snapshot(self, *, audit: bool) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = super().snapshot()
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"operation": self.operation,
|
||||||
|
"layer": self.layer,
|
||||||
|
"medium": self.medium,
|
||||||
|
"cacheLookups": self.cache_lookups,
|
||||||
|
"cacheHits": self.cache_hits,
|
||||||
|
"cacheMisses": self.cache_misses,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if audit:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"exactInputUnique": self.exact_input_unique,
|
||||||
|
"exactInputRepeats": self.exact_input_repeats,
|
||||||
|
"iterationCalls": self.iteration_calls,
|
||||||
|
"iterationTotal": self.iteration_total,
|
||||||
|
"iterationMax": self.iteration_max,
|
||||||
|
"iterationConverged": self.iteration_converged,
|
||||||
|
"iterationNonconverged": self.iteration_nonconverged,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ActiveSpan:
|
||||||
|
trace: PerformanceTrace
|
||||||
|
name: str
|
||||||
|
started_ns: int
|
||||||
|
property_key: str | None = None
|
||||||
|
property_operation: str | None = None
|
||||||
|
property_outermost: bool = False
|
||||||
|
child_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PerformanceTrace:
|
||||||
|
"""Mutable counters owned by exactly one :func:`profile_run` context."""
|
||||||
|
|
||||||
|
mode: ProfileMode
|
||||||
|
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
|
||||||
|
_properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False)
|
||||||
|
_property_outermost_ns: int = field(default=0, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.mode != "off"
|
||||||
|
|
||||||
|
def _property_stats(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
) -> _PropertyStats:
|
||||||
|
stats = self._properties.get(key)
|
||||||
|
if stats is None:
|
||||||
|
stats = _PropertyStats(
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
self._properties[key] = stats
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None:
|
||||||
|
self_ns = max(0, elapsed_ns - frame.child_ns)
|
||||||
|
if frame.property_key is None:
|
||||||
|
stats = self._phases.setdefault(frame.name, _TimingStats())
|
||||||
|
else:
|
||||||
|
layer, medium, operation = frame.property_key.split("|", 2)
|
||||||
|
stats = self._property_stats(
|
||||||
|
frame.property_key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
if frame.property_outermost:
|
||||||
|
self._property_outermost_ns += elapsed_ns
|
||||||
|
stats.record(elapsed_ns, self_ns, error)
|
||||||
|
|
||||||
|
def _record_exact_input(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
fingerprint: object,
|
||||||
|
) -> None:
|
||||||
|
stats = self._property_stats(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
shadow_key = (key, fingerprint)
|
||||||
|
shadow = _PROPERTY_SHADOW.get()
|
||||||
|
if shadow is None:
|
||||||
|
# A trace normally installs its own set in ``profile_run``. Keep
|
||||||
|
# the ContextVar default immutable so no task can accidentally
|
||||||
|
# share a process-global shadow set.
|
||||||
|
shadow = set()
|
||||||
|
_PROPERTY_SHADOW.set(shadow)
|
||||||
|
if shadow_key in shadow:
|
||||||
|
stats.exact_input_repeats += 1
|
||||||
|
else:
|
||||||
|
shadow.add(shadow_key)
|
||||||
|
stats.exact_input_unique += 1
|
||||||
|
|
||||||
|
def _record_iterations(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
iterations: int,
|
||||||
|
converged: bool,
|
||||||
|
) -> None:
|
||||||
|
stats = self._property_stats(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
iteration_count = max(0, int(iterations))
|
||||||
|
stats.iteration_calls += 1
|
||||||
|
stats.iteration_total += iteration_count
|
||||||
|
stats.iteration_max = max(stats.iteration_max, iteration_count)
|
||||||
|
if converged:
|
||||||
|
stats.iteration_converged += 1
|
||||||
|
else:
|
||||||
|
stats.iteration_nonconverged += 1
|
||||||
|
|
||||||
|
def _record_cache(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
layer: str,
|
||||||
|
medium: str,
|
||||||
|
hits: int,
|
||||||
|
misses: int,
|
||||||
|
) -> None:
|
||||||
|
stats = self._property_stats(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
)
|
||||||
|
hit_delta = max(0, hits)
|
||||||
|
miss_delta = max(0, misses)
|
||||||
|
stats.cache_hits += hit_delta
|
||||||
|
stats.cache_misses += miss_delta
|
||||||
|
stats.cache_lookups += hit_delta + miss_delta
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
"""Return a detached, JSON-serializable copy of all counters."""
|
||||||
|
|
||||||
|
audit = self.mode == "audit"
|
||||||
|
return {
|
||||||
|
"mode": self.mode,
|
||||||
|
"phases": {
|
||||||
|
name: self._phases[name].snapshot()
|
||||||
|
for name in sorted(self._phases)
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
_public_property_key(key): self._properties[key].snapshot(audit=audit)
|
||||||
|
for key in sorted(self._properties)
|
||||||
|
},
|
||||||
|
"propertyOutermostNs": self._property_outermost_ns,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar(
|
||||||
|
"simulation_performance_trace",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar(
|
||||||
|
"simulation_performance_spans",
|
||||||
|
default=(),
|
||||||
|
)
|
||||||
|
_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar(
|
||||||
|
"simulation_property_shadow",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_property_key(key: str) -> str:
|
||||||
|
layer, medium, operation = key.split("|", 2)
|
||||||
|
return f"{layer}.{medium}.{operation}"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _tracked_span(
|
||||||
|
trace: PerformanceTrace,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
property_key: str | None = None,
|
||||||
|
property_operation: str | None = None,
|
||||||
|
reset_property_shadow: bool = False,
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
stack = _ACTIVE_SPANS.get()
|
||||||
|
property_outermost = property_key is not None and not any(
|
||||||
|
item.property_key is not None for item in stack
|
||||||
|
)
|
||||||
|
frame = _ActiveSpan(
|
||||||
|
trace=trace,
|
||||||
|
name=name,
|
||||||
|
started_ns=perf_counter_ns(),
|
||||||
|
property_key=property_key,
|
||||||
|
property_operation=property_operation,
|
||||||
|
property_outermost=property_outermost,
|
||||||
|
)
|
||||||
|
stack_token = _ACTIVE_SPANS.set((*stack, frame))
|
||||||
|
shadow_token: Token[set[object] | None] | None = None
|
||||||
|
if reset_property_shadow and trace.mode == "audit":
|
||||||
|
shadow_token = _PROPERTY_SHADOW.set(set())
|
||||||
|
error = False
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except BaseException:
|
||||||
|
error = True
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
elapsed_ns = max(0, perf_counter_ns() - frame.started_ns)
|
||||||
|
_ACTIVE_SPANS.reset(stack_token)
|
||||||
|
if shadow_token is not None:
|
||||||
|
_PROPERTY_SHADOW.reset(shadow_token)
|
||||||
|
if stack:
|
||||||
|
stack[-1].child_ns += elapsed_ns
|
||||||
|
trace._record_span(frame, elapsed_ns, error)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def profile_run() -> Generator[PerformanceTrace, None, None]:
|
||||||
|
"""Create and bind an isolated trace for one simulation run.
|
||||||
|
|
||||||
|
The yielded trace remains usable after the context exits, which lets the
|
||||||
|
caller attach ``trace.snapshot()`` to a result without exposing live state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
trace = PerformanceTrace(mode=PROFILE_MODE)
|
||||||
|
trace_token = _CURRENT_TRACE.set(trace)
|
||||||
|
spans_token = _ACTIVE_SPANS.set(())
|
||||||
|
shadow_token = _PROPERTY_SHADOW.set(set())
|
||||||
|
try:
|
||||||
|
if trace.enabled:
|
||||||
|
with _tracked_span(trace, "simulation.total"):
|
||||||
|
yield trace
|
||||||
|
else:
|
||||||
|
yield trace
|
||||||
|
finally:
|
||||||
|
_PROPERTY_SHADOW.reset(shadow_token)
|
||||||
|
_ACTIVE_SPANS.reset(spans_token)
|
||||||
|
_CURRENT_TRACE.reset(trace_token)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def performance_span(
|
||||||
|
name: str,
|
||||||
|
minimum_mode: str = "standard",
|
||||||
|
reset_property_shadow: bool = False,
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
"""Time a block in the current run, or act as a no-op outside one."""
|
||||||
|
|
||||||
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None or not _mode_enabled(minimum):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
name,
|
||||||
|
reset_property_shadow=reset_property_shadow,
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def profile_phase(
|
||||||
|
name: str,
|
||||||
|
minimum_mode: str = "standard",
|
||||||
|
reset_property_shadow: bool = False,
|
||||||
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
|
"""Decorate a simulation phase while preserving the off-mode callable."""
|
||||||
|
|
||||||
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
|
||||||
|
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
if not _mode_enabled(minimum):
|
||||||
|
return function
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(function):
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None:
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
name,
|
||||||
|
reset_property_shadow=reset_property_shadow,
|
||||||
|
):
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
|
||||||
|
return cast(Callable[_P, _R], async_wrapper)
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
name,
|
||||||
|
reset_property_shadow=reset_property_shadow,
|
||||||
|
):
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def _medium_name(args: tuple[object, ...]) -> str:
|
||||||
|
if not args:
|
||||||
|
return "unknown"
|
||||||
|
owner = args[0]
|
||||||
|
configured_name = getattr(owner, "name", None)
|
||||||
|
if isinstance(configured_name, str) and configured_name:
|
||||||
|
return configured_name
|
||||||
|
return type(owner).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(value: object) -> object:
|
||||||
|
"""Build a hashable, bit-exact token without retaining arbitrary objects."""
|
||||||
|
|
||||||
|
if value is None or isinstance(value, (bool, int, str, bytes)):
|
||||||
|
return (type(value).__name__, value)
|
||||||
|
if isinstance(value, float):
|
||||||
|
return ("float64", struct.pack("!d", value))
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return ("tuple", tuple(_fingerprint(item) for item in value))
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ("list", tuple(_fingerprint(item) for item in value))
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()]
|
||||||
|
items.sort(key=repr)
|
||||||
|
return ("mapping", tuple(items))
|
||||||
|
return (
|
||||||
|
"object",
|
||||||
|
type(value).__module__,
|
||||||
|
type(value).__qualname__,
|
||||||
|
id(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _input_fingerprint(
|
||||||
|
signature: inspect.Signature | None,
|
||||||
|
args: tuple[object, ...],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> object:
|
||||||
|
if signature is not None:
|
||||||
|
try:
|
||||||
|
bound = signature.bind(*args, **kwargs)
|
||||||
|
bound.apply_defaults()
|
||||||
|
return tuple(
|
||||||
|
(name, _fingerprint(value))
|
||||||
|
for name, value in bound.arguments.items()
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return (
|
||||||
|
_fingerprint(args),
|
||||||
|
tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None:
|
||||||
|
cache_info = getattr(function, "cache_info", None)
|
||||||
|
if not callable(cache_info):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
info = cache_info()
|
||||||
|
return int(info.hits), int(info.misses)
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None:
|
||||||
|
for attribute in ("cache_clear", "cache_info", "cache_parameters"):
|
||||||
|
value = getattr(source, attribute, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(target, attribute, value)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_property(
|
||||||
|
operation: str,
|
||||||
|
layer: str = "semantic",
|
||||||
|
minimum_mode: str = "audit",
|
||||||
|
capture_inputs: bool = True,
|
||||||
|
track_cache: bool = False,
|
||||||
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
|
"""Decorate one thermodynamic property operation."""
|
||||||
|
|
||||||
|
minimum = _minimum_mode(minimum_mode)
|
||||||
|
|
||||||
|
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
if not _mode_enabled(minimum):
|
||||||
|
return function
|
||||||
|
try:
|
||||||
|
signature: inspect.Signature | None = inspect.signature(function)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
signature = None
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
args: tuple[object, ...],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]:
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
medium = _medium_name(args)
|
||||||
|
key = f"{layer}|{medium}|{operation}"
|
||||||
|
if trace is not None and trace.mode == "audit" and capture_inputs:
|
||||||
|
trace._record_exact_input(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
fingerprint=_input_fingerprint(signature, args, kwargs),
|
||||||
|
)
|
||||||
|
before = _cache_counts(function) if trace is not None and track_cache else None
|
||||||
|
return trace, medium, key, before
|
||||||
|
|
||||||
|
def finish_cache(
|
||||||
|
trace: PerformanceTrace | None,
|
||||||
|
medium: str,
|
||||||
|
key: str,
|
||||||
|
before: tuple[int, int] | None,
|
||||||
|
) -> None:
|
||||||
|
if trace is None or before is None:
|
||||||
|
return
|
||||||
|
after = _cache_counts(function)
|
||||||
|
if after is None:
|
||||||
|
return
|
||||||
|
trace._record_cache(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
hits=after[0] - before[0],
|
||||||
|
misses=after[1] - before[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(function):
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||||
|
object_args = cast(tuple[object, ...], args)
|
||||||
|
object_kwargs = cast(dict[str, object], kwargs)
|
||||||
|
trace, medium, key, before = prepare(object_args, object_kwargs)
|
||||||
|
try:
|
||||||
|
if trace is None:
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
f"property.{_public_property_key(key)}",
|
||||||
|
property_key=key,
|
||||||
|
property_operation=operation,
|
||||||
|
):
|
||||||
|
return await function(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
finish_cache(trace, medium, key, before)
|
||||||
|
|
||||||
|
_copy_cache_api(function, async_wrapper)
|
||||||
|
return cast(Callable[_P, _R], async_wrapper)
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
object_args = cast(tuple[object, ...], args)
|
||||||
|
object_kwargs = cast(dict[str, object], kwargs)
|
||||||
|
trace, medium, key, before = prepare(object_args, object_kwargs)
|
||||||
|
try:
|
||||||
|
if trace is None:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
with _tracked_span(
|
||||||
|
trace,
|
||||||
|
f"property.{_public_property_key(key)}",
|
||||||
|
property_key=key,
|
||||||
|
property_operation=operation,
|
||||||
|
):
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
finish_cache(trace, medium, key, before)
|
||||||
|
|
||||||
|
_copy_cache_api(function, wrapper)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def record_property_iterations(
|
||||||
|
operation: str,
|
||||||
|
iterations: int,
|
||||||
|
converged: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Record inverse-property solver iterations in audit mode."""
|
||||||
|
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None or trace.mode != "audit":
|
||||||
|
return
|
||||||
|
for frame in reversed(_ACTIVE_SPANS.get()):
|
||||||
|
if (
|
||||||
|
frame.property_key is not None
|
||||||
|
and frame.property_operation == operation
|
||||||
|
):
|
||||||
|
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
||||||
|
trace._record_iterations(
|
||||||
|
frame.property_key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
iterations=iterations,
|
||||||
|
converged=converged,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
key = f"semantic|unknown|{operation}"
|
||||||
|
trace._record_iterations(
|
||||||
|
key,
|
||||||
|
operation=operation,
|
||||||
|
layer="semantic",
|
||||||
|
medium="unknown",
|
||||||
|
iterations=iterations,
|
||||||
|
converged=converged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_property_cache(operation: str, *, hit: bool) -> None:
|
||||||
|
"""Record one run-local property-cache lookup in audit mode."""
|
||||||
|
|
||||||
|
trace = _CURRENT_TRACE.get()
|
||||||
|
if trace is None or trace.mode != "audit":
|
||||||
|
return
|
||||||
|
for frame in reversed(_ACTIVE_SPANS.get()):
|
||||||
|
if (
|
||||||
|
frame.property_key is not None
|
||||||
|
and frame.property_operation == operation
|
||||||
|
):
|
||||||
|
layer, medium, _unused_operation = frame.property_key.split("|", 2)
|
||||||
|
trace._record_cache(
|
||||||
|
frame.property_key,
|
||||||
|
operation=operation,
|
||||||
|
layer=layer,
|
||||||
|
medium=medium,
|
||||||
|
hits=1 if hit else 0,
|
||||||
|
misses=0 if hit else 1,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PROFILE_MODE",
|
||||||
|
"PerformanceTrace",
|
||||||
|
"performance_span",
|
||||||
|
"profile_phase",
|
||||||
|
"profile_property",
|
||||||
|
"profile_run",
|
||||||
|
"record_property_cache",
|
||||||
|
"record_property_iterations",
|
||||||
|
]
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Run-local, exact-key cache for expensive thermodynamic calculations.
|
||||||
|
|
||||||
|
The cache is deliberately bound to one simulation through ``ContextVar``.
|
||||||
|
That keeps concurrent runs isolated and releases all cached states when the
|
||||||
|
run finishes. Keys use the original Python values with no rounding or
|
||||||
|
tolerance-based reuse that could flatten numerical residuals seen by ODE and
|
||||||
|
nonlinear solvers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache, wraps
|
||||||
|
import os
|
||||||
|
from typing import ParamSpec, TypeVar
|
||||||
|
|
||||||
|
from app.simulation.performance import PROFILE_MODE, record_property_cache
|
||||||
|
|
||||||
|
|
||||||
|
_P = ParamSpec("_P")
|
||||||
|
_R = TypeVar("_R")
|
||||||
|
DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache_enabled() -> bool:
|
||||||
|
raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower()
|
||||||
|
if raw_value in {"", "1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if raw_value in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise ValueError(
|
||||||
|
"SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROPERTY_CACHE_ENABLED = _read_cache_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PropertyCacheInfo:
|
||||||
|
hits: int
|
||||||
|
misses: int
|
||||||
|
max_entries_per_cache: int
|
||||||
|
cache_count: int
|
||||||
|
current_entries: int
|
||||||
|
evictions: int
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPropertyCache:
|
||||||
|
"""Bounded C-level LRUs owned by one simulation run."""
|
||||||
|
|
||||||
|
def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None:
|
||||||
|
if max_entries <= 0:
|
||||||
|
raise ValueError("Property cache max_entries must be positive.")
|
||||||
|
self.max_entries_per_cache = int(max_entries)
|
||||||
|
self._functions: dict[
|
||||||
|
tuple[str, int, Callable[..., object]],
|
||||||
|
Callable[..., object],
|
||||||
|
] = {}
|
||||||
|
self._failed_misses: dict[
|
||||||
|
tuple[str, int, Callable[..., object]],
|
||||||
|
int,
|
||||||
|
] = {}
|
||||||
|
self._owners: dict[int, tuple[object, int]] = {}
|
||||||
|
self._next_owner_token = 0
|
||||||
|
|
||||||
|
def owner_token(self, owner: object) -> int:
|
||||||
|
"""Return a stable identity token and retain its owner for this run."""
|
||||||
|
|
||||||
|
identity = id(owner)
|
||||||
|
existing = self._owners.get(identity)
|
||||||
|
if existing is not None and existing[0] is owner:
|
||||||
|
return existing[1]
|
||||||
|
self._next_owner_token += 1
|
||||||
|
self._owners[identity] = (owner, self._next_owner_token)
|
||||||
|
return self._next_owner_token
|
||||||
|
|
||||||
|
def get_or_compute(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
owner: object,
|
||||||
|
function: Callable[..., _R],
|
||||||
|
args: tuple[object, ...],
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
) -> _R:
|
||||||
|
cache_key = (operation, self.owner_token(owner), function)
|
||||||
|
cached_function = self._functions.get(cache_key)
|
||||||
|
if cached_function is None:
|
||||||
|
|
||||||
|
@lru_cache(maxsize=self.max_entries_per_cache, typed=True)
|
||||||
|
def invoke(*cached_args: object, **cached_kwargs: object) -> _R:
|
||||||
|
return function(owner, *cached_args, **cached_kwargs)
|
||||||
|
|
||||||
|
cached_function = invoke
|
||||||
|
self._functions[cache_key] = cached_function
|
||||||
|
|
||||||
|
if PROFILE_MODE != "audit":
|
||||||
|
try:
|
||||||
|
return cached_function(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
self._failed_misses[cache_key] = (
|
||||||
|
self._failed_misses.get(cache_key, 0) + 1
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
before = cached_function.cache_info() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
value = cached_function(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
self._failed_misses[cache_key] = (
|
||||||
|
self._failed_misses.get(cache_key, 0) + 1
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
after = cached_function.cache_info() # type: ignore[attr-defined]
|
||||||
|
hit = after.hits > before.hits
|
||||||
|
record_property_cache(operation, hit=hit)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def info(self) -> PropertyCacheInfo:
|
||||||
|
cache_infos = {
|
||||||
|
key: cached.cache_info() # type: ignore[attr-defined]
|
||||||
|
for key, cached in self._functions.items()
|
||||||
|
}
|
||||||
|
return PropertyCacheInfo(
|
||||||
|
hits=sum(info.hits for info in cache_infos.values()),
|
||||||
|
misses=sum(info.misses for info in cache_infos.values()),
|
||||||
|
max_entries_per_cache=self.max_entries_per_cache,
|
||||||
|
cache_count=len(self._functions),
|
||||||
|
current_entries=sum(info.currsize for info in cache_infos.values()),
|
||||||
|
evictions=sum(
|
||||||
|
max(
|
||||||
|
0,
|
||||||
|
info.misses
|
||||||
|
- self._failed_misses.get(key, 0)
|
||||||
|
- info.currsize,
|
||||||
|
)
|
||||||
|
for key, info in cache_infos.items()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar(
|
||||||
|
"simulation_property_cache",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_property_cache() -> SimulationPropertyCache | None:
|
||||||
|
return _CURRENT_PROPERTY_CACHE.get()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def property_cache_run(
|
||||||
|
*,
|
||||||
|
max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES,
|
||||||
|
) -> Generator[SimulationPropertyCache | None, None, None]:
|
||||||
|
"""Bind a fresh cache to one top-level simulation run.
|
||||||
|
|
||||||
|
Nested uses reuse the existing cache so lower-level simulation helpers can
|
||||||
|
safely opt in without replacing the cache created by the API entry point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
existing = _CURRENT_PROPERTY_CACHE.get()
|
||||||
|
if existing is not None:
|
||||||
|
yield existing
|
||||||
|
return
|
||||||
|
if not PROPERTY_CACHE_ENABLED:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
|
||||||
|
cache = SimulationPropertyCache(max_entries=max_entries)
|
||||||
|
token = _CURRENT_PROPERTY_CACHE.set(cache)
|
||||||
|
try:
|
||||||
|
yield cache
|
||||||
|
finally:
|
||||||
|
_CURRENT_PROPERTY_CACHE.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_property_calculation(
|
||||||
|
operation: str,
|
||||||
|
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||||
|
"""Cache one pure property calculation with hashable arguments per run."""
|
||||||
|
|
||||||
|
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
if not PROPERTY_CACHE_ENABLED:
|
||||||
|
return function
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
cache = _CURRENT_PROPERTY_CACHE.get()
|
||||||
|
if cache is None:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
owner = args[0] if args else function
|
||||||
|
return cache.get_or_compute(
|
||||||
|
operation,
|
||||||
|
owner,
|
||||||
|
function,
|
||||||
|
tuple(args[1:] if args else ()),
|
||||||
|
dict(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||||
|
"""Ensure a simulation entry point has a run-local cache."""
|
||||||
|
|
||||||
|
if not PROPERTY_CACHE_ENABLED:
|
||||||
|
return function
|
||||||
|
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||||
|
with property_cache_run():
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_PROPERTY_CACHE_MAX_ENTRIES",
|
||||||
|
"PROPERTY_CACHE_ENABLED",
|
||||||
|
"PropertyCacheInfo",
|
||||||
|
"SimulationPropertyCache",
|
||||||
|
"cache_property_calculation",
|
||||||
|
"current_property_cache",
|
||||||
|
"property_cache_run",
|
||||||
|
"with_property_cache",
|
||||||
|
]
|
||||||
+1128
-38
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,9 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import isfinite
|
from math import isfinite
|
||||||
|
|
||||||
|
from app.simulation.core.base import Component
|
||||||
|
from app.simulation.core.ports import PortState
|
||||||
|
from app.simulation.performance import profile_phase
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
@@ -18,34 +21,62 @@ class PneumaticVolumeDiagnostics:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PneumaticVolumeConnectionBinding:
|
||||||
|
connected_endpoint: Endpoint
|
||||||
|
connected_port: PortState
|
||||||
|
|
||||||
|
|
||||||
class PneumaticVolumeResolver:
|
class PneumaticVolumeResolver:
|
||||||
"""Propagate AMESim pneumatic external-volume connector variables."""
|
"""Propagate AMESim pneumatic external-volume connector variables."""
|
||||||
|
|
||||||
def __init__(self, network: SimulationNetwork) -> None:
|
def __init__(self, network: SimulationNetwork) -> None:
|
||||||
self.network = network
|
self.network = network
|
||||||
|
self._pneumatic_ports = tuple(
|
||||||
|
component.get_port(definition.name)
|
||||||
|
for component in network.components.values()
|
||||||
|
for definition in component.active_port_definitions
|
||||||
|
if definition.kind == "physical" and definition.domain == "pneumatic"
|
||||||
|
)
|
||||||
|
self._output_components = tuple(
|
||||||
|
component
|
||||||
|
for component in network.components.values()
|
||||||
|
if type(component).pneumatic_volume_outputs
|
||||||
|
is not Component.pneumatic_volume_outputs
|
||||||
|
)
|
||||||
self._connected_endpoint = self._build_connection_map()
|
self._connected_endpoint = self._build_connection_map()
|
||||||
self.last_diagnostics: PneumaticVolumeDiagnostics | None = None
|
self.last_diagnostics: PneumaticVolumeDiagnostics | None = None
|
||||||
|
|
||||||
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
def _build_connection_map(
|
||||||
result: dict[Endpoint, Endpoint] = {}
|
self,
|
||||||
|
) -> dict[Endpoint, _PneumaticVolumeConnectionBinding]:
|
||||||
|
result: dict[Endpoint, _PneumaticVolumeConnectionBinding] = {}
|
||||||
for connection in self.network.connections:
|
for connection in self.network.connections:
|
||||||
if connection.kind != "physical" or connection.domain != "pneumatic":
|
if connection.kind != "physical" or connection.domain != "pneumatic":
|
||||||
continue
|
continue
|
||||||
first, second = connection.endpoints
|
first, second = connection.endpoints
|
||||||
result[first] = second
|
result[first] = _PneumaticVolumeConnectionBinding(
|
||||||
result[second] = first
|
connected_endpoint=second,
|
||||||
|
connected_port=self.network.components[second.component].get_port(
|
||||||
|
second.port
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result[second] = _PneumaticVolumeConnectionBinding(
|
||||||
|
connected_endpoint=first,
|
||||||
|
connected_port=self.network.components[first.component].get_port(
|
||||||
|
first.port
|
||||||
|
),
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@profile_phase("simulation.pneumatic_volume", minimum_mode="audit")
|
||||||
def solve(self) -> PneumaticVolumeDiagnostics:
|
def solve(self) -> PneumaticVolumeDiagnostics:
|
||||||
for component in self.network.components.values():
|
for port in self._pneumatic_ports:
|
||||||
for definition in component.active_port_definitions:
|
port.volume = 0.0
|
||||||
if definition.kind == "physical" and definition.domain == "pneumatic":
|
port.volume_flow = 0.0
|
||||||
port = component.get_port(definition.name)
|
|
||||||
port.volume = 0.0
|
|
||||||
port.volume_flow = 0.0
|
|
||||||
|
|
||||||
outputs: dict[Endpoint, tuple[float, float]] = {}
|
outputs: dict[Endpoint, tuple[float, float]] = {}
|
||||||
for component in self.network.components.values():
|
for component in self._output_components:
|
||||||
for port_name, raw_values in component.pneumatic_volume_outputs().items():
|
for port_name, raw_values in component.pneumatic_volume_outputs().items():
|
||||||
port = component.get_port(port_name)
|
port = component.get_port(port_name)
|
||||||
definition = port.definition
|
definition = port.definition
|
||||||
@@ -71,18 +102,15 @@ class PneumaticVolumeResolver:
|
|||||||
|
|
||||||
propagated = 0
|
propagated = 0
|
||||||
for endpoint, values in outputs.items():
|
for endpoint, values in outputs.items():
|
||||||
connected = self._connected_endpoint.get(endpoint)
|
binding = self._connected_endpoint.get(endpoint)
|
||||||
if connected is None:
|
if binding is None:
|
||||||
continue
|
continue
|
||||||
if connected in outputs:
|
if binding.connected_endpoint in outputs:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"A pneumatic connection cannot contain two external-volume "
|
"A pneumatic connection cannot contain two external-volume "
|
||||||
f"sources: {endpoint} and {connected}."
|
f"sources: {endpoint} and {binding.connected_endpoint}."
|
||||||
)
|
)
|
||||||
connected_port = self.network.components[connected.component].get_port(
|
binding.connected_port.volume, binding.connected_port.volume_flow = values
|
||||||
connected.port
|
|
||||||
)
|
|
||||||
connected_port.volume, connected_port.volume_flow = values
|
|
||||||
propagated += 1
|
propagated += 1
|
||||||
|
|
||||||
diagnostics = PneumaticVolumeDiagnostics(
|
diagnostics = PneumaticVolumeDiagnostics(
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import isfinite
|
from math import isfinite
|
||||||
from typing import Protocol
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
|
from app.simulation.core.base import Component
|
||||||
|
from app.simulation.core.ports import PortState
|
||||||
|
from app.simulation.performance import profile_phase
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
@@ -37,30 +40,56 @@ class SignalSolveDiagnostics:
|
|||||||
return {"propagated": self.propagated}
|
return {"propagated": self.propagated}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SignalOutputBinding:
|
||||||
|
component: Component
|
||||||
|
evaluate: Callable[[float], dict[str, float]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SignalConnectionBinding:
|
||||||
|
source: PortState
|
||||||
|
target: PortState
|
||||||
|
|
||||||
|
|
||||||
class SignalResolver:
|
class SignalResolver:
|
||||||
"""Propagate scalar signal connections from output ports to input ports."""
|
"""Propagate scalar signal connections from output ports to input ports."""
|
||||||
|
|
||||||
def __init__(self, network: SimulationNetwork) -> None:
|
def __init__(self, network: SimulationNetwork) -> None:
|
||||||
self.network = network
|
self.network = network
|
||||||
self._connections = [
|
self._output_bindings = tuple(
|
||||||
connection for connection in network.connections if connection.kind == "signal"
|
_SignalOutputBinding(component=component, evaluate=evaluate)
|
||||||
]
|
for component in network.components.values()
|
||||||
|
if (evaluate := getattr(component, "signal_output_values", None)) is not None
|
||||||
|
)
|
||||||
|
self._event_sources = tuple(
|
||||||
|
(component.name, source_event_times)
|
||||||
|
for component in network.components.values()
|
||||||
|
if (
|
||||||
|
source_event_times := getattr(
|
||||||
|
component,
|
||||||
|
"signal_event_times",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
self._connections = tuple(
|
||||||
|
self._connection_binding(connection.endpoints)
|
||||||
|
for connection in network.connections
|
||||||
|
if connection.kind == "signal"
|
||||||
|
)
|
||||||
self.last_diagnostics: SignalSolveDiagnostics | None = None
|
self.last_diagnostics: SignalSolveDiagnostics | None = None
|
||||||
|
|
||||||
|
@profile_phase("simulation.signal", minimum_mode="audit")
|
||||||
def solve(self, time: float) -> SignalSolveDiagnostics:
|
def solve(self, time: float) -> SignalSolveDiagnostics:
|
||||||
for component in self.network.components.values():
|
for binding in self._output_bindings:
|
||||||
signal_output_values = getattr(component, "signal_output_values", None)
|
for port_name, value in binding.evaluate(time).items():
|
||||||
if signal_output_values is None:
|
binding.component.get_port(port_name).signal = float(value)
|
||||||
continue
|
|
||||||
for port_name, value in signal_output_values(time).items():
|
|
||||||
component.get_port(port_name).signal = float(value)
|
|
||||||
|
|
||||||
propagated = 0
|
propagated = 0
|
||||||
for connection in self._connections:
|
for binding in self._connections:
|
||||||
source, target = self._source_target(connection.endpoints)
|
binding.target.signal = binding.source.signal
|
||||||
source_port = self.network.components[source.component].get_port(source.port)
|
|
||||||
target_port = self.network.components[target.component].get_port(target.port)
|
|
||||||
target_port.signal = source_port.signal
|
|
||||||
propagated += 1
|
propagated += 1
|
||||||
|
|
||||||
diagnostics = SignalSolveDiagnostics(propagated=propagated)
|
diagnostics = SignalSolveDiagnostics(propagated=propagated)
|
||||||
@@ -84,15 +113,12 @@ class SignalResolver:
|
|||||||
return ()
|
return ()
|
||||||
|
|
||||||
events: set[float] = set()
|
events: set[float] = set()
|
||||||
for component in self.network.components.values():
|
for component_name, source_event_times in self._event_sources:
|
||||||
source_event_times = getattr(component, "signal_event_times", None)
|
|
||||||
if source_event_times is None:
|
|
||||||
continue
|
|
||||||
for raw_time in source_event_times(start, stop):
|
for raw_time in source_event_times(start, stop):
|
||||||
event_time = float(raw_time)
|
event_time = float(raw_time)
|
||||||
if not isfinite(event_time):
|
if not isfinite(event_time):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Signal event time from component '{component.name}' must be finite."
|
f"Signal event time from component '{component_name}' must be finite."
|
||||||
)
|
)
|
||||||
if start < event_time < stop:
|
if start < event_time < stop:
|
||||||
events.add(event_time)
|
events.add(event_time)
|
||||||
@@ -107,3 +133,13 @@ class SignalResolver:
|
|||||||
if second_port.definition is not None and second_port.definition.nominal_role == "output":
|
if second_port.definition is not None and second_port.definition.nominal_role == "output":
|
||||||
return second, first
|
return second, first
|
||||||
raise ValueError("Signal connection must contain one output endpoint.")
|
raise ValueError("Signal connection must contain one output endpoint.")
|
||||||
|
|
||||||
|
def _connection_binding(
|
||||||
|
self,
|
||||||
|
endpoints: tuple[Endpoint, Endpoint],
|
||||||
|
) -> _SignalConnectionBinding:
|
||||||
|
source, target = self._source_target(endpoints)
|
||||||
|
return _SignalConnectionBinding(
|
||||||
|
source=self.network.components[source.component].get_port(source.port),
|
||||||
|
target=self.network.components[target.component].get_port(target.port),
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
|
from app.simulation.performance import profile_phase
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -806,9 +807,13 @@ def _integrate_scipy_stepwise(
|
|||||||
segment_accepted_steps += 1
|
segment_accepted_steps += 1
|
||||||
step_end_time = float(solver.t)
|
step_end_time = float(solver.t)
|
||||||
step_end_state = [float(value) for value in solver.y]
|
step_end_state = [float(value) for value in solver.y]
|
||||||
|
crosses_sample = (
|
||||||
|
sample_index < len(sample_times)
|
||||||
|
and sample_times[sample_index] <= step_end_time
|
||||||
|
)
|
||||||
dense_output = (
|
dense_output = (
|
||||||
solver.dense_output()
|
solver.dense_output()
|
||||||
if sample_times or state_transition_handler is not None
|
if crosses_sample or state_transition_handler is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -917,11 +922,11 @@ def _integrate_scipy_stepwise(
|
|||||||
else last_accepted_time
|
else last_accepted_time
|
||||||
)
|
)
|
||||||
if sample_times:
|
if sample_times:
|
||||||
assert dense_output is not None
|
|
||||||
while (
|
while (
|
||||||
sample_index < len(sample_times)
|
sample_index < len(sample_times)
|
||||||
and sample_times[sample_index] <= last_accepted_time
|
and sample_times[sample_index] <= last_accepted_time
|
||||||
):
|
):
|
||||||
|
assert dense_output is not None
|
||||||
sample_time = float(sample_times[sample_index])
|
sample_time = float(sample_times[sample_index])
|
||||||
sample_state = [
|
sample_state = [
|
||||||
float(value) for value in dense_output(sample_time)
|
float(value) for value in dense_output(sample_time)
|
||||||
@@ -1018,6 +1023,7 @@ def _integrate_scipy_stepwise(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@profile_phase("simulation.integration")
|
||||||
def integrate_ode(
|
def integrate_ode(
|
||||||
rhs: Callable[[float, list[float]], list[float]],
|
rhs: Callable[[float, list[float]], list[float]],
|
||||||
initial_state: list[float],
|
initial_state: list[float],
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.simulation.core.base import DynamicComponent
|
from app.simulation.core.base import Component, DynamicComponent
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
from app.simulation.core.ports import PortState
|
||||||
|
from app.simulation.performance import profile_phase
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
class StreamSolveError(RuntimeError):
|
class StreamSolveError(RuntimeError):
|
||||||
@@ -26,6 +28,14 @@ class StreamSolveDiagnostics:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _StreamConnectionBinding:
|
||||||
|
component_name: str
|
||||||
|
port_name: str
|
||||||
|
connected_component: Component
|
||||||
|
connected_port: PortState
|
||||||
|
|
||||||
|
|
||||||
class StreamResolver:
|
class StreamResolver:
|
||||||
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
||||||
|
|
||||||
@@ -39,28 +49,59 @@ class StreamResolver:
|
|||||||
self.network = network
|
self.network = network
|
||||||
self.relative_tolerance = relative_tolerance
|
self.relative_tolerance = relative_tolerance
|
||||||
self.max_iterations = max_iterations
|
self.max_iterations = max_iterations
|
||||||
self._connected_endpoint = self._build_connection_map()
|
self._components = tuple(network.components.values())
|
||||||
|
self._dynamic_components = tuple(
|
||||||
|
component
|
||||||
|
for component in self._components
|
||||||
|
if isinstance(component, DynamicComponent)
|
||||||
|
)
|
||||||
|
self._non_dynamic_components = tuple(
|
||||||
|
component
|
||||||
|
for component in self._components
|
||||||
|
if not isinstance(component, DynamicComponent)
|
||||||
|
)
|
||||||
|
self._ports = tuple(
|
||||||
|
(component.name, port_name, port)
|
||||||
|
for component in self._components
|
||||||
|
for port_name, port in component.ports.items()
|
||||||
|
)
|
||||||
|
self._connection_bindings = self._build_connection_bindings()
|
||||||
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
||||||
|
|
||||||
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
def _build_connection_bindings(self) -> tuple[_StreamConnectionBinding, ...]:
|
||||||
result: dict[Endpoint, Endpoint] = {}
|
result: list[_StreamConnectionBinding] = []
|
||||||
for connection in self.network.connections:
|
for connection in self.network.connections:
|
||||||
if connection.kind != "physical":
|
if connection.kind != "physical":
|
||||||
continue
|
continue
|
||||||
first, second = connection.endpoints
|
first, second = connection.endpoints
|
||||||
result[first] = second
|
first_component = self.network.components[first.component]
|
||||||
result[second] = first
|
second_component = self.network.components[second.component]
|
||||||
return result
|
result.append(
|
||||||
|
_StreamConnectionBinding(
|
||||||
|
component_name=first.component,
|
||||||
|
port_name=first.port,
|
||||||
|
connected_component=second_component,
|
||||||
|
connected_port=second_component.get_port(second.port),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.append(
|
||||||
|
_StreamConnectionBinding(
|
||||||
|
component_name=second.component,
|
||||||
|
port_name=second.port,
|
||||||
|
connected_component=first_component,
|
||||||
|
connected_port=first_component.get_port(first.port),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
||||||
values: dict[str, dict[str, float]] = {
|
values: dict[str, dict[str, float]] = {
|
||||||
component.name: {} for component in self.network.components.values()
|
component.name: {} for component in self._components
|
||||||
}
|
}
|
||||||
for endpoint, connected in self._connected_endpoint.items():
|
for binding in self._connection_bindings:
|
||||||
connected_port = self.network.components[connected.component].get_port(
|
values[binding.component_name][binding.port_name] = (
|
||||||
connected.port
|
binding.connected_port.h_outflow
|
||||||
)
|
)
|
||||||
values[endpoint.component][endpoint.port] = connected_port.h_outflow
|
|
||||||
return values
|
return values
|
||||||
|
|
||||||
def connected_temperature_reference_enthalpies(
|
def connected_temperature_reference_enthalpies(
|
||||||
@@ -69,52 +110,56 @@ class StreamResolver:
|
|||||||
"""Return connector references used for upstream temperature only."""
|
"""Return connector references used for upstream temperature only."""
|
||||||
|
|
||||||
values: dict[str, dict[str, float]] = {
|
values: dict[str, dict[str, float]] = {
|
||||||
component.name: {} for component in self.network.components.values()
|
component.name: {} for component in self._components
|
||||||
}
|
}
|
||||||
for endpoint, connected in self._connected_endpoint.items():
|
for binding in self._connection_bindings:
|
||||||
connected_component = self.network.components[connected.component]
|
values[binding.component_name][binding.port_name] = float(
|
||||||
connected_port = connected_component.get_port(connected.port)
|
|
||||||
values[endpoint.component][endpoint.port] = float(
|
|
||||||
getattr(
|
getattr(
|
||||||
connected_component,
|
binding.connected_component,
|
||||||
"temperature_reference_h",
|
"temperature_reference_h",
|
||||||
connected_port.h_outflow,
|
binding.connected_port.h_outflow,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return values
|
return values
|
||||||
|
|
||||||
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||||
dynamic_components = [
|
def _refresh_dynamic_components(self) -> None:
|
||||||
component
|
for component in self._dynamic_components:
|
||||||
for component in self.network.components.values()
|
|
||||||
if isinstance(component, DynamicComponent)
|
|
||||||
]
|
|
||||||
for component in dynamic_components:
|
|
||||||
component.refresh_thermodynamic_ports()
|
component.refresh_thermodynamic_ports()
|
||||||
|
|
||||||
|
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||||
|
def _refresh_stream_components(
|
||||||
|
self,
|
||||||
|
connected: dict[str, dict[str, float]],
|
||||||
|
) -> None:
|
||||||
|
for component in self._non_dynamic_components:
|
||||||
|
component.update_stream_outflows(connected[component.name])
|
||||||
|
|
||||||
|
@profile_phase("simulation.stream", minimum_mode="audit")
|
||||||
|
def solve(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
dynamic_ports_are_current: bool = False,
|
||||||
|
) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||||
|
if not dynamic_ports_are_current:
|
||||||
|
self._refresh_dynamic_components()
|
||||||
|
|
||||||
max_delta = 0.0
|
max_delta = 0.0
|
||||||
for iteration in range(1, self.max_iterations + 1):
|
for iteration in range(1, self.max_iterations + 1):
|
||||||
previous = {
|
previous = {
|
||||||
(component.name, port_name): port.h_outflow
|
(component_name, port_name): port.h_outflow
|
||||||
for component in self.network.components.values()
|
for component_name, port_name, port in self._ports
|
||||||
for port_name, port in component.ports.items()
|
|
||||||
}
|
}
|
||||||
connected = self.connected_enthalpies()
|
connected = self.connected_enthalpies()
|
||||||
for component in self.network.components.values():
|
self._refresh_stream_components(connected)
|
||||||
if isinstance(component, DynamicComponent):
|
|
||||||
component.refresh_thermodynamic_ports()
|
|
||||||
else:
|
|
||||||
component.update_stream_outflows(connected[component.name])
|
|
||||||
|
|
||||||
deltas = [
|
deltas = [
|
||||||
abs(port.h_outflow - previous[(component.name, port_name)])
|
abs(port.h_outflow - previous[(component_name, port_name)])
|
||||||
for component in self.network.components.values()
|
for component_name, port_name, port in self._ports
|
||||||
for port_name, port in component.ports.items()
|
|
||||||
]
|
]
|
||||||
magnitudes = [
|
magnitudes = [
|
||||||
abs(port.h_outflow)
|
abs(port.h_outflow)
|
||||||
for component in self.network.components.values()
|
for _component_name, _port_name, port in self._ports
|
||||||
for port in component.ports.values()
|
|
||||||
]
|
]
|
||||||
max_delta = max(deltas, default=0.0)
|
max_delta = max(deltas, default=0.0)
|
||||||
scale = max(magnitudes + [1.0])
|
scale = max(magnitudes + [1.0])
|
||||||
|
|||||||
+729
-150
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
|||||||
|
"""Process-local warm-up for the numerical simulation runtime."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from threading import Lock
|
||||||
|
from time import perf_counter
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
WarmupStatus = Literal["completed", "failed", "disabled"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SimulationWarmupReport:
|
||||||
|
status: WarmupStatus
|
||||||
|
duration_ms: float
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
_WARMUP_LOCK = Lock()
|
||||||
|
_WARMUP_REPORT: SimulationWarmupReport | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def simulation_warmup_enabled() -> bool:
|
||||||
|
raw_value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower()
|
||||||
|
if raw_value in {"", "1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if raw_value in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise ValueError(
|
||||||
|
"SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_numerical_warmup() -> None:
|
||||||
|
"""Exercise only in-memory SciPy paths used by real simulations."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau, solve_ivp
|
||||||
|
from scipy.optimize import brentq, least_squares
|
||||||
|
from scipy.optimize._numdiff import group_columns
|
||||||
|
from scipy.sparse import csc_matrix, csr_matrix
|
||||||
|
|
||||||
|
# Importing these classes is intentional even though the micro solve below
|
||||||
|
# uses BDF: the stepwise solver selects them dynamically at runtime.
|
||||||
|
solver_types = (BDF, DOP853, LSODA, RK23, RK45, Radau)
|
||||||
|
if len(solver_types) != 6:
|
||||||
|
raise RuntimeError("SciPy solver warm-up did not load every supported method.")
|
||||||
|
|
||||||
|
sparsity = csc_matrix(np.array([[1.0]], dtype=float))
|
||||||
|
groups = group_columns(sparsity)
|
||||||
|
if groups.shape != (1,):
|
||||||
|
raise RuntimeError("SciPy Jacobian grouping warm-up returned an invalid shape.")
|
||||||
|
|
||||||
|
integration = solve_ivp(
|
||||||
|
lambda _time, state: -state,
|
||||||
|
(0.0, 1.0e-4),
|
||||||
|
np.array([1.0], dtype=float),
|
||||||
|
method="BDF",
|
||||||
|
t_eval=np.array([0.0, 1.0e-4], dtype=float),
|
||||||
|
jac_sparsity=sparsity,
|
||||||
|
rtol=1.0e-6,
|
||||||
|
atol=1.0e-9,
|
||||||
|
)
|
||||||
|
if not integration.success or not np.isfinite(integration.y).all():
|
||||||
|
raise RuntimeError("SciPy integration warm-up did not complete successfully.")
|
||||||
|
|
||||||
|
algebraic_sparsity = csr_matrix(np.eye(2, dtype=bool))
|
||||||
|
algebraic = least_squares(
|
||||||
|
lambda state: np.array(
|
||||||
|
[state[0] - 1.0, state[1] - 2.0],
|
||||||
|
dtype=float,
|
||||||
|
),
|
||||||
|
np.array([0.5, 0.5], dtype=float),
|
||||||
|
bounds=(
|
||||||
|
np.array([0.0, 0.0], dtype=float),
|
||||||
|
np.array([3.0, 3.0], dtype=float),
|
||||||
|
),
|
||||||
|
jac_sparsity=algebraic_sparsity,
|
||||||
|
tr_solver="lsmr",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not algebraic.success
|
||||||
|
or not np.isfinite(algebraic.x).all()
|
||||||
|
or not np.allclose(algebraic.x, np.array([1.0, 2.0]), atol=1.0e-8)
|
||||||
|
):
|
||||||
|
raise RuntimeError("SciPy algebraic warm-up did not complete successfully.")
|
||||||
|
|
||||||
|
root = brentq(lambda value: value - 0.5, 0.0, 1.0)
|
||||||
|
if abs(root - 0.5) > 1.0e-12:
|
||||||
|
raise RuntimeError("SciPy scalar root warm-up returned an invalid result.")
|
||||||
|
|
||||||
|
# Compile the cached v3 XSD through the same public validation path. The
|
||||||
|
# intentionally incomplete document is never accepted or persisted.
|
||||||
|
from app.system_xml import validate_system_xml_document
|
||||||
|
|
||||||
|
validate_system_xml_document(b"<System/>")
|
||||||
|
|
||||||
|
|
||||||
|
def warm_up_simulation_runtime() -> SimulationWarmupReport:
|
||||||
|
"""Warm one worker exactly once, returning a startup diagnostic report.
|
||||||
|
|
||||||
|
Ordinary warm-up failures are reported but do not prevent the editor and
|
||||||
|
non-simulation APIs from starting. ``MemoryError`` remains fatal because
|
||||||
|
continuing a worker under memory exhaustion is unsafe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _WARMUP_REPORT
|
||||||
|
|
||||||
|
with _WARMUP_LOCK:
|
||||||
|
if _WARMUP_REPORT is not None:
|
||||||
|
return _WARMUP_REPORT
|
||||||
|
if not simulation_warmup_enabled():
|
||||||
|
_WARMUP_REPORT = SimulationWarmupReport(
|
||||||
|
status="disabled",
|
||||||
|
duration_ms=0.0,
|
||||||
|
)
|
||||||
|
return _WARMUP_REPORT
|
||||||
|
|
||||||
|
started = perf_counter()
|
||||||
|
try:
|
||||||
|
_run_numerical_warmup()
|
||||||
|
except MemoryError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_WARMUP_REPORT = SimulationWarmupReport(
|
||||||
|
status="failed",
|
||||||
|
duration_ms=(perf_counter() - started) * 1000.0,
|
||||||
|
error=f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
LOGGER.exception("Simulation runtime warm-up failed; startup will continue.")
|
||||||
|
else:
|
||||||
|
_WARMUP_REPORT = SimulationWarmupReport(
|
||||||
|
status="completed",
|
||||||
|
duration_ms=(perf_counter() - started) * 1000.0,
|
||||||
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"Simulation runtime warm-up completed in %.1f ms.",
|
||||||
|
_WARMUP_REPORT.duration_ms,
|
||||||
|
)
|
||||||
|
return _WARMUP_REPORT
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_simulation_warmup_for_tests() -> None:
|
||||||
|
global _WARMUP_REPORT
|
||||||
|
with _WARMUP_LOCK:
|
||||||
|
_WARMUP_REPORT = None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SimulationWarmupReport",
|
||||||
|
"simulation_warmup_enabled",
|
||||||
|
"warm_up_simulation_runtime",
|
||||||
|
]
|
||||||
@@ -10,6 +10,7 @@ from typing import Literal
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from app.simulation.core.ports import PortDefinition
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
from app.simulation.performance import profile_phase
|
||||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec
|
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec
|
||||||
from app.simulation.solvers.solver import SolveIVPConfig
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
from app.simulation.systems.generic import (
|
from app.simulation.systems.generic import (
|
||||||
@@ -191,6 +192,7 @@ class SystemXmlValidationReport:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@profile_phase("simulation.xml_validation")
|
||||||
def validate_system_xml_document(source: bytes | str) -> SystemXmlValidationReport:
|
def validate_system_xml_document(source: bytes | str) -> SystemXmlValidationReport:
|
||||||
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
||||||
if not xml_bytes.strip():
|
if not xml_bytes.strip():
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
|
|
||||||
- [更新日志 2026-08-15](更新日志-2026-08-15.md)
|
- [更新日志 2026-08-15](更新日志-2026-08-15.md)
|
||||||
|
|
||||||
|
## 求解与性能
|
||||||
|
|
||||||
|
- [后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
|
||||||
|
- [仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)
|
||||||
|
|
||||||
## 模型开发
|
## 模型开发
|
||||||
|
|
||||||
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
|
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ app/simulation/components/experimental/junctions/tee.py
|
|||||||
```python
|
```python
|
||||||
MODEL_TYPE = "example_component"
|
MODEL_TYPE = "example_component"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (...)
|
PORTS = (...)
|
||||||
PARAMETERS = (...)
|
PARAMETERS = (...)
|
||||||
RESULT_VARIABLES = (...)
|
RESULT_VARIABLES = (...)
|
||||||
@@ -153,6 +154,18 @@ def create(
|
|||||||
- `pressure_flow_equation_residuals()`。
|
- `pressure_flow_equation_residuals()`。
|
||||||
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
|
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
|
||||||
|
|
||||||
|
实现 `update_stream_outflows()` 或 `update_flow_temperature_references()` 的公开模型,
|
||||||
|
还应在该公开类自身显式声明 `PRESSURE_FLOW_DEPENDS_ON_STREAM`:构成压力/流量方程
|
||||||
|
会读取这些 hook 写入的焓或温度引用时设为 `True`,否则设为 `False`。省略声明、
|
||||||
|
声明非法值或由自定义子类仅继承父类声明时,求解器会保守使用全网热流闭合;不要
|
||||||
|
为了获得分块加速而错误声明 `False`。
|
||||||
|
|
||||||
|
`pressure_flow_equation_residuals()` 返回的每条 `EquationResidual.variables` 必须完整
|
||||||
|
列出该残差实际读取的全部代数端口量(`p/m_flow/x/v/f`),不能只写“主要变量”。
|
||||||
|
求解器会用这份声明编译稀疏 Jacobian 和独立方程块;漏写依赖可能让有限差分方向
|
||||||
|
不完整。仓库内置组件会接受结构与数值依赖回归,外部自定义组件当前仍保守使用
|
||||||
|
全网 dense 回退,直到具备同等的依赖验证边界。
|
||||||
|
|
||||||
### 6.2 `ThermodynamicVolumeComponent`
|
### 6.2 `ThermodynamicVolumeComponent`
|
||||||
|
|
||||||
适用于包含质量和能量状态的气体容腔,例如:
|
适用于包含质量和能量状态的气体容腔,例如:
|
||||||
@@ -406,6 +419,27 @@ def create(
|
|||||||
- 必要正则化必须有物理解释,并通过边界测试保护。
|
- 必要正则化必须有物理解释,并通过边界测试保护。
|
||||||
- 不得用画布坐标、连接线方向或组件名称决定方程。
|
- 不得用画布坐标、连接线方向或组件名称决定方程。
|
||||||
|
|
||||||
|
### 12.1 可因果执行的残差语义
|
||||||
|
|
||||||
|
后端只会对经过结构门控的内置模型启用完全因果执行。除完整声明
|
||||||
|
`variables` 外,这些模型还必须遵守以下可执行语义:
|
||||||
|
|
||||||
|
- `role="effort", relation="state"` 的残差写成
|
||||||
|
`端口 effort - 状态给定值`,被约束的端口量系数必须为 `+1`。
|
||||||
|
- `role="effort", relation="equal"` 的残差写成两个同类 effort 的差。
|
||||||
|
- `role="flow", relation="sumToZero"` 按“流入组件为正”的约定求和,待消元
|
||||||
|
flow 的系数必须为 `+1`。
|
||||||
|
- `role="flow", relation="constitutive"` 写成
|
||||||
|
`待消元 flow - 本构计算值`,待消元 flow 的系数必须为 `+1`。
|
||||||
|
- `pressure_flow_equation_values()` 必须是无副作用的只读计算,返回顺序和长度
|
||||||
|
必须与 `pressure_flow_equation_residuals()` 的编译结果永久一致;不得在求残差时
|
||||||
|
修改端口、状态或活动集缓存。
|
||||||
|
|
||||||
|
求解器仍会在首次闭合、离散事件之后和固定周期执行完整残差审计。结构不满足、
|
||||||
|
运行时覆盖不完整或审计不通过时,会立即熔断到原有残差/非线性求解路径。现场诊断
|
||||||
|
时可在启动进程前设置 `SIMULATION_CAUSAL_FAST_PATH=0`,一键关闭该优化而不改变模型
|
||||||
|
文件。
|
||||||
|
|
||||||
动态模型还必须:
|
动态模型还必须:
|
||||||
|
|
||||||
- 状态向量长度稳定。
|
- 状态向量长度稳定。
|
||||||
@@ -435,6 +469,7 @@ from app.simulation.core.ports import PortDefinition
|
|||||||
class ExampleRestriction(AlgebraicComponent):
|
class ExampleRestriction(AlgebraicComponent):
|
||||||
MODEL_TYPE = "example_restriction"
|
MODEL_TYPE = "example_restriction"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# SystemSimulationApp 仿真性能评估(2026-08-15)
|
||||||
|
|
||||||
|
> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。
|
||||||
|
> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。
|
||||||
|
> 2026-08-16 已按本报告建议实现“仿真内独立物性缓存”“worker 启动暖机”、高刚度试探压力边界修复、方程关联块闭合、代数稀疏回退、外部 volume 跨域 ODE Jacobian 修正和 dense output 惰性构造;原始基线数据保留用于对照,当前大型 XML 复验见第 11 节。
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
1. **压力—流量闭合是原始基线的首要热点。** 2026-08-15 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加;2026-08-16 已完成方程块与稀疏首轮,当前现状见第 11 节。
|
||||||
|
2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。
|
||||||
|
3. **评估基线已有的两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些数据描述 2026-08-15 的原始基线,后续实现见第 9 节。
|
||||||
|
4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。
|
||||||
|
5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。
|
||||||
|
6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。
|
||||||
|
7. **用户提供的高刚度 XML 已能完成 10 s 仿真。** 原始基线在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;原因是压力优化下界为 1 Pa,排除了 RK45 合法产生的、仍严格大于 0 Pa 的亚帕试探值。2026-08-16 将优化器压力下界放宽到 0 Pa 后,构成方程仍要求压力严格为正,完整 RK45 仿真通过且没有触发可恢复重试。
|
||||||
|
8. **闭合不再固定执行第二次全网压力求解,也不再把一个大物理岛等同于一个求解块。** 每次闭合仍先保证全网成立;stream 更新后,只重算声明为 stream-sensitive 的方程—未知量关联块。物理连通岛只是安全范围,当前 `secondaryBlockCount` 是真实方程块数;无法安全分类的自定义模型会保守回退原全网路径。
|
||||||
|
9. **历史物理岛版收益取决于模型拓扑。** `off` 模式配对测试中,空气链、空气分支、氦气阶跃、机械接触和高刚度短算例分别改善 7.9%、6.5%、0.6%、21.4% 和 14.6%。这些数据保留作纵向基线,但该版已由方程关联块实现取代。
|
||||||
|
10. **大型分支 XML 的 `0.69 s` 现象已定位并完整跑通。** 输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量、74 个 ODE 状态。根因是外部 volume 跨域耦合在 ODE Jacobian 依赖图中漏 12 个实测显著项,而不是线程死锁;修正后结构由 1092 非零/27 色变为 1284 非零/31 色。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009。
|
||||||
|
11. **代数非线性回退已有可信声明图上的稀疏保护链。** 先求本轮未闭合方程块的 union sparse;失败恢复原始 `x0` 后做 global sparse,再失败才做 dense。受控扰动微基准在相同 `max_nfev=20` 下把真实残差回调由 3796 降至 164、墙钟约 7.357 s 降至 0.634 s(约 11.6 倍);活动接触或不可信声明仍走兼容 dense 路径。
|
||||||
|
12. **首轮其他优化均按适用范围解释。** worker 暖机已覆盖 sparse `least_squares` 的 LSMR 路径;dense output 仅在跨采样点或需要状态事件时构造,但本次大型 XML 含状态事件,因此没有本案收益。机械 `atol` 的 `1e-12→1e-10` A/B 约快 16%,但会改变机械误差合同,未采用;外层 thermofluid 流量固定点相对容差的 `1e-12→1e-9` A/B 反而增加 BDF 步数并改变轨迹,也未采用。
|
||||||
|
|
||||||
|
## 2. 埋点实现与污染控制
|
||||||
|
|
||||||
|
性能开关由进程启动环境变量 `SIMULATIONAPP_PROFILE` 决定:
|
||||||
|
|
||||||
|
| 模式 | 用途 | 记录内容 | 适合场景 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `off` | 正常运行,默认值 | 不在响应中加入性能数据;装饰器在模块加载时直接返回原函数 | 正式仿真和最终性能对比 |
|
||||||
|
| `standard` | 低开销阶段统计 | XML 校验、网络编译、系统构造、初始化、积分、后处理、结果组装 | 日常定位“大阶段” |
|
||||||
|
| `audit` | 深度审计 | 再展开 RHS、代数闭合、压力流量、stream、刷新、导数和物性内核 | 短算例诊断、调用频率与缓存评估 |
|
||||||
|
|
||||||
|
一次运行使用一个 `ContextVar` 隔离的 `PerformanceTrace`,不会把不同仿真任务的阶段计数混在一起。成功或失败的求解结果在 profiling 模式下都会把快照放入 `diagnostics.performance`。主要字段为:
|
||||||
|
|
||||||
|
- 阶段:`calls`、`inclusiveNs`、`selfNs`、`maxNs`、`errors`;
|
||||||
|
- 物性:上述时间字段,以及介质、操作、缓存查询/命中/未命中;
|
||||||
|
- audit 专有:闭合内精确输入唯一数/重复数、逆解迭代总数/最大值/收敛与未收敛次数;
|
||||||
|
- `propertyOutermostNs`:只累计最外层物性调用,避免把嵌套 PR 内核时间重复相加。
|
||||||
|
|
||||||
|
标准模式只保留低频的大阶段计时。21 次氦气阶跃配对运行中,标准模式相对关闭模式的中位开销为 1.9%;四个短算例分开校准为 0.5%~2.9%。audit 会逐次生成精确指纹并计时,短算例可慢到约 2.5~5 倍,因此 audit 数据用于定位和计数,最终优化收益必须回到 `off` 模式复测。
|
||||||
|
|
||||||
|
将当前代码的 `off` 模式与备份提交 `6a06489` 同时运行 21 次氦气阶跃,墙钟中位数差为约 0.3%,处于本机噪声范围。也就是说,默认关闭时没有观察到稳定的热路径退化。
|
||||||
|
|
||||||
|
## 3. 测试方法
|
||||||
|
|
||||||
|
环境:Windows 11、Python 3.12.3、SciPy 1.18.0、64 位 Intel 处理器。仓库没有 PyInstaller/Nuitka 等可执行文件构建链,本次直接使用项目实际启动后端的 `.venv-win` 解释器。把同一 Python 代码再包成单文件只会混入解包和启动成本,不会使这里的求解内核更接近生产路径。
|
||||||
|
|
||||||
|
基准工具入口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
|
||||||
|
--mode audit --warmups 1 --runs 3 `
|
||||||
|
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
|
||||||
|
--output app/data/performance-evaluations/helium-step.json
|
||||||
|
```
|
||||||
|
|
||||||
|
工具默认传入取消检查回调,从而走与前端流式仿真相同的低层逐步积分路径。它记录墙钟、进程 CPU、最终 NDJSON 编码、输入 SHA-256 和完整性能快照。原始 JSON 写入被 Git 忽略的 `app/data/performance-evaluations/`,避免把机器相关的大量样本提交到仓库。
|
||||||
|
|
||||||
|
本次代表算例:
|
||||||
|
|
||||||
|
| 算例 | 内容 | 暖机后 `off` 墙钟中位数 | 重复次数 |
|
||||||
|
| --- | --- | ---: | ---: |
|
||||||
|
| `air_chain` | 空气气缸—节流孔—管路—储罐 | 62.4 ms | 9 |
|
||||||
|
| `air_branched` | 空气分支网络 | 130.3 ms | 9 |
|
||||||
|
| `helium_step` | 高压 PR 氦气、信号阶跃阀 | 65.2 ms | 9 |
|
||||||
|
| `mechanical_contact` | MECMAS21/LSTP00A 弹性接触 | 33.0 ms | 9 |
|
||||||
|
| `helium_long` | 10 s PR 氦气均压、501 个输出点 | 10.63 s | 1 |
|
||||||
|
|
||||||
|
短算例先暖机 2 次再测 9 次;缓存 A/B 使用两个同时启动的独立进程各暖机 5 次、测量 21 次,以尽量抵消瞬时系统负载。长算例只测 1 次,因此它只用于判断数量级与阶段占比。
|
||||||
|
|
||||||
|
## 4. 深度阶段结果
|
||||||
|
|
||||||
|
下表时间是 audit 中位数,会包含审计自身开销;调用数和相对热点比绝对时间更可靠。
|
||||||
|
|
||||||
|
| 算例 | RHS | 完整闭合 | 压力流量求解 | 压力流量包含时间占 audit 总时间 | 物性调用 | 闭合内精确重复率 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| `air_chain` | 33 | 37 | 74 | 77.0% | 4,265 | 91.2% |
|
||||||
|
| `air_branched` | 23 | 26 | 56 | 84.6% | 7,378 | 96.5% |
|
||||||
|
| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% |
|
||||||
|
| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 |
|
||||||
|
|
||||||
|
这是 2026-08-15 原始基线的闭合数据:当时每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点,理论上最多 26 次;四个算例平均为 2.00、2.15、2.30 和 2.00 次/闭合。随后 2026-08-16 的第一版先把后续重算缩到 stream-sensitive 物理连通岛;该历史版本又被当前方程关联块版取代。当前做法是在安全物理范围内只重算敏感方程实际关联的块,没有敏感块时不强制第二次压力求解。这样裁剪的是无效重算,不是删除真实耦合。
|
||||||
|
|
||||||
|
## 5. 物性调用与缓存结果
|
||||||
|
|
||||||
|
氦气阶跃的冷缓存 audit 代表运行:
|
||||||
|
|
||||||
|
| 操作 | 调用 | 命中/未命中 | 命中率 | 真实逆解次数 | 平均迭代 | 最大迭代 | 未收敛 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 |
|
||||||
|
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 |
|
||||||
|
|
||||||
|
在原始基线中,暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当时的进程级精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
|
||||||
|
|
||||||
|
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,712 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
|
||||||
|
|
||||||
|
空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。
|
||||||
|
|
||||||
|
## 6. 输出与失败样本
|
||||||
|
|
||||||
|
| 算例 | 最终结果大小 | NDJSON 编码中位数 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| `air_chain` | 29.2 KiB | 0.39 ms |
|
||||||
|
| `air_branched` | 59.1 KiB | 1.21 ms |
|
||||||
|
| `helium_step` | 55.5 KiB | 1.10 ms |
|
||||||
|
| `mechanical_contact` | 51.4 KiB | 0.62 ms |
|
||||||
|
| `helium_long` | 507.4 KiB | 18.48 ms |
|
||||||
|
|
||||||
|
用户高刚度 XML 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。原始 audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;其 1.08 s 只代表历史失败路径,不能当作完整模型性能。允许严格正的亚帕试探压力后,同一模型已完成 10 s,当前完整性能结果见第 10 节。
|
||||||
|
|
||||||
|
## 7. 后续优化顺序
|
||||||
|
|
||||||
|
1. **[2026-08-16 已落实方程块与稀疏首轮] 优化压力流量执行计划。** 压力/流量方程、显式赋值、热流依赖和方程—未知量关联图已预编译;每轮 stream 更新后只重算敏感方程块。非线性时先做可信未闭合块的 union sparse,失败从原 `x0` 做 global sparse→dense;自定义、活动接触或结构不安全的网络保守回退兼容路径。后续仍可评估 equality group 真正消元和解析 Jacobian。
|
||||||
|
2. **[2026-08-16 已落实] 减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,并缓存相同输入的密度和等熵因子;沿用精确键、有界容量和按仿真隔离原则。
|
||||||
|
3. **[2026-08-16 已落实] 处理冷启动。** worker 在 FastAPI lifespan 中完成无业务副作用的 SciPy/XSD 微型暖机后再接收请求;不要把约 0.65 s 冷启动归因到每次仿真。
|
||||||
|
4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。
|
||||||
|
5. **[2026-08-16 已落实] 修复高刚度 XML 的压力试探边界。** 优化器允许严格正的亚帕试探值,物理构成方程仍拒绝零压和负压;该模型已用 10 s RK45 回归验证,不需要把这类合法试探误报为可恢复拒步。
|
||||||
|
6. **[2026-08-16 已落实] 补全外部 volume 的跨域 ODE Jacobian 依赖。** 机械位移写入气室容积后,气动储能与机械力平衡必须在状态稀疏图中双向关联;大型分支 XML 据此完整跑通。机械 `atol` 放宽虽有约 16% 的单次改善但改变精度合同;flow 固定点容差放宽反而增加步数,均未采用。
|
||||||
|
7. **[2026-08-16 已落实] dense output 惰性构造。** 仅当已接受步跨越下一样本或需要状态事件时构造插值;含状态事件的模型每步仍需要,不将其宣传为大型分支 XML 的收益来源。
|
||||||
|
|
||||||
|
## 8. 本次评估边界
|
||||||
|
|
||||||
|
- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。
|
||||||
|
- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。
|
||||||
|
- 原始基线的 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告当时均为单任务运行。该并发统计限制已由第 9 节的仿真内独立缓存消除。
|
||||||
|
- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。
|
||||||
|
- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。
|
||||||
|
- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。
|
||||||
|
|
||||||
|
## 9. 2026-08-16 缓存与启动暖机复验
|
||||||
|
|
||||||
|
原先两个函数级 LRU 会在 Python 进程内跨仿真共享条目。现已改为每次仿真通过
|
||||||
|
`ContextVar` 创建独立缓存,并在运行结束后整体释放;并发任务不会共享缓存或
|
||||||
|
命中统计。每个“物性操作 + 介质实例”使用独立的 C 层有界 LRU,默认上限为
|
||||||
|
8192 项。当前只缓存四条有明确重复收益的氦气路径:密度、等熵密度—压力因子、
|
||||||
|
`properties_from_mU` 和 `temperature_from_pressure_enthalpy`。
|
||||||
|
|
||||||
|
同一个高压氦气阶跃算例的冷缓存 audit 结果为:
|
||||||
|
|
||||||
|
| 操作 | 调用 | 命中 / 未命中 | 命中率 |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `density` | 578 | 403 / 175 | 69.7% |
|
||||||
|
| `isentropic_density_pressure_factor` | 398 | 310 / 88 | 77.9% |
|
||||||
|
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% |
|
||||||
|
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% |
|
||||||
|
|
||||||
|
四项合计 2,869 次命中、435 次未命中、435 个最终条目,未发生驱逐。PR 三次根
|
||||||
|
计算从原审计的 1,712 次降到 689 次。关闭埋点、各自预热 5 次并测量 21 次时,
|
||||||
|
缓存开启/完全关闭的墙钟中位数在本机分别为 71.6 ms 和 154.0 ms。这个对比表示
|
||||||
|
“四项缓存整体”相对“完全不缓存”的收益,不能误解为相对旧版两个 LRU 又提升
|
||||||
|
53.5%。缓存开关两次运行的完整 `series` 和 `final` SHA-256 一致。
|
||||||
|
|
||||||
|
10 s、501 个输出点的氦气均压算例单次复验中,缓存开启和关闭分别用时
|
||||||
|
14.18 s 与 26.64 s;开启时命中 400,571 次、未命中 96,427 次。四个缓存均达到
|
||||||
|
各自 8192 项上限,共发生 63,659 次 LRU 驱逐,但仍完成到 10 s。该长算例每种
|
||||||
|
配置只测了一次,只能说明容量上限确实生效且仍有收益,不能作为稳定百分比承诺。
|
||||||
|
|
||||||
|
worker 暖机只执行内存中的一维 BDF、`least_squares`、`brentq`、稀疏 Jacobian
|
||||||
|
分组和 XSD 编译,不运行用户模型、不写文件、不填充氦气业务缓存。当前暖机还显式
|
||||||
|
覆盖携带 `jac_sparsity`、使用 sparse LSMR trust-region 子问题的代数路径,避免真实
|
||||||
|
用户任务第一次触发该 SciPy 分支时再承担惰性初始化。三个新进程的
|
||||||
|
中位数为:不暖机首个仿真 709.1 ms;启动暖机本身 637.1 ms;暖机后的首个仿真
|
||||||
|
69.8 ms,同进程第二次约 68~74 ms。也就是说总初始化成本没有消失,而是被
|
||||||
|
移到服务宣告就绪之前。
|
||||||
|
|
||||||
|
## 10. 2026-08-16 历史物理岛版闭合复验
|
||||||
|
|
||||||
|
> 本节保留方程关联块实现之前的物理岛版数据,用于纵向对照。它已不是当前执行计划;当前结果见第 11 节。
|
||||||
|
|
||||||
|
该轮把 signal 源/连接、stream 组件/端口/连接以及气动外部 volume 组件/连接的
|
||||||
|
静态查找移到系统构造阶段;运行时仍按每个状态执行实际传播和热力刷新。压力流量
|
||||||
|
闭合先进行一次全网求解,再根据预编译的依赖声明,只对 stream-sensitive 的物理
|
||||||
|
连通块做后续固定点重算。对未声明依赖的自定义 stream 组件、跨组件方程或非方阵
|
||||||
|
物理岛,执行计划保守回退到原全网求解,不以性能换取模型兼容性。
|
||||||
|
|
||||||
|
该历史版本的 `off` 模式配对结果如下。表中百分比是同一模型、同一数值配置下的墙钟改善,适合
|
||||||
|
判断优化方向,不是跨机器速度承诺:
|
||||||
|
|
||||||
|
| 算例 | 历史物理岛版相对原全网闭合的改善 |
|
||||||
|
| --- | ---: |
|
||||||
|
| `air_chain` | 7.9% |
|
||||||
|
| `air_branched` | 6.5% |
|
||||||
|
| `helium_step` | 0.6% |
|
||||||
|
| `mechanical_contact` | 21.4% |
|
||||||
|
| high-stiffness short | 14.6% |
|
||||||
|
|
||||||
|
`helium_step` 在该历史版本里只有一个需要在 stream 后继续求解的敏感物理岛,因此 0.6% 的改善处于
|
||||||
|
小幅范围;不能用其他无敏感岛模型的收益夸大氦气模型的效果。积分完成后的后处理
|
||||||
|
也没有跳过闭合:每个输出采样点仍重新应用状态、执行完整 `_close_current_state()`
|
||||||
|
并提取结果,只是闭合内部使用同一安全执行计划。
|
||||||
|
|
||||||
|
完整 high-stiffness 10 s 算例的历史 `off` 基线约为 28.126 s;本轮全部改动后的
|
||||||
|
多次运行中位数为 13.694 s。这个跨版本对比同时包含本轮多项改动,不能把差额全部
|
||||||
|
归因于物理岛裁剪。为了单独核对当时的闭合计划,在同版代码上做 optimized/forced-global
|
||||||
|
对照,墙钟分别为 14.676 s 和 16.929 s,完整 `series` 完全一致;这组受控对比才
|
||||||
|
直接反映该模型的执行计划收益。
|
||||||
|
|
||||||
|
当前诊断已经进一步消除旧命名歧义:`secondaryPhysicalIslandCount` 表示安全分类的
|
||||||
|
物理范围,`secondaryBlockCount` 表示方程—未知量关联图中的真实块数,
|
||||||
|
`secondaryUnknownCount` 表示这些方程块合计未知量。`solveCount` 统计实际求解器调用,
|
||||||
|
`closurePassCount` 单列发生过压力求解的闭合 pass;`last` 中还包含
|
||||||
|
`residualEvaluations`、`jacobianMode`、dense/方程块回退状态。不能再把
|
||||||
|
`secondaryBlockCount` 解释为物理岛数。
|
||||||
|
|
||||||
|
## 11. 2026-08-16 大型分支 XML 与方程块首轮复验
|
||||||
|
|
||||||
|
验证输入 `test_mql-full-branches-01-04.xml` 的 SHA-256 为
|
||||||
|
`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`。模型规模如下:
|
||||||
|
|
||||||
|
| 项目 | 数量 |
|
||||||
|
| --- | ---: |
|
||||||
|
| 组件 | 98 |
|
||||||
|
| ODE 状态 | 74 |
|
||||||
|
| 压力/流量/机械代数未知量与方程 | 472 / 472 |
|
||||||
|
| 代数声明图结构非零 | 919(约 0.413%) |
|
||||||
|
| 全部独立代数方程块 | 58 |
|
||||||
|
| stream 后续敏感方程块 | 9,合计 192 个未知量 |
|
||||||
|
|
||||||
|
### 11.1 `0.69 s` 慢区的根因与完整结果
|
||||||
|
|
||||||
|
旧代码在 `0.69 s` 左右不是线程死锁:它仍会缓慢前进,但 BDF 在刚性变化区大量
|
||||||
|
缩步、重建有限差分 Jacobian 和执行 LU。定位出的结构错误是气动外部 volume
|
||||||
|
跨域耦合没有完整进入 ODE 状态依赖图。机械位置先写入气室容积,气室压力又反馈到
|
||||||
|
机械力平衡;旧图只沿普通物理端口追踪,漏掉这条闭环中的 12 个实测显著导数项。
|
||||||
|
|
||||||
|
修正后,状态稀疏图由 1092 个非零项、27 个颜色组变为 1284 个非零项、31 个颜色组。
|
||||||
|
颜色数增加是因为补上了真实依赖,并非回退到更差算法;完整 Jacobian 让 BDF 少走
|
||||||
|
错误 Newton 方向和重复试步。功能收口过程中的较早阶段测量为 92.187 s;最终稳定
|
||||||
|
代码连续三次完整 `0~0.81 s` 分别用时 79.049 s、74.658 s 和 85.103 s,数值工作量一致:
|
||||||
|
|
||||||
|
| 指标 | 结果 |
|
||||||
|
| --- | ---: |
|
||||||
|
| `nfev` | 3393 |
|
||||||
|
| `njev` | 226 |
|
||||||
|
| `nlu` | 667 |
|
||||||
|
| 接受步 | 1009 |
|
||||||
|
| 求解器启动次数 | 3 |
|
||||||
|
| 压力流量求解 | 28008,全部 seeded |
|
||||||
|
| 方程块/dense 非线性回退 | 0 / 0 |
|
||||||
|
| 三次 full-response 规范 JSON SHA-256 | `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f` |
|
||||||
|
| `physical-solution-v1` SHA-256 | `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7` |
|
||||||
|
|
||||||
|
这里的 full-response 哈希覆盖完整响应,所以诊断字段增删也会改变它。为稳定比较物理
|
||||||
|
结果,`physical-solution-v1` 只把 `{status, simulatedUntil, requestedStopTime, series,
|
||||||
|
final}` 投影为待哈希对象;schema 名只是外部标签,不进入对象。两种口径都使用
|
||||||
|
`json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 后计算 SHA-256。
|
||||||
|
旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,响应结构不同,
|
||||||
|
不作为最终结果,也不能与当前口径直接比较。
|
||||||
|
|
||||||
|
容差 A/B 必须分开解释:机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次测试约快
|
||||||
|
16%,但会改变机械状态与事件的误差合同,当前未采用;外层 thermofluid 流量固定点
|
||||||
|
相对容差从 `1e-12` 放宽到 `1e-9` 后,BDF 内部步数反而增加并改变积分轨迹,也未
|
||||||
|
采用。完整成功来自依赖图修正和闭合优化,不是牺牲积分精度或闭合精度。
|
||||||
|
|
||||||
|
### 11.2 stream 方程块与受控 A/B
|
||||||
|
|
||||||
|
第一次压力流量求解仍承担“全网必须成立”的语义;但可信声明图允许它在非线性时只把
|
||||||
|
本轮未闭合的独立方程块合并成一个 union sparse 问题,而不是固定构造 472 变量的
|
||||||
|
dense 问题。stream 更新后的固定点进一步只处理 9 个敏感方程块、合计 192 个未知量,
|
||||||
|
不再因为机械总线把拓扑连成一个大物理岛,就重复求解全部 472 个未知量。
|
||||||
|
|
||||||
|
在相同当前代码、相同 `0~0.01 s` 区间做 optimized/forced-global 配对,墙钟分别为
|
||||||
|
16.200 s 和 18.584 s,物理解与 `series` 逐值一致。这组对照隔离的是后续 stream
|
||||||
|
闭合作用域;它不包含完整 `0.81 s` 慢区的全部收益,不能与最终完整运行直接换算百分比。
|
||||||
|
|
||||||
|
### 11.3 非线性稀疏回退与其他首轮项
|
||||||
|
|
||||||
|
全局非线性回退当前采用兼容保护链:可信声明图先求未闭合方程块的 union sparse;
|
||||||
|
若块解失败,先把所有共享端口未知量恢复到原始 `x0`,再做 global sparse;若 sparse
|
||||||
|
仍未达到既有残差合同,再次从原 `x0` 做 global dense。活动接触会改变坐标/活动集,
|
||||||
|
不可信自定义声明也可能漏依赖,这两类不冒险使用静态稀疏图,继续走 dense 兼容路径。
|
||||||
|
|
||||||
|
受控扰动微基准在相同 `max_nfev=20` 下得到:
|
||||||
|
|
||||||
|
| 路径 | 真实残差回调 | 墙钟 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| dense | 3796 | 7.357 s |
|
||||||
|
| sparse | 164 | 0.634 s |
|
||||||
|
|
||||||
|
同一评估预算下约为 11.6 倍的回退成本改善;两条路径在 20 次优化器评估内都没有收敛,
|
||||||
|
所以这是“数值 Jacobian 试算成本”微基准,不是整体仿真加速承诺。诊断用
|
||||||
|
`residualEvaluations` 记录真实残差回调,避免仅看 SciPy `nfev` 漏掉内部差分调用。
|
||||||
|
|
||||||
|
worker 暖机现已覆盖带 `jac_sparsity` 的 sparse LSMR `least_squares` 路径。逐步积分的
|
||||||
|
dense output 也改为只在当前步跨越下一采样点或需要状态事件定位时构造;本 XML 含
|
||||||
|
状态事件,所以每步仍需要插值,这项优化对最终 79.049 s/74.658 s/85.103 s 复验没有收益。
|
||||||
+112
-57
@@ -1,7 +1,7 @@
|
|||||||
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
|
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
|
||||||
|
|
||||||
> 调研基线:2026-08-12(System XML v3 迁移后),依据当前仓库代码、配置、说明文档与测试。
|
> 调研基线:2026-08-15(System XML v3 迁移后);2026-08-16 已补充压力边界、预编译闭合执行计划、方程关联图分块、代数稀疏回退、ODE Jacobian 修正和实测复验的当前状态。
|
||||||
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。文中没有把静态代码分析冒充 CPU、内存实测。
|
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。机器相关的实测结果单独见[仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)。
|
||||||
|
|
||||||
## 0. 三分钟读懂
|
## 0. 三分钟读懂
|
||||||
|
|
||||||
@@ -74,12 +74,15 @@
|
|||||||
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
|
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
|
||||||
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
|
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
|
||||||
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
|
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
|
||||||
4. **[已实现] 每次 RHS 的完整闭合至少调用 2 次、存在外部容积传播时最多调用 3 次压力流量求解。** 积分结束后,每个输出采样点又执行一次完整闭合并提取全部公开结果。这是当前最明确的单任务重复工作来源。
|
4. **[已优化] 每次完整闭合仍先做 1 次语义上的全网压力流量求解,再传播 stream;后续固定点只重算 stream-sensitive 方程实际关联的方程块。** 物理连通岛现在只是安全分类的第一层,真正执行的 `secondaryBlockCount` 是方程—未知量关联图中的块数;无法可信分类的自定义或异常结构仍保守回退原全网路径。积分结束后,每个输出采样点仍执行一次完整闭合并提取结果。
|
||||||
5. **[已实现] 代数求解已有因果化快路径。** 压力流量求解器预编译相等组与显式流量计划,种子残差足够小时不调用非线性优化;否则对全局未知向量调用 SciPy `least_squares`,当前未提供解析 Jacobian 或 `jac_sparsity`。
|
5. **[已实现] 代数求解已有因果化快路径、方程块和稀疏 `least_squares` 回退。** 对可信内置声明,求解器从方程—未知量关联图编译 `jac_sparsity`,先把本轮未闭合的独立方程块合并成一个 union sparse 问题;若失败,恢复到同一个原始 `x0`,再执行全局 sparse,仍失败才执行兼容的 dense 路径。活动接触或不可信自定义声明不会冒险稀疏化,保留 dense 兼容路径。当前没有解析 Jacobian,但不再是“完全未提供 `jac_sparsity`”。
|
||||||
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
|
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
|
||||||
7. **[推断] 优化应分两条线:**
|
7. **[推断] 优化应分两条线:**
|
||||||
- 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算;
|
- 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算;
|
||||||
- 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。
|
- 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。
|
||||||
|
8. **[已修复] 高刚度 XML 不再因合法亚帕试探压力失败。** 压力优化下界由 1 Pa 放宽到 0 Pa,允许严格正的亚帕试探值,同时构成方程仍拒绝零压和负压;同一 10 s RK45 算例已完成且没有可恢复重试。
|
||||||
|
9. **[历史实测] 闭合裁剪的收益随拓扑明显变化。** 已被方程块版取代的物理岛版短算例 `off` 配对改善为 0.6%~21.4%;其 high-stiffness 10 s optimized/forced-global 对照为 14.676 s/16.929 s,完整 `series` 一致。数据保留作纵向基线,不能当作当前方程块版结果,也不能把其他拓扑的收益外推给单一氦气敏感岛。
|
||||||
|
10. **[实测] 98 组件的大型分支 XML 在 `0.69 s` 附近的长时间停留不是死锁。** 根因是气动外部 volume 跨域耦合在 ODE 有限差分 Jacobian 依赖图中漏了 12 个实测显著项,BDF 因此反复做小步和 Jacobian/LU 工作;修正后结构非零数由 1092 增至 1284、颜色组由 27 增至 31。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009;stream 后续闭合为 9 个方程块、合计 192 个未知量。
|
||||||
|
|
||||||
## 2. 证据标签与范围
|
## 2. 证据标签与范围
|
||||||
|
|
||||||
@@ -182,18 +185,21 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
|||||||
|
|
||||||
### 4.3 压力流量因果化与非线性回退
|
### 4.3 压力流量因果化与非线性回退
|
||||||
|
|
||||||
`PressureFlowSolver` 初始化时预编译 effort 等值组、显式流/力赋值计划和未知量布局(`app/simulation/solvers/algebraic.py:86-106, 637-729`)。每次 `solve()`:
|
`PressureFlowSolver` 初始化时预编译方程模板、effort 等值组、显式流/力赋值计划、未知量布局,以及方程声明到未知量的静态关联图。热流闭合先按物理连通关系划定安全范围,再在可信内置模型中按这张关联图拆成更细的方程块。每次 `solve()`:
|
||||||
|
|
||||||
1. 从上一解与当前动态状态播种端口未知量;
|
1. 从上一解与当前动态状态播种端口未知量;
|
||||||
2. 传播状态拥有的压力、位移和速度;
|
2. 传播状态拥有的压力、位移和速度;
|
||||||
3. 执行可显式求值的构成关系与守恒关系;
|
3. 执行可显式求值的构成关系与守恒关系;
|
||||||
4. 处理单边接触约束;
|
4. 处理单边接触约束;
|
||||||
5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`;
|
5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`;
|
||||||
6. 否则调用 `scipy.optimize.least_squares`。
|
6. 否则找出残差未闭合或种子不可行的方程块,把这些互相独立的块合并成一次 sparse `least_squares`;
|
||||||
|
7. 方程块失败时恢复原始 `x0`,执行全局 sparse `least_squares`;全局 sparse 仍失败时再次恢复同一个 `x0`,最后执行 dense 兼容回退。
|
||||||
|
|
||||||
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`,没有传入解析 Jacobian 或 `jac_sparsity`(`app/simulation/solvers/algebraic.py:771-968`)。
|
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`。可信结构会传入由声明图编译的 `jac_sparsity`;活动单边接触会改变坐标和活动集,自定义组件也可能没有完整依赖声明,因此这些情况继续使用 dense 路径。稀疏/分块是加速层,不会把失败候选带入兼容回退。诊断中的 `evaluations` 是 SciPy 报告的优化器 `nfev`,`residualEvaluations` 才包含数值 Jacobian 在内的真实残差回调数,并通过 `jacobianMode`、`denseFallbackUsed` 和块回退字段说明实际路径。
|
||||||
|
|
||||||
**[推断]** 即使快速路径经常命中,固定的多次全网扫描、残差重建和热物性刷新仍会发生;一旦回退到有限差分 `least_squares`,成本会随全局未知量数量快速上升。
|
**[当前边界]** 第一次压力流量求解仍要保证全网所有方程成立,但“全网语义”不再等于“总是把全部未知量交给一个 dense 优化器”:可信声明图可只求本轮未闭合方程块的 union。后续 stream 固定点则进一步只重算敏感方程块。保守条件会恢复全网 sparse→dense 行为;没有可信依赖声明或活动接触时,仍可能直接恢复 dense,成本会随未知量数快速上升。
|
||||||
|
|
||||||
|
在用户提供的 `test_mql-full-branches-01-04.xml`(SHA-256 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`)中,472×472 代数关联矩阵只有 919 个结构非零项,密度约 0.413%,可拆为 58 个独立方程块。一个受控扰动实验在相同 `max_nfev=20` 下,dense 与 sparse 分别触发 3796 和 164 次真实残差回调,墙钟约为 7.357 s 和 0.634 s,即同一预算下约 11.6 倍的单次回退成本改善;这项实验只衡量回退开销,不代表两条路径在 20 次评估内已经收敛。
|
||||||
|
|
||||||
## 5. 每次导数计算的代数闭合
|
## 5. 每次导数计算的代数闭合
|
||||||
|
|
||||||
@@ -205,30 +211,25 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
|||||||
- 气体携带的能量按实际流向交给下游;
|
- 气体携带的能量按实际流向交给下游;
|
||||||
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。
|
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。
|
||||||
|
|
||||||
代码目前不是一次对完,而是按固定顺序做若干轮专项检查。因此一个“计算变化率”的请求会调用 **2 次压力/流量闭合**;涉及外部容积传播时会调用 **3 次**。这也是后文首要优化方向。
|
代码先建立全网初始压力解,再传播 stream 焓;只有组件方程明确依赖 stream 的范围,才让 stream 焓和其方程—未知量关联块继续迭代到同一个固定点。这样既让当前 RHS 不依赖上一次调用留下的焓/流量历史、保持有限差分 Jacobian 可重复,又避免无关方程重复对账。物理岛仍用于确认范围不会跨越未声明边界,但不再等同于实际求解块。
|
||||||
|
|
||||||
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py:258-290`:
|
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py`:
|
||||||
|
|
||||||
| 顺序 | 操作 | 目的 |
|
| 顺序 | 操作 | 目的 |
|
||||||
| ---: | --- | --- |
|
| ---: | --- | --- |
|
||||||
| 1 | `SignalResolver.solve(time)` | 更新时间信号源并从 output 传播到 input |
|
| 1 | `SignalResolver.solve(time)` | 用构造时预绑定的信号源和连接更新时间信号 |
|
||||||
| 2 | 刷新动态组件热力端口 | 由当前 `m/U/V` 恢复压力、温度、焓等 |
|
| 2 | 传播机械 `x/v` 等值关系 | 把当前机械状态同步到刚性连接端口 |
|
||||||
| 3 | 第一次 `PressureFlowSolver.solve()` | 闭合当前压力、流量、机械端口代数关系 |
|
| 3 | `PneumaticVolumeResolver.solve()` | 用预绑定的气动端口、输出组件和连接传播外部 `volume/volume_flow` |
|
||||||
| 4 | `PneumaticVolumeResolver.solve()` | 沿气动连接传播外部 `volume/volume_flow` |
|
| 4 | 刷新动态组件热力端口 | 由当前 `m/U/V` 和最新体积恢复压力、温度、焓 |
|
||||||
| 5 | 若发生容积传播,再刷新热力状态并第二次求压力/流量 | 让变容边界进入气室状态关系 |
|
| 5 | 第一次全网 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 |
|
||||||
| 6 | `StreamResolver.solve()` | 按实际流向迭代传播/混合 `h_outflow` |
|
| 6 | `StreamResolver.solve()`,必要时最多 25 轮重算敏感方程块 | 用预绑定的组件、端口和连接传播焓;让焓、温度引用和敏感构成流量同时收敛 |
|
||||||
| 7 | 无条件再次求压力/流量 | 让依赖 stream/温度的构成关系重新闭合 |
|
| 7 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
|
||||||
| 8 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
|
|
||||||
|
|
||||||
随后 `rhs()` 才收集各动态组件的导数(`app/simulation/systems/generic.py:298-301`)。
|
随后 `rhs()` 才收集各动态组件的导数。
|
||||||
|
|
||||||
因此:
|
因此每次闭合固定有 **1 次全网语义**的压力流量求解;没有敏感方程块时,stream 传播一次后结束,不再有第二次压力求解。若有 `B` 个敏感方程块,每一轮外层热流固定点会把相应块作为一个 union 快路径求解,最多 25 轮;保守回退时恢复原全网求解器。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代,外层热流固定点最多 25 轮,两层上限不能混为一个数。上述大型 XML 的敏感 union 是 9 个方程块、合计 192 个未知量,而不是其单一巨大物理连通岛中的全部 472 个未知量。
|
||||||
|
|
||||||
- 无外部容积传播时,每次闭合固定有 **2 次**压力流量求解;
|
signal、stream 和外部 volume resolver 已把静态组件列表、端口引用和连接绑定预编译到系统构造阶段;运行时仍执行真实的信号赋值、焓迭代、volume 传播和动态热力刷新。闭合计划只在组件以布尔能力声明明确说明压力流量方程是否依赖 stream、且方程变量声明可信时裁剪;未分类自定义 stream 组件、非法声明、跨组件残差或局部非方阵都会保守回退全网。这个边界避免把“少扫描”误做成“少算物理关系”。
|
||||||
- 有外部容积传播时固定有 **3 次**;
|
|
||||||
- stream 默认相对容差 `1e-9`、最多 100 次迭代,每轮复制端口焓并扫描组件/端口(`app/simulation/solvers/stream.py:29-119`)。
|
|
||||||
|
|
||||||
**[发现]** 这套固定顺序没有按模型实际能力裁剪。例如没有信号、没有外部容积源或 stream 不反向影响构成关系的网络,仍经过对应全网 pass。是否能安全删去某一 pass 必须由依赖关系和回归测试决定,不能只凭某个算例结果不变。
|
|
||||||
|
|
||||||
## 6. 初始化、积分参数与推进方式
|
## 6. 初始化、积分参数与推进方式
|
||||||
|
|
||||||
@@ -256,8 +257,8 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
|||||||
| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` |
|
| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` |
|
||||||
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
|
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
|
||||||
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
|
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
|
||||||
| `rtol` | 通用 XML 路径硬编码 `1e-5` | 外层 ODE 相对误差;用户不可配置 |
|
| `rtol` | 通用 XML 路径硬编码 `1e-6` | 外层 ODE 相对误差;用户不可配置 |
|
||||||
| `atol` | `SolveIVPConfig` 标量默认 `1e-8` | 同时用于不同量纲的全部状态;用户不可配置 |
|
| `atol` | `SolveIVPConfig` 标量默认 `1e-8`;机械状态收紧到 `min(default, 1e-12)` | 热力状态使用默认值,机械速度/位置使用更紧的分量容差;用户不可配置 |
|
||||||
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
|
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
|
||||||
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
|
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
|
||||||
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
|
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
|
||||||
@@ -281,13 +282,29 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
|
|||||||
|
|
||||||
1. 检查取消;
|
1. 检查取消;
|
||||||
2. 推进一步;
|
2. 推进一步;
|
||||||
3. 构造 dense output,并插入跨越到的 `t_eval` 样本;
|
3. 仅当本步跨越下一个 `t_eval` 样本或需要机械状态事件检测时构造 dense output,并插入样本;
|
||||||
4. 检测状态事件;
|
4. 检测状态事件;
|
||||||
5. 报告进度;
|
5. 报告进度;
|
||||||
6. 必要时重建求解器。
|
6. 必要时重建求解器。
|
||||||
|
|
||||||
|
**[已优化]** 没有跨采样点、也没有状态事件时,不再为每个已接受步无条件构造插值对象。该优化对稀疏采样、无机械事件的模型有收益;用户大型分支 XML 含机械状态事件,所以仍必须在每步保留用于事件定位的 dense output,本轮实测中这项优化对该 XML 没有收益。
|
||||||
|
|
||||||
Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。
|
Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。
|
||||||
|
|
||||||
|
高刚度 XML 的历史失败不是“所有亚帕压力都不物理”,而是优化器原 1 Pa 下界过严:RK45 的内部自适应试探会短暂给出仍严格大于 0 Pa、但小于 1 Pa 的压力种子。当前 `PRESSURE_LOWER_BOUND_PA=0.0`,合法正压试探交回 RK45 自身判断,零压/负压仍由构成方程拒绝。该模型 10 s RK45 已通过且可恢复重试数为 0,因此这里没有用异常重启掩盖真实模型错误。
|
||||||
|
|
||||||
|
### 6.4 大型分支 XML 的 `0.69 s` 慢区
|
||||||
|
|
||||||
|
用户 XML 包含 98 个组件、472 个代数未知量和 74 个 ODE 状态。旧代码不是在 `0.69 s` 死锁,而是 BDF 到达这一刚性变化区后开始大量缩步、重建有限差分 Jacobian 并做 LU 分解。定位发现,气动外部 volume resolver 把机械位移写入气室容积,形成“机械位置 → 气室热力/压力 → 气动力”的跨域闭环;旧 ODE 稀疏依赖图只沿普通物理端口追踪,漏掉了这条外部 volume 边的 12 个实测显著导数项。
|
||||||
|
|
||||||
|
修正采用保守的双向跨域依赖:接收外部容积的气动储能状态依赖相关机械状态,机械力平衡也依赖被耦合的气动状态。结构非零数因此由 1092 增至 1284,有限差分颜色组由 27 增至 31。颜色组稍多不是退化:旧图更小是因为漏项,给 BDF 的 Jacobian 数值不完整,导致后续重复试步的总成本更高。
|
||||||
|
|
||||||
|
功能收口过程中的较早阶段测量为 92.187 s;最终稳定代码连续三次完整运行到 `0.81 s`,分别用时 79.049 s、74.658 s 和 85.103 s。三次积分统计均为 `nfev=3393`、`njev=226`、`nlu=667`、接受步 1009、求解器启动 3 次;压力流量求解 28008 次,全部由 seeded 快路径满足残差合同,没有触发方程块或 dense 非线性回退。
|
||||||
|
|
||||||
|
三次完整响应按规范 JSON 序列化后的 SHA-256 均为 `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f`。为避免诊断字段增删造成“物理结果没变但响应哈希变化”,另定义外部标签 `physical-solution-v1`:待哈希对象只投影 `{status, simulatedUntil, requestedStopTime, series, final}`,标签本身不放入对象;用 `json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 规范化后 SHA-256 为 `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7`。旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,受响应结构影响,不能与当前哈希直接比较。
|
||||||
|
|
||||||
|
本轮还区分验证了两种容易混淆的“容差”。机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次 A/B 约快 16%,但这会改变机械状态与事件的误差合同,当前证据不足,未采用。另一项是外层 thermofluid **流量固定点**相对容差从 `1e-12` 放宽到 `1e-9`;它反而增加 BDF 内部步数并改变积分轨迹,也未采用。这里的正式修复是补全依赖图,不是通过放宽精度或闭合容差掩盖问题。
|
||||||
|
|
||||||
仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。
|
仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。
|
||||||
|
|
||||||
## 7. 事件、取消与停止
|
## 7. 事件、取消与停止
|
||||||
@@ -322,18 +339,20 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
|
|||||||
|
|
||||||
**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。
|
**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。
|
||||||
|
|
||||||
|
**[当前边界]** 本轮优化没有复用积分期间的闭合快照,也没有跳过后处理对账。每个输出点仍执行完整 `_close_current_state()`;变化仅在于该闭合内部使用同一套预编译绑定和敏感方程块执行计划。
|
||||||
|
|
||||||
### 8.2 当前返回的诊断
|
### 8.2 当前返回的诊断
|
||||||
|
|
||||||
**[已实现]** 结果包含:
|
**[已实现]** 结果包含:
|
||||||
|
|
||||||
- 状态数、采样数;
|
- 状态数、采样数;
|
||||||
- 压力流量 `solveCount`、最大残差、最大单次评估数;
|
- 压力流量 `solveCount`、`closurePassCount`、`secondaryPhysicalIslandCount`、真实方程 `secondaryBlockCount`、`secondaryUnknownCount`、保守回退原因、最大残差、最大单次评估数和最后求解作用域;
|
||||||
- stream 最大迭代数;
|
- stream 最大迭代数;
|
||||||
- 停止状态及部分错误上下文。
|
- 停止状态及部分错误上下文。
|
||||||
|
|
||||||
**[发现]** `_close_current_state()` 中局部变量 `algebraic` 会被后续 pass 覆盖;最大残差/评估统计只采集每次闭合最后一次压力求解,而 `solveCount` 才累计了全部调用。现有响应还没有外层积分 `nfev/njev/nlu`、接受/拒绝步、重启次数、分阶段墙钟时间、热物性调用数、峰值 RSS、队列深度和结果字节数。
|
**[已实现]** 积分诊断已经包含分段及汇总的 `nfev/njev/nlu`、已接受步、求解器启动、状态迁移和可恢复重试数。设置 `SIMULATIONAPP_PROFILE=standard|audit` 后,响应还会加入分阶段墙钟时间;audit 进一步记录物性调用、精确重复、缓存命中和逆解迭代。
|
||||||
|
|
||||||
因此本文可静态定位重复工作,但不能用现有诊断精确量化每个热点的时间占比。
|
**[已修复]** 闭合现在聚合所有实际压力求解的最大残差和最大单次评估数,`last` 与 `lastScope` 指向最后一个真实求解作用域,不再被一个未执行或较早 pass 的局部变量覆盖。`solveCount` 统计求解器调用次数,`closurePassCount` 单列发生过压力求解的固定点 pass;`secondaryPhysicalIslandCount` 只表示安全分类得到的物理范围,`secondaryBlockCount` 明确表示方程关联块数,不能再把两者混称为“块”。代数诊断还返回真实 `residualEvaluations`、`jacobianMode`、dense/方程块回退状态。子作用域失败时,API 返回 `scopeKind` 和 `scopeComponents`。仍缺少峰值 RSS、任务队列深度等服务级指标;结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。
|
||||||
|
|
||||||
## 9. 求解时前后端交流
|
## 9. 求解时前后端交流
|
||||||
|
|
||||||
@@ -440,19 +459,38 @@ O(组件 + 连接 + 代数结构)
|
|||||||
|
|
||||||
| 热点 | 代码证据 | 影响范围 | 判断 |
|
| 热点 | 代码证据 | 影响范围 | 判断 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 每次闭合固定 2~3 次压力流量求解 | `generic.py:258-290` | 每个 RHS、初始化、每个结果采样点 | [已实现] 重复 pass;真实耗时待测 |
|
| 每次闭合的压力流量重算 | `generic.py` 的预编译热流闭合计划 | 每个 RHS、初始化、每个结果采样点 | [已优化] 固定 1 次全网语义初解;后续只重算 stream-sensitive 方程关联块,不再把整个敏感物理岛重复求解 |
|
||||||
| stream 每轮复制/扫描并重复刷新 | `stream.py:29-119` | 每个闭合,最多 100 轮 | [已实现];网络越大越明显 |
|
| stream 每轮复制/比较焓并刷新 | `stream.py` | 每个闭合,最多 100 轮 | [静态预编译已完成] 组件、端口和连接已预绑定;每轮必要的数值复制、比较和刷新仍保留 |
|
||||||
| 非线性回退用全局有限差分 least-squares | `algebraic.py:927-947` | 快速路径失效时 | [已实现];大非线性网络潜在陡增 |
|
| 非线性回退使用有限差分 least-squares | `algebraic.py` | 快速路径失效时 | [首轮已优化] 可信声明图先做未闭合方程块 union sparse;失败恢复 `x0` 后做 global sparse→dense;接触/不可信结构保留 dense |
|
||||||
| 外层刚性积分器看不到显式稀疏 Jacobian | `solver.py:528-1009` | BDF/Radau 的每步/Newton | [已实现] |
|
| 外层 ODE Jacobian 稀疏依赖图 | `generic.py`、`solver.py` | BDF/Radau 的每步/Newton | [已修复] 已传 `jac_sparsity`;补上外部 volume 的跨域双向依赖,用户 XML 为 1284 非零/31 色 |
|
||||||
| 热物性重复反算 | `mediums.py:199-266` 及各动态组件 refresh | 每个 RHS/闭合 pass | [推断] 需调用计数确认 |
|
| 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [已优化] 2026-08-16 已加入仿真隔离的四项氦气精确 LRU;代表算例 2,869/435 次命中/未命中,PR 三次根调用由 1,712 降至 689 |
|
||||||
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
|
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
|
||||||
| 每个已接受步构造 dense output | `solver.py:528-914` | 流式逐步路径 | [已实现];无跨样本/事件时可能浪费 |
|
| dense output 插值对象 | `solver.py` | 流式逐步路径 | [已优化] 仅跨样本或需要状态事件时构造;含状态事件的用户 XML 每步仍需要,因此无本案收益 |
|
||||||
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
|
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
|
||||||
| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 |
|
| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 |
|
||||||
|
|
||||||
|
下面是 2026-08-16 **物理岛版首轮实现的历史 `off` 基线**。这些数据仍可说明旧执行计划相对更早“每轮全网”的收益,但该实现已由方程关联块版取代,不能把表中的“块”解释为当前 `secondaryBlockCount`:
|
||||||
|
|
||||||
|
| 算例 | 墙钟改善 |
|
||||||
|
| --- | ---: |
|
||||||
|
| `air_chain` | 7.9% |
|
||||||
|
| `air_branched` | 6.5% |
|
||||||
|
| `helium_step` | 0.6% |
|
||||||
|
| `mechanical_contact` | 21.4% |
|
||||||
|
| high-stiffness short | 14.6% |
|
||||||
|
|
||||||
|
完整 high-stiffness 10 s 的历史基线约 28.126 s,本轮全部改动后的中位数为
|
||||||
|
13.694 s;这个跨版本差额不能归到某一项优化。当前代码上单独强制恢复全网后续
|
||||||
|
闭合的受控对照为 optimized 14.676 s、forced-global 16.929 s,完整 `series`
|
||||||
|
一致。`helium_step` 只有一个仍需重算的敏感岛,改善仅 0.6%;这说明收益取决于
|
||||||
|
可跳过多少无关网络,不能宣称单一氦岛也有两位数提升。后处理的逐采样点完整闭合
|
||||||
|
仍然保留。
|
||||||
|
|
||||||
|
当前方程关联块版另用用户大型分支 XML 做了受控短区间 A/B:输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量和 74 个 ODE 状态。stream 后续求解的 9 个方程块合计 192 个未知量;`0~0.01 s` optimized 与 forced-global 分别为 16.200 s 和 18.584 s,物理解与 `series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`。短区间 A/B 只归因于后续 stream 闭合作用域,完整运行同时包含 Jacobian 修正和最终执行路径收口,二者不能混算成一个百分比。
|
||||||
|
|
||||||
## 13. 优化建议排序
|
## 13. 优化建议排序
|
||||||
|
|
||||||
以下按**预期综合收益**排序;同档位优先低风险、低难度项。收益是基于调用频率与复杂度的代码推断,不是基准测试结果。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
|
以下按**预期综合收益**排序;同档位优先低风险、低难度项。排序同时参考代码结构和 2026-08-15 的阶段/物性实测,但尚未覆盖大规模拓扑与多任务吞吐。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
|
||||||
|
|
||||||
### 13.1 先看人话版
|
### 13.1 先看人话版
|
||||||
|
|
||||||
@@ -460,8 +498,8 @@ O(组件 + 连接 + 代数结构)
|
|||||||
|
|
||||||
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
|
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
|
||||||
| ---: | --- | --- | --- |
|
| ---: | --- | --- | --- |
|
||||||
| 1 | 少做重复“瞬时对账” | 当前每次变化率计算固定做 2~3 次压力/流量闭合,调用频率最高 | 少做一轮可能漏掉真实耦合,必须按组件依赖裁剪 |
|
| 1 | 少做重复“瞬时对账” | [方程块首轮已完成] 初解保持全网语义,后续只重算 stream-sensitive 的关联方程块 | 自定义/异常结构必须继续保守回退,不能漏掉真实耦合 |
|
||||||
| 2 | 先整理方程,再求解 | 合并重复未知量,把关联较弱的方程分组;大模型回退迭代时收益很高 | 连接、接触和跨域活塞会让分组出错 |
|
| 2 | 先整理方程,再求解 | [稀疏首轮已完成] 可信声明图将未闭合块合并求解;大模型回退时减少数值 Jacobian 试算 | 接触活动集和不可信自定义声明必须走兼容回退 |
|
||||||
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
|
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
|
||||||
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
|
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
|
||||||
| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 |
|
| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 |
|
||||||
@@ -471,30 +509,30 @@ O(组件 + 连接 + 代数结构)
|
|||||||
|
|
||||||
| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 |
|
| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 |
|
||||||
| ---: | --- | --- | --- | --- | --- |
|
| ---: | --- | --- | --- | --- | --- |
|
||||||
| 1 | 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:无信号不扫信号、无外部容积源不传播;仅在 stream 或体积确实使构成关系变脏时追加压力求解。保留可收敛的耦合迭代上限。 | 单算例 | 高;覆盖每个 RHS 和每个后处理点 | 中:错误裁剪会破坏耦合一致性 | 中 |
|
| 1 | [方程块首轮已完成] 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:signal/stream/volume 预绑定;首次压力求解保留全网语义,仅在 stream 确实使构成关系变脏时重算相关方程关联块,并保留全网回退和耦合迭代上限。 | 单算例 | 历史物理岛版实测 0.6%~21.4%;大型 XML 方程块版短区间 16.200 s 对 18.584 s | 中:错误裁剪会破坏耦合一致性,需持续回归自定义模型 | 已完成首轮 |
|
||||||
| 2 | 强化代数结构消元:合并 equality group 中重复未知量,按方程关联图分块,预编译残差/尺度;为非线性回退提供解析或稀疏 Jacobian/`jac_sparsity`。跨域活塞应按方程关联而非仅按物理域分块。 | 单算例、大网络 | 很高,尤其 least-squares 回退时 | 高:影响收敛与接触约束 | 高 |
|
| 2 | [稀疏首轮已完成] 可信方程声明图已用于 union block sparse 和 global sparse→dense 回退;后续继续评估 equality group 真正消元、解析 Jacobian,以及活动接触的安全分块。 | 单算例、大网络 | 扰动实验同预算残差回调 3796→164,约 11.6 倍;实际收益取决于是否触发非线性回退 | 高:接触活动集与错误声明会影响收敛 | 首轮已完成,继续深化 |
|
||||||
| 3 | 为 BDF/Radau 提供状态缩放、分量级 `atol` 和 Jacobian 稀疏结构;允许有边界地配置 `rtol/atol`,依据物理时标选择 `max_step`,不要简单全局放宽容差。 | 单算例、刚性网络 | 中到高 | 中高:会改变误差轨迹/事件时刻 | 中高 |
|
| 3 | [依赖图已修复] BDF/Radau 已使用状态 `jac_sparsity`,外部 volume 跨域漏边已补;后续再评估状态缩放和可配置分量级 `rtol/atol`,不要简单全局放宽容差。 | 单算例、刚性网络 | 大型 XML 已从 `0.69 s` 慢区定位并完整跑通;机械 `atol` A/B 虽约快 16%但改变精度合同,flow 固定点放宽则增加步数,均未采用 | 中高:会改变误差轨迹/事件时刻 | 稀疏图首轮完成,缩放待评估 |
|
||||||
| 4 | 在单次闭包内缓存热物性结果,并预计算 dynamic components、signal sources、stream components、端口引用和结果访问器;状态或体积变化时严格失效。 | 单算例 | 中到高,热力网络可能高 | 中:缓存失效错误会污染物理结果 | 中 |
|
| 4 | [已完成] 氦气高成本物性已按单次仿真做精确、有界缓存;dynamic components 及 signal/stream/volume 的静态组件、端口和连接也已预绑定。 | 单算例 | 已取得可见收益,且减少固定拓扑的重复查找 | 中:缓存失效错误会污染物理结果 | 已完成 |
|
||||||
| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 |
|
| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 |
|
||||||
| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 |
|
| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 |
|
||||||
| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 |
|
| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 |
|
||||||
| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 |
|
| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 |
|
||||||
| 9 | 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;记录并优化事件重启。大量周期 UD00 事件采用惰性调度。 | 单算例、事件密集模型 | 中 | 低到中 | 低到中 |
|
| 9 | [首轮已完成] 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;后续记录并优化事件重启,大量周期 UD00 事件再评估惰性调度。 | 单算例、事件密集模型 | 无事件且采样稀疏时可减少插值对象;本次含状态事件 XML 无收益 | 低到中 | 首轮已完成 |
|
||||||
| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 |
|
| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 |
|
||||||
| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 |
|
| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 |
|
||||||
| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 |
|
| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 |
|
||||||
|
|
||||||
|
结果访问器和每轮剩余临时容器不属于第 4 项已经完成的 signal/stream/volume 静态预绑定;前者应结合第 5 项后处理改造单独基准,不把尚未实测的小项混入已完成收益。
|
||||||
|
|
||||||
### 13.2 推荐落地顺序
|
### 13.2 推荐落地顺序
|
||||||
|
|
||||||
在改算法前先增加低侵入观测,但不把“加指标”误列为直接加速:
|
低侵入阶段/物性观测、积分计数和代表算例首轮基准已经落地,但不把“加指标”误列为直接加速。下一步建议:
|
||||||
|
|
||||||
1. 记录每个闭合 pass 的调用数、墙钟时间、代数 `nfev` 与是否命中快路径;
|
1. 继续汇总压力流量快路径命中、非线性 `nfev`、真实 `residualEvaluations` 和残差装配时间;单次诊断已能区分 block sparse、global sparse 和 dense 回退;
|
||||||
2. 记录热物性调用数/迭代数、stream 迭代数;
|
2. 用同一套基准把方程声明图 A/B 扩展到更多自定义组件、活动接触和保守回退路径;大型 98 组件 XML 与首轮短算例已经覆盖可信内置路径;
|
||||||
3. 导出外层 `nfev/njev/nlu`、接受/拒绝步、事件与重启次数;
|
3. 补 1/8/32 单元规模曲线、1/2/4 并发吞吐和峰值 RSS;
|
||||||
4. 记录状态/结果数组字节数、最终 JSON 字节、任务队列深度和进程 RSS;
|
4. 记录状态/结果数组字节、任务队列深度;结果 JSON 字节可继续由基准工具测量;
|
||||||
5. 用小、中、大三类基准网络定位排名 1~5 的真实占比;
|
5. 再决定 equality group 真正消元/解析 Jacobian、结果按需计算和进程 worker 的实施深度。
|
||||||
6. 先实施第 1、4、8、9 项的可回滚改造,再决定第 2、3、5 项的深度;
|
|
||||||
7. 服务并发需求明确后并行推进第 6、7 项。
|
|
||||||
|
|
||||||
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
|
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
|
||||||
|
|
||||||
@@ -504,7 +542,12 @@ O(组件 + 连接 + 代数结构)
|
|||||||
|
|
||||||
- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。
|
- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。
|
||||||
- 气动、机械和信号的专用闭合顺序。
|
- 气动、机械和信号的专用闭合顺序。
|
||||||
- 压力流量显式因果化快路径与 `least_squares` 回退。
|
- 压力流量显式因果化快路径,以及可信声明图上的 union block sparse、global sparse→dense `least_squares` 兼容回退;失败候选不会污染原始 `x0`。
|
||||||
|
- signal/stream/外部 volume 静态绑定,以及“全网语义初解 + stream-sensitive 方程块重算 + 保守全网回退”的闭合执行计划。
|
||||||
|
- 明确区分物理岛、方程块和方程块未知量的诊断,并记录实际残差回调、Jacobian 模式、最后作用域和子块失败作用域。
|
||||||
|
- BDF/Radau 状态 `jac_sparsity`,以及外部 volume 跨域双向依赖修正。
|
||||||
|
- dense output 按采样跨越/状态事件惰性构造;状态事件模型仍保持每步插值能力。
|
||||||
|
- 允许严格正亚帕试探压力的高刚度 RK45 路径;零压和负压仍不接受。
|
||||||
- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。
|
- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。
|
||||||
- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。
|
- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。
|
||||||
- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。
|
- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。
|
||||||
@@ -516,9 +559,17 @@ O(组件 + 连接 + 代数结构)
|
|||||||
- 采样上限、超时、心跳和任务名义保留时长。
|
- 采样上限、超时、心跳和任务名义保留时长。
|
||||||
- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。
|
- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。
|
||||||
|
|
||||||
### 推断及必须实测
|
### 已有初步实测、仍需扩大样本
|
||||||
|
|
||||||
|
- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,仿真隔离的四项 PR 氦气缓存已取得可见端到端收益。
|
||||||
|
- 长氦气代表算例的积分阶段占约 90.5%,后处理约 5%。
|
||||||
|
- 物理岛版闭合执行计划的历史短算例配对收益为 0.6%~21.4%;完整 high-stiffness optimized/forced-global 历史对照为 14.676 s/16.929 s,数值序列一致。这些基线保留用于纵向比较,但已不是当前方程块实现。
|
||||||
|
- 当前大型分支 XML 的 stream 后续闭合为 9 个方程块/192 个未知量;`0~0.01 s` optimized/forced-global 为 16.200 s/18.584 s 且物理解/`series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s/74.658 s/85.103 s,积分统计一致。
|
||||||
|
- 代数 sparse 扰动实验将真实残差回调由 3796 降至 164(约 11.6 倍耗时改善);它是回退微基准,不能外推为所有仿真的整体加速倍数。
|
||||||
|
- 上述结论仍需在更大拓扑、更多真实工程和固定硬件环境复测。
|
||||||
|
|
||||||
|
### 推断及必须继续实测
|
||||||
|
|
||||||
- 哪一类闭合 pass、热物性或 Jacobian 估计占主要墙钟时间。
|
|
||||||
- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。
|
- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。
|
||||||
- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。
|
- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。
|
||||||
- 各优化的实际收益;表中排序应在观测数据出现后更新。
|
- 各优化的实际收益;表中排序应在观测数据出现后更新。
|
||||||
@@ -531,12 +582,16 @@ O(组件 + 连接 + 代数结构)
|
|||||||
| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` |
|
| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` |
|
||||||
| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` |
|
| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` |
|
||||||
| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` |
|
| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` |
|
||||||
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py:86-968` | `PressureFlowSolver.solve()` |
|
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py` | `PressureFlowSolver.solve()`、方程关联图、sparse→dense 回退 |
|
||||||
|
| stream 方程块闭合 | `app/simulation/solvers/algebraic_blocks.py` | `StreamPressureBlockSolver` |
|
||||||
| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` |
|
| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` |
|
||||||
| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` |
|
| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` |
|
||||||
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
|
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
|
||||||
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
|
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
|
||||||
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
|
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
|
||||||
|
| 启动暖机 | `app/simulation/warmup.py` | BDF、稀疏分组、`least_squares` sparse LSMR 路径 |
|
||||||
|
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
|
||||||
|
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
|
||||||
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
|
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
|
||||||
| 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 |
|
| 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 |
|
||||||
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
|
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
|
||||||
+987
-262
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
|||||||
|
import { Background, BackgroundVariant } from "@xyflow/react";
|
||||||
|
|
||||||
|
export const CANVAS_GRID_SIZE = 18;
|
||||||
|
|
||||||
|
export type CanvasGridVisibility = {
|
||||||
|
lines: boolean;
|
||||||
|
dots: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CanvasGridBackgroundProps = {
|
||||||
|
visibility: CanvasGridVisibility;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasGridBackground({
|
||||||
|
visibility,
|
||||||
|
}: CanvasGridBackgroundProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibility.lines ? (
|
||||||
|
<Background
|
||||||
|
color="#d6e0ea"
|
||||||
|
gap={CANVAS_GRID_SIZE * 5}
|
||||||
|
id="major-grid-lines"
|
||||||
|
lineWidth={1}
|
||||||
|
variant={BackgroundVariant.Lines}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{visibility.dots ? (
|
||||||
|
<Background
|
||||||
|
color="#c4d0dd"
|
||||||
|
gap={CANVAS_GRID_SIZE}
|
||||||
|
id="minor-grid-dots"
|
||||||
|
size={1}
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { memo, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AmesimPnpl01Symbol,
|
AmesimPnpl01Symbol,
|
||||||
@@ -665,7 +665,7 @@ export function componentSymbolVariantKey(
|
|||||||
return definition.variantKey?.(parameters) ?? "";
|
return definition.variantKey?.(parameters) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComponentSymbol({
|
function ComponentSymbolImpl({
|
||||||
symbol,
|
symbol,
|
||||||
surface,
|
surface,
|
||||||
className,
|
className,
|
||||||
@@ -752,3 +752,6 @@ export function ComponentSymbol({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ComponentSymbol = memo(ComponentSymbolImpl);
|
||||||
|
ComponentSymbol.displayName = "ComponentSymbol";
|
||||||
@@ -20,15 +20,19 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Background,
|
|
||||||
ConnectionLineType,
|
ConnectionLineType,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
Controls,
|
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
|
useReactFlow,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Node,
|
type Node,
|
||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import {
|
||||||
|
CanvasGridBackground,
|
||||||
|
type CanvasGridVisibility,
|
||||||
|
} from "./CanvasGridBackground";
|
||||||
|
import { hasComponentSymbol } from "./ComponentSymbol";
|
||||||
import {
|
import {
|
||||||
WorkspaceViewTabs,
|
WorkspaceViewTabs,
|
||||||
type WorkspaceView,
|
type WorkspaceView,
|
||||||
@@ -52,6 +56,7 @@ type ResultNodeData = {
|
|||||||
label: string;
|
label: string;
|
||||||
componentType: string;
|
componentType: string;
|
||||||
modelType: string;
|
modelType: string;
|
||||||
|
symbol?: string;
|
||||||
ports: ResultPortDefinition[];
|
ports: ResultPortDefinition[];
|
||||||
parameters: Record<string, number | string>;
|
parameters: Record<string, number | string>;
|
||||||
parameterUnits?: Record<string, string>;
|
parameterUnits?: Record<string, string>;
|
||||||
@@ -170,6 +175,7 @@ type SimulationResultFile = {
|
|||||||
|
|
||||||
type SimulationResultsViewProps = {
|
type SimulationResultsViewProps = {
|
||||||
snapshot: SimulationResultsSnapshot;
|
snapshot: SimulationResultsSnapshot;
|
||||||
|
gridVisibility: CanvasGridVisibility;
|
||||||
nodeTypes: NodeTypes;
|
nodeTypes: NodeTypes;
|
||||||
onViewChange: (view: WorkspaceView) => void;
|
onViewChange: (view: WorkspaceView) => void;
|
||||||
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
|
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
|
||||||
@@ -335,10 +341,12 @@ const RESULT_UNIT_OPTIONS: Record<string, ResultUnitOption[]> = {
|
|||||||
|
|
||||||
export function SimulationResultsView({
|
export function SimulationResultsView({
|
||||||
snapshot,
|
snapshot,
|
||||||
|
gridVisibility,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
onSnapshotLoad,
|
onSnapshotLoad,
|
||||||
}: SimulationResultsViewProps) {
|
}: SimulationResultsViewProps) {
|
||||||
|
const reactFlow = useReactFlow();
|
||||||
const resultStatus = simulationResultStatus(snapshot.result);
|
const resultStatus = simulationResultStatus(snapshot.result);
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
|
||||||
snapshot.project.nodes[0]?.id ?? null,
|
snapshot.project.nodes[0]?.id ?? null,
|
||||||
@@ -467,7 +475,15 @@ export function SimulationResultsView({
|
|||||||
() =>
|
() =>
|
||||||
snapshot.project.nodes.map((node) => ({
|
snapshot.project.nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
className: "results-readonly-node",
|
className: [
|
||||||
|
"results-readonly-node",
|
||||||
|
hasComponentSymbol(node.data.symbol ?? node.data.componentType) ||
|
||||||
|
hasComponentSymbol(node.data.modelType)
|
||||||
|
? "component-icon-node"
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
connectable: false,
|
connectable: false,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
focusable: true,
|
focusable: true,
|
||||||
@@ -1134,7 +1150,23 @@ export function SimulationResultsView({
|
|||||||
<section className="results-system-panel">
|
<section className="results-system-panel">
|
||||||
<div className="results-section-heading">
|
<div className="results-section-heading">
|
||||||
<strong>系统图</strong>
|
<strong>系统图</strong>
|
||||||
<span>{snapshot.project.nodes.length} 个组件</span>
|
<div className="results-system-heading-tools">
|
||||||
|
<span>{snapshot.project.nodes.length} 个组件</span>
|
||||||
|
<button
|
||||||
|
aria-label="适应系统图窗口"
|
||||||
|
onClick={() => {
|
||||||
|
void reactFlow.fitView({
|
||||||
|
duration: 180,
|
||||||
|
minZoom: 0.1,
|
||||||
|
padding: 0.18,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="适应窗口"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Scaling aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="results-system-canvas">
|
<div className="results-system-canvas">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -1166,10 +1198,7 @@ export function SimulationResultsView({
|
|||||||
fitOnMount
|
fitOnMount
|
||||||
requestKey={snapshot.id}
|
requestKey={snapshot.id}
|
||||||
/>
|
/>
|
||||||
<Background gap={18} />
|
<CanvasGridBackground visibility={gridVisibility} />
|
||||||
<Controls
|
|
||||||
fitViewOptions={{ duration: 180, minZoom: 0.1, padding: 0.18 }}
|
|
||||||
/>
|
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2138,6 +2138,32 @@ textarea {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-system-heading-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-heading-tools button {
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
place-items: center;
|
||||||
|
color: #526172;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-heading-tools button:hover,
|
||||||
|
.results-system-heading-tools button:focus-visible {
|
||||||
|
border-color: #c5cfda;
|
||||||
|
color: #1d6fb8;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.results-chart-heading {
|
.results-chart-heading {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 25;
|
z-index: 25;
|
||||||
@@ -2265,6 +2291,18 @@ textarea {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .react-flow__node.component-icon-node,
|
||||||
|
.results-system-canvas .react-flow__node.component-icon-node .sim-node {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas
|
||||||
|
.react-flow__node.component-icon-node
|
||||||
|
.node-symbol-envelope {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.results-system-canvas .react-flow__handle {
|
.results-system-canvas .react-flow__handle {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -2902,6 +2902,8 @@ test("恢复工程时采用当前目录端口并明确丢弃已删除端口的
|
|||||||
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
|
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
@@ -3214,6 +3216,39 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
|||||||
await expect(signalInput).toHaveCSS("opacity", "0");
|
await expect(signalInput).toHaveCSS("opacity", "0");
|
||||||
await expect(signalOutput).toHaveCSS("pointer-events", "none");
|
await expect(signalOutput).toHaveCSS("pointer-events", "none");
|
||||||
await expect(signalInput).toHaveCSS("pointer-events", "none");
|
await expect(signalInput).toHaveCSS("pointer-events", "none");
|
||||||
|
|
||||||
|
const renderedEdges = page.locator(".flow-canvas .react-flow__edge");
|
||||||
|
const clickEdgeMiddle = async (edgeIndex: number) => {
|
||||||
|
const point = await renderedEdges
|
||||||
|
.nth(edgeIndex)
|
||||||
|
.locator(".react-flow__edge-interaction")
|
||||||
|
.evaluate((path: SVGPathElement) => {
|
||||||
|
const localPoint = path.getPointAtLength(path.getTotalLength() / 2);
|
||||||
|
const matrix = path.getScreenCTM();
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error("Unable to resolve the edge screen position");
|
||||||
|
}
|
||||||
|
const screenPoint = new DOMPoint(
|
||||||
|
localPoint.x,
|
||||||
|
localPoint.y,
|
||||||
|
).matrixTransform(matrix);
|
||||||
|
return { x: screenPoint.x, y: screenPoint.y };
|
||||||
|
});
|
||||||
|
await page.mouse.click(point.x, point.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
await clickEdgeMiddle(0);
|
||||||
|
await expect(renderedEdges).toHaveCount(2);
|
||||||
|
await expect(renderedEdges.nth(0)).toHaveClass(
|
||||||
|
/selected|editor-edge-selected/,
|
||||||
|
);
|
||||||
|
await clickEdgeMiddle(1);
|
||||||
|
await expect(renderedEdges).toHaveCount(2);
|
||||||
|
await expect(renderedEdges.nth(1)).toHaveClass(
|
||||||
|
/selected|editor-edge-selected/,
|
||||||
|
);
|
||||||
|
await expect(page.getByText("System Simulation Flow", { exact: true })).toBeVisible();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("端口图标随画布缩小且物理端口始终保持标准圆形", async ({ page }) => {
|
test("端口图标随画布缩小且物理端口始终保持标准圆形", async ({ page }) => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
wideProject,
|
wideProject,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
|
||||||
async function readModelingViewport(page: Page) {
|
async function readViewport(page: Page, canvasSelector: string) {
|
||||||
return page
|
return page
|
||||||
.locator(".flow-canvas .react-flow__viewport")
|
.locator(`${canvasSelector} .react-flow__viewport`)
|
||||||
.evaluate((viewport) => {
|
.evaluate((viewport) => {
|
||||||
const values =
|
const values =
|
||||||
getComputedStyle(viewport)
|
getComputedStyle(viewport)
|
||||||
@@ -22,6 +22,10 @@ async function readModelingViewport(page: Page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readModelingViewport(page: Page) {
|
||||||
|
return readViewport(page, ".flow-canvas");
|
||||||
|
}
|
||||||
|
|
||||||
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
|
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
|
||||||
await prepareApp(page);
|
await prepareApp(page);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
@@ -257,10 +261,24 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
"system-simulation-flow:project:demo-system",
|
"system-simulation-flow:project:demo-system",
|
||||||
JSON.stringify(project),
|
JSON.stringify(project),
|
||||||
);
|
);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:canvas-grid-visibility",
|
||||||
|
JSON.stringify({ lines: true, dots: false }),
|
||||||
|
);
|
||||||
}, { project: wideProject, snapshot: resultSnapshot });
|
}, { project: wideProject, snapshot: resultSnapshot });
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: "加载工程" }).click();
|
await page.getByRole("button", { name: "加载工程" }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
|
||||||
|
),
|
||||||
|
).toHaveCount(1);
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
|
||||||
|
),
|
||||||
|
).toHaveCount(0);
|
||||||
const freeModelingHandle = page.locator(
|
const freeModelingHandle = page.locator(
|
||||||
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
|
||||||
);
|
);
|
||||||
@@ -271,6 +289,24 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
);
|
);
|
||||||
await page.getByRole("tab", { name: /^结果/ }).click();
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||||
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
|
||||||
|
),
|
||||||
|
).toHaveCount(1);
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
|
||||||
|
),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.locator(".results-system-canvas .react-flow__controls"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
const fitSystemViewButton = page.getByRole("button", {
|
||||||
|
name: "适应系统图窗口",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(fitSystemViewButton).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(".results-system-canvas .react-flow__attribution"),
|
page.locator(".results-system-canvas .react-flow__attribution"),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -278,6 +314,49 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
const resultPane = page.locator(".results-system-canvas .react-flow__pane");
|
||||||
|
const resultPaneBox = await resultPane.boundingBox();
|
||||||
|
expect(resultPaneBox).not.toBeNull();
|
||||||
|
const fittedResultViewport = await readViewport(
|
||||||
|
page,
|
||||||
|
".results-system-canvas",
|
||||||
|
);
|
||||||
|
await page.mouse.move(
|
||||||
|
resultPaneBox!.x + resultPaneBox!.width / 2,
|
||||||
|
resultPaneBox!.y + resultPaneBox!.height / 2,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
resultPaneBox!.x + resultPaneBox!.width / 2 + 180,
|
||||||
|
resultPaneBox!.y + resultPaneBox!.height / 2 + 90,
|
||||||
|
{ steps: 5 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const viewport = await readViewport(page, ".results-system-canvas");
|
||||||
|
return Math.hypot(
|
||||||
|
viewport.x - fittedResultViewport.x,
|
||||||
|
viewport.y - fittedResultViewport.y,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(100);
|
||||||
|
const pannedResultViewport = await readViewport(
|
||||||
|
page,
|
||||||
|
".results-system-canvas",
|
||||||
|
);
|
||||||
|
await fitSystemViewButton.click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const viewport = await readViewport(page, ".results-system-canvas");
|
||||||
|
return Math.hypot(
|
||||||
|
viewport.x - pannedResultViewport.x,
|
||||||
|
viewport.y - pannedResultViewport.y,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(100);
|
||||||
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "建模" }).click();
|
await page.getByRole("tab", { name: "建模" }).click();
|
||||||
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||||
await expect(freeModelingHandle).toHaveAttribute(
|
await expect(freeModelingHandle).toHaveAttribute(
|
||||||
@@ -354,3 +433,153 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
|
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
const pneumaticPort = (name: string, side: "left" | "right") => ({
|
||||||
|
name,
|
||||||
|
kind: "physical",
|
||||||
|
domain: "pneumatic",
|
||||||
|
nominalRole: "bidirectional",
|
||||||
|
positiveFlowDirection: "intoComponent",
|
||||||
|
side,
|
||||||
|
});
|
||||||
|
const pnplNode = (
|
||||||
|
id: string,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
rotation: 0 | 90 | 180 | 270,
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: "simulationComponent",
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
label: id,
|
||||||
|
componentType: "amesim_pnpl01",
|
||||||
|
modelType: "amesim_pnpl01",
|
||||||
|
modelVersion: "0.1.0",
|
||||||
|
ports: [pneumaticPort("port_1", "left")],
|
||||||
|
parameters: {},
|
||||||
|
parameterUnits: {},
|
||||||
|
parameterScientificNotation: {},
|
||||||
|
rotation,
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const targetNodeId = "amesim_pnch012_2";
|
||||||
|
const overlapProject = {
|
||||||
|
...wideProject,
|
||||||
|
name: "overlapped-symbols",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "generic_sensor_0",
|
||||||
|
type: "simulationComponent",
|
||||||
|
position: { x: 360, y: 390 },
|
||||||
|
data: {
|
||||||
|
label: "generic_sensor_0",
|
||||||
|
componentType: "generic_sensor",
|
||||||
|
modelType: "generic_sensor",
|
||||||
|
modelVersion: "1.0.0",
|
||||||
|
symbol: "symbol-not-registered",
|
||||||
|
ports: [],
|
||||||
|
parameters: {},
|
||||||
|
parameterUnits: {},
|
||||||
|
parameterScientificNotation: {},
|
||||||
|
rotation: 0,
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: targetNodeId,
|
||||||
|
type: "simulationComponent",
|
||||||
|
position: { x: 569.2129060235693, y: 515.6448805401301 },
|
||||||
|
data: {
|
||||||
|
label: targetNodeId,
|
||||||
|
componentType: "amesim_pnch012",
|
||||||
|
modelType: "amesim_pnch012",
|
||||||
|
modelVersion: "0.1.0",
|
||||||
|
ports: [
|
||||||
|
pneumaticPort("port_1", "left"),
|
||||||
|
pneumaticPort("port_2", "right"),
|
||||||
|
pneumaticPort("port_3", "left"),
|
||||||
|
pneumaticPort("port_4", "right"),
|
||||||
|
],
|
||||||
|
parameters: {},
|
||||||
|
parameterUnits: {},
|
||||||
|
parameterScientificNotation: {},
|
||||||
|
rotation: 0,
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pnplNode(
|
||||||
|
"amesim_pnpl01_7",
|
||||||
|
{ x: 535.6129060235693, y: 523.6448805401301 },
|
||||||
|
180,
|
||||||
|
),
|
||||||
|
pnplNode(
|
||||||
|
"amesim_pnpl01_8",
|
||||||
|
{ x: 587.2129060235693, y: 555.2448805401301 },
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
pnplNode(
|
||||||
|
"amesim_pnpl01_9",
|
||||||
|
{ x: 618.8129060235693, y: 523.6448805401301 },
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
const overlapSnapshot = {
|
||||||
|
...resultSnapshot,
|
||||||
|
id: "overlapped-symbols-result",
|
||||||
|
project: overlapProject,
|
||||||
|
};
|
||||||
|
await page.addInitScript((snapshot) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
}, overlapSnapshot);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||||
|
await expectAllNodesInsideCanvas(
|
||||||
|
page,
|
||||||
|
".results-system-canvas",
|
||||||
|
overlapProject.nodes.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetNode = page.locator(
|
||||||
|
`.results-system-canvas .react-flow__node[data-id="${targetNodeId}"]`,
|
||||||
|
);
|
||||||
|
const targetEnvelope = targetNode.locator('[data-symbol-envelope="true"]');
|
||||||
|
await expect(targetNode).toHaveClass(/component-icon-node/);
|
||||||
|
await expect(targetNode).toHaveCSS("pointer-events", "none");
|
||||||
|
await expect(targetEnvelope).toHaveCSS("pointer-events", "auto");
|
||||||
|
|
||||||
|
const pane = page.locator(".results-system-canvas .react-flow__pane");
|
||||||
|
const paneBox = await pane.boundingBox();
|
||||||
|
expect(paneBox).not.toBeNull();
|
||||||
|
await page.mouse.click(paneBox!.x + 6, paneBox!.y + 6);
|
||||||
|
await expect(targetNode).not.toHaveClass(/selected/);
|
||||||
|
|
||||||
|
const envelopeBox = await targetEnvelope.boundingBox();
|
||||||
|
expect(envelopeBox).not.toBeNull();
|
||||||
|
const targetPoint = {
|
||||||
|
x: envelopeBox!.x + envelopeBox!.width / 2,
|
||||||
|
y: envelopeBox!.y + envelopeBox!.height / 2,
|
||||||
|
};
|
||||||
|
const hitNodeId = await page.evaluate(({ x, y }) => {
|
||||||
|
const element = document.elementFromPoint(x, y);
|
||||||
|
return element?.closest<HTMLElement>(".react-flow__node")?.dataset.id ?? null;
|
||||||
|
}, targetPoint);
|
||||||
|
expect(hitNodeId).toBe(targetNodeId);
|
||||||
|
await page.mouse.click(targetPoint.x, targetPoint.y);
|
||||||
|
await expect(targetNode).toHaveClass(/selected/);
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
'.results-system-canvas .react-flow__node[data-id^="amesim_pnpl01_"].selected',
|
||||||
|
),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<System name="demo-system" schemaVersion="3" unitSystem="SI">
|
||||||
|
<Simulation tStart="0" tStop="10" sampleStep="0.02" maxStep="0.02" method="RK45"/>
|
||||||
|
<Components>
|
||||||
|
<Component id="amesim_pnch023_1" type="amesim_pnch023" modelVersion="0.1.0">
|
||||||
|
<Parameter name="gi" value="1"/>
|
||||||
|
<Parameter name="cvol" value="0.057"/>
|
||||||
|
<Parameter name="kth" value="0"/>
|
||||||
|
<Parameter name="sth" value="0.1"/>
|
||||||
|
<Parameter name="extemp" value="293.15"/>
|
||||||
|
<Parameter name="p0" value="15300000"/>
|
||||||
|
<Parameter name="T0" value="293.15"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_pnvo001_1" type="amesim_pnvo001" modelVersion="0.2.0">
|
||||||
|
<Parameter name="gi" value="1"/>
|
||||||
|
<Parameter name="cq" value="0.45"/>
|
||||||
|
<Parameter name="area0" value="0.0000785"/>
|
||||||
|
<Parameter name="Cv" value="0.5"/>
|
||||||
|
<Parameter name="Kv" value="0.4"/>
|
||||||
|
<Parameter name="flowset" value="1"/>
|
||||||
|
<Parameter name="opening0" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_step0_1" type="amesim_step0" modelVersion="0.1.0">
|
||||||
|
<Parameter name="initial" value="0"/>
|
||||||
|
<Parameter name="final" value="1"/>
|
||||||
|
<Parameter name="time" value="0.04"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_pnch012_1" type="amesim_pnch012" modelVersion="0.1.0">
|
||||||
|
<Parameter name="gi" value="1"/>
|
||||||
|
<Parameter name="cvol0" value="0.015"/>
|
||||||
|
<Parameter name="kth" value="1500"/>
|
||||||
|
<Parameter name="sth" value="0.7"/>
|
||||||
|
<Parameter name="extemp" value="293.15"/>
|
||||||
|
<Parameter name="p0" value="100000"/>
|
||||||
|
<Parameter name="T0" value="293.15"/>
|
||||||
|
<Parameter name="vol1" value="0"/>
|
||||||
|
<Parameter name="vol2" value="0"/>
|
||||||
|
<Parameter name="vol3" value="0"/>
|
||||||
|
<Parameter name="vol4" value="0"/>
|
||||||
|
<Parameter name="dvol1" value="0"/>
|
||||||
|
<Parameter name="dvol2" value="0"/>
|
||||||
|
<Parameter name="dvol3" value="0"/>
|
||||||
|
<Parameter name="dvol4" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_pnpl01_2" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_pnpl01_3" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_pnpl01_4" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_ud00_1" type="amesim_ud00" modelVersion="0.2.0">
|
||||||
|
<Parameter name="tstart" value="0"/>
|
||||||
|
<Parameter name="start1" value="100000000000000000"/>
|
||||||
|
<Parameter name="end1" value="100000000000000000"/>
|
||||||
|
<Parameter name="t1" value="0.8"/>
|
||||||
|
<Parameter name="start2" value="49000"/>
|
||||||
|
<Parameter name="end2" value="49000"/>
|
||||||
|
<Parameter name="t2" value="10"/>
|
||||||
|
<Parameter name="start3" value="1"/>
|
||||||
|
<Parameter name="end3" value="1"/>
|
||||||
|
<Parameter name="t3" value="0"/>
|
||||||
|
<Parameter name="start4" value="1"/>
|
||||||
|
<Parameter name="end4" value="1"/>
|
||||||
|
<Parameter name="t4" value="0"/>
|
||||||
|
<Parameter name="start5" value="1"/>
|
||||||
|
<Parameter name="end5" value="1"/>
|
||||||
|
<Parameter name="t5" value="0"/>
|
||||||
|
<Parameter name="start6" value="1"/>
|
||||||
|
<Parameter name="end6" value="1"/>
|
||||||
|
<Parameter name="t6" value="0"/>
|
||||||
|
<Parameter name="start7" value="1"/>
|
||||||
|
<Parameter name="end7" value="1"/>
|
||||||
|
<Parameter name="t7" value="0"/>
|
||||||
|
<Parameter name="start8" value="1"/>
|
||||||
|
<Parameter name="end8" value="1"/>
|
||||||
|
<Parameter name="t8" value="0"/>
|
||||||
|
<Parameter name="nstages" value="2"/>
|
||||||
|
<Parameter name="iscyclic" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_forc_1" type="amesim_forc" modelVersion="0.2.0">
|
||||||
|
<Parameter name="direction" value="1"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_pnrp17_1" type="amesim_pnrp17" modelVersion="0.1.0">
|
||||||
|
<Parameter name="gi" value="1"/>
|
||||||
|
<Parameter name="dp" value="0.2"/>
|
||||||
|
<Parameter name="dr" value="0.001"/>
|
||||||
|
<Parameter name="x0" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_helium_medium_1" type="amesim_helium_medium" modelVersion="0.1.0">
|
||||||
|
<Parameter name="gi" value="1"/>
|
||||||
|
<Parameter name="property_model" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_pnch012_2" type="amesim_pnch012" modelVersion="0.1.0">
|
||||||
|
<Parameter name="gi" value="1"/>
|
||||||
|
<Parameter name="cvol0" value="0.015"/>
|
||||||
|
<Parameter name="kth" value="1500"/>
|
||||||
|
<Parameter name="sth" value="0.7"/>
|
||||||
|
<Parameter name="extemp" value="293.15"/>
|
||||||
|
<Parameter name="p0" value="100000"/>
|
||||||
|
<Parameter name="T0" value="293.15"/>
|
||||||
|
<Parameter name="vol1" value="0"/>
|
||||||
|
<Parameter name="vol2" value="0"/>
|
||||||
|
<Parameter name="vol3" value="0"/>
|
||||||
|
<Parameter name="vol4" value="0"/>
|
||||||
|
<Parameter name="dvol1" value="0"/>
|
||||||
|
<Parameter name="dvol2" value="0"/>
|
||||||
|
<Parameter name="dvol3" value="0"/>
|
||||||
|
<Parameter name="dvol4" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_pnpl01_6" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_pnpl01_7" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_pnpl01_8" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_pnpl01_9" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_mecmas21_2" type="amesim_mecmas21" modelVersion="0.2.0">
|
||||||
|
<Parameter name="mass" value="50"/>
|
||||||
|
<Parameter name="fstick" value="0"/>
|
||||||
|
<Parameter name="fcoul" value="0"/>
|
||||||
|
<Parameter name="rvisc" value="0"/>
|
||||||
|
<Parameter name="wind" value="0"/>
|
||||||
|
<Parameter name="dvel" value="0.000001"/>
|
||||||
|
<Parameter name="restdvel" value="0.000001"/>
|
||||||
|
<Parameter name="restcoeff" value="0.65"/>
|
||||||
|
<Parameter name="astrib" value="0.001"/>
|
||||||
|
<Parameter name="xmin" value="-1"/>
|
||||||
|
<Parameter name="Kbmin" value="1000000000"/>
|
||||||
|
<Parameter name="Dbmin" value="10000"/>
|
||||||
|
<Parameter name="Pdmin" value="0.0001"/>
|
||||||
|
<Parameter name="xmax" value="0.8"/>
|
||||||
|
<Parameter name="Kbmax" value="1000000000"/>
|
||||||
|
<Parameter name="Dbmax" value="10000"/>
|
||||||
|
<Parameter name="Pdmax" value="0.0001"/>
|
||||||
|
<Parameter name="theta" value="0"/>
|
||||||
|
<Parameter name="useFriction" value="1"/>
|
||||||
|
<Parameter name="stoptype" value="4"/>
|
||||||
|
<Parameter name="discContactOption" value="1"/>
|
||||||
|
<Parameter name="strib" value="1"/>
|
||||||
|
<Parameter name="frictionType" value="1"/>
|
||||||
|
<Parameter name="v0" value="0"/>
|
||||||
|
<Parameter name="x0" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_f000_1" type="amesim_f000" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_f000_2" type="amesim_f000" modelVersion="0.1.0"/>
|
||||||
|
<Component id="amesim_lstp00a_1" type="amesim_lstp00a" modelVersion="0.2.0">
|
||||||
|
<Parameter name="na" value="10"/>
|
||||||
|
<Parameter name="gap0" value="0"/>
|
||||||
|
<Parameter name="kcont" value="100000000000"/>
|
||||||
|
<Parameter name="G" value="85700000000"/>
|
||||||
|
<Parameter name="sdiam" value="0.02"/>
|
||||||
|
<Parameter name="wdiam" value="0.002"/>
|
||||||
|
<Parameter name="rcont" value="100000000000"/>
|
||||||
|
<Parameter name="Pdis" value="1e-7"/>
|
||||||
|
<Parameter name="stiffmode" value="1"/>
|
||||||
|
<Parameter name="discContactOption" value="1"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_forc_2" type="amesim_forc" modelVersion="0.2.0">
|
||||||
|
<Parameter name="direction" value="1"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_ud00_2" type="amesim_ud00" modelVersion="0.2.0">
|
||||||
|
<Parameter name="tstart" value="0"/>
|
||||||
|
<Parameter name="start1" value="1000000000000"/>
|
||||||
|
<Parameter name="end1" value="1000000000000"/>
|
||||||
|
<Parameter name="t1" value="0.8"/>
|
||||||
|
<Parameter name="start2" value="0"/>
|
||||||
|
<Parameter name="end2" value="0"/>
|
||||||
|
<Parameter name="t2" value="10"/>
|
||||||
|
<Parameter name="start3" value="1"/>
|
||||||
|
<Parameter name="end3" value="1"/>
|
||||||
|
<Parameter name="t3" value="0"/>
|
||||||
|
<Parameter name="start4" value="1"/>
|
||||||
|
<Parameter name="end4" value="1"/>
|
||||||
|
<Parameter name="t4" value="0"/>
|
||||||
|
<Parameter name="start5" value="1"/>
|
||||||
|
<Parameter name="end5" value="1"/>
|
||||||
|
<Parameter name="t5" value="0"/>
|
||||||
|
<Parameter name="start6" value="1"/>
|
||||||
|
<Parameter name="end6" value="1"/>
|
||||||
|
<Parameter name="t6" value="0"/>
|
||||||
|
<Parameter name="start7" value="1"/>
|
||||||
|
<Parameter name="end7" value="1"/>
|
||||||
|
<Parameter name="t7" value="0"/>
|
||||||
|
<Parameter name="start8" value="1"/>
|
||||||
|
<Parameter name="end8" value="1"/>
|
||||||
|
<Parameter name="t8" value="0"/>
|
||||||
|
<Parameter name="nstages" value="2"/>
|
||||||
|
<Parameter name="iscyclic" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_mecmas21_5" type="amesim_mecmas21" modelVersion="0.2.0">
|
||||||
|
<Parameter name="mass" value="170000"/>
|
||||||
|
<Parameter name="fstick" value="0"/>
|
||||||
|
<Parameter name="fcoul" value="0"/>
|
||||||
|
<Parameter name="rvisc" value="0"/>
|
||||||
|
<Parameter name="wind" value="0"/>
|
||||||
|
<Parameter name="dvel" value="0.000001"/>
|
||||||
|
<Parameter name="restdvel" value="0.000001"/>
|
||||||
|
<Parameter name="restcoeff" value="0.65"/>
|
||||||
|
<Parameter name="astrib" value="0.001"/>
|
||||||
|
<Parameter name="xmin" value="0"/>
|
||||||
|
<Parameter name="Kbmin" value="1000000000"/>
|
||||||
|
<Parameter name="Dbmin" value="10000"/>
|
||||||
|
<Parameter name="Pdmin" value="0.0001"/>
|
||||||
|
<Parameter name="xmax" value="0.37"/>
|
||||||
|
<Parameter name="Kbmax" value="1000000000"/>
|
||||||
|
<Parameter name="Dbmax" value="10000"/>
|
||||||
|
<Parameter name="Pdmax" value="0.0001"/>
|
||||||
|
<Parameter name="theta" value="0"/>
|
||||||
|
<Parameter name="useFriction" value="1"/>
|
||||||
|
<Parameter name="stoptype" value="1"/>
|
||||||
|
<Parameter name="discContactOption" value="1"/>
|
||||||
|
<Parameter name="strib" value="1"/>
|
||||||
|
<Parameter name="frictionType" value="1"/>
|
||||||
|
<Parameter name="v0" value="0"/>
|
||||||
|
<Parameter name="x0" value="0"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="amesim_mecmas21_7" type="amesim_mecmas21" modelVersion="0.2.0">
|
||||||
|
<Parameter name="mass" value="90000"/>
|
||||||
|
<Parameter name="fstick" value="0"/>
|
||||||
|
<Parameter name="fcoul" value="0"/>
|
||||||
|
<Parameter name="rvisc" value="0"/>
|
||||||
|
<Parameter name="wind" value="0"/>
|
||||||
|
<Parameter name="dvel" value="0.000001"/>
|
||||||
|
<Parameter name="restdvel" value="0.000001"/>
|
||||||
|
<Parameter name="restcoeff" value="0.65"/>
|
||||||
|
<Parameter name="astrib" value="0.001"/>
|
||||||
|
<Parameter name="xmin" value="-0.72"/>
|
||||||
|
<Parameter name="Kbmin" value="1000000000"/>
|
||||||
|
<Parameter name="Dbmin" value="10000"/>
|
||||||
|
<Parameter name="Pdmin" value="0.0001"/>
|
||||||
|
<Parameter name="xmax" value="0"/>
|
||||||
|
<Parameter name="Kbmax" value="1000000000"/>
|
||||||
|
<Parameter name="Dbmax" value="10000"/>
|
||||||
|
<Parameter name="Pdmax" value="0.0001"/>
|
||||||
|
<Parameter name="theta" value="0"/>
|
||||||
|
<Parameter name="useFriction" value="1"/>
|
||||||
|
<Parameter name="stoptype" value="1"/>
|
||||||
|
<Parameter name="discContactOption" value="1"/>
|
||||||
|
<Parameter name="strib" value="1"/>
|
||||||
|
<Parameter name="frictionType" value="1"/>
|
||||||
|
<Parameter name="v0" value="0"/>
|
||||||
|
<Parameter name="x0" value="0"/>
|
||||||
|
</Component>
|
||||||
|
</Components>
|
||||||
|
<Connections>
|
||||||
|
<Connection id="edge-amesim_pnch023_1-port_2-amesim_pnvo001_1-port_2-1786524999267">
|
||||||
|
<Endpoint component="amesim_pnch023_1" port="port_2"/>
|
||||||
|
<Endpoint component="amesim_pnvo001_1" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_pnvo001_1-port_3-amesim_pnch012_1-port_1-1786525009973">
|
||||||
|
<Endpoint component="amesim_pnvo001_1" port="port_3"/>
|
||||||
|
<Endpoint component="amesim_pnch012_1" port="port_1"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_2-port_1-amesim_pnch012_1-port_2-1786525035706-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_2" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_1" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_3-port_1-amesim_pnch012_1-port_3-1786525054054-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_3" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_1" port="port_3"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_4-port_1-amesim_pnch012_1-port_4-1786525061349-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_4" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_1" port="port_4"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_step0_1-out-amesim_pnvo001_1-res-1786525074494">
|
||||||
|
<Endpoint component="amesim_step0_1" port="out"/>
|
||||||
|
<Endpoint component="amesim_pnvo001_1" port="res"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_ud00_1-out-amesim_forc_1-res-1786525088412">
|
||||||
|
<Endpoint component="amesim_ud00_1" port="out"/>
|
||||||
|
<Endpoint component="amesim_forc_1" port="res"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_6-port_1-amesim_pnch023_1-port_1-1786525164428-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_6" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch023_1" port="port_1"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_7-port_1-amesim_pnch012_2-port_1-1786525181969-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_7" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_2" port="port_1"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_8-port_1-amesim_pnch012_2-port_4-1786525185493-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_8" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_2" port="port_4"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnpl01_9-port_1-amesim_pnch012_2-port_2-1786525188482-0">
|
||||||
|
<Endpoint component="amesim_pnpl01_9" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_2" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_f000_1-port_1-amesim_mecmas21_2-port_2-1786525202665-0">
|
||||||
|
<Endpoint component="amesim_f000_1" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_mecmas21_2" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_mecmas21_2-port_1-amesim_pnrp17_1-port_2-1786525203778">
|
||||||
|
<Endpoint component="amesim_mecmas21_2" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnrp17_1" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_pnrp17_1-port_5-amesim_lstp00a_1-port_1-1786525213548">
|
||||||
|
<Endpoint component="amesim_pnrp17_1" port="port_5"/>
|
||||||
|
<Endpoint component="amesim_lstp00a_1" port="port_1"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_ud00_2-out-amesim_forc_2-res-1786525226761">
|
||||||
|
<Endpoint component="amesim_ud00_2" port="out"/>
|
||||||
|
<Endpoint component="amesim_forc_2" port="res"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnrp17_1-port_4-amesim_f000_2-port_1-1786525729832-0">
|
||||||
|
<Endpoint component="amesim_pnrp17_1" port="port_4"/>
|
||||||
|
<Endpoint component="amesim_f000_2" port="port_1"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-contact-amesim_pnrp17_1-port_1-amesim_pnch012_2-port_3-1786525729832-1">
|
||||||
|
<Endpoint component="amesim_pnrp17_1" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnch012_2" port="port_3"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_forc_1-port_2-amesim_mecmas21_7-port_2-1786525915163">
|
||||||
|
<Endpoint component="amesim_forc_1" port="port_2"/>
|
||||||
|
<Endpoint component="amesim_mecmas21_7" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_mecmas21_7-port_1-amesim_pnrp17_1-port_3-1786525916652">
|
||||||
|
<Endpoint component="amesim_mecmas21_7" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_pnrp17_1" port="port_3"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_mecmas21_5-port_1-amesim_forc_2-port_2-1786525922010">
|
||||||
|
<Endpoint component="amesim_mecmas21_5" port="port_1"/>
|
||||||
|
<Endpoint component="amesim_forc_2" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
<Connection id="edge-amesim_lstp00a_1-port_2-amesim_mecmas21_5-port_2-1786525924224">
|
||||||
|
<Endpoint component="amesim_lstp00a_1" port="port_2"/>
|
||||||
|
<Endpoint component="amesim_mecmas21_5" port="port_2"/>
|
||||||
|
</Connection>
|
||||||
|
</Connections>
|
||||||
|
</System>
|
||||||
@@ -13,6 +13,7 @@ from app.simulation.components.amesim.media.mediums import (
|
|||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from app.simulation.core.peng_robinson import HELIUM_PR
|
from app.simulation.core.peng_robinson import HELIUM_PR
|
||||||
|
from app.simulation.property_cache import property_cache_run
|
||||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
||||||
from tests.test_generic_system_xml_simulation import component_node
|
from tests.test_generic_system_xml_simulation import component_node
|
||||||
from tests.test_system_xml_protocol import physical_port
|
from tests.test_system_xml_protocol import physical_port
|
||||||
@@ -93,17 +94,18 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
|
|||||||
delta=1.0e-8,
|
delta=1.0e-8,
|
||||||
)
|
)
|
||||||
|
|
||||||
medium.temperature_from_pressure_enthalpy.cache_clear()
|
with property_cache_run() as cache:
|
||||||
first = medium.temperature_from_pressure_enthalpy(
|
assert cache is not None
|
||||||
pressure,
|
first = medium.temperature_from_pressure_enthalpy(
|
||||||
transport_enthalpy,
|
pressure,
|
||||||
)
|
transport_enthalpy,
|
||||||
after_first = medium.temperature_from_pressure_enthalpy.cache_info()
|
)
|
||||||
second = medium.temperature_from_pressure_enthalpy(
|
after_first = cache.info()
|
||||||
pressure,
|
second = medium.temperature_from_pressure_enthalpy(
|
||||||
transport_enthalpy,
|
pressure,
|
||||||
)
|
transport_enthalpy,
|
||||||
after_second = medium.temperature_from_pressure_enthalpy.cache_info()
|
)
|
||||||
|
after_second = cache.info()
|
||||||
self.assertEqual(second, first)
|
self.assertEqual(second, first)
|
||||||
self.assertEqual(after_first.misses, 1)
|
self.assertEqual(after_first.misses, 1)
|
||||||
self.assertEqual(after_second.hits, 1)
|
self.assertEqual(after_second.hits, 1)
|
||||||
@@ -146,13 +148,14 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
|
|||||||
temperature,
|
temperature,
|
||||||
)
|
)
|
||||||
|
|
||||||
first = medium.properties_from_mU(mass, internal_energy, volume)
|
with property_cache_run():
|
||||||
second = medium.properties_from_mU(mass, internal_energy, volume)
|
first = medium.properties_from_mU(mass, internal_energy, volume)
|
||||||
changed = medium.properties_from_mU(
|
second = medium.properties_from_mU(mass, internal_energy, volume)
|
||||||
mass,
|
changed = medium.properties_from_mU(
|
||||||
internal_energy,
|
mass,
|
||||||
volume * 1.01,
|
internal_energy,
|
||||||
)
|
volume * 1.01,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertIs(second, first)
|
self.assertIs(second, first)
|
||||||
self.assertIsNot(changed, first)
|
self.assertIsNot(changed, first)
|
||||||
|
|||||||
@@ -241,6 +241,85 @@ class AmesimPnl0003ComponentTests(unittest.TestCase):
|
|||||||
self.assertGreater(forward, 0.0)
|
self.assertGreater(forward, 0.0)
|
||||||
self.assertLess(reverse, 0.0)
|
self.assertLess(reverse, 0.0)
|
||||||
|
|
||||||
|
def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None:
|
||||||
|
base_pressure = 8.0e6
|
||||||
|
former_threshold = base_pressure * 1.0e-7
|
||||||
|
|
||||||
|
below = AmesimPnl0003(
|
||||||
|
"below",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure + 0.99 * former_threshold,
|
||||||
|
p2_0=base_pressure,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
above = AmesimPnl0003(
|
||||||
|
"above",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure + 1.01 * former_threshold,
|
||||||
|
p2_0=base_pressure,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
reverse = AmesimPnl0003(
|
||||||
|
"reverse",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure,
|
||||||
|
p2_0=base_pressure + 0.99 * former_threshold,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
equal = AmesimPnl0003(
|
||||||
|
"equal",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure,
|
||||||
|
p2_0=base_pressure,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
small = AmesimPnl0003(
|
||||||
|
"small",
|
||||||
|
self.medium,
|
||||||
|
p1_0=15.3e6 + 0.1,
|
||||||
|
p2_0=15.3e6,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
|
||||||
|
self.assertGreater(below, 0.0)
|
||||||
|
self.assertGreater(above, below)
|
||||||
|
self.assertLess(reverse, 0.0)
|
||||||
|
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
|
||||||
|
self.assertLess(abs(above - below), abs(above) * 0.05)
|
||||||
|
self.assertEqual(equal, 0.0)
|
||||||
|
self.assertGreater(small, 0.0)
|
||||||
|
|
||||||
|
def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None:
|
||||||
|
base_pressure = 8.0e6
|
||||||
|
former_threshold = base_pressure * 1.0e-7
|
||||||
|
|
||||||
|
below = AmesimPnl0003(
|
||||||
|
"below",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure + 0.99 * former_threshold,
|
||||||
|
p2_0=base_pressure,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
above = AmesimPnl0003(
|
||||||
|
"above",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure + 1.01 * former_threshold,
|
||||||
|
p2_0=base_pressure,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
reverse = AmesimPnl0003(
|
||||||
|
"reverse",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure,
|
||||||
|
p2_0=base_pressure + 0.99 * former_threshold,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
equal = AmesimPnl0003(
|
||||||
|
"equal",
|
||||||
|
self.medium,
|
||||||
|
p1_0=base_pressure,
|
||||||
|
p2_0=base_pressure,
|
||||||
|
).resistance_mass_flow()
|
||||||
|
|
||||||
|
self.assertGreater(below, 0.0)
|
||||||
|
self.assertGreater(above, below)
|
||||||
|
self.assertLess(reverse, 0.0)
|
||||||
|
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
|
||||||
|
self.assertLess(abs(above - below), abs(above) * 0.05)
|
||||||
|
self.assertEqual(equal, 0.0)
|
||||||
|
|
||||||
def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None:
|
def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None:
|
||||||
pipe = AmesimPnl0003("pnl_3", self.medium)
|
pipe = AmesimPnl0003("pnl_3", self.medium)
|
||||||
pipe.properties_1()
|
pipe.properties_1()
|
||||||
|
|||||||
@@ -59,6 +59,39 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
|
|||||||
self.assertLess(reverse, 0.0)
|
self.assertLess(reverse, 0.0)
|
||||||
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
|
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
|
||||||
|
|
||||||
|
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
|
||||||
|
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
|
||||||
|
base_pressure = 8.0e6
|
||||||
|
former_threshold = base_pressure * 1.0e-7
|
||||||
|
|
||||||
|
below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
|
||||||
|
above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
|
||||||
|
reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
|
||||||
|
|
||||||
|
self.assertGreater(below, 0.0)
|
||||||
|
self.assertGreater(above, below)
|
||||||
|
self.assertLess(reverse, 0.0)
|
||||||
|
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
|
||||||
|
self.assertLess(abs(above - below), abs(above) * 0.05)
|
||||||
|
self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0)
|
||||||
|
self.assertGreater(pipe.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0)
|
||||||
|
|
||||||
|
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
|
||||||
|
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
|
||||||
|
base_pressure = 8.0e6
|
||||||
|
former_threshold = base_pressure * 1.0e-7
|
||||||
|
|
||||||
|
below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
|
||||||
|
above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
|
||||||
|
reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
|
||||||
|
|
||||||
|
self.assertGreater(below, 0.0)
|
||||||
|
self.assertGreater(above, below)
|
||||||
|
self.assertLess(reverse, 0.0)
|
||||||
|
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
|
||||||
|
self.assertLess(abs(above - below), abs(above) * 0.05)
|
||||||
|
self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0)
|
||||||
|
|
||||||
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
|
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
|
||||||
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
|
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase):
|
|||||||
self.assertLess(reverse, 0.0)
|
self.assertLess(reverse, 0.0)
|
||||||
self.assertAlmostEqual(forward, -reverse)
|
self.assertAlmostEqual(forward, -reverse)
|
||||||
|
|
||||||
|
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
|
||||||
|
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
|
||||||
|
base_pressure = 8.0e6
|
||||||
|
former_threshold = base_pressure * 1.0e-7
|
||||||
|
|
||||||
|
below = valve.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
|
||||||
|
above = valve.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
|
||||||
|
reverse = valve.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
|
||||||
|
|
||||||
|
self.assertGreater(below, 0.0)
|
||||||
|
self.assertGreater(above, below)
|
||||||
|
self.assertLess(reverse, 0.0)
|
||||||
|
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
|
||||||
|
self.assertLess(abs(above - below), abs(above) * 0.05)
|
||||||
|
self.assertEqual(valve.mass_flow(base_pressure, base_pressure), 0.0)
|
||||||
|
self.assertGreater(valve.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0)
|
||||||
|
|
||||||
def test_mass_flow_uses_connected_enthalpy_from_the_upstream_side(self) -> None:
|
def test_mass_flow_uses_connected_enthalpy_from_the_upstream_side(self) -> None:
|
||||||
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
|
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
|
||||||
hot_h = self.medium.specific_enthalpy(600.0)
|
hot_h = self.medium.specific_enthalpy(600.0)
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ class ContactSolverCausalizationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(diagnostics.success, diagnostics.message)
|
self.assertTrue(diagnostics.success, diagnostics.message)
|
||||||
self.assertGreater(diagnostics.evaluations, 0)
|
self.assertGreater(diagnostics.evaluations, 0)
|
||||||
|
self.assertEqual(diagnostics.jacobian_mode, "dense")
|
||||||
|
self.assertFalse(diagnostics.dense_fallback_used)
|
||||||
self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3)
|
self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3)
|
||||||
self.assertAlmostEqual(load.mechanical.f, -41.0, delta=1.0e-3)
|
self.assertAlmostEqual(load.mechanical.f, -41.0, delta=1.0e-3)
|
||||||
self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3)
|
self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3)
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ from app.simulation.solvers.solver import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _counting_fixed_step_solver(
|
||||||
|
step_size: float,
|
||||||
|
dense_output_times: list[float],
|
||||||
|
):
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
class FixedStepSolver:
|
||||||
|
def __init__(self, _fun, t0, y0, t_bound, **_kwargs):
|
||||||
|
self.t = float(t0)
|
||||||
|
self.y = np.asarray(y0, dtype=float)
|
||||||
|
self.t_bound = float(t_bound)
|
||||||
|
self.status = "running"
|
||||||
|
self.nfev = 0
|
||||||
|
self.njev = 0
|
||||||
|
self.nlu = 0
|
||||||
|
|
||||||
|
def step(self):
|
||||||
|
self.t = min(self.t + step_size, self.t_bound)
|
||||||
|
self.y = np.asarray([self.t], dtype=float)
|
||||||
|
if self.t >= self.t_bound:
|
||||||
|
self.status = "finished"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dense_output(self):
|
||||||
|
dense_output_times.append(self.t)
|
||||||
|
return lambda time: np.asarray([float(time)], dtype=float)
|
||||||
|
|
||||||
|
return FixedStepSolver
|
||||||
|
|
||||||
|
|
||||||
class IntegrateOdeTests(unittest.TestCase):
|
class IntegrateOdeTests(unittest.TestCase):
|
||||||
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
|
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
|
||||||
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
|
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
|
||||||
@@ -159,6 +189,151 @@ class IntegrateOdeTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.status, "cancelled")
|
self.assertEqual(result.status, "cancelled")
|
||||||
self.assertEqual(result.t, [0.0])
|
self.assertEqual(result.t, [0.0])
|
||||||
|
|
||||||
|
def test_stepwise_solvers_build_dense_output_only_when_crossing_a_sample(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
import scipy.integrate
|
||||||
|
|
||||||
|
for method in ("BDF", "Radau", "RK45"):
|
||||||
|
with self.subTest(method=method):
|
||||||
|
dense_output_times: list[float] = []
|
||||||
|
fixed_step_solver = _counting_fixed_step_solver(
|
||||||
|
0.2,
|
||||||
|
dense_output_times,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(scipy.integrate, method, fixed_step_solver):
|
||||||
|
result = integrate_ode(
|
||||||
|
rhs=lambda _time, _state: [1.0],
|
||||||
|
initial_state=[0.0],
|
||||||
|
config=SolveIVPConfig(
|
||||||
|
t_start=0.0,
|
||||||
|
t_stop=1.0,
|
||||||
|
method=method,
|
||||||
|
max_step=1.0,
|
||||||
|
),
|
||||||
|
t_eval=[0.0, 0.75, 1.0],
|
||||||
|
cancel_check=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.success, result.message)
|
||||||
|
self.assertEqual(result.t, [0.0, 0.75, 1.0])
|
||||||
|
self.assertEqual(result.y, [[0.0, 0.75, 1.0]])
|
||||||
|
self.assertEqual(dense_output_times, [0.8, 1.0])
|
||||||
|
self.assertEqual(
|
||||||
|
result.solver_segments[0].accepted_step_count,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stepwise_solvers_keep_dense_output_for_state_event_detection(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
import scipy.integrate
|
||||||
|
|
||||||
|
for method in ("BDF", "Radau", "RK45"):
|
||||||
|
with self.subTest(method=method):
|
||||||
|
dense_output_times: list[float] = []
|
||||||
|
inspected_steps = 0
|
||||||
|
fixed_step_solver = _counting_fixed_step_solver(
|
||||||
|
0.25,
|
||||||
|
dense_output_times,
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect_state_event(
|
||||||
|
previous_time,
|
||||||
|
_previous_state,
|
||||||
|
current_time,
|
||||||
|
_current_state,
|
||||||
|
dense_state,
|
||||||
|
):
|
||||||
|
nonlocal inspected_steps
|
||||||
|
inspected_steps += 1
|
||||||
|
midpoint = 0.5 * (previous_time + current_time)
|
||||||
|
self.assertAlmostEqual(dense_state(midpoint)[0], midpoint)
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(scipy.integrate, method, fixed_step_solver):
|
||||||
|
result = integrate_ode(
|
||||||
|
rhs=lambda _time, _state: [1.0],
|
||||||
|
initial_state=[0.0],
|
||||||
|
config=SolveIVPConfig(
|
||||||
|
t_start=0.0,
|
||||||
|
t_stop=1.0,
|
||||||
|
method=method,
|
||||||
|
max_step=1.0,
|
||||||
|
),
|
||||||
|
t_eval=[0.0, 1.0],
|
||||||
|
state_transition_handler=inspect_state_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.success, result.message)
|
||||||
|
self.assertEqual(len(dense_output_times), 4)
|
||||||
|
self.assertEqual(inspected_steps, 4)
|
||||||
|
|
||||||
|
def test_stepwise_dense_output_preserves_adjacent_float_samples(self) -> None:
|
||||||
|
import scipy.integrate
|
||||||
|
|
||||||
|
dense_output_times: list[float] = []
|
||||||
|
adjacent_time = math.nextafter(0.5, math.inf)
|
||||||
|
half_interval_solver = _counting_fixed_step_solver(
|
||||||
|
0.5,
|
||||||
|
dense_output_times,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(scipy.integrate, "RK45", half_interval_solver):
|
||||||
|
result = integrate_ode(
|
||||||
|
rhs=lambda _time, _state: [1.0],
|
||||||
|
initial_state=[0.0],
|
||||||
|
config=SolveIVPConfig(
|
||||||
|
t_start=0.0,
|
||||||
|
t_stop=1.0,
|
||||||
|
method="RK45",
|
||||||
|
max_step=1.0,
|
||||||
|
),
|
||||||
|
t_eval=[0.0, 0.5, adjacent_time, 1.0],
|
||||||
|
cancel_check=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.success, result.message)
|
||||||
|
self.assertEqual(result.t, [0.0, 0.5, adjacent_time, 1.0])
|
||||||
|
self.assertEqual(result.y[0], result.t)
|
||||||
|
self.assertEqual(dense_output_times, [0.5, 1.0])
|
||||||
|
|
||||||
|
def test_dense_output_pruning_preserves_cancelled_partial_samples(self) -> None:
|
||||||
|
import scipy.integrate
|
||||||
|
|
||||||
|
cancellation_requested = False
|
||||||
|
dense_output_times: list[float] = []
|
||||||
|
fixed_step_solver = _counting_fixed_step_solver(
|
||||||
|
0.2,
|
||||||
|
dense_output_times,
|
||||||
|
)
|
||||||
|
|
||||||
|
def request_cancel(time: float) -> None:
|
||||||
|
nonlocal cancellation_requested
|
||||||
|
cancellation_requested = time >= 0.4
|
||||||
|
|
||||||
|
with patch.object(scipy.integrate, "BDF", fixed_step_solver):
|
||||||
|
result = integrate_ode(
|
||||||
|
rhs=lambda _time, _state: [1.0],
|
||||||
|
initial_state=[0.0],
|
||||||
|
config=SolveIVPConfig(
|
||||||
|
t_start=0.0,
|
||||||
|
t_stop=1.0,
|
||||||
|
method="BDF",
|
||||||
|
max_step=1.0,
|
||||||
|
),
|
||||||
|
t_eval=[0.0, 0.3, 0.8, 1.0],
|
||||||
|
cancel_check=lambda: cancellation_requested,
|
||||||
|
accepted_step_callback=request_cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result.success)
|
||||||
|
self.assertEqual(result.status, "cancelled")
|
||||||
|
self.assertEqual(result.t, [0.0, 0.3, 0.4])
|
||||||
|
self.assertEqual(result.y, [[0.0, 0.3, 0.4]])
|
||||||
|
self.assertEqual(dense_output_times, [0.4])
|
||||||
|
|
||||||
def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None:
|
def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None:
|
||||||
import scipy.integrate
|
import scipy.integrate
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate._ivp.common import num_jac
|
||||||
|
|
||||||
|
from app.main import ReactFlowProjectPayload, compile_reactflow_network
|
||||||
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
|
from app.simulation.components.experimental.storage.tank import Tank
|
||||||
|
from app.simulation.core.base import DynamicComponent
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
|
||||||
|
from app.simulation.systems.generic import GenericFluidSystem
|
||||||
|
from app.simulation.systems.network import Endpoint
|
||||||
|
from tests.test_amesim_pnrp17_xml import pnrp17_coupled_project
|
||||||
|
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||||
|
from tests.test_system_xml_protocol import physical_port
|
||||||
|
|
||||||
|
|
||||||
|
class _DynamicVolumeSource(DynamicComponent):
|
||||||
|
PORTS = (PortDefinition.pneumatic("port"),)
|
||||||
|
state_size = 1
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
super().__init__(name)
|
||||||
|
self.state = 0.25
|
||||||
|
self.port = self.register_declared_port("port")
|
||||||
|
|
||||||
|
def get_state_vector(self) -> list[float]:
|
||||||
|
return [self.state]
|
||||||
|
|
||||||
|
def set_state_vector(self, values: list[float]) -> None:
|
||||||
|
self.state = float(values[0])
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
return [0.0]
|
||||||
|
|
||||||
|
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
|
||||||
|
return {"port": (self.state, 0.0)}
|
||||||
|
|
||||||
|
|
||||||
|
class _TrustedDynamicVolumeSource(_DynamicVolumeSource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_TrustedDynamicVolumeSource.__module__ = "app.simulation.components.synthetic"
|
||||||
|
|
||||||
|
|
||||||
|
class _UntrustedDynamicVolumeSource(_DynamicVolumeSource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cross_domain_storage_project() -> ReactFlowProjectPayload:
|
||||||
|
"""PNL storage -> variable chamber -> pneumatic piston -> two masses."""
|
||||||
|
|
||||||
|
base = pnrp17_coupled_project()
|
||||||
|
nodes = [node.model_dump() for node in base.nodes]
|
||||||
|
nodes.append(
|
||||||
|
component_node(
|
||||||
|
"line_storage",
|
||||||
|
"amesim_pnl0001",
|
||||||
|
[
|
||||||
|
physical_port("port_1", "bidirectional", "left"),
|
||||||
|
physical_port("port_2", "bidirectional", "right"),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"diam": 0.01,
|
||||||
|
"le": 1.0,
|
||||||
|
"rr": 1.0e-5,
|
||||||
|
"k": 1.35,
|
||||||
|
"kth": 0.0,
|
||||||
|
"extemp": 300.0,
|
||||||
|
"gi": 0.0,
|
||||||
|
"mode": 2.0,
|
||||||
|
"p0": 200000.0,
|
||||||
|
"T0": 300.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
edges = [
|
||||||
|
edge.model_dump()
|
||||||
|
for edge in base.edges
|
||||||
|
if edge.id != "edge-boundary-1"
|
||||||
|
]
|
||||||
|
edges.extend(
|
||||||
|
(
|
||||||
|
physical_edge(
|
||||||
|
"edge-chamber-line",
|
||||||
|
"chamber_1",
|
||||||
|
"port_1",
|
||||||
|
"line_storage",
|
||||||
|
"port_1",
|
||||||
|
),
|
||||||
|
physical_edge(
|
||||||
|
"edge-line-boundary",
|
||||||
|
"line_storage",
|
||||||
|
"port_2",
|
||||||
|
"boundary_1",
|
||||||
|
"port_1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ReactFlowProjectPayload(
|
||||||
|
projectSchemaVersion=base.projectSchemaVersion,
|
||||||
|
name="cross-domain-jacobian-sparsity",
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
simulation=base.simulation.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def state_slices(system: GenericFluidSystem) -> dict[str, slice]:
|
||||||
|
result: dict[str, slice] = {}
|
||||||
|
cursor = 0
|
||||||
|
for entry in system.mechanical_state_reducer.state_entries:
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
entry_size = 2
|
||||||
|
names = tuple(component.name for component in entry.components)
|
||||||
|
else:
|
||||||
|
entry_size = entry.state_size
|
||||||
|
names = (entry.name,)
|
||||||
|
state_slice = slice(cursor, cursor + entry_size)
|
||||||
|
for name in names:
|
||||||
|
result[name] = state_slice
|
||||||
|
cursor += entry_size
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class GenericJacobianSparsityTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.system = GenericFluidSystem(
|
||||||
|
compile_reactflow_network(cross_domain_storage_project())
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_volume_connects_mechanical_and_nearby_storage_states(self) -> None:
|
||||||
|
slices = state_slices(self.system)
|
||||||
|
pattern = self.system.jacobian_sparsity().toarray().astype(bool)
|
||||||
|
line_states = range(
|
||||||
|
slices["line_storage"].start,
|
||||||
|
slices["line_storage"].stop,
|
||||||
|
)
|
||||||
|
mechanical_states = [
|
||||||
|
state_index
|
||||||
|
for name in ("piston_mass", "cylinder_mass")
|
||||||
|
for state_index in range(slices[name].start, slices[name].stop)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
pattern[np.ix_(tuple(line_states), tuple(mechanical_states))].all()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
pattern[np.ix_(tuple(mechanical_states), tuple(line_states))].all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_dynamic_volume_dependency_probe(
|
||||||
|
self,
|
||||||
|
source: _DynamicVolumeSource,
|
||||||
|
) -> list[set[int]]:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
receiver = Cylinder("receiver", medium, V=0.1, p0=200_000.0)
|
||||||
|
remote = Tank("remote", medium, V=0.1, p0=100_000.0)
|
||||||
|
system = GenericFluidSystem.__new__(GenericFluidSystem)
|
||||||
|
system.pneumatic_volume_resolver = SimpleNamespace(
|
||||||
|
_output_components=(source,),
|
||||||
|
_connected_endpoint={
|
||||||
|
Endpoint(source.name, "port"): SimpleNamespace(
|
||||||
|
connected_endpoint=Endpoint("receiver", "port_b")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dependencies = [
|
||||||
|
{0, 1},
|
||||||
|
{0, 1},
|
||||||
|
{1, 2},
|
||||||
|
]
|
||||||
|
system._add_pneumatic_volume_state_dependencies(
|
||||||
|
dependencies,
|
||||||
|
(source, receiver, remote),
|
||||||
|
{"source": 0, "receiver": 1, "remote": 2},
|
||||||
|
)
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def test_dynamic_volume_source_ode_state_drives_remote_pneumatic_state(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
dependencies = self._apply_dynamic_volume_dependency_probe(
|
||||||
|
_TrustedDynamicVolumeSource("source")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(0, dependencies[2])
|
||||||
|
self.assertIn(2, dependencies[0])
|
||||||
|
|
||||||
|
def test_untrusted_volume_source_disables_ode_jacobian_sparsity(self) -> None:
|
||||||
|
dependencies = self._apply_dynamic_volume_dependency_probe(
|
||||||
|
_UntrustedDynamicVolumeSource("source")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(dependencies, [{0, 1, 2}] * 3)
|
||||||
|
|
||||||
|
def test_dense_numerical_jacobian_has_no_significant_entry_outside_pattern(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
state = np.asarray(self.system.consistent_initial_state_vector(0.0))
|
||||||
|
slices = state_slices(self.system)
|
||||||
|
# Move one piston face away from the zero-volume reference so the
|
||||||
|
# chamber/line flow has a measurable local volume derivative. This is
|
||||||
|
# an operating-point probe only; the state remains well inside the
|
||||||
|
# chamber's positive total-volume domain.
|
||||||
|
state[slices["piston_mass"].stop - 1] = 1.0e-3
|
||||||
|
|
||||||
|
def evaluate_one(values: np.ndarray) -> np.ndarray:
|
||||||
|
return np.asarray(
|
||||||
|
self.system.rhs(0.0, [float(value) for value in values])
|
||||||
|
)
|
||||||
|
|
||||||
|
def evaluate(_time: float, values: np.ndarray) -> np.ndarray:
|
||||||
|
if values.ndim == 1:
|
||||||
|
return evaluate_one(values)
|
||||||
|
return np.column_stack(
|
||||||
|
[evaluate_one(values[:, index]) for index in range(values.shape[1])]
|
||||||
|
)
|
||||||
|
|
||||||
|
derivative = evaluate(0.0, state)
|
||||||
|
absolute_tolerance = np.asarray(
|
||||||
|
self.system.mechanical_state_reducer.absolute_tolerances(1.0e-8)
|
||||||
|
)
|
||||||
|
dense_jacobian, _factor = num_jac(
|
||||||
|
evaluate,
|
||||||
|
0.0,
|
||||||
|
state,
|
||||||
|
derivative,
|
||||||
|
absolute_tolerance / 1.0e-6,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
pattern = self.system.jacobian_sparsity().toarray().astype(bool)
|
||||||
|
missed = np.abs(np.asarray(dense_jacobian)) * ~pattern
|
||||||
|
column_scale = np.maximum(
|
||||||
|
1.0,
|
||||||
|
np.max(np.abs(np.asarray(dense_jacobian)), axis=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
np.any(missed > 1.0e-6 * column_scale[np.newaxis, :]),
|
||||||
|
f"maximum omitted derivative was {float(np.max(missed))}",
|
||||||
|
)
|
||||||
|
line_rows = range(
|
||||||
|
slices["line_storage"].start,
|
||||||
|
slices["line_storage"].stop,
|
||||||
|
)
|
||||||
|
piston_columns = range(
|
||||||
|
slices["piston_mass"].start,
|
||||||
|
slices["piston_mass"].stop,
|
||||||
|
)
|
||||||
|
self.assertGreater(
|
||||||
|
float(
|
||||||
|
np.max(
|
||||||
|
np.abs(
|
||||||
|
np.asarray(dense_jacobian)[
|
||||||
|
np.ix_(tuple(line_rows), tuple(piston_columns))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -298,9 +298,20 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
|
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
|
||||||
self.assertLess(
|
pressure_flow = result.diagnostics["pressureFlow"]
|
||||||
result.diagnostics["pressureFlow"]["maxScaledResidual"],
|
self.assertLess(pressure_flow["maxScaledResidual"], 1e-7)
|
||||||
1e-7,
|
self.assertEqual(
|
||||||
|
pressure_flow["solveCount"],
|
||||||
|
pressure_flow["seededSolveCount"]
|
||||||
|
+ pressure_flow["nonlinearSolveCount"],
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
pressure_flow["fastPathHitRate"],
|
||||||
|
pressure_flow["seededSolveCount"] / pressure_flow["solveCount"],
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
pressure_flow["residualEvaluationCount"],
|
||||||
|
pressure_flow["optimizerEvaluationCount"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
|
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.main import run_system_xml_simulation
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE_PATH = (
|
||||||
|
Path(__file__).resolve().parent
|
||||||
|
/ "fixtures"
|
||||||
|
/ "high_stiffness_explicit_rk45.xml"
|
||||||
|
)
|
||||||
|
ORIGINAL_XML_SHA256 = (
|
||||||
|
"27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def short_explicit_rk45_xml() -> bytes:
|
||||||
|
original = FIXTURE_PATH.read_bytes()
|
||||||
|
canonical = original.replace(b"\r\n", b"\n").rstrip(b"\n")
|
||||||
|
if hashlib.sha256(canonical).hexdigest() != ORIGINAL_XML_SHA256:
|
||||||
|
raise AssertionError("The high-stiffness RK45 fixture differs from the user XML.")
|
||||||
|
if original.count(b'tStop="10"') != 1:
|
||||||
|
raise AssertionError("Expected one original simulation stop time.")
|
||||||
|
if original.count(b'sampleStep="0.02"') != 1:
|
||||||
|
raise AssertionError("Expected one original simulation sample step.")
|
||||||
|
return original.replace(
|
||||||
|
b'tStop="10"',
|
||||||
|
b'tStop="0.005"',
|
||||||
|
1,
|
||||||
|
).replace(
|
||||||
|
b'sampleStep="0.02"',
|
||||||
|
b'sampleStep="0.001"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HighStiffnessExplicitRk45RegressionTests(unittest.TestCase):
|
||||||
|
def test_short_user_model_crosses_the_early_stiff_limit_event(self) -> None:
|
||||||
|
result = run_system_xml_simulation(
|
||||||
|
short_explicit_rk45_xml(),
|
||||||
|
cancel_check=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result["message"])
|
||||||
|
self.assertEqual(result["status"], "completed")
|
||||||
|
self.assertFalse(result["partial"])
|
||||||
|
self.assertEqual(result["simulatedUntil"], 0.005)
|
||||||
|
self.assertNotIn("provided bounds", result["message"])
|
||||||
|
|
||||||
|
integration = result["diagnostics"]["integration"]
|
||||||
|
totals = integration["totals"]
|
||||||
|
self.assertEqual(integration["method"], "RK45")
|
||||||
|
self.assertGreaterEqual(totals["stateTransitionCount"], 1)
|
||||||
|
self.assertGreaterEqual(totals["solverStartCount"], 2)
|
||||||
|
|
||||||
|
series = result["series"]
|
||||||
|
times = series["time"]
|
||||||
|
self.assertEqual(times[0], 0.0)
|
||||||
|
self.assertEqual(times[-1], 0.005)
|
||||||
|
self.assertTrue(
|
||||||
|
all(first < second for first, second in zip(times, times[1:]))
|
||||||
|
)
|
||||||
|
self.assertTrue(any(0.0 < time < 0.001 for time in times))
|
||||||
|
for values in series.values():
|
||||||
|
self.assertEqual(len(values), len(times))
|
||||||
|
self.assertTrue(all(math.isfinite(value) for value in values))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.simulation.benchmark_performance import (
|
||||||
|
_duration_summary,
|
||||||
|
_load_factory_xml,
|
||||||
|
_named_value,
|
||||||
|
_parse_arguments,
|
||||||
|
_serialize_result_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_xml_factory() -> bytes:
|
||||||
|
return b"<System/>"
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceBenchmarkToolTests(unittest.TestCase):
|
||||||
|
def test_named_value_requires_an_explicit_name(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
_named_value("chain=tests.example:project", option="--factory"),
|
||||||
|
("chain", "tests.example:project"),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "NAME=VALUE"):
|
||||||
|
_named_value("tests.example:project", option="--factory")
|
||||||
|
|
||||||
|
def test_duration_summary_reports_repeatable_order_statistics(self) -> None:
|
||||||
|
summary = _duration_summary([5.0, 1.0, 3.0, 2.0, 4.0])
|
||||||
|
|
||||||
|
self.assertEqual(summary["minimumMs"], 1.0)
|
||||||
|
self.assertEqual(summary["medianMs"], 3.0)
|
||||||
|
self.assertEqual(summary["p95Ms"], 5.0)
|
||||||
|
self.assertEqual(summary["maximumMs"], 5.0)
|
||||||
|
|
||||||
|
def test_factory_loader_accepts_a_bytes_factory(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
_load_factory_xml(
|
||||||
|
"tests.test_performance_benchmark:sample_xml_factory"
|
||||||
|
),
|
||||||
|
b"<System/>",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_result_event_serialization_uses_ndjson_shape(self) -> None:
|
||||||
|
payload = _serialize_result_event(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"status": "completed",
|
||||||
|
"simulatedUntil": 1.0,
|
||||||
|
"requestedStopTime": 1.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(payload.endswith(b"\n"))
|
||||||
|
self.assertIn(b'"event":"result"', payload)
|
||||||
|
self.assertIn(b'"result":{"success":true', payload)
|
||||||
|
|
||||||
|
def test_cache_ab_flag_is_explicit(self) -> None:
|
||||||
|
arguments = _parse_arguments(
|
||||||
|
[
|
||||||
|
"--factory",
|
||||||
|
"sample=tests.test_performance_benchmark:sample_xml_factory",
|
||||||
|
"--disable-property-cache",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(arguments.disable_property_cache)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.main import compile_reactflow_network
|
||||||
|
from app.simulation.solvers.algebraic import (
|
||||||
|
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
|
||||||
|
)
|
||||||
|
from app.simulation.systems.generic import GenericFluidSystem
|
||||||
|
from tests.test_amesim_mechanical_xml import zero_force_mass_project
|
||||||
|
from tests.test_amesim_pnvo001_signal_xml import (
|
||||||
|
high_pressure_helium_step_project,
|
||||||
|
)
|
||||||
|
from tests.test_generic_system_xml_simulation import chain_project
|
||||||
|
|
||||||
|
|
||||||
|
def _system(project) -> GenericFluidSystem:
|
||||||
|
return GenericFluidSystem(compile_reactflow_network(project))
|
||||||
|
|
||||||
|
|
||||||
|
class PressureFlowCausalExecutionTests(unittest.TestCase):
|
||||||
|
def test_strict_causal_rhs_matches_environment_disabled_legacy_bitwise(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
optimized = _system(high_pressure_helium_step_project())
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE: "0"},
|
||||||
|
):
|
||||||
|
legacy = _system(high_pressure_helium_step_project())
|
||||||
|
|
||||||
|
optimized_state = optimized.initial_state_vector()
|
||||||
|
legacy_state = legacy.initial_state_vector()
|
||||||
|
for time in (0.0, 0.041, 0.8):
|
||||||
|
optimized_derivative = optimized.rhs(time, optimized_state)
|
||||||
|
legacy_derivative = legacy.rhs(time, legacy_state)
|
||||||
|
self.assertEqual(optimized_derivative, legacy_derivative)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(
|
||||||
|
unknown.read()
|
||||||
|
for unknown in optimized.pressure_flow_solver.unknowns
|
||||||
|
),
|
||||||
|
tuple(
|
||||||
|
unknown.read()
|
||||||
|
for unknown in legacy.pressure_flow_solver.unknowns
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
global_diagnostics = (
|
||||||
|
optimized.pressure_flow_solver.causal_execution_diagnostics()
|
||||||
|
)
|
||||||
|
self.assertTrue(global_diagnostics["eligible"])
|
||||||
|
self.assertGreater(global_diagnostics["fastSolveCount"], 0)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
global_diagnostics["fullResidualAuditCount"],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
secondary = (
|
||||||
|
optimized._thermofluid_closure_plan.secondary_block_solvers[0]
|
||||||
|
)
|
||||||
|
secondary_diagnostics = secondary.causal_execution_diagnostics()
|
||||||
|
self.assertTrue(secondary_diagnostics["eligible"])
|
||||||
|
self.assertGreater(secondary_diagnostics["fastSolveCount"], 0)
|
||||||
|
|
||||||
|
legacy_diagnostics = (
|
||||||
|
legacy.pressure_flow_solver.causal_execution_diagnostics()
|
||||||
|
)
|
||||||
|
self.assertFalse(legacy_diagnostics["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
legacy_diagnostics["disabledReason"],
|
||||||
|
"disabledByEnvironment",
|
||||||
|
)
|
||||||
|
self.assertEqual(legacy_diagnostics["fastSolveCount"], 0)
|
||||||
|
|
||||||
|
def test_multiple_effort_anchors_conservatively_keep_legacy_path(self) -> None:
|
||||||
|
system = _system(chain_project())
|
||||||
|
diagnostics = (
|
||||||
|
system.pressure_flow_solver.causal_execution_diagnostics()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(diagnostics["eligible"])
|
||||||
|
self.assertFalse(diagnostics["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
diagnostics["fallbackReason"],
|
||||||
|
"effortGroupDoesNotHaveOneAnchor",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_flow_coverage_failure_fuses_to_verified_legacy_path(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
system = _system(zero_force_mass_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
original = solver._solve_explicit_flow_unknowns
|
||||||
|
|
||||||
|
def hide_coverage(*args, **kwargs):
|
||||||
|
original(*args, **kwargs)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"_solve_explicit_flow_unknowns",
|
||||||
|
side_effect=hide_coverage,
|
||||||
|
):
|
||||||
|
diagnostics = solver.solve(effort_variables=("p",))
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
self.assertTrue(diagnostics.residual_verified_this_solve)
|
||||||
|
execution = solver.causal_execution_diagnostics()
|
||||||
|
self.assertFalse(execution["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
execution["disabledReason"],
|
||||||
|
"causalRuntimeGateFailed",
|
||||||
|
)
|
||||||
|
self.assertEqual(execution["legacyFallbackCount"], 1)
|
||||||
|
|
||||||
|
def test_periodic_audit_failure_disables_fast_path_before_fallback(self) -> None:
|
||||||
|
system = _system(zero_force_mass_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
solver._causal_audit_interval = 0
|
||||||
|
original_values = solver._pressure_flow_equation_values
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def one_bad_audit_value():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
values = original_values()
|
||||||
|
if call_count != 1:
|
||||||
|
return values
|
||||||
|
return (values[0] + 1.0, *values[1:])
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"_pressure_flow_equation_values",
|
||||||
|
side_effect=one_bad_audit_value,
|
||||||
|
):
|
||||||
|
diagnostics = solver.solve(effort_variables=("p",))
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
execution = solver.causal_execution_diagnostics()
|
||||||
|
self.assertFalse(execution["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
execution["disabledReason"],
|
||||||
|
"causalResidualAuditFailed",
|
||||||
|
)
|
||||||
|
self.assertEqual(execution["auditFailureCount"], 1)
|
||||||
|
self.assertEqual(execution["legacyFallbackCount"], 1)
|
||||||
|
|
||||||
|
def test_requested_audit_interrupts_periodic_fast_sequence(self) -> None:
|
||||||
|
system = _system(zero_force_mass_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
before = solver.causal_execution_diagnostics()
|
||||||
|
self.assertEqual(before["fullResidualAuditCount"], 1)
|
||||||
|
self.assertEqual(before["fastSolveCount"], 1)
|
||||||
|
|
||||||
|
solver.request_causal_audit()
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
after = solver.causal_execution_diagnostics()
|
||||||
|
self.assertEqual(after["fullResidualAuditCount"], 2)
|
||||||
|
self.assertEqual(after["fastSolveCount"], 1)
|
||||||
|
|
||||||
|
def test_fast_solve_skips_the_full_residual_evaluator(self) -> None:
|
||||||
|
system = _system(zero_force_mass_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"_pressure_flow_equation_values",
|
||||||
|
wraps=solver._pressure_flow_equation_values,
|
||||||
|
) as evaluate_all:
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
|
||||||
|
evaluate_all.assert_not_called()
|
||||||
|
self.assertTrue(solver.last_diagnostics.causal_fast_path_used)
|
||||||
|
self.assertFalse(
|
||||||
|
solver.last_diagnostics.residual_verified_this_solve
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nonfinite_explicit_assignment_fuses_and_verifies_same_solve(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
system = _system(zero_force_mass_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
original = solver._evaluate_explicit_flow_stage
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def one_nonfinite_assignment(stage):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
values = original(stage)
|
||||||
|
if call_count != 1:
|
||||||
|
return values
|
||||||
|
return (float("nan"), *values[1:])
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"_evaluate_explicit_flow_stage",
|
||||||
|
side_effect=one_nonfinite_assignment,
|
||||||
|
):
|
||||||
|
diagnostics = solver.solve(effort_variables=("p",))
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
self.assertTrue(diagnostics.residual_verified_this_solve)
|
||||||
|
execution = solver.causal_execution_diagnostics()
|
||||||
|
self.assertFalse(execution["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
execution["disabledReason"],
|
||||||
|
"causalRuntimeGateFailed",
|
||||||
|
)
|
||||||
|
self.assertEqual(execution["legacyFallbackCount"], 1)
|
||||||
|
|
||||||
|
def test_nonpositive_pressure_fuses_and_verifies_same_solve(self) -> None:
|
||||||
|
system = _system(high_pressure_helium_step_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
original = solver._solve_explicit_flow_unknowns
|
||||||
|
pressure = next(
|
||||||
|
unknown for unknown in solver.unknowns if unknown.variable == "p"
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_pressure_invalid(*args, **kwargs):
|
||||||
|
seeded = original(*args, **kwargs)
|
||||||
|
pressure.write(-1.0)
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"_solve_explicit_flow_unknowns",
|
||||||
|
side_effect=make_pressure_invalid,
|
||||||
|
):
|
||||||
|
diagnostics = solver.solve(effort_variables=("p",))
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
self.assertTrue(diagnostics.residual_verified_this_solve)
|
||||||
|
self.assertGreater(pressure.read(), 0.0)
|
||||||
|
execution = solver.causal_execution_diagnostics()
|
||||||
|
self.assertFalse(execution["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
execution["disabledReason"],
|
||||||
|
"causalRuntimeGateFailed",
|
||||||
|
)
|
||||||
|
self.assertEqual(execution["legacyFallbackCount"], 1)
|
||||||
|
|
||||||
|
def test_default_periodic_audit_runs_after_sixty_four_fast_solves(self) -> None:
|
||||||
|
system = _system(zero_force_mass_project())
|
||||||
|
state = system.initial_state_vector()
|
||||||
|
solver = system.pressure_flow_solver
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
|
||||||
|
for _iteration in range(64):
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
before_boundary = solver.causal_execution_diagnostics()
|
||||||
|
self.assertEqual(before_boundary["auditInterval"], 64)
|
||||||
|
self.assertEqual(before_boundary["fastSolveCount"], 64)
|
||||||
|
self.assertEqual(before_boundary["fullResidualAuditCount"], 1)
|
||||||
|
|
||||||
|
system.rhs(0.0, state)
|
||||||
|
after_boundary = solver.causal_execution_diagnostics()
|
||||||
|
self.assertEqual(after_boundary["fastSolveCount"], 64)
|
||||||
|
self.assertEqual(after_boundary["fullResidualAuditCount"], 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from scipy.optimize import least_squares as scipy_least_squares
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
|
||||||
|
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
||||||
|
from app.simulation.components.amesim.mechanical.translational import (
|
||||||
|
AmesimF000,
|
||||||
|
AmesimForc,
|
||||||
|
AmesimLstp00a,
|
||||||
|
AmesimMecmas21,
|
||||||
|
)
|
||||||
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
|
from app.simulation.components.experimental.storage.tank import Tank
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
class _CustomPnl0002(AmesimPnl0002):
|
||||||
|
"""External subclass: structural declarations alone are not a purity promise."""
|
||||||
|
|
||||||
|
|
||||||
|
def _branched_solver(
|
||||||
|
*,
|
||||||
|
custom_pipe: bool = False,
|
||||||
|
include_contact: bool = False,
|
||||||
|
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
|
||||||
|
right = Tank("right", medium, V=0.1, p0=100_000.0)
|
||||||
|
pipe_type = _CustomPnl0002 if custom_pipe else AmesimPnl0002
|
||||||
|
pipe = pipe_type("pipe", medium, p0=300_000.0, T0=300.0)
|
||||||
|
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
|
||||||
|
plug = AmesimPnpl01("isolated_plug")
|
||||||
|
network = SimulationNetwork("nonlinear-equation-blocks")
|
||||||
|
for component in (left, right, pipe, isolated, plug):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("left", "port_b", "pipe", "port_1")
|
||||||
|
network.connect("pipe", "port_2", "right", "port_a")
|
||||||
|
network.connect("isolated", "port_b", "isolated_plug", "port_1")
|
||||||
|
|
||||||
|
if include_contact:
|
||||||
|
force = AmesimForc("contact_force")
|
||||||
|
force.res.signal = 40.0
|
||||||
|
contact = AmesimLstp00a(
|
||||||
|
"contact",
|
||||||
|
medium,
|
||||||
|
gap0=0.0,
|
||||||
|
kcont=1.0e11,
|
||||||
|
rcont=0.0,
|
||||||
|
Pdis=1.0e-7,
|
||||||
|
discContactOption=1.0,
|
||||||
|
)
|
||||||
|
mass = AmesimMecmas21(
|
||||||
|
"mass",
|
||||||
|
medium,
|
||||||
|
mass=2.0,
|
||||||
|
useFriction=1.0,
|
||||||
|
x0=1.0e9,
|
||||||
|
)
|
||||||
|
zero = AmesimF000("zero")
|
||||||
|
for component in (force, contact, mass, zero):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("contact_force", "port_2", "contact", "port_1")
|
||||||
|
network.connect("contact", "port_2", "mass", "port_1")
|
||||||
|
network.connect("mass", "port_2", "zero", "port_1")
|
||||||
|
|
||||||
|
for component in network.dynamic_components():
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
solver = PressureFlowSolver(network)
|
||||||
|
solver.solve()
|
||||||
|
return solver, pipe
|
||||||
|
|
||||||
|
|
||||||
|
def _damage_flows_after_seed(
|
||||||
|
solver: PressureFlowSolver,
|
||||||
|
pipe: AmesimPnl0002,
|
||||||
|
ports: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
snapshots: list[tuple[float, ...]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
original = solver._solve_explicit_flow_unknowns
|
||||||
|
|
||||||
|
def damaged(*args, **kwargs):
|
||||||
|
result = original(*args, **kwargs)
|
||||||
|
for index, port_name in enumerate(ports, start=1):
|
||||||
|
port = pipe.get_port(port_name)
|
||||||
|
port.m_flow += index * 0.01
|
||||||
|
if snapshots is not None:
|
||||||
|
snapshots.append(tuple(unknown.read() for unknown in solver.unknowns))
|
||||||
|
return result
|
||||||
|
|
||||||
|
solver._solve_explicit_flow_unknowns = damaged
|
||||||
|
|
||||||
|
|
||||||
|
class PressureFlowEquationBlockTests(unittest.TestCase):
|
||||||
|
def test_pnl0002_owner_is_safely_split_across_two_blocks(self) -> None:
|
||||||
|
solver, _pipe = _branched_solver()
|
||||||
|
pipe_rows = {
|
||||||
|
index
|
||||||
|
for index, equation in enumerate(solver.equation_templates)
|
||||||
|
if equation.owner == "component" and equation.owner_id == "pipe"
|
||||||
|
}
|
||||||
|
owner_blocks = [
|
||||||
|
block
|
||||||
|
for block in solver.equation_blocks
|
||||||
|
if pipe_rows.intersection(block.equation_indices)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertTrue(solver.equation_blocks_are_trusted)
|
||||||
|
self.assertEqual(len(pipe_rows), 2)
|
||||||
|
self.assertEqual(len(owner_blocks), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
set(owner_blocks[0].unknown_indices).isdisjoint(
|
||||||
|
owner_blocks[1].unknown_indices
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_bad_block_uses_scoped_sparse_residual(self) -> None:
|
||||||
|
optimized, optimized_pipe = _branched_solver()
|
||||||
|
dense, dense_pipe = _branched_solver()
|
||||||
|
_damage_flows_after_seed(optimized, optimized_pipe, ("port_1",))
|
||||||
|
_damage_flows_after_seed(dense, dense_pipe, ("port_1",))
|
||||||
|
dense._equation_blocks_are_trusted = False
|
||||||
|
dense._jacobian_sparsity_is_trusted = False
|
||||||
|
|
||||||
|
block_diagnostics = optimized.solve()
|
||||||
|
dense_diagnostics = dense.solve()
|
||||||
|
|
||||||
|
self.assertEqual(block_diagnostics.jacobian_mode, "blockSparse")
|
||||||
|
self.assertEqual(block_diagnostics.nonlinear_block_count, 1)
|
||||||
|
self.assertEqual(block_diagnostics.nonlinear_block_unknown_count, 4)
|
||||||
|
self.assertFalse(block_diagnostics.block_fallback_used)
|
||||||
|
self.assertLess(
|
||||||
|
block_diagnostics.residual_evaluations,
|
||||||
|
dense_diagnostics.residual_evaluations,
|
||||||
|
)
|
||||||
|
for block_unknown, dense_unknown in zip(
|
||||||
|
optimized.unknowns,
|
||||||
|
dense.unknowns,
|
||||||
|
):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
block_unknown.read(),
|
||||||
|
dense_unknown.read(),
|
||||||
|
delta=1.0e-6 * max(abs(dense_unknown.read()), 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_bad_blocks_are_solved_in_one_union_call(self) -> None:
|
||||||
|
solver, pipe = _branched_solver()
|
||||||
|
_damage_flows_after_seed(solver, pipe, ("port_1", "port_2"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"scipy.optimize.least_squares",
|
||||||
|
wraps=scipy_least_squares,
|
||||||
|
) as least_squares:
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertEqual(least_squares.call_count, 1)
|
||||||
|
self.assertEqual(diagnostics.jacobian_mode, "blockSparse")
|
||||||
|
self.assertEqual(diagnostics.nonlinear_block_count, 2)
|
||||||
|
self.assertEqual(diagnostics.nonlinear_block_unknown_count, 8)
|
||||||
|
subset_pattern = least_squares.call_args.kwargs["jac_sparsity"]
|
||||||
|
self.assertEqual(subset_pattern.shape, (8, 8))
|
||||||
|
self.assertLess(subset_pattern.nnz, solver.jacobian_sparsity.nnz)
|
||||||
|
|
||||||
|
def test_failed_local_candidate_restores_global_seed_before_fallback(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
solver, pipe = _branched_solver()
|
||||||
|
seeded_snapshots: list[tuple[float, ...]] = []
|
||||||
|
_damage_flows_after_seed(
|
||||||
|
solver,
|
||||||
|
pipe,
|
||||||
|
("port_1",),
|
||||||
|
snapshots=seeded_snapshots,
|
||||||
|
)
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def fail_block_then_solve_global(fun, x0, **kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
self.assertEqual(x0.shape, (4,))
|
||||||
|
return SimpleNamespace(
|
||||||
|
x=x0 + 123.0,
|
||||||
|
success=False,
|
||||||
|
status=-1,
|
||||||
|
message="forced block failure",
|
||||||
|
nfev=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(x0.shape, (len(solver.unknowns),))
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in solver.unknowns),
|
||||||
|
seeded_snapshots[-1],
|
||||||
|
)
|
||||||
|
return scipy_least_squares(fun, x0, **kwargs)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"scipy.optimize.least_squares",
|
||||||
|
side_effect=fail_block_then_solve_global,
|
||||||
|
):
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertGreaterEqual(call_count, 2)
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
self.assertTrue(diagnostics.block_fallback_used)
|
||||||
|
self.assertEqual(diagnostics.nonlinear_block_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
diagnostics.block_fallback_reason,
|
||||||
|
"blockResidualNotConverged",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_block_memory_error_restores_seed_and_remains_fatal(self) -> None:
|
||||||
|
solver, pipe = _branched_solver()
|
||||||
|
seeded_snapshots: list[tuple[float, ...]] = []
|
||||||
|
_damage_flows_after_seed(
|
||||||
|
solver,
|
||||||
|
pipe,
|
||||||
|
("port_1",),
|
||||||
|
snapshots=seeded_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"scipy.optimize.least_squares",
|
||||||
|
side_effect=MemoryError("forced allocation failure"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(MemoryError):
|
||||||
|
solver.solve()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in solver.unknowns),
|
||||||
|
seeded_snapshots[-1],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_component_keeps_the_legacy_global_path(self) -> None:
|
||||||
|
solver, pipe = _branched_solver(custom_pipe=True)
|
||||||
|
_damage_flows_after_seed(solver, pipe, ("port_1",))
|
||||||
|
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertFalse(solver.equation_blocks_are_trusted)
|
||||||
|
self.assertEqual(
|
||||||
|
solver.equation_blocks_fallback_reason,
|
||||||
|
"untrustedCustomComponent",
|
||||||
|
)
|
||||||
|
self.assertEqual(diagnostics.jacobian_mode, "dense")
|
||||||
|
self.assertTrue(diagnostics.block_fallback_used)
|
||||||
|
self.assertEqual(
|
||||||
|
diagnostics.block_fallback_reason,
|
||||||
|
"untrustedCustomComponent",
|
||||||
|
)
|
||||||
|
self.assertFalse(solver.causal_fast_path_eligible)
|
||||||
|
self.assertEqual(
|
||||||
|
solver.causal_execution_diagnostics()["fallbackReason"],
|
||||||
|
"untrustedCustomComponent",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_active_contact_conservatively_keeps_global_dense_fallback(self) -> None:
|
||||||
|
solver, pipe = _branched_solver(include_contact=True)
|
||||||
|
_damage_flows_after_seed(solver, pipe, ("port_1",))
|
||||||
|
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertEqual(diagnostics.jacobian_mode, "dense")
|
||||||
|
self.assertTrue(diagnostics.block_fallback_used)
|
||||||
|
self.assertEqual(
|
||||||
|
diagnostics.block_fallback_reason,
|
||||||
|
"activeCausalContact",
|
||||||
|
)
|
||||||
|
contact = solver.network.components["contact"]
|
||||||
|
self.assertIsNotNone(contact._causal_penetration)
|
||||||
|
self.assertAlmostEqual(contact.contact_force, 40.0, places=7)
|
||||||
|
self.assertFalse(solver.causal_fast_path_eligible)
|
||||||
|
self.assertEqual(
|
||||||
|
solver.causal_execution_diagnostics()["fallbackReason"],
|
||||||
|
"activeSetCausalizationRequired",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -44,13 +44,19 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
|||||||
chamber.refresh_thermodynamic_ports()
|
chamber.refresh_thermodynamic_ports()
|
||||||
pipe.refresh_thermodynamic_ports()
|
pipe.refresh_thermodynamic_ports()
|
||||||
|
|
||||||
result = PressureFlowSolver(network).solve()
|
solver = PressureFlowSolver(network)
|
||||||
|
result = solver.solve()
|
||||||
|
|
||||||
self.assertTrue(result.success)
|
self.assertTrue(result.success)
|
||||||
self.assertGreater(orifice.port_2.p, pipe.port_2.p)
|
self.assertGreater(orifice.port_2.p, pipe.port_2.p)
|
||||||
self.assertLess(orifice.port_2.p, chamber.port_2.p)
|
self.assertLess(orifice.port_2.p, chamber.port_2.p)
|
||||||
self.assertAlmostEqual(orifice.port_2.p, pipe.port_1.p)
|
self.assertAlmostEqual(orifice.port_2.p, pipe.port_1.p)
|
||||||
self.assertAlmostEqual(-orifice.port_2.m_flow, pipe.port_1.m_flow)
|
self.assertAlmostEqual(-orifice.port_2.m_flow, pipe.port_1.m_flow)
|
||||||
|
self.assertFalse(solver.causal_fast_path_eligible)
|
||||||
|
self.assertEqual(
|
||||||
|
solver.causal_execution_diagnostics()["fallbackReason"],
|
||||||
|
"specialSeriesPressureSeed",
|
||||||
|
)
|
||||||
|
|
||||||
def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None:
|
def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None:
|
||||||
medium = IdealGasMedium()
|
medium = IdealGasMedium()
|
||||||
@@ -63,13 +69,19 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
|||||||
network.add_component(plug)
|
network.add_component(plug)
|
||||||
network.connect("resistance", "port_2", "closed", "port_1")
|
network.connect("resistance", "port_2", "closed", "port_1")
|
||||||
|
|
||||||
result = PressureFlowSolver(network).solve()
|
solver = PressureFlowSolver(network)
|
||||||
|
result = solver.solve()
|
||||||
|
|
||||||
self.assertTrue(result.success)
|
self.assertTrue(result.success)
|
||||||
self.assertEqual(result.evaluations, 0)
|
self.assertEqual(result.evaluations, 0)
|
||||||
self.assertAlmostEqual(pipe.port_2.p, pipe.port_1.p)
|
self.assertAlmostEqual(pipe.port_2.p, pipe.port_1.p)
|
||||||
self.assertEqual(pipe.port_1.m_flow, 0.0)
|
self.assertEqual(pipe.port_1.m_flow, 0.0)
|
||||||
self.assertEqual(pipe.port_2.m_flow, 0.0)
|
self.assertEqual(pipe.port_2.m_flow, 0.0)
|
||||||
|
self.assertFalse(solver.causal_fast_path_eligible)
|
||||||
|
self.assertEqual(
|
||||||
|
solver.causal_execution_diagnostics()["fallbackReason"],
|
||||||
|
"specialClosedResistancePressureSeed",
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _near_equal_pressure_network() -> tuple[
|
def _near_equal_pressure_network() -> tuple[
|
||||||
@@ -338,6 +350,48 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(solver.last_diagnostics)
|
self.assertIsNotNone(solver.last_diagnostics)
|
||||||
self.assertFalse(solver.last_diagnostics.success)
|
self.assertFalse(solver.last_diagnostics.success)
|
||||||
|
|
||||||
|
def test_positive_sub_pascal_seed_uses_the_exact_pressure_without_optimizer(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
pressure = 0.43301848566882734
|
||||||
|
temperature = pressure / medium.R_gas
|
||||||
|
tank = Tank("tank", medium, V=1.0)
|
||||||
|
plug = AmesimPnpl01("plug")
|
||||||
|
network = SimulationNetwork("positive-sub-pascal-pressure")
|
||||||
|
network.add_component(tank)
|
||||||
|
network.add_component(plug)
|
||||||
|
network.connect("tank", "port_a", "plug", "port_1")
|
||||||
|
|
||||||
|
tank.state = VolumeState(
|
||||||
|
m=1.0,
|
||||||
|
U=medium.specific_internal_energy(temperature),
|
||||||
|
)
|
||||||
|
tank.refresh_thermodynamic_ports()
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
tank.port_a.p,
|
||||||
|
pressure,
|
||||||
|
delta=pressure * 1.0e-11,
|
||||||
|
)
|
||||||
|
|
||||||
|
solver = PressureFlowSolver(network, max_evaluations=10)
|
||||||
|
with patch(
|
||||||
|
"scipy.optimize.least_squares",
|
||||||
|
side_effect=AssertionError(
|
||||||
|
"A finite positive pressure seed must stay on the fast path."
|
||||||
|
),
|
||||||
|
) as least_squares:
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
self.assertEqual(diagnostics.evaluations, 0)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
tank.port_a.p,
|
||||||
|
pressure,
|
||||||
|
delta=pressure * 1.0e-11,
|
||||||
|
)
|
||||||
|
least_squares.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
|
||||||
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
class _IncompleteDependencyComponent(AlgebraicComponent):
|
||||||
|
"""Deliberately violate the residual dependency authoring contract."""
|
||||||
|
|
||||||
|
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
super().__init__(name)
|
||||||
|
self.port_1 = self.register_declared_port("port_1")
|
||||||
|
self.port_1.p = 90_000.0
|
||||||
|
self.port_1.m_flow = 1.0
|
||||||
|
|
||||||
|
def pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
|
return self.port_1.p - 100_000.0, self.port_1.m_flow
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:pressure",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="state",
|
||||||
|
# The live residual reads p, but the declaration omits it on
|
||||||
|
# purpose. A zero structural row must disable sparse FD.
|
||||||
|
variables=(),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_1.p - 100_000.0,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:flow",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="constitutive",
|
||||||
|
variables=(f"{self.name}.port_1.m_flow",),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_1.m_flow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_boundary_solver(name: str) -> PressureFlowSolver:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
storage = Cylinder(f"{name}_storage", medium, V=0.1, p0=500_000.0)
|
||||||
|
boundary = AmesimPnpl01(f"{name}_boundary")
|
||||||
|
network = SimulationNetwork(name)
|
||||||
|
network.add_component(storage)
|
||||||
|
network.add_component(boundary)
|
||||||
|
network.connect(storage.name, "port_b", boundary.name, "port_1")
|
||||||
|
storage.refresh_thermodynamic_ports()
|
||||||
|
boundary.port_1.m_flow = 1.0
|
||||||
|
return PressureFlowSolver(network)
|
||||||
|
|
||||||
|
|
||||||
|
class PressureFlowSolverSparsityTests(unittest.TestCase):
|
||||||
|
def test_sparse_and_dense_paths_produce_the_same_solution(self) -> None:
|
||||||
|
sparse_solver = _storage_boundary_solver("sparse")
|
||||||
|
dense_solver = _storage_boundary_solver("dense")
|
||||||
|
dense_solver._jacobian_sparsity_is_trusted = False
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
sparse_solver,
|
||||||
|
"_solve_explicit_flow_unknowns",
|
||||||
|
return_value=set(),
|
||||||
|
):
|
||||||
|
sparse = sparse_solver.solve()
|
||||||
|
with patch.object(
|
||||||
|
dense_solver,
|
||||||
|
"_solve_explicit_flow_unknowns",
|
||||||
|
return_value=set(),
|
||||||
|
):
|
||||||
|
dense = dense_solver.solve()
|
||||||
|
|
||||||
|
self.assertEqual(sparse.jacobian_mode, "blockSparse")
|
||||||
|
self.assertEqual(sparse.nonlinear_block_count, 1)
|
||||||
|
self.assertLess(
|
||||||
|
sparse.nonlinear_block_unknown_count,
|
||||||
|
len(sparse_solver.unknowns),
|
||||||
|
)
|
||||||
|
self.assertEqual(dense.jacobian_mode, "dense")
|
||||||
|
self.assertFalse(sparse.dense_fallback_used)
|
||||||
|
self.assertFalse(dense.dense_fallback_used)
|
||||||
|
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
|
||||||
|
self.assertLess(sparse.residual_evaluations, dense.residual_evaluations)
|
||||||
|
sparse_values = {
|
||||||
|
unknown.id.split("_", maxsplit=1)[-1]: unknown.read()
|
||||||
|
for unknown in sparse_solver.unknowns
|
||||||
|
}
|
||||||
|
dense_values = {
|
||||||
|
unknown.id.split("_", maxsplit=1)[-1]: unknown.read()
|
||||||
|
for unknown in dense_solver.unknowns
|
||||||
|
}
|
||||||
|
self.assertEqual(sparse_values.keys(), dense_values.keys())
|
||||||
|
for variable_id in sparse_values:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
sparse_values[variable_id],
|
||||||
|
dense_values[variable_id],
|
||||||
|
delta=1.0e-9,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sparse.as_dict()["residualEvaluations"],
|
||||||
|
sparse.residual_evaluations,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incomplete_dependency_metadata_uses_dense_finite_differences(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
component = _IncompleteDependencyComponent("incomplete")
|
||||||
|
network = SimulationNetwork("incomplete-dependency")
|
||||||
|
network.add_component(component)
|
||||||
|
solver = PressureFlowSolver(network)
|
||||||
|
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertFalse(solver.jacobian_sparsity_is_trusted)
|
||||||
|
self.assertEqual(
|
||||||
|
solver.jacobian_sparsity_fallback_reason,
|
||||||
|
"equationWithoutDeclaredUnknown",
|
||||||
|
)
|
||||||
|
self.assertEqual(diagnostics.jacobian_mode, "dense")
|
||||||
|
self.assertFalse(diagnostics.dense_fallback_used)
|
||||||
|
self.assertAlmostEqual(component.port_1.p, 100_000.0, places=6)
|
||||||
|
self.assertAlmostEqual(component.port_1.m_flow, 0.0, places=12)
|
||||||
|
|
||||||
|
def test_failed_sparse_candidate_restarts_dense_from_original_x0(self) -> None:
|
||||||
|
solver = _storage_boundary_solver("retry")
|
||||||
|
# This regression specifically exercises the final whole-network
|
||||||
|
# sparse -> dense compatibility fallback, not the preceding block
|
||||||
|
# optimization.
|
||||||
|
solver._equation_blocks_are_trusted = False
|
||||||
|
original_x0: list[np.ndarray] = []
|
||||||
|
calls: list[str] = []
|
||||||
|
flow_indices = tuple(
|
||||||
|
index
|
||||||
|
for index, unknown in enumerate(solver.unknowns)
|
||||||
|
if unknown.variable == "m_flow"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_least_squares(_fun, x0, **kwargs):
|
||||||
|
if "jac_sparsity" in kwargs:
|
||||||
|
calls.append("sparse")
|
||||||
|
original_x0.append(x0.copy())
|
||||||
|
failed = x0.copy()
|
||||||
|
failed[list(flow_indices)] = 123.0
|
||||||
|
return SimpleNamespace(
|
||||||
|
x=failed,
|
||||||
|
success=False,
|
||||||
|
status=-1,
|
||||||
|
message="forced sparse failure",
|
||||||
|
nfev=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls.append("dense")
|
||||||
|
np.testing.assert_array_equal(x0, original_x0[0])
|
||||||
|
solved = x0.copy()
|
||||||
|
solved[list(flow_indices)] = 0.0
|
||||||
|
return SimpleNamespace(
|
||||||
|
x=solved,
|
||||||
|
success=True,
|
||||||
|
status=1,
|
||||||
|
message="dense fallback solved",
|
||||||
|
nfev=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"_solve_explicit_flow_unknowns",
|
||||||
|
return_value=set(),
|
||||||
|
), patch(
|
||||||
|
"scipy.optimize.least_squares",
|
||||||
|
side_effect=fake_least_squares,
|
||||||
|
):
|
||||||
|
diagnostics = solver.solve()
|
||||||
|
|
||||||
|
self.assertEqual(calls, ["sparse", "dense"])
|
||||||
|
self.assertTrue(diagnostics.success)
|
||||||
|
self.assertEqual(diagnostics.evaluations, 7)
|
||||||
|
self.assertEqual(diagnostics.jacobian_mode, "sparseThenDense")
|
||||||
|
self.assertTrue(diagnostics.dense_fallback_used)
|
||||||
|
for index in flow_indices:
|
||||||
|
self.assertAlmostEqual(solver.unknowns[index].read(), 0.0, places=12)
|
||||||
|
|
||||||
|
def test_compiled_pattern_matches_declared_storage_boundary_structure(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
solver = _storage_boundary_solver("pattern")
|
||||||
|
|
||||||
|
self.assertTrue(solver.jacobian_sparsity_is_trusted)
|
||||||
|
self.assertEqual(solver.jacobian_sparsity.shape, (4, 4))
|
||||||
|
self.assertEqual(solver.jacobian_sparsity.nnz, 6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.media.mediums import (
|
||||||
|
AmesimHeliumPengRobinsonMedium,
|
||||||
|
)
|
||||||
|
from app.simulation.property_cache import property_cache_run
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyPerformanceInstrumentationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
|
||||||
|
def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None:
|
||||||
|
pressure = 15.3e6
|
||||||
|
temperature = 293.15
|
||||||
|
enthalpy = self.medium.specific_enthalpy_at_pressure(
|
||||||
|
pressure,
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.simulation.components.amesim.media.mediums."
|
||||||
|
"record_property_iterations"
|
||||||
|
) as record_iterations:
|
||||||
|
with property_cache_run():
|
||||||
|
first = self.medium.temperature_from_pressure_enthalpy(
|
||||||
|
pressure,
|
||||||
|
enthalpy,
|
||||||
|
)
|
||||||
|
second = self.medium.temperature_from_pressure_enthalpy(
|
||||||
|
pressure,
|
||||||
|
enthalpy,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(second, first)
|
||||||
|
record_iterations.assert_called_once()
|
||||||
|
operation, iterations, converged = record_iterations.call_args.args
|
||||||
|
self.assertEqual(operation, "temperature_from_pressure_enthalpy")
|
||||||
|
self.assertEqual(iterations, 5)
|
||||||
|
self.assertTrue(converged)
|
||||||
|
|
||||||
|
def test_state_recovery_iterations_are_recorded_only_on_cache_miss(self) -> None:
|
||||||
|
pressure = 15.3e6
|
||||||
|
temperature = 293.15
|
||||||
|
volume = 0.057
|
||||||
|
mass = self.medium.density(pressure, temperature) * volume
|
||||||
|
internal_energy = mass * self.medium.specific_internal_energy_at_pressure(
|
||||||
|
pressure,
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.simulation.components.amesim.media.mediums."
|
||||||
|
"record_property_iterations"
|
||||||
|
) as record_iterations:
|
||||||
|
with property_cache_run():
|
||||||
|
first = self.medium.properties_from_mU(
|
||||||
|
mass,
|
||||||
|
internal_energy,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
second = self.medium.properties_from_mU(
|
||||||
|
mass,
|
||||||
|
internal_energy,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(second, first)
|
||||||
|
record_iterations.assert_called_once()
|
||||||
|
operation, iterations, converged = record_iterations.call_args.args
|
||||||
|
self.assertEqual(operation, "properties_from_mU")
|
||||||
|
self.assertEqual(iterations, 5)
|
||||||
|
self.assertTrue(converged)
|
||||||
|
|
||||||
|
def test_temperature_iteration_limit_is_reported_as_not_converged(self) -> None:
|
||||||
|
pressure = 123_456.0
|
||||||
|
base_temperature = 300.0
|
||||||
|
enthalpy = self.medium.specific_enthalpy(base_temperature)
|
||||||
|
fake_fluid = Mock()
|
||||||
|
fake_fluid.residual_specific_enthalpy.side_effect = (
|
||||||
|
lambda _pressure, temperature: (
|
||||||
|
self.medium.cp_ref * 10.0
|
||||||
|
if temperature >= base_temperature
|
||||||
|
else -self.medium.cp_ref * 10.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
|
||||||
|
patch(
|
||||||
|
"app.simulation.components.amesim.media.mediums."
|
||||||
|
"record_property_iterations"
|
||||||
|
) as record_iterations,
|
||||||
|
):
|
||||||
|
self.medium.temperature_from_pressure_enthalpy(
|
||||||
|
pressure,
|
||||||
|
enthalpy,
|
||||||
|
)
|
||||||
|
|
||||||
|
record_iterations.assert_called_once_with(
|
||||||
|
"temperature_from_pressure_enthalpy",
|
||||||
|
16,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_state_recovery_iteration_limit_is_reported_as_not_converged(self) -> None:
|
||||||
|
base_temperature = 300.0
|
||||||
|
target_internal_energy = self.medium.specific_internal_energy(
|
||||||
|
base_temperature
|
||||||
|
)
|
||||||
|
fake_fluid = Mock()
|
||||||
|
fake_fluid.residual_specific_internal_energy_at_density.side_effect = (
|
||||||
|
lambda temperature, _density: (
|
||||||
|
self.medium.cv * 10.0
|
||||||
|
if temperature >= base_temperature
|
||||||
|
else -self.medium.cv * 10.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fake_fluid.pressure_from_density.return_value = 101_325.0
|
||||||
|
fake_fluid.residual_specific_enthalpy.return_value = 0.0
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
|
||||||
|
patch(
|
||||||
|
"app.simulation.components.amesim.media.mediums."
|
||||||
|
"record_property_iterations"
|
||||||
|
) as record_iterations,
|
||||||
|
):
|
||||||
|
self.medium.properties_from_mU(
|
||||||
|
1.0,
|
||||||
|
target_internal_energy,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
record_iterations.assert_called_once_with(
|
||||||
|
"properties_from_mU",
|
||||||
|
16,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from functools import lru_cache
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import app.simulation.performance as performance
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def profiling_mode(mode: str):
|
||||||
|
previous = os.environ.get("SIMULATIONAPP_PROFILE")
|
||||||
|
os.environ["SIMULATIONAPP_PROFILE"] = mode
|
||||||
|
module = importlib.reload(performance)
|
||||||
|
try:
|
||||||
|
yield module
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
os.environ.pop("SIMULATIONAPP_PROFILE", None)
|
||||||
|
else:
|
||||||
|
os.environ["SIMULATIONAPP_PROFILE"] = previous
|
||||||
|
importlib.reload(performance)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPerformanceTests(unittest.TestCase):
|
||||||
|
def test_off_mode_decorators_return_original_callables(self) -> None:
|
||||||
|
with profiling_mode("off") as module:
|
||||||
|
def phase_function(value: int) -> int:
|
||||||
|
return value + 1
|
||||||
|
|
||||||
|
def property_function(value: int) -> int:
|
||||||
|
return value * 2
|
||||||
|
|
||||||
|
self.assertIs(
|
||||||
|
module.profile_phase("phase")(phase_function),
|
||||||
|
phase_function,
|
||||||
|
)
|
||||||
|
self.assertIs(
|
||||||
|
module.profile_phase("phase", minimum_mode="off")(phase_function),
|
||||||
|
phase_function,
|
||||||
|
)
|
||||||
|
self.assertIs(
|
||||||
|
module.profile_property("property")(property_function),
|
||||||
|
property_function,
|
||||||
|
)
|
||||||
|
self.assertIs(
|
||||||
|
module.profile_property(
|
||||||
|
"property",
|
||||||
|
minimum_mode="off",
|
||||||
|
)(property_function),
|
||||||
|
property_function,
|
||||||
|
)
|
||||||
|
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
self.assertEqual(phase_function(2), 3)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
trace.snapshot(),
|
||||||
|
{
|
||||||
|
"mode": "off",
|
||||||
|
"phases": {},
|
||||||
|
"properties": {},
|
||||||
|
"propertyOutermostNs": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_standard_mode_records_nested_inclusive_and_self_time(self) -> None:
|
||||||
|
with profiling_mode("standard") as module:
|
||||||
|
@module.profile_phase("inner")
|
||||||
|
def inner() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@module.profile_phase("outer")
|
||||||
|
def outer() -> None:
|
||||||
|
inner()
|
||||||
|
|
||||||
|
clock = iter((0, 10, 20, 30, 50, 80))
|
||||||
|
with patch.object(module, "perf_counter_ns", side_effect=clock):
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
outer()
|
||||||
|
|
||||||
|
snapshot = trace.snapshot()
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["phases"]["inner"],
|
||||||
|
{
|
||||||
|
"calls": 1,
|
||||||
|
"inclusiveNs": 10,
|
||||||
|
"selfNs": 10,
|
||||||
|
"maxNs": 10,
|
||||||
|
"errors": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(snapshot["phases"]["outer"]["inclusiveNs"], 40)
|
||||||
|
self.assertEqual(snapshot["phases"]["outer"]["selfNs"], 30)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["phases"]["simulation.total"]["inclusiveNs"],
|
||||||
|
80,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["phases"]["simulation.total"]["selfNs"],
|
||||||
|
40,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_errors_are_recorded_and_propagated(self) -> None:
|
||||||
|
with profiling_mode("standard") as module:
|
||||||
|
@module.profile_phase("explode")
|
||||||
|
def explode() -> None:
|
||||||
|
raise RuntimeError("expected")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "expected"):
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
explode()
|
||||||
|
|
||||||
|
snapshot = trace.snapshot()
|
||||||
|
self.assertEqual(snapshot["phases"]["explode"]["errors"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["phases"]["simulation.total"]["errors"],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manual_performance_span_uses_the_current_trace(self) -> None:
|
||||||
|
with profiling_mode("standard") as module:
|
||||||
|
clock = iter((0, 10, 20, 30))
|
||||||
|
with patch.object(module, "perf_counter_ns", side_effect=clock):
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
with module.performance_span("manual"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
snapshot = trace.snapshot()
|
||||||
|
self.assertEqual(snapshot["phases"]["manual"]["inclusiveNs"], 10)
|
||||||
|
self.assertEqual(snapshot["phases"]["simulation.total"]["selfNs"], 20)
|
||||||
|
|
||||||
|
def test_nested_properties_only_add_outermost_time_once(self) -> None:
|
||||||
|
with profiling_mode("standard") as module:
|
||||||
|
class TestMedium:
|
||||||
|
name = "TestMedium"
|
||||||
|
|
||||||
|
@module.profile_property("inner", minimum_mode="standard")
|
||||||
|
def inner(self) -> float:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
@module.profile_property("outer", minimum_mode="standard")
|
||||||
|
def outer(self) -> float:
|
||||||
|
return self.inner()
|
||||||
|
|
||||||
|
medium = TestMedium()
|
||||||
|
clock = iter((0, 10, 20, 30, 50, 80))
|
||||||
|
with patch.object(module, "perf_counter_ns", side_effect=clock):
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
self.assertEqual(medium.outer(), 1.0)
|
||||||
|
|
||||||
|
snapshot = trace.snapshot()
|
||||||
|
outer = snapshot["properties"]["semantic.TestMedium.outer"]
|
||||||
|
inner = snapshot["properties"]["semantic.TestMedium.inner"]
|
||||||
|
self.assertEqual(outer["inclusiveNs"], 40)
|
||||||
|
self.assertEqual(outer["selfNs"], 30)
|
||||||
|
self.assertEqual(inner["inclusiveNs"], 10)
|
||||||
|
self.assertEqual(snapshot["propertyOutermostNs"], 40)
|
||||||
|
|
||||||
|
def test_audit_resets_exact_input_shadow_and_records_iterations_and_cache(self) -> None:
|
||||||
|
with profiling_mode("audit") as module:
|
||||||
|
class TestMedium:
|
||||||
|
name = "AuditMedium"
|
||||||
|
|
||||||
|
@module.profile_property("inverse", track_cache=True)
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def inverse(self, value: float) -> float:
|
||||||
|
module.record_property_iterations(
|
||||||
|
"inverse",
|
||||||
|
3 if value == 1.0 else 4,
|
||||||
|
value == 1.0,
|
||||||
|
)
|
||||||
|
return value * 2.0
|
||||||
|
|
||||||
|
medium = TestMedium()
|
||||||
|
|
||||||
|
@module.profile_phase("closure", reset_property_shadow=True)
|
||||||
|
def closure() -> None:
|
||||||
|
medium.inverse(1.0)
|
||||||
|
medium.inverse(1.0)
|
||||||
|
medium.inverse(2.0)
|
||||||
|
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
closure()
|
||||||
|
closure()
|
||||||
|
|
||||||
|
metric = trace.snapshot()["properties"][
|
||||||
|
"semantic.AuditMedium.inverse"
|
||||||
|
]
|
||||||
|
self.assertEqual(metric["calls"], 6)
|
||||||
|
self.assertEqual(metric["exactInputUnique"], 4)
|
||||||
|
self.assertEqual(metric["exactInputRepeats"], 2)
|
||||||
|
self.assertEqual(metric["iterationCalls"], 2)
|
||||||
|
self.assertEqual(metric["iterationTotal"], 7)
|
||||||
|
self.assertEqual(metric["iterationMax"], 4)
|
||||||
|
self.assertEqual(metric["iterationConverged"], 1)
|
||||||
|
self.assertEqual(metric["iterationNonconverged"], 1)
|
||||||
|
self.assertEqual(metric["cacheLookups"], 6)
|
||||||
|
self.assertEqual(metric["cacheHits"], 4)
|
||||||
|
self.assertEqual(metric["cacheMisses"], 2)
|
||||||
|
|
||||||
|
self.assertTrue(callable(medium.inverse.cache_clear))
|
||||||
|
self.assertTrue(callable(medium.inverse.cache_info))
|
||||||
|
self.assertTrue(callable(medium.inverse.cache_parameters))
|
||||||
|
medium.inverse.cache_clear()
|
||||||
|
self.assertEqual(medium.inverse.cache_info().currsize, 0)
|
||||||
|
|
||||||
|
def test_audit_minimum_decorator_is_absent_in_standard_mode(self) -> None:
|
||||||
|
with profiling_mode("standard") as module:
|
||||||
|
def kernel(value: float) -> float:
|
||||||
|
return value
|
||||||
|
|
||||||
|
self.assertIs(
|
||||||
|
module.profile_property(
|
||||||
|
"kernel",
|
||||||
|
layer="kernel",
|
||||||
|
minimum_mode="audit",
|
||||||
|
)(kernel),
|
||||||
|
kernel,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_async_profile_runs_are_context_isolated(self) -> None:
|
||||||
|
with profiling_mode("standard") as module:
|
||||||
|
@module.profile_phase("work")
|
||||||
|
async def work() -> None:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
async def one_run() -> dict[str, object]:
|
||||||
|
with module.profile_run() as trace:
|
||||||
|
await work()
|
||||||
|
return trace.snapshot()
|
||||||
|
|
||||||
|
async def exercise() -> list[dict[str, object]]:
|
||||||
|
return await asyncio.gather(one_run(), one_run())
|
||||||
|
|
||||||
|
snapshots = asyncio.run(exercise())
|
||||||
|
self.assertEqual(
|
||||||
|
[snapshot["phases"]["work"]["calls"] for snapshot in snapshots],
|
||||||
|
[1, 1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPerformancePipelineTests(unittest.TestCase):
|
||||||
|
def test_off_mode_does_not_change_result_diagnostics(self) -> None:
|
||||||
|
script = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
from app.main import (
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
run_system_xml_simulation,
|
||||||
|
)
|
||||||
|
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
|
||||||
|
|
||||||
|
result = run_system_xml_simulation(
|
||||||
|
build_reactflow_system_xml(amesim_pnl00r_project())
|
||||||
|
)
|
||||||
|
assert result["success"], result["message"]
|
||||||
|
print("performance" in result["diagnostics"])
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SIMULATIONAPP_PROFILE"] = "off"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(completed.stdout.strip(), "False")
|
||||||
|
|
||||||
|
def test_standard_mode_reports_coarse_pipeline_phases_per_run(self) -> None:
|
||||||
|
script = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.main import (
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
run_system_xml_simulation,
|
||||||
|
)
|
||||||
|
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
|
||||||
|
|
||||||
|
xml = build_reactflow_system_xml(amesim_pnl00r_project())
|
||||||
|
snapshots = []
|
||||||
|
for _ in range(2):
|
||||||
|
result = run_system_xml_simulation(xml)
|
||||||
|
assert result["success"], result["message"]
|
||||||
|
performance = result["diagnostics"]["performance"]
|
||||||
|
snapshots.append(
|
||||||
|
{
|
||||||
|
"mode": performance["mode"],
|
||||||
|
"calls": {
|
||||||
|
name: metrics["calls"]
|
||||||
|
for name, metrics in performance["phases"].items()
|
||||||
|
},
|
||||||
|
"propertyCount": len(performance["properties"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(json.dumps(snapshots))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SIMULATIONAPP_PROFILE"] = "standard"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
snapshots = json.loads(completed.stdout)
|
||||||
|
required_phases = {
|
||||||
|
"simulation.total",
|
||||||
|
"simulation.xml_validation",
|
||||||
|
"simulation.network_compilation",
|
||||||
|
"simulation.system_construction",
|
||||||
|
"simulation.sample_initialization",
|
||||||
|
"simulation.integration",
|
||||||
|
"simulation.postprocessing",
|
||||||
|
"simulation.result_assembly",
|
||||||
|
"simulation.response_assembly",
|
||||||
|
}
|
||||||
|
audit_only_phases = {
|
||||||
|
"simulation.rhs",
|
||||||
|
"simulation.closure",
|
||||||
|
"simulation.refresh",
|
||||||
|
"simulation.pressure_flow",
|
||||||
|
"simulation.pneumatic_volume",
|
||||||
|
"simulation.stream",
|
||||||
|
"simulation.signal",
|
||||||
|
"simulation.derivatives",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 2)
|
||||||
|
for snapshot in snapshots:
|
||||||
|
self.assertEqual(snapshot["mode"], "standard")
|
||||||
|
self.assertTrue(required_phases.issubset(snapshot["calls"]))
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.total"], 1)
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.xml_validation"], 1)
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.network_compilation"], 1)
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.integration"], 1)
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.postprocessing"], 1)
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.result_assembly"], 1)
|
||||||
|
self.assertEqual(snapshot["calls"]["simulation.response_assembly"], 1)
|
||||||
|
self.assertTrue(audit_only_phases.isdisjoint(snapshot["calls"]))
|
||||||
|
self.assertEqual(snapshot["propertyCount"], 0)
|
||||||
|
|
||||||
|
def test_audit_mode_reports_hot_phases_and_property_metrics(self) -> None:
|
||||||
|
script = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.main import (
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
run_system_xml_simulation,
|
||||||
|
)
|
||||||
|
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
|
||||||
|
|
||||||
|
result = run_system_xml_simulation(
|
||||||
|
build_reactflow_system_xml(amesim_pnl00r_project())
|
||||||
|
)
|
||||||
|
assert result["success"], result["message"]
|
||||||
|
print(json.dumps(result["diagnostics"]["performance"]))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SIMULATIONAPP_PROFILE"] = "audit"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
snapshot = json.loads(completed.stdout)
|
||||||
|
|
||||||
|
for phase in (
|
||||||
|
"simulation.rhs",
|
||||||
|
"simulation.closure",
|
||||||
|
"simulation.refresh",
|
||||||
|
"simulation.pressure_flow",
|
||||||
|
"simulation.pneumatic_volume",
|
||||||
|
"simulation.stream",
|
||||||
|
"simulation.signal",
|
||||||
|
"simulation.derivatives",
|
||||||
|
):
|
||||||
|
self.assertGreater(snapshot["phases"][phase]["calls"], 0)
|
||||||
|
self.assertGreater(len(snapshot["properties"]), 0)
|
||||||
|
self.assertGreater(snapshot["propertyOutermostNs"], 0)
|
||||||
|
|
||||||
|
def test_audit_property_totals_match_run_local_cache(self) -> None:
|
||||||
|
script = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.media.mediums import (
|
||||||
|
AmesimHeliumPengRobinsonMedium,
|
||||||
|
)
|
||||||
|
from app.simulation.performance import profile_run
|
||||||
|
from app.simulation.property_cache import property_cache_run
|
||||||
|
|
||||||
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
pressure = 15.3e6
|
||||||
|
temperature = 293.15
|
||||||
|
volume = 0.057
|
||||||
|
with property_cache_run() as cache:
|
||||||
|
assert cache is not None
|
||||||
|
with profile_run() as trace:
|
||||||
|
density = medium.density(pressure, temperature)
|
||||||
|
medium.density(pressure, temperature)
|
||||||
|
medium.isentropic_density_pressure_factor(
|
||||||
|
pressure, temperature, 1.0e6
|
||||||
|
)
|
||||||
|
medium.isentropic_density_pressure_factor(
|
||||||
|
pressure, temperature, 1.0e6
|
||||||
|
)
|
||||||
|
enthalpy = medium.specific_enthalpy_at_pressure(
|
||||||
|
pressure, temperature
|
||||||
|
)
|
||||||
|
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
|
||||||
|
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
|
||||||
|
mass = density * volume
|
||||||
|
energy = mass * medium.specific_internal_energy_at_pressure(
|
||||||
|
pressure, temperature
|
||||||
|
)
|
||||||
|
medium.properties_from_mU(mass, energy, volume)
|
||||||
|
medium.properties_from_mU(mass, energy, volume)
|
||||||
|
cache_info = cache.info()
|
||||||
|
snapshot = trace.snapshot()
|
||||||
|
cached_properties = [
|
||||||
|
metrics
|
||||||
|
for metrics in snapshot["properties"].values()
|
||||||
|
if metrics["cacheLookups"] > 0
|
||||||
|
]
|
||||||
|
print(json.dumps({
|
||||||
|
"cacheHits": cache_info.hits,
|
||||||
|
"cacheMisses": cache_info.misses,
|
||||||
|
"propertyHits": sum(item["cacheHits"] for item in cached_properties),
|
||||||
|
"propertyMisses": sum(item["cacheMisses"] for item in cached_properties),
|
||||||
|
}))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SIMULATIONAPP_PROFILE"] = "audit"
|
||||||
|
environment["SIMULATIONAPP_PROPERTY_CACHE"] = "on"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
totals = json.loads(completed.stdout)
|
||||||
|
|
||||||
|
self.assertGreater(totals["cacheHits"], 0)
|
||||||
|
self.assertGreater(totals["cacheMisses"], 0)
|
||||||
|
self.assertEqual(totals["cacheHits"], totals["propertyHits"])
|
||||||
|
self.assertEqual(totals["cacheMisses"], totals["propertyMisses"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from math import nextafter
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from threading import Barrier
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.media.mediums import (
|
||||||
|
AmesimHeliumPengRobinsonMedium,
|
||||||
|
)
|
||||||
|
from app.simulation.property_cache import (
|
||||||
|
cache_property_calculation,
|
||||||
|
current_property_cache,
|
||||||
|
property_cache_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPropertyCacheTests(unittest.TestCase):
|
||||||
|
def test_reuses_only_exact_inputs(self) -> None:
|
||||||
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
pressure = 15.3e6
|
||||||
|
temperature = 293.15
|
||||||
|
|
||||||
|
with property_cache_run() as cache:
|
||||||
|
assert cache is not None
|
||||||
|
first = medium.density(pressure, temperature)
|
||||||
|
repeated = medium.density(pressure, temperature)
|
||||||
|
changed = medium.density(nextafter(pressure, float("inf")), temperature)
|
||||||
|
info = cache.info()
|
||||||
|
|
||||||
|
self.assertEqual(repeated, first)
|
||||||
|
self.assertNotEqual(changed, first)
|
||||||
|
self.assertEqual(info.hits, 1)
|
||||||
|
self.assertEqual(info.misses, 2)
|
||||||
|
self.assertEqual(info.current_entries, 2)
|
||||||
|
|
||||||
|
def test_failed_calculations_are_not_cached(self) -> None:
|
||||||
|
class FailingProperty:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
@cache_property_calculation("failure")
|
||||||
|
def calculate(self, value: float) -> float:
|
||||||
|
self.calls += 1
|
||||||
|
raise ValueError(f"invalid {value}")
|
||||||
|
|
||||||
|
owner = FailingProperty()
|
||||||
|
with property_cache_run() as cache:
|
||||||
|
assert cache is not None
|
||||||
|
for _ in range(2):
|
||||||
|
with self.assertRaisesRegex(ValueError, "invalid"):
|
||||||
|
owner.calculate(1.0)
|
||||||
|
info = cache.info()
|
||||||
|
|
||||||
|
self.assertEqual(owner.calls, 2)
|
||||||
|
self.assertEqual(info.hits, 0)
|
||||||
|
self.assertEqual(info.misses, 2)
|
||||||
|
self.assertEqual(info.current_entries, 0)
|
||||||
|
|
||||||
|
def test_lru_capacity_is_bounded_and_oldest_entry_is_evicted(self) -> None:
|
||||||
|
class CachedProperty:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
@cache_property_calculation("bounded")
|
||||||
|
def calculate(self, value: int) -> int:
|
||||||
|
self.calls += 1
|
||||||
|
return value * 10
|
||||||
|
|
||||||
|
owner = CachedProperty()
|
||||||
|
with property_cache_run(max_entries=2) as cache:
|
||||||
|
assert cache is not None
|
||||||
|
owner.calculate(1)
|
||||||
|
owner.calculate(2)
|
||||||
|
owner.calculate(1)
|
||||||
|
owner.calculate(3)
|
||||||
|
second = owner.calculate(2)
|
||||||
|
info = cache.info()
|
||||||
|
|
||||||
|
self.assertEqual(second, 20)
|
||||||
|
self.assertEqual(owner.calls, 4)
|
||||||
|
self.assertEqual(info.current_entries, 2)
|
||||||
|
self.assertEqual(info.evictions, 2)
|
||||||
|
|
||||||
|
def test_each_run_gets_an_independent_cache_and_releases_context(self) -> None:
|
||||||
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
with property_cache_run() as first_cache:
|
||||||
|
assert first_cache is not None
|
||||||
|
medium.density(100_000.0, 300.0)
|
||||||
|
medium.density(100_000.0, 300.0)
|
||||||
|
first_info = first_cache.info()
|
||||||
|
|
||||||
|
self.assertIsNone(current_property_cache())
|
||||||
|
|
||||||
|
with property_cache_run() as second_cache:
|
||||||
|
assert second_cache is not None
|
||||||
|
medium.density(100_000.0, 300.0)
|
||||||
|
second_info = second_cache.info()
|
||||||
|
|
||||||
|
self.assertIsNot(first_cache, second_cache)
|
||||||
|
self.assertEqual(first_info.hits, 1)
|
||||||
|
self.assertEqual(second_info.hits, 0)
|
||||||
|
self.assertEqual(second_info.misses, 1)
|
||||||
|
self.assertIsNone(current_property_cache())
|
||||||
|
|
||||||
|
def test_async_tasks_do_not_share_run_local_caches(self) -> None:
|
||||||
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
|
||||||
|
async def exercise(pressure: float) -> tuple[int, int, int]:
|
||||||
|
with property_cache_run() as cache:
|
||||||
|
assert cache is not None
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
medium.density(pressure, 300.0)
|
||||||
|
medium.density(pressure, 300.0)
|
||||||
|
info = cache.info()
|
||||||
|
return id(cache), info.hits, info.misses
|
||||||
|
|
||||||
|
async def run_both() -> list[tuple[int, int, int]]:
|
||||||
|
return list(
|
||||||
|
await asyncio.gather(
|
||||||
|
exercise(100_000.0),
|
||||||
|
exercise(200_000.0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = asyncio.run(run_both())
|
||||||
|
|
||||||
|
self.assertNotEqual(results[0][0], results[1][0])
|
||||||
|
self.assertEqual(results[0][1:], (1, 1))
|
||||||
|
self.assertEqual(results[1][1:], (1, 1))
|
||||||
|
|
||||||
|
def test_worker_threads_do_not_share_run_local_caches(self) -> None:
|
||||||
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def exercise(pressure: float) -> tuple[int, int, int]:
|
||||||
|
with property_cache_run() as cache:
|
||||||
|
assert cache is not None
|
||||||
|
barrier.wait(timeout=5.0)
|
||||||
|
medium.density(pressure, 300.0)
|
||||||
|
medium.density(pressure, 300.0)
|
||||||
|
info = cache.info()
|
||||||
|
return id(cache), info.hits, info.misses
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
results = list(
|
||||||
|
executor.map(
|
||||||
|
exercise,
|
||||||
|
(100_000.0, 200_000.0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(results[0][0], results[1][0])
|
||||||
|
self.assertEqual(results[0][1:], (1, 1))
|
||||||
|
self.assertEqual(results[1][1:], (1, 1))
|
||||||
|
|
||||||
|
def test_cache_on_and_off_produce_identical_helium_results(self) -> None:
|
||||||
|
script = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.main import build_reactflow_system_xml, run_system_xml_simulation
|
||||||
|
from tests.test_amesim_pnvo001_signal_xml import (
|
||||||
|
high_pressure_helium_step_project,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_system_xml_simulation(
|
||||||
|
build_reactflow_system_xml(high_pressure_helium_step_project())
|
||||||
|
)
|
||||||
|
assert result["success"], result["message"]
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"status": result["status"],
|
||||||
|
"series": result["series"],
|
||||||
|
"final": result["final"],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
print(hashlib.sha256(payload).hexdigest())
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
hashes: list[str] = []
|
||||||
|
for enabled in ("on", "off"):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SIMULATIONAPP_PROFILE"] = "off"
|
||||||
|
environment["SIMULATIONAPP_PROPERTY_CACHE"] = enabled
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
hashes.append(completed.stdout.strip())
|
||||||
|
|
||||||
|
self.assertEqual(hashes[0], hashes[1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.main import _app_lifespan, app
|
||||||
|
from app.simulation.warmup import (
|
||||||
|
SimulationWarmupReport,
|
||||||
|
_run_numerical_warmup,
|
||||||
|
_reset_simulation_warmup_for_tests,
|
||||||
|
warm_up_simulation_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationWarmupTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
_reset_simulation_warmup_for_tests()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
_reset_simulation_warmup_for_tests()
|
||||||
|
|
||||||
|
def test_warmup_runs_only_once_per_process(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
|
||||||
|
patch("app.simulation.warmup._run_numerical_warmup") as run_warmup,
|
||||||
|
):
|
||||||
|
first = warm_up_simulation_runtime()
|
||||||
|
second = warm_up_simulation_runtime()
|
||||||
|
|
||||||
|
self.assertIs(second, first)
|
||||||
|
self.assertEqual(first.status, "completed")
|
||||||
|
run_warmup.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_disabled_warmup_does_not_touch_numerical_runtime(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "off"}),
|
||||||
|
patch("app.simulation.warmup._run_numerical_warmup") as run_warmup,
|
||||||
|
):
|
||||||
|
report = warm_up_simulation_runtime()
|
||||||
|
|
||||||
|
self.assertEqual(report.status, "disabled")
|
||||||
|
run_warmup.assert_not_called()
|
||||||
|
|
||||||
|
def test_regular_failure_is_reported_without_blocking_startup(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
|
||||||
|
patch(
|
||||||
|
"app.simulation.warmup._run_numerical_warmup",
|
||||||
|
side_effect=RuntimeError("broken warmup"),
|
||||||
|
),
|
||||||
|
self.assertLogs("app.simulation.warmup", level="ERROR"),
|
||||||
|
):
|
||||||
|
report = warm_up_simulation_runtime()
|
||||||
|
|
||||||
|
self.assertEqual(report.status, "failed")
|
||||||
|
self.assertIn("broken warmup", report.error or "")
|
||||||
|
|
||||||
|
def test_memory_error_remains_fatal(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
|
||||||
|
patch(
|
||||||
|
"app.simulation.warmup._run_numerical_warmup",
|
||||||
|
side_effect=MemoryError("out of memory"),
|
||||||
|
),
|
||||||
|
self.assertRaises(MemoryError),
|
||||||
|
):
|
||||||
|
warm_up_simulation_runtime()
|
||||||
|
|
||||||
|
def test_lifespan_stores_warmup_report_before_serving(self) -> None:
|
||||||
|
report = SimulationWarmupReport(status="completed", duration_ms=12.5)
|
||||||
|
|
||||||
|
async def enter_lifespan() -> None:
|
||||||
|
with patch(
|
||||||
|
"app.simulation.warmup.warm_up_simulation_runtime",
|
||||||
|
return_value=report,
|
||||||
|
) as warmup:
|
||||||
|
async with _app_lifespan(app):
|
||||||
|
self.assertEqual(
|
||||||
|
app.state.simulation_warmup,
|
||||||
|
report.as_dict(),
|
||||||
|
)
|
||||||
|
warmup.assert_called_once_with()
|
||||||
|
|
||||||
|
asyncio.run(enter_lifespan())
|
||||||
|
|
||||||
|
def test_real_numerical_warmup_completes(self) -> None:
|
||||||
|
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}):
|
||||||
|
report = warm_up_simulation_runtime()
|
||||||
|
|
||||||
|
self.assertEqual(report.status, "completed", report.error)
|
||||||
|
self.assertGreater(report.duration_ms, 0.0)
|
||||||
|
|
||||||
|
def test_numerical_warmup_exercises_sparse_lsmr_algebraic_path(self) -> None:
|
||||||
|
import scipy.optimize
|
||||||
|
|
||||||
|
actual_least_squares = scipy.optimize.least_squares
|
||||||
|
optimizer_calls: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def recording_least_squares(*args, **kwargs):
|
||||||
|
optimizer_calls.append(dict(kwargs))
|
||||||
|
return actual_least_squares(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
scipy.optimize,
|
||||||
|
"least_squares",
|
||||||
|
recording_least_squares,
|
||||||
|
):
|
||||||
|
_run_numerical_warmup()
|
||||||
|
|
||||||
|
self.assertEqual(len(optimizer_calls), 1)
|
||||||
|
call = optimizer_calls[0]
|
||||||
|
self.assertEqual(call["tr_solver"], "lsmr")
|
||||||
|
sparsity = call["jac_sparsity"]
|
||||||
|
self.assertEqual(sparsity.shape, (2, 2))
|
||||||
|
self.assertEqual(sparsity.nnz, 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
|
||||||
|
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
||||||
|
from app.simulation.components.amesim.mechanical.translational import (
|
||||||
|
AmesimLstp00a,
|
||||||
|
)
|
||||||
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
|
from app.simulation.components.experimental.storage.tank import Tank
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.solvers.algebraic import (
|
||||||
|
AlgebraicSolveDiagnostics,
|
||||||
|
PressureFlowSolver,
|
||||||
|
)
|
||||||
|
from app.simulation.solvers.algebraic_blocks import (
|
||||||
|
StreamPressureBlockSolver,
|
||||||
|
_BlockSolveAttempt,
|
||||||
|
)
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
def _pnl0002_solver(
|
||||||
|
*,
|
||||||
|
include_unselected_island: bool = False,
|
||||||
|
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
|
||||||
|
right = Tank("right", medium, V=0.1, p0=100_000.0)
|
||||||
|
pipe = AmesimPnl0002(
|
||||||
|
"pipe",
|
||||||
|
medium,
|
||||||
|
p0=300_000.0,
|
||||||
|
T0=300.0,
|
||||||
|
)
|
||||||
|
network = SimulationNetwork("pnl0002-equation-blocks")
|
||||||
|
for component in (left, right, pipe):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("left", "port_b", "pipe", "port_1")
|
||||||
|
network.connect("pipe", "port_2", "right", "port_a")
|
||||||
|
|
||||||
|
if include_unselected_island:
|
||||||
|
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
|
||||||
|
plug = AmesimPnpl01("isolated_plug")
|
||||||
|
network.add_component(isolated)
|
||||||
|
network.add_component(plug)
|
||||||
|
network.connect("isolated", "port_b", "isolated_plug", "port_1")
|
||||||
|
|
||||||
|
for component in network.dynamic_components():
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
solver = PressureFlowSolver(network)
|
||||||
|
solver.solve()
|
||||||
|
return solver, pipe
|
||||||
|
|
||||||
|
|
||||||
|
class StreamPressureBlockSolverTests(unittest.TestCase):
|
||||||
|
def test_pnl0002_uses_two_blocks_with_one_shared_component(self) -> None:
|
||||||
|
solver, pipe = _pnl0002_solver()
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
|
||||||
|
self.assertTrue(block_solver.available, block_solver.fallback_reason)
|
||||||
|
self.assertEqual(len(block_solver.blocks), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
all("pipe" in block.scope_components for block in block_solver.blocks)
|
||||||
|
)
|
||||||
|
pipe_component_evaluations = [
|
||||||
|
evaluation
|
||||||
|
for block in block_solver.blocks
|
||||||
|
for evaluation in block.component_evaluations
|
||||||
|
if getattr(evaluation.evaluate, "__self__", None) is pipe
|
||||||
|
]
|
||||||
|
self.assertEqual(len(pipe_component_evaluations), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
{unknown.id for unknown in block_solver.blocks[0].unknowns}.isdisjoint(
|
||||||
|
unknown.id for unknown in block_solver.blocks[1].unknowns
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
self.assertFalse(result.used_global_fallback)
|
||||||
|
self.assertEqual(len(result.diagnostics), 1)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
pipe.port_1.m_flow,
|
||||||
|
pipe.port_mass_flow(
|
||||||
|
pipe.port_1.p,
|
||||||
|
pipe.properties().p,
|
||||||
|
pipe.properties().T,
|
||||||
|
port_name="port_1",
|
||||||
|
),
|
||||||
|
places=12,
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
pipe.port_2.m_flow,
|
||||||
|
pipe.port_mass_flow(
|
||||||
|
pipe.port_2.p,
|
||||||
|
pipe.properties().p,
|
||||||
|
pipe.properties().T,
|
||||||
|
port_name="port_2",
|
||||||
|
),
|
||||||
|
places=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selected_block_seeding_preserves_every_unselected_unknown(self) -> None:
|
||||||
|
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
unselected = tuple(
|
||||||
|
unknown
|
||||||
|
for unknown in solver.unknowns
|
||||||
|
if unknown.component in {"isolated", "isolated_plug"}
|
||||||
|
)
|
||||||
|
for index, unknown in enumerate(unselected, start=1):
|
||||||
|
unknown.write(10_000.0 * index)
|
||||||
|
expected = tuple(unknown.read() for unknown in unselected)
|
||||||
|
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
self.assertFalse(result.used_global_fallback)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in unselected),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_causal_secondary_pressure_mutation_fuses_to_verified_path(self) -> None:
|
||||||
|
solver, _pipe = _pnl0002_solver()
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
first = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
self.assertFalse(first.used_global_fallback)
|
||||||
|
expected_pressures = tuple(
|
||||||
|
unknown.read()
|
||||||
|
for unknown in block_solver._selected_unknowns
|
||||||
|
if unknown.variable == "p"
|
||||||
|
)
|
||||||
|
original_seed = block_solver._seed_selected_blocks
|
||||||
|
mutated_pressure = next(
|
||||||
|
unknown
|
||||||
|
for unknown in block_solver._selected_unknowns
|
||||||
|
if unknown.variable == "p"
|
||||||
|
)
|
||||||
|
|
||||||
|
def seed_then_mutate(entry_values):
|
||||||
|
seeded = original_seed(entry_values)
|
||||||
|
mutated_pressure.write(mutated_pressure.read() + 10_000.0)
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seed_selected_blocks",
|
||||||
|
side_effect=seed_then_mutate,
|
||||||
|
):
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
self.assertFalse(result.used_global_fallback)
|
||||||
|
self.assertTrue(result.diagnostics[0].residual_verified_this_solve)
|
||||||
|
actual_pressures = tuple(
|
||||||
|
unknown.read()
|
||||||
|
for unknown in block_solver._selected_unknowns
|
||||||
|
if unknown.variable == "p"
|
||||||
|
)
|
||||||
|
for expected, actual in zip(expected_pressures, actual_pressures):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
actual,
|
||||||
|
expected,
|
||||||
|
delta=1.0e-12 * max(abs(expected), 1.0),
|
||||||
|
)
|
||||||
|
execution = block_solver.causal_execution_diagnostics()
|
||||||
|
self.assertFalse(execution["enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
execution["disabledReason"],
|
||||||
|
"causalSecondaryRuntimeGateFailed",
|
||||||
|
)
|
||||||
|
self.assertEqual(execution["legacyFallbackCount"], 1)
|
||||||
|
|
||||||
|
def test_sparse_block_reports_actual_residual_evaluations(self) -> None:
|
||||||
|
solver, pipe = _pnl0002_solver()
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
pipe.port_1.m_flow += 0.01
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seed_selected_blocks",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
sparse = result.diagnostics[0]
|
||||||
|
self.assertEqual(sparse.jacobian_mode, "blockSparse")
|
||||||
|
self.assertGreater(sparse.evaluations, 0)
|
||||||
|
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
|
||||||
|
self.assertFalse(sparse.dense_fallback_used)
|
||||||
|
|
||||||
|
def test_failed_sparse_block_restores_its_original_unknowns(self) -> None:
|
||||||
|
solver, pipe = _pnl0002_solver()
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
block = block_solver.blocks[0]
|
||||||
|
pipe.port_1.m_flow += 0.01
|
||||||
|
expected = tuple(unknown.read() for unknown in block.unknowns)
|
||||||
|
|
||||||
|
def failed_sparse(_fun, x0, **_kwargs):
|
||||||
|
return SimpleNamespace(
|
||||||
|
x=x0 + 123.0,
|
||||||
|
success=False,
|
||||||
|
status=-1,
|
||||||
|
message="forced sparse block failure",
|
||||||
|
nfev=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("scipy.optimize.least_squares", side_effect=failed_sparse):
|
||||||
|
attempt = block_solver._solve_block(
|
||||||
|
block,
|
||||||
|
solver.scale_context(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(attempt.diagnostics)
|
||||||
|
self.assertEqual(attempt.optimizer_evaluations, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in block.unknowns),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_block_failure_restores_full_snapshot_before_global_fallback(self) -> None:
|
||||||
|
solver, pipe = _pnl0002_solver(include_unselected_island=True)
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
pipe.port_1.m_flow += 0.01
|
||||||
|
solver.network.components["isolated_plug"].port_1.p = 12_345.0
|
||||||
|
expected = tuple(unknown.read() for unknown in solver.unknowns)
|
||||||
|
original_global_solve = solver.solve
|
||||||
|
|
||||||
|
def checked_global_solve(*args, **kwargs):
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in solver.unknowns),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
return original_global_solve(*args, **kwargs)
|
||||||
|
|
||||||
|
def failed_block(block, _scale_context, **_kwargs):
|
||||||
|
for unknown in block.unknowns:
|
||||||
|
unknown.write(unknown.read() + 321.0)
|
||||||
|
return _BlockSolveAttempt(
|
||||||
|
diagnostics=None,
|
||||||
|
optimizer_evaluations=2,
|
||||||
|
residual_evaluations=7,
|
||||||
|
failure_reason="blockResidualNotConverged",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seed_selected_blocks",
|
||||||
|
return_value=None,
|
||||||
|
), patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_solve_block",
|
||||||
|
side_effect=failed_block,
|
||||||
|
), patch.object(
|
||||||
|
solver,
|
||||||
|
"solve",
|
||||||
|
side_effect=checked_global_solve,
|
||||||
|
) as global_solve:
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
global_solve.assert_called_once()
|
||||||
|
self.assertTrue(result.used_global_fallback)
|
||||||
|
self.assertEqual(len(result.diagnostics), 1)
|
||||||
|
fallback_diagnostics = result.diagnostics[0]
|
||||||
|
self.assertIn(
|
||||||
|
fallback_diagnostics.jacobian_mode,
|
||||||
|
{
|
||||||
|
"seeded",
|
||||||
|
"sparse",
|
||||||
|
"dense",
|
||||||
|
"sparseThenDense",
|
||||||
|
"blockSparse",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
fallback_diagnostics.residual_evaluations,
|
||||||
|
fallback_diagnostics.evaluations,
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(fallback_diagnostics.evaluations, 2)
|
||||||
|
self.assertGreaterEqual(fallback_diagnostics.residual_evaluations, 7)
|
||||||
|
self.assertTrue(fallback_diagnostics.block_fallback_used)
|
||||||
|
self.assertIn(
|
||||||
|
"blockResidualNotConverged",
|
||||||
|
fallback_diagnostics.block_fallback_reason or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_seed_exception_restores_snapshot_before_global_fallback(self) -> None:
|
||||||
|
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
expected = tuple(unknown.read() for unknown in solver.unknowns)
|
||||||
|
original_global_solve = solver.solve
|
||||||
|
|
||||||
|
def broken_seed(_entry_values) -> None:
|
||||||
|
solver.unknowns[0].write(solver.unknowns[0].read() + 123_456.0)
|
||||||
|
raise ValueError("forced seed failure")
|
||||||
|
|
||||||
|
def checked_global_solve(*args, **kwargs):
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in solver.unknowns),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
return original_global_solve(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seed_selected_blocks",
|
||||||
|
side_effect=broken_seed,
|
||||||
|
), patch.object(
|
||||||
|
solver,
|
||||||
|
"solve",
|
||||||
|
side_effect=checked_global_solve,
|
||||||
|
) as global_solve:
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
global_solve.assert_called_once()
|
||||||
|
self.assertTrue(result.used_global_fallback)
|
||||||
|
|
||||||
|
def test_failed_global_fallback_does_not_leak_candidate_state(self) -> None:
|
||||||
|
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
expected = tuple(unknown.read() for unknown in solver.unknowns)
|
||||||
|
|
||||||
|
def failed_block(block, _scale_context, **_kwargs):
|
||||||
|
for unknown in block.unknowns:
|
||||||
|
unknown.write(unknown.read() + 123.0)
|
||||||
|
return _BlockSolveAttempt(
|
||||||
|
diagnostics=None,
|
||||||
|
optimizer_evaluations=1,
|
||||||
|
residual_evaluations=4,
|
||||||
|
failure_reason="blockResidualNotConverged",
|
||||||
|
)
|
||||||
|
|
||||||
|
def failed_global_solve(*_args, **_kwargs):
|
||||||
|
for unknown in solver.unknowns:
|
||||||
|
unknown.write(unknown.read() - 456.0)
|
||||||
|
raise RuntimeError("forced global fallback failure")
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seed_selected_blocks",
|
||||||
|
return_value=None,
|
||||||
|
), patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seeded_diagnostics",
|
||||||
|
return_value=None,
|
||||||
|
), patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_solve_block",
|
||||||
|
side_effect=failed_block,
|
||||||
|
), patch.object(
|
||||||
|
solver,
|
||||||
|
"solve",
|
||||||
|
side_effect=failed_global_solve,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"forced global fallback failure",
|
||||||
|
):
|
||||||
|
block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in solver.unknowns),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_to_global_fallback_aggregates_attempt_chain_once(self) -> None:
|
||||||
|
solver, _pipe = _pnl0002_solver()
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
accepted_global = AlgebraicSolveDiagnostics(
|
||||||
|
success=True,
|
||||||
|
message="accepted global result",
|
||||||
|
evaluations=3,
|
||||||
|
pressure_scale=400_000.0,
|
||||||
|
flow_scale=0.01,
|
||||||
|
max_scaled_residual=0.25,
|
||||||
|
max_raw_residual=5.0,
|
||||||
|
residual_evaluations=11,
|
||||||
|
jacobian_mode="dense",
|
||||||
|
)
|
||||||
|
failed_local = _BlockSolveAttempt(
|
||||||
|
diagnostics=None,
|
||||||
|
optimizer_evaluations=2,
|
||||||
|
residual_evaluations=7,
|
||||||
|
failure_reason="blockResidualNotConverged",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_seeded_diagnostics",
|
||||||
|
return_value=None,
|
||||||
|
), patch.object(
|
||||||
|
block_solver,
|
||||||
|
"_solve_block",
|
||||||
|
return_value=failed_local,
|
||||||
|
), patch.object(
|
||||||
|
solver,
|
||||||
|
"solve",
|
||||||
|
return_value=accepted_global,
|
||||||
|
) as global_solve:
|
||||||
|
result = block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
global_solve.assert_called_once()
|
||||||
|
self.assertTrue(result.used_global_fallback)
|
||||||
|
self.assertEqual(len(result.diagnostics), 1)
|
||||||
|
aggregate = result.diagnostics[0]
|
||||||
|
self.assertEqual(aggregate.evaluations, 5)
|
||||||
|
self.assertEqual(aggregate.residual_evaluations, 18)
|
||||||
|
self.assertEqual(aggregate.jacobian_mode, "dense")
|
||||||
|
self.assertEqual(aggregate.max_scaled_residual, 0.25)
|
||||||
|
self.assertTrue(aggregate.block_fallback_used)
|
||||||
|
self.assertEqual(
|
||||||
|
aggregate.block_fallback_reason,
|
||||||
|
"blockResidualNotConverged",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_global_fallback_restores_lstp_causal_cache_for_base_errors(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
class ForcedFatalError(BaseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
for error_type in (MemoryError, ForcedFatalError):
|
||||||
|
with self.subTest(error_type=error_type.__name__):
|
||||||
|
solver, _pipe = _pnl0002_solver()
|
||||||
|
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
|
||||||
|
block_solver.fallback_reason = "forcedUntrustedStructure"
|
||||||
|
contact = AmesimLstp00a(
|
||||||
|
"contact",
|
||||||
|
IdealGasMedium(),
|
||||||
|
gap0=0.0,
|
||||||
|
kcont=1.0e6,
|
||||||
|
rcont=0.0,
|
||||||
|
Pdis=1.0e-6,
|
||||||
|
discContactOption=1.0,
|
||||||
|
)
|
||||||
|
contact.port_1.x = 1.25
|
||||||
|
contact.port_2.x = 1.0
|
||||||
|
contact.port_1.v = 0.5
|
||||||
|
contact.port_2.v = -0.25
|
||||||
|
contact.set_causal_contact(penetration=0.25, force=12.0)
|
||||||
|
solver._causal_contact_components = (contact,)
|
||||||
|
expected_unknowns = tuple(
|
||||||
|
unknown.read() for unknown in solver.unknowns
|
||||||
|
)
|
||||||
|
causal_names = tuple(
|
||||||
|
name
|
||||||
|
for name in vars(contact)
|
||||||
|
if name.startswith("_causal_")
|
||||||
|
)
|
||||||
|
expected_causal = tuple(
|
||||||
|
getattr(contact, name) for name in causal_names
|
||||||
|
)
|
||||||
|
expected_last = solver.last_diagnostics
|
||||||
|
|
||||||
|
def failed_global_solve(*_args, **_kwargs):
|
||||||
|
solver.unknowns[0].write(
|
||||||
|
solver.unknowns[0].read() + 123_456.0
|
||||||
|
)
|
||||||
|
contact.clear_causal_contact()
|
||||||
|
solver.last_diagnostics = None
|
||||||
|
raise error_type("forced global fallback failure")
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
solver,
|
||||||
|
"solve",
|
||||||
|
side_effect=failed_global_solve,
|
||||||
|
):
|
||||||
|
with self.assertRaises(error_type):
|
||||||
|
block_solver.solve(scale_context=solver.scale_context())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(unknown.read() for unknown in solver.unknowns),
|
||||||
|
expected_unknowns,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(getattr(contact, name) for name in causal_names),
|
||||||
|
expected_causal,
|
||||||
|
)
|
||||||
|
self.assertIs(solver.last_diagnostics, expected_last)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
from app.simulation.solvers.stream import StreamResolver
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
class _CountingDynamicAnchor(DynamicComponent):
|
||||||
|
PORTS = (PortDefinition.pneumatic("port"),)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
enthalpy: float,
|
||||||
|
temperature_reference_h: float,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name)
|
||||||
|
self.enthalpy = enthalpy
|
||||||
|
self.temperature_reference_h = temperature_reference_h
|
||||||
|
self.refresh_count = 0
|
||||||
|
self.port = self.register_declared_port("port")
|
||||||
|
self.port.h_outflow = -1.0
|
||||||
|
|
||||||
|
def make_ports_current(self) -> None:
|
||||||
|
self.port.h_outflow = self.enthalpy
|
||||||
|
|
||||||
|
def get_state_vector(self) -> list[float]:
|
||||||
|
return [0.0, 0.0]
|
||||||
|
|
||||||
|
def set_state_vector(self, values: list[float]) -> None:
|
||||||
|
if len(values) != self.state_size:
|
||||||
|
raise ValueError("Unexpected test state size.")
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self) -> None:
|
||||||
|
self.refresh_count += 1
|
||||||
|
self.make_ports_current()
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
return [0.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
class _PassThrough(AlgebraicComponent):
|
||||||
|
PORTS = (
|
||||||
|
PortDefinition.pneumatic("left"),
|
||||||
|
PortDefinition.pneumatic("right"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, name: str, update_log: list[str]) -> None:
|
||||||
|
super().__init__(name)
|
||||||
|
self.left = self.register_declared_port("left")
|
||||||
|
self.right = self.register_declared_port("right")
|
||||||
|
self.update_log = update_log
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
self.update_log.append(self.name)
|
||||||
|
self.left.h_outflow = connected_h["right"]
|
||||||
|
self.right.h_outflow = connected_h["left"]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chain() -> tuple[
|
||||||
|
SimulationNetwork,
|
||||||
|
_CountingDynamicAnchor,
|
||||||
|
_PassThrough,
|
||||||
|
_PassThrough,
|
||||||
|
_CountingDynamicAnchor,
|
||||||
|
list[str],
|
||||||
|
]:
|
||||||
|
update_log: list[str] = []
|
||||||
|
left = _CountingDynamicAnchor(
|
||||||
|
"left_anchor",
|
||||||
|
enthalpy=100.0,
|
||||||
|
temperature_reference_h=1_100.0,
|
||||||
|
)
|
||||||
|
first = _PassThrough("first", update_log)
|
||||||
|
second = _PassThrough("second", update_log)
|
||||||
|
right = _CountingDynamicAnchor(
|
||||||
|
"right_anchor",
|
||||||
|
enthalpy=400.0,
|
||||||
|
temperature_reference_h=1_400.0,
|
||||||
|
)
|
||||||
|
network = SimulationNetwork("stream-chain")
|
||||||
|
for component in (left, first, second, right):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("left_anchor", "port", "first", "left")
|
||||||
|
network.connect("first", "right", "second", "left")
|
||||||
|
network.connect("second", "right", "right_anchor", "port")
|
||||||
|
return network, left, first, second, right, update_log
|
||||||
|
|
||||||
|
|
||||||
|
class StreamResolverExecutionPlanTests(unittest.TestCase):
|
||||||
|
def test_standalone_solve_refreshes_each_dynamic_exactly_once(self) -> None:
|
||||||
|
network, left, _first, _second, right, _update_log = _build_chain()
|
||||||
|
|
||||||
|
diagnostics, _connected = StreamResolver(network).solve()
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.converged)
|
||||||
|
self.assertEqual(left.refresh_count, 1)
|
||||||
|
self.assertEqual(right.refresh_count, 1)
|
||||||
|
|
||||||
|
def test_current_dynamic_ports_skip_refresh(self) -> None:
|
||||||
|
network, left, _first, _second, right, _update_log = _build_chain()
|
||||||
|
left.make_ports_current()
|
||||||
|
right.make_ports_current()
|
||||||
|
|
||||||
|
diagnostics, connected = StreamResolver(network).solve(
|
||||||
|
dynamic_ports_are_current=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics.converged)
|
||||||
|
self.assertEqual(left.refresh_count, 0)
|
||||||
|
self.assertEqual(right.refresh_count, 0)
|
||||||
|
self.assertEqual(connected["first"], {"left": 100.0, "right": 400.0})
|
||||||
|
|
||||||
|
def test_multiple_iterations_do_not_repeat_dynamic_refresh(self) -> None:
|
||||||
|
network, left, _first, _second, right, update_log = _build_chain()
|
||||||
|
|
||||||
|
diagnostics, _connected = StreamResolver(network).solve()
|
||||||
|
|
||||||
|
self.assertGreater(diagnostics.iterations, 1)
|
||||||
|
self.assertEqual(left.refresh_count, 1)
|
||||||
|
self.assertEqual(right.refresh_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
update_log,
|
||||||
|
["first", "second"] * diagnostics.iterations,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_precompiled_bindings_preserve_outputs_and_references(self) -> None:
|
||||||
|
default_network, default_left, default_first, default_second, default_right, _ = (
|
||||||
|
_build_chain()
|
||||||
|
)
|
||||||
|
current_network, current_left, current_first, current_second, current_right, _ = (
|
||||||
|
_build_chain()
|
||||||
|
)
|
||||||
|
current_left.make_ports_current()
|
||||||
|
current_right.make_ports_current()
|
||||||
|
default_resolver = StreamResolver(default_network)
|
||||||
|
current_resolver = StreamResolver(current_network)
|
||||||
|
|
||||||
|
default_diagnostics, default_connected = default_resolver.solve()
|
||||||
|
current_diagnostics, current_connected = current_resolver.solve(
|
||||||
|
dynamic_ports_are_current=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(default_diagnostics, current_diagnostics)
|
||||||
|
self.assertEqual(default_connected, current_connected)
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
default_left.port.h_outflow,
|
||||||
|
default_first.left.h_outflow,
|
||||||
|
default_first.right.h_outflow,
|
||||||
|
default_second.left.h_outflow,
|
||||||
|
default_second.right.h_outflow,
|
||||||
|
default_right.port.h_outflow,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
current_left.port.h_outflow,
|
||||||
|
current_first.left.h_outflow,
|
||||||
|
current_first.right.h_outflow,
|
||||||
|
current_second.left.h_outflow,
|
||||||
|
current_second.right.h_outflow,
|
||||||
|
current_right.port.h_outflow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
references = default_resolver.connected_temperature_reference_enthalpies()
|
||||||
|
self.assertEqual(references["first"]["left"], 1_100.0)
|
||||||
|
self.assertEqual(references["second"]["right"], 1_400.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.main import compile_reactflow_network, compile_system_xml_network
|
||||||
|
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
|
||||||
|
from app.simulation.components.amesim.flow.orifices import (
|
||||||
|
AmesimPnor001,
|
||||||
|
AmesimPnvo001FixedOpening,
|
||||||
|
)
|
||||||
|
from app.simulation.components.amesim.flow.pipes import (
|
||||||
|
AmesimPnl00r,
|
||||||
|
AmesimPnl0001,
|
||||||
|
AmesimPnl0002,
|
||||||
|
)
|
||||||
|
from app.simulation.components.amesim.storage.chambers import AmesimPnch023
|
||||||
|
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||||
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
|
from app.simulation.components.experimental.storage.tank import Tank
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.solvers.algebraic import (
|
||||||
|
AlgebraicSolveDiagnostics,
|
||||||
|
AlgebraicSolveError,
|
||||||
|
)
|
||||||
|
from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult
|
||||||
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
|
from app.simulation.systems.generic import (
|
||||||
|
GenericFluidSystem,
|
||||||
|
simulation_preparation_issues,
|
||||||
|
)
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
from app.system_xml import validate_system_xml_document
|
||||||
|
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
|
||||||
|
from tests.test_amesim_mechanical_xml import elastic_contact_project
|
||||||
|
from tests.test_generic_system_xml_simulation import chain_project
|
||||||
|
from tests.test_high_stiffness_explicit_rk45 import short_explicit_rk45_xml
|
||||||
|
|
||||||
|
|
||||||
|
class _UnclassifiedCustomOrifice(Orifice):
|
||||||
|
"""A custom subclass must not inherit the catalog purity declaration."""
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
super().update_stream_outflows(connected_h)
|
||||||
|
|
||||||
|
|
||||||
|
class _InvalidDependencyDeclarationOrifice(Orifice):
|
||||||
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = 1
|
||||||
|
|
||||||
|
|
||||||
|
class _CountingCylinder(Cylinder):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
self.refresh_count = 0
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self):
|
||||||
|
self.refresh_count += 1
|
||||||
|
return super().refresh_thermodynamic_ports()
|
||||||
|
|
||||||
|
|
||||||
|
def _three_component_network(
|
||||||
|
middle: Orifice,
|
||||||
|
*,
|
||||||
|
prefix: str = "independent",
|
||||||
|
) -> tuple[SimulationNetwork, _CountingCylinder, Tank]:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
source = _CountingCylinder(
|
||||||
|
f"{prefix}_source",
|
||||||
|
medium,
|
||||||
|
V=0.02,
|
||||||
|
p0=500_000.0,
|
||||||
|
T0=320.0,
|
||||||
|
)
|
||||||
|
sink = Tank(
|
||||||
|
f"{prefix}_sink",
|
||||||
|
medium,
|
||||||
|
V=0.05,
|
||||||
|
p0=100_000.0,
|
||||||
|
T0=290.0,
|
||||||
|
)
|
||||||
|
network = SimulationNetwork(prefix)
|
||||||
|
for component in (source, middle, sink):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect(source.name, "port_b", middle.name, "port_a")
|
||||||
|
network.connect(middle.name, "port_b", sink.name, "port_a")
|
||||||
|
return network, source, sink
|
||||||
|
|
||||||
|
|
||||||
|
def _mixed_island_network(
|
||||||
|
*,
|
||||||
|
independent_pressure: float = 450_000.0,
|
||||||
|
) -> SimulationNetwork:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
sensitive_source = Cylinder(
|
||||||
|
"sensitive_source",
|
||||||
|
medium,
|
||||||
|
V=0.02,
|
||||||
|
p0=600_000.0,
|
||||||
|
T0=350.0,
|
||||||
|
)
|
||||||
|
sensitive_sink = Tank(
|
||||||
|
"sensitive_sink",
|
||||||
|
medium,
|
||||||
|
V=0.05,
|
||||||
|
p0=100_000.0,
|
||||||
|
T0=280.0,
|
||||||
|
)
|
||||||
|
valve = AmesimPnvo001FixedOpening(
|
||||||
|
"sensitive_valve",
|
||||||
|
medium,
|
||||||
|
area0=1.0e-5,
|
||||||
|
opening=0.8,
|
||||||
|
)
|
||||||
|
independent_source = Cylinder(
|
||||||
|
"independent_source",
|
||||||
|
medium,
|
||||||
|
V=0.02,
|
||||||
|
p0=independent_pressure,
|
||||||
|
T0=310.0,
|
||||||
|
)
|
||||||
|
independent_sink = Tank(
|
||||||
|
"independent_sink",
|
||||||
|
medium,
|
||||||
|
V=0.05,
|
||||||
|
p0=120_000.0,
|
||||||
|
T0=295.0,
|
||||||
|
)
|
||||||
|
independent_orifice = Orifice("independent_orifice", K=2.0e-5)
|
||||||
|
|
||||||
|
network = SimulationNetwork("mixed-islands")
|
||||||
|
for component in (
|
||||||
|
sensitive_source,
|
||||||
|
sensitive_sink,
|
||||||
|
valve,
|
||||||
|
independent_source,
|
||||||
|
independent_sink,
|
||||||
|
independent_orifice,
|
||||||
|
):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("sensitive_source", "port_b", "sensitive_valve", "port_2")
|
||||||
|
network.connect("sensitive_valve", "port_3", "sensitive_sink", "port_a")
|
||||||
|
network.connect("independent_source", "port_b", "independent_orifice", "port_a")
|
||||||
|
network.connect("independent_orifice", "port_b", "independent_sink", "port_a")
|
||||||
|
return network
|
||||||
|
|
||||||
|
|
||||||
|
def _special_seed_islands_network() -> SimulationNetwork:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
series_source = AmesimPnch023("series_source", medium, p0=15.3e6)
|
||||||
|
series_source_plug = AmesimPnpl01("series_source_plug")
|
||||||
|
series_orifice = AmesimPnor001("series_orifice", medium)
|
||||||
|
series_pipe = AmesimPnl0001("series_pipe", medium, p0=14.0e6)
|
||||||
|
series_pipe_plug = AmesimPnpl01("series_pipe_plug")
|
||||||
|
|
||||||
|
closed_pipe = AmesimPnl0002("closed_pipe", medium, p0=200_000.0)
|
||||||
|
closed_pipe_left = AmesimPnpl01("closed_pipe_left")
|
||||||
|
closed_pipe_right = AmesimPnpl01("closed_pipe_right")
|
||||||
|
|
||||||
|
resistance_source = Cylinder(
|
||||||
|
"resistance_source",
|
||||||
|
medium,
|
||||||
|
V=0.02,
|
||||||
|
p0=500_000.0,
|
||||||
|
T0=310.0,
|
||||||
|
)
|
||||||
|
resistance = AmesimPnl00r("resistance", medium)
|
||||||
|
resistance_plug = AmesimPnpl01("resistance_plug")
|
||||||
|
|
||||||
|
network = SimulationNetwork("special-seed-islands")
|
||||||
|
for component in (
|
||||||
|
series_source,
|
||||||
|
series_source_plug,
|
||||||
|
series_orifice,
|
||||||
|
series_pipe,
|
||||||
|
series_pipe_plug,
|
||||||
|
closed_pipe,
|
||||||
|
closed_pipe_left,
|
||||||
|
closed_pipe_right,
|
||||||
|
resistance_source,
|
||||||
|
resistance,
|
||||||
|
resistance_plug,
|
||||||
|
):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("series_source_plug", "port_1", "series_source", "port_1")
|
||||||
|
network.connect("series_source", "port_2", "series_orifice", "port_1")
|
||||||
|
network.connect("series_orifice", "port_2", "series_pipe", "port_1")
|
||||||
|
network.connect("series_pipe", "port_2", "series_pipe_plug", "port_1")
|
||||||
|
network.connect("closed_pipe_left", "port_1", "closed_pipe", "port_1")
|
||||||
|
network.connect("closed_pipe", "port_2", "closed_pipe_right", "port_1")
|
||||||
|
network.connect("resistance_source", "port_b", "resistance", "port_1")
|
||||||
|
network.connect("resistance", "port_2", "resistance_plug", "port_1")
|
||||||
|
return network
|
||||||
|
|
||||||
|
|
||||||
|
def _force_legacy_global_coupling(system: GenericFluidSystem) -> None:
|
||||||
|
plan = system._thermofluid_closure_plan
|
||||||
|
system._thermofluid_closure_plan = replace(
|
||||||
|
plan,
|
||||||
|
secondary_pressure_solvers=(system.pressure_flow_solver,),
|
||||||
|
secondary_component_groups=(plan.global_component_group,),
|
||||||
|
uses_conservative_global_solver=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThermofluidClosurePlanTests(unittest.TestCase):
|
||||||
|
def test_independent_network_solves_pressure_once_and_refreshes_once(self) -> None:
|
||||||
|
network, source, _sink = _three_component_network(
|
||||||
|
Orifice("independent_orifice", K=1.0e-5)
|
||||||
|
)
|
||||||
|
system = GenericFluidSystem(network)
|
||||||
|
source.refresh_count = 0
|
||||||
|
|
||||||
|
system.consistent_initial_state_vector()
|
||||||
|
|
||||||
|
self.assertEqual(system._thermofluid_closure_plan.secondary_pressure_solvers, ())
|
||||||
|
self.assertEqual(system.algebraic_solve_count, 1)
|
||||||
|
self.assertEqual(system.thermofluid_pressure_pass_count, 1)
|
||||||
|
self.assertEqual(source.refresh_count, 1)
|
||||||
|
|
||||||
|
def test_mixed_network_revisits_only_the_stream_sensitive_island(self) -> None:
|
||||||
|
system = GenericFluidSystem(_mixed_island_network())
|
||||||
|
plan = system._thermofluid_closure_plan
|
||||||
|
|
||||||
|
self.assertFalse(plan.uses_conservative_global_solver)
|
||||||
|
self.assertEqual(len(plan.secondary_pressure_solvers), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
set(plan.secondary_component_groups[0]),
|
||||||
|
{"sensitive_source", "sensitive_sink", "sensitive_valve"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
set(plan.secondary_pressure_solvers[0].network.components),
|
||||||
|
set(plan.secondary_component_groups[0]),
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"independent_orifice",
|
||||||
|
plan.secondary_pressure_solvers[0].network.components,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = system.consistent_initial_state_vector()
|
||||||
|
first = system.rhs(0.0, state)
|
||||||
|
second = system.rhs(0.0, state)
|
||||||
|
for first_value, second_value in zip(first, second):
|
||||||
|
self.assertAlmostEqual(first_value, second_value, delta=1.0e-9)
|
||||||
|
|
||||||
|
def test_secondary_attempt_chain_is_counted_as_one_logical_solve(self) -> None:
|
||||||
|
system = GenericFluidSystem(_mixed_island_network())
|
||||||
|
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
|
||||||
|
aggregate = AlgebraicSolveDiagnostics(
|
||||||
|
success=True,
|
||||||
|
message="accepted global fallback",
|
||||||
|
evaluations=5,
|
||||||
|
pressure_scale=600_000.0,
|
||||||
|
flow_scale=0.01,
|
||||||
|
max_scaled_residual=0.2,
|
||||||
|
max_raw_residual=2.0,
|
||||||
|
residual_evaluations=18,
|
||||||
|
jacobian_mode="dense",
|
||||||
|
block_fallback_used=True,
|
||||||
|
block_fallback_reason="blockResidualNotConverged",
|
||||||
|
)
|
||||||
|
fake_result = StreamBlockSolveResult(
|
||||||
|
diagnostics=(aggregate,),
|
||||||
|
scopes=(tuple(system.network.components),),
|
||||||
|
used_global_fallback=True,
|
||||||
|
)
|
||||||
|
initial_diagnostics: list[AlgebraicSolveDiagnostics] = []
|
||||||
|
original_initial_solve = system.pressure_flow_solver.solve
|
||||||
|
|
||||||
|
def recorded_initial_solve(*args, **kwargs):
|
||||||
|
result = original_initial_solve(*args, **kwargs)
|
||||||
|
initial_diagnostics.append(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
system.pressure_flow_solver,
|
||||||
|
"solve",
|
||||||
|
side_effect=recorded_initial_solve,
|
||||||
|
), patch.object(secondary, "solve", return_value=fake_result):
|
||||||
|
system.consistent_initial_state_vector()
|
||||||
|
|
||||||
|
self.assertEqual(len(initial_diagnostics), 1)
|
||||||
|
initial = initial_diagnostics[0]
|
||||||
|
self.assertEqual(system.algebraic_solve_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
system.algebraic_block_fallback_count,
|
||||||
|
int(initial.block_fallback_used) + 1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
system.algebraic_optimizer_evaluation_count,
|
||||||
|
initial.evaluations + 5,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
system.algebraic_residual_evaluation_count,
|
||||||
|
initial.residual_evaluations + 18,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_secondary_island_failure_reports_its_physical_scope(self) -> None:
|
||||||
|
system = GenericFluidSystem(_mixed_island_network())
|
||||||
|
plan = system._thermofluid_closure_plan
|
||||||
|
secondary = plan.secondary_pressure_solvers[0]
|
||||||
|
|
||||||
|
def failed_result(_fun, x0, **_kwargs):
|
||||||
|
return SimpleNamespace(
|
||||||
|
x=x0.copy(),
|
||||||
|
success=False,
|
||||||
|
status=-1,
|
||||||
|
message="forced secondary-island failure",
|
||||||
|
nfev=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
secondary,
|
||||||
|
"_solve_explicit_flow_unknowns",
|
||||||
|
return_value=None,
|
||||||
|
), patch("scipy.optimize.least_squares", side_effect=failed_result):
|
||||||
|
with self.assertRaises(AlgebraicSolveError) as raised:
|
||||||
|
secondary.solve(effort_variables=())
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.scope_kind, "physicalIsland")
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.scope_components,
|
||||||
|
plan.secondary_component_groups[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_secondary_islands_preserve_special_pressure_seed_plans(self) -> None:
|
||||||
|
network = _special_seed_islands_network()
|
||||||
|
system = GenericFluidSystem(network)
|
||||||
|
plan = system._thermofluid_closure_plan
|
||||||
|
solvers = {
|
||||||
|
frozenset(solver.network.components): solver
|
||||||
|
for solver in plan.secondary_pressure_solvers
|
||||||
|
}
|
||||||
|
|
||||||
|
series_solver = solvers[
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"series_source",
|
||||||
|
"series_source_plug",
|
||||||
|
"series_orifice",
|
||||||
|
"series_pipe",
|
||||||
|
"series_pipe_plug",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
closed_pipe_solver = solvers[
|
||||||
|
frozenset(
|
||||||
|
{"closed_pipe", "closed_pipe_left", "closed_pipe_right"}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
resistance_solver = solvers[
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"resistance_source",
|
||||||
|
"resistance",
|
||||||
|
"resistance_plug",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(len(series_solver._pnor_pnl0001_series_plan), 1)
|
||||||
|
self.assertEqual(len(closed_pipe_solver._closed_resistance_pressure_plan), 2)
|
||||||
|
self.assertEqual(len(resistance_solver._closed_resistance_pressure_plan), 1)
|
||||||
|
|
||||||
|
closed_pipe = network.components["closed_pipe"]
|
||||||
|
expected_pressure = closed_pipe.properties().p
|
||||||
|
closed_pipe.port_1.p = 10_000.0
|
||||||
|
closed_pipe.port_2.p = 20_000.0
|
||||||
|
network.components["closed_pipe_left"].port_1.p = 30_000.0
|
||||||
|
network.components["closed_pipe_right"].port_1.p = 40_000.0
|
||||||
|
|
||||||
|
closed_pipe_solver._seed_closed_resistance_pressures()
|
||||||
|
|
||||||
|
self.assertAlmostEqual(closed_pipe.port_1.p, expected_pressure)
|
||||||
|
self.assertAlmostEqual(closed_pipe.port_2.p, expected_pressure)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
network.components["closed_pipe_left"].port_1.p,
|
||||||
|
expected_pressure,
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
network.components["closed_pipe_right"].port_1.p,
|
||||||
|
expected_pressure,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unclassified_custom_stream_component_uses_legacy_global_solver(self) -> None:
|
||||||
|
network, _source, _sink = _three_component_network(
|
||||||
|
_UnclassifiedCustomOrifice("custom_orifice", K=1.0e-5),
|
||||||
|
prefix="custom",
|
||||||
|
)
|
||||||
|
system = GenericFluidSystem(network)
|
||||||
|
plan = system._thermofluid_closure_plan
|
||||||
|
|
||||||
|
self.assertTrue(plan.uses_conservative_global_solver)
|
||||||
|
self.assertEqual(plan.secondary_pressure_solvers, (system.pressure_flow_solver,))
|
||||||
|
self.assertEqual(plan.secondary_component_groups, (plan.global_component_group,))
|
||||||
|
|
||||||
|
def test_invalid_dependency_declaration_uses_legacy_global_solver(self) -> None:
|
||||||
|
network, _source, _sink = _three_component_network(
|
||||||
|
_InvalidDependencyDeclarationOrifice("invalid_orifice", K=1.0e-5),
|
||||||
|
prefix="invalid",
|
||||||
|
)
|
||||||
|
plan = GenericFluidSystem(network)._thermofluid_closure_plan
|
||||||
|
|
||||||
|
self.assertTrue(plan.uses_conservative_global_solver)
|
||||||
|
self.assertEqual(
|
||||||
|
plan.conservative_fallback_reason,
|
||||||
|
"invalidDependencyDeclaration",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_square_physical_island_metadata_forces_global_fallback(self) -> None:
|
||||||
|
system = GenericFluidSystem(_mixed_island_network())
|
||||||
|
templates = list(system.pressure_flow_solver.equation_templates)
|
||||||
|
moved = next(
|
||||||
|
index
|
||||||
|
for index, equation in enumerate(templates)
|
||||||
|
if equation.owner == "component"
|
||||||
|
and equation.owner_id == "independent_orifice"
|
||||||
|
)
|
||||||
|
equation = templates[moved]
|
||||||
|
templates[moved] = replace(
|
||||||
|
equation,
|
||||||
|
owner_id="sensitive_valve",
|
||||||
|
variables=tuple(
|
||||||
|
variable.replace("independent_orifice", "sensitive_valve")
|
||||||
|
for variable in equation.variables
|
||||||
|
),
|
||||||
|
)
|
||||||
|
system.pressure_flow_solver._equation_templates = tuple(templates)
|
||||||
|
|
||||||
|
plan = system._build_thermofluid_closure_plan()
|
||||||
|
|
||||||
|
self.assertTrue(plan.uses_conservative_global_solver)
|
||||||
|
self.assertEqual(
|
||||||
|
plan.conservative_fallback_reason,
|
||||||
|
"nonSquarePhysicalIsland",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pruned_and_legacy_chain_results_are_numerically_equivalent(self) -> None:
|
||||||
|
config = SolveIVPConfig(
|
||||||
|
t_start=0.0,
|
||||||
|
t_stop=0.01,
|
||||||
|
method="BDF",
|
||||||
|
max_step=0.001,
|
||||||
|
)
|
||||||
|
optimized = GenericFluidSystem(compile_reactflow_network(chain_project()))
|
||||||
|
legacy = GenericFluidSystem(compile_reactflow_network(chain_project()))
|
||||||
|
_force_legacy_global_coupling(legacy)
|
||||||
|
|
||||||
|
optimized_result = optimized.simulate(config, sample_step=0.005)
|
||||||
|
legacy_result = legacy.simulate(config, sample_step=0.005)
|
||||||
|
|
||||||
|
self.assertTrue(optimized_result.success)
|
||||||
|
self.assertTrue(legacy_result.success)
|
||||||
|
self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys())
|
||||||
|
for key, optimized_values in optimized_result.series.items():
|
||||||
|
legacy_values = legacy_result.series[key]
|
||||||
|
self.assertEqual(len(optimized_values), len(legacy_values), key)
|
||||||
|
for optimized_value, legacy_value in zip(
|
||||||
|
optimized_values,
|
||||||
|
legacy_values,
|
||||||
|
):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
optimized_value,
|
||||||
|
legacy_value,
|
||||||
|
delta=1.0e-11 * max(abs(legacy_value), 1.0),
|
||||||
|
msg=key,
|
||||||
|
)
|
||||||
|
self.assertEqual(optimized_result.final.keys(), legacy_result.final.keys())
|
||||||
|
for key, optimized_value in optimized_result.final.items():
|
||||||
|
legacy_value = legacy_result.final[key]
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
optimized_value,
|
||||||
|
legacy_value,
|
||||||
|
delta=1.0e-11 * max(abs(legacy_value), 1.0),
|
||||||
|
msg=key,
|
||||||
|
)
|
||||||
|
self.assertLess(optimized.algebraic_solve_count, legacy.algebraic_solve_count)
|
||||||
|
|
||||||
|
def test_stream_dependent_rhs_is_history_independent_after_other_trial(self) -> None:
|
||||||
|
network_a = compile_reactflow_network(high_pressure_helium_step_project())
|
||||||
|
network_fresh = compile_reactflow_network(high_pressure_helium_step_project())
|
||||||
|
system = GenericFluidSystem(network_a)
|
||||||
|
fresh = GenericFluidSystem(network_fresh)
|
||||||
|
state = system.consistent_initial_state_vector()
|
||||||
|
fresh_state = fresh.consistent_initial_state_vector()
|
||||||
|
perturbed = list(state)
|
||||||
|
perturbed[0] *= 1.000001
|
||||||
|
perturbed[1] *= 0.999999
|
||||||
|
|
||||||
|
first = system.rhs(0.041, state)
|
||||||
|
system.rhs(0.041, perturbed)
|
||||||
|
repeated = system.rhs(0.041, state)
|
||||||
|
reference = fresh.rhs(0.041, fresh_state)
|
||||||
|
|
||||||
|
for expected, actual in zip(first, repeated):
|
||||||
|
self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0))
|
||||||
|
for expected, actual in zip(reference, repeated):
|
||||||
|
self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0))
|
||||||
|
|
||||||
|
def test_block_solve_reuses_global_scales_from_extreme_other_island(self) -> None:
|
||||||
|
optimized = GenericFluidSystem(
|
||||||
|
_mixed_island_network(independent_pressure=1.0e10)
|
||||||
|
)
|
||||||
|
legacy = GenericFluidSystem(
|
||||||
|
_mixed_island_network(independent_pressure=1.0e10)
|
||||||
|
)
|
||||||
|
_force_legacy_global_coupling(legacy)
|
||||||
|
optimized_state = optimized.initial_state_vector()
|
||||||
|
legacy_state = legacy.initial_state_vector()
|
||||||
|
|
||||||
|
optimized_rhs = optimized.rhs(0.0, optimized_state)
|
||||||
|
legacy_rhs = legacy.rhs(0.0, legacy_state)
|
||||||
|
|
||||||
|
for expected, actual in zip(legacy_rhs, optimized_rhs):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
actual,
|
||||||
|
expected,
|
||||||
|
delta=1.0e-10 * max(abs(expected), 1.0),
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
optimized.max_algebraic_residual,
|
||||||
|
max(legacy.max_algebraic_residual, 1.0e-14),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_high_stiffness_contact_island_matches_legacy_global_closure(self) -> None:
|
||||||
|
report = validate_system_xml_document(short_explicit_rk45_xml())
|
||||||
|
self.assertTrue(report.valid)
|
||||||
|
assert report.document is not None
|
||||||
|
document = report.document
|
||||||
|
optimized = GenericFluidSystem(compile_system_xml_network(document))
|
||||||
|
legacy = GenericFluidSystem(compile_system_xml_network(document))
|
||||||
|
_force_legacy_global_coupling(legacy)
|
||||||
|
config = SolveIVPConfig(
|
||||||
|
t_start=document.simulation.t_start,
|
||||||
|
t_stop=document.simulation.t_stop,
|
||||||
|
method=document.simulation.method,
|
||||||
|
rtol=1.0e-6,
|
||||||
|
max_step=document.simulation.max_step,
|
||||||
|
)
|
||||||
|
|
||||||
|
optimized_result = optimized.simulate(
|
||||||
|
config,
|
||||||
|
sample_step=document.simulation.sample_step,
|
||||||
|
)
|
||||||
|
legacy_result = legacy.simulate(
|
||||||
|
config,
|
||||||
|
sample_step=document.simulation.sample_step,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(optimized_result.success)
|
||||||
|
self.assertTrue(legacy_result.success)
|
||||||
|
optimized_totals = optimized_result.diagnostics["integration"]["totals"]
|
||||||
|
legacy_totals = legacy_result.diagnostics["integration"]["totals"]
|
||||||
|
self.assertEqual(
|
||||||
|
optimized_totals["stateTransitionCount"],
|
||||||
|
legacy_totals["stateTransitionCount"],
|
||||||
|
)
|
||||||
|
self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys())
|
||||||
|
for key, optimized_values in optimized_result.series.items():
|
||||||
|
legacy_values = legacy_result.series[key]
|
||||||
|
self.assertEqual(len(optimized_values), len(legacy_values), key)
|
||||||
|
for optimized_value, legacy_value in zip(
|
||||||
|
optimized_values,
|
||||||
|
legacy_values,
|
||||||
|
):
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
optimized_value,
|
||||||
|
legacy_value,
|
||||||
|
delta=2.0e-7 * max(abs(legacy_value), 1.0),
|
||||||
|
msg=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_secondary_fluid_island_does_not_clear_active_contact_state(self) -> None:
|
||||||
|
network = compile_reactflow_network(elastic_contact_project())
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
source = Cylinder(
|
||||||
|
"separate_source",
|
||||||
|
medium,
|
||||||
|
V=0.02,
|
||||||
|
p0=600_000.0,
|
||||||
|
T0=350.0,
|
||||||
|
)
|
||||||
|
sink = Tank(
|
||||||
|
"separate_sink",
|
||||||
|
medium,
|
||||||
|
V=0.05,
|
||||||
|
p0=100_000.0,
|
||||||
|
T0=280.0,
|
||||||
|
)
|
||||||
|
valve = AmesimPnvo001FixedOpening(
|
||||||
|
"separate_valve",
|
||||||
|
medium,
|
||||||
|
area0=1.0e-5,
|
||||||
|
opening=0.8,
|
||||||
|
)
|
||||||
|
for component in (source, sink, valve):
|
||||||
|
network.add_component(component)
|
||||||
|
network.connect("separate_source", "port_b", "separate_valve", "port_2")
|
||||||
|
network.connect("separate_valve", "port_3", "separate_sink", "port_a")
|
||||||
|
self.assertEqual(simulation_preparation_issues(network), ())
|
||||||
|
system = GenericFluidSystem(network)
|
||||||
|
contact = network.components["contact_1"]
|
||||||
|
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
|
||||||
|
original_solve = secondary.solve
|
||||||
|
observed: list[tuple[float | None, ...]] = []
|
||||||
|
|
||||||
|
def checked_solve(*, scale_context=None):
|
||||||
|
if contact._causal_penetration is None:
|
||||||
|
contact.set_causal_contact(penetration=1.0e-4, force=10.0)
|
||||||
|
before = (
|
||||||
|
contact._causal_penetration,
|
||||||
|
contact._causal_contact_force,
|
||||||
|
contact._causal_port_1_x,
|
||||||
|
contact._causal_port_2_x,
|
||||||
|
contact._causal_port_1_v,
|
||||||
|
contact._causal_port_2_v,
|
||||||
|
)
|
||||||
|
result = original_solve(scale_context=scale_context)
|
||||||
|
after = (
|
||||||
|
contact._causal_penetration,
|
||||||
|
contact._causal_contact_force,
|
||||||
|
contact._causal_port_1_x,
|
||||||
|
contact._causal_port_2_x,
|
||||||
|
contact._causal_port_1_v,
|
||||||
|
contact._causal_port_2_v,
|
||||||
|
)
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
observed.append(before)
|
||||||
|
return result
|
||||||
|
|
||||||
|
secondary.solve = checked_solve
|
||||||
|
system.consistent_initial_state_vector()
|
||||||
|
|
||||||
|
self.assertTrue(observed)
|
||||||
|
self.assertIsNotNone(observed[0][0])
|
||||||
|
self.assertIsNotNone(observed[0][1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user