增加可选性能埋点并完成物性效率评估
This commit is contained in:
1 parent
4e0b9fd8cc
commit
57b459bc72
21 files changed
+2029
-179
No files matched your search
@@ -42,6 +42,8 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
||||
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
|
||||
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
|
||||
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
|
||||
- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点
|
||||
- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具
|
||||
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||
@@ -58,6 +60,22 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
|
||||
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
|
||||
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
|
||||
|
||||
## 可选性能诊断
|
||||
|
||||
`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard`
|
||||
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
|
||||
明显更高。最终优化收益必须在 `off` 下复测。
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
|
||||
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。
|
||||
|
||||
## 当前阶段进度
|
||||
|
||||
这一阶段原先有 4 件重点工作,现在的状态如下:
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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 _clear_property_caches() -> None:
|
||||
from app.simulation.components.amesim.media.mediums import (
|
||||
AmesimHeliumPengRobinsonMedium,
|
||||
)
|
||||
|
||||
for method_name in (
|
||||
"temperature_from_pressure_enthalpy",
|
||||
"properties_from_mU",
|
||||
):
|
||||
method = getattr(AmesimHeliumPengRobinsonMedium, method_name)
|
||||
cache_clear = getattr(method, "cache_clear", None)
|
||||
if cache_clear is not None:
|
||||
cache_clear()
|
||||
|
||||
|
||||
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,
|
||||
clear_property_cache: 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):
|
||||
if clear_property_cache:
|
||||
_clear_property_caches()
|
||||
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):
|
||||
if clear_property_cache:
|
||||
_clear_property_caches()
|
||||
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(
|
||||
"--cold-property-cache",
|
||||
action="store_true",
|
||||
help="Clear the two helium property LRU caches before every warmup and measured run.",
|
||||
)
|
||||
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
|
||||
|
||||
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,
|
||||
"coldPropertyCache": bool(arguments.cold_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,
|
||||
clear_property_cache=arguments.cold_property_cache,
|
||||
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())
|
||||
@@ -12,6 +12,7 @@ from app.simulation.core.medium import (
|
||||
ThermodynamicProperties,
|
||||
)
|
||||
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
|
||||
from app.simulation.performance import profile_property, record_property_iterations
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -69,6 +70,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
del T
|
||||
return self.cv
|
||||
|
||||
@profile_property("density")
|
||||
def density(self, p: float, T: float) -> float:
|
||||
return self.fluid.density(p, T)
|
||||
|
||||
@@ -134,6 +136,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
)
|
||||
return factor, exponent
|
||||
|
||||
@profile_property("isentropic_density_pressure_factor")
|
||||
def isentropic_density_pressure_factor(
|
||||
self,
|
||||
p: float,
|
||||
@@ -166,12 +169,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
raise ValueError("Volume must stay positive.")
|
||||
return self.fluid.pressure_from_density(T, m / V)
|
||||
|
||||
@profile_property("specific_internal_energy")
|
||||
def specific_internal_energy(self, T: float) -> float:
|
||||
return self.R_gas * (
|
||||
(self.nasa_cp_over_R - 1.0) * T
|
||||
+ self.nasa_enthalpy_constant_K
|
||||
)
|
||||
|
||||
@profile_property("specific_internal_energy_at_pressure")
|
||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
||||
density = self.density(p, T)
|
||||
return (
|
||||
@@ -179,12 +184,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
+ self.fluid.residual_specific_internal_energy_at_density(T, density)
|
||||
)
|
||||
|
||||
@profile_property("specific_enthalpy")
|
||||
def specific_enthalpy(self, T: float) -> float:
|
||||
return self.R_gas * (
|
||||
self.nasa_cp_over_R * T
|
||||
+ self.nasa_enthalpy_constant_K
|
||||
)
|
||||
|
||||
@profile_property("specific_enthalpy_at_pressure")
|
||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
||||
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
|
||||
|
||||
@@ -198,6 +205,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
h / self.R_gas - self.nasa_enthalpy_constant_K
|
||||
) / self.nasa_cp_over_R
|
||||
|
||||
@profile_property("temperature_from_pressure_enthalpy", track_cache=True)
|
||||
@lru_cache(maxsize=8192)
|
||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
||||
@@ -211,8 +219,18 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
temperature,
|
||||
1.0,
|
||||
):
|
||||
record_property_iterations(
|
||||
"temperature_from_pressure_enthalpy",
|
||||
_iteration + 1,
|
||||
True,
|
||||
)
|
||||
return next_temperature
|
||||
temperature = next_temperature
|
||||
record_property_iterations(
|
||||
"temperature_from_pressure_enthalpy",
|
||||
16,
|
||||
False,
|
||||
)
|
||||
return temperature
|
||||
|
||||
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
|
||||
@@ -222,6 +240,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
)
|
||||
return self.temperature_from_internal_energy(U / m)
|
||||
|
||||
@profile_property("properties_from_mU", track_cache=True)
|
||||
@lru_cache(maxsize=8192)
|
||||
def properties_from_mU(
|
||||
self,
|
||||
@@ -249,6 +268,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
self.temperature_from_internal_energy(target_internal_energy),
|
||||
2.2,
|
||||
)
|
||||
converged = False
|
||||
for _iteration in range(16):
|
||||
residual_internal_energy = (
|
||||
self.fluid.residual_specific_internal_energy_at_density(
|
||||
@@ -267,8 +287,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
||||
1.0,
|
||||
):
|
||||
temperature = next_temperature
|
||||
converged = True
|
||||
break
|
||||
temperature = next_temperature
|
||||
record_property_iterations(
|
||||
"properties_from_mU",
|
||||
_iteration + 1,
|
||||
converged,
|
||||
)
|
||||
pressure = self.fluid.pressure_from_density(temperature, density)
|
||||
return ThermodynamicProperties(
|
||||
p=pressure,
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from app.simulation.core.errors import RecoverableTrialStateError
|
||||
from app.simulation.performance import profile_property
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -108,9 +109,11 @@ class IdealGasMedium:
|
||||
def cv_at_temperature(self, T: float) -> float:
|
||||
return self.cp_at_temperature(T) - self.R_gas
|
||||
|
||||
@profile_property("density")
|
||||
def density(self, p: float, T: float) -> float:
|
||||
return p / (self.R_gas * T)
|
||||
|
||||
@profile_property("isentropic_density_pressure_factor")
|
||||
def isentropic_density_pressure_factor(
|
||||
self,
|
||||
p: float,
|
||||
@@ -123,6 +126,7 @@ class IdealGasMedium:
|
||||
cv = self.cv_at_temperature(T)
|
||||
return cv / cp
|
||||
|
||||
@profile_property("dynamic_viscosity")
|
||||
def dynamic_viscosity(self, T: float) -> float:
|
||||
"""Return dynamic viscosity using the default air Sutherland law."""
|
||||
|
||||
@@ -135,6 +139,7 @@ class IdealGasMedium:
|
||||
/ (T + self.sutherland_constant)
|
||||
)
|
||||
|
||||
@profile_property("specific_internal_energy")
|
||||
def specific_internal_energy(self, T: float) -> float:
|
||||
delta_T = T - self.T_ref
|
||||
return (
|
||||
@@ -143,10 +148,12 @@ class IdealGasMedium:
|
||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
||||
)
|
||||
|
||||
@profile_property("specific_internal_energy_at_pressure")
|
||||
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
||||
del p
|
||||
return self.specific_internal_energy(T)
|
||||
|
||||
@profile_property("specific_enthalpy")
|
||||
def specific_enthalpy(self, T: float) -> float:
|
||||
delta_T = T - self.T_ref
|
||||
return (
|
||||
@@ -155,6 +162,7 @@ class IdealGasMedium:
|
||||
+ 0.5 * self.cp_slope * delta_T * delta_T
|
||||
)
|
||||
|
||||
@profile_property("specific_enthalpy_at_pressure")
|
||||
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
|
||||
del p
|
||||
return self.specific_enthalpy(T)
|
||||
@@ -191,6 +199,7 @@ class IdealGasMedium:
|
||||
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
|
||||
return self.T_ref + delta_T
|
||||
|
||||
@profile_property("temperature_from_pressure_enthalpy")
|
||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||
del p
|
||||
return self.temperature_from_enthalpy(h)
|
||||
@@ -207,6 +216,7 @@ class IdealGasMedium:
|
||||
raise ValueError("Volume must stay positive.")
|
||||
return m * self.R_gas * T / V
|
||||
|
||||
@profile_property("properties_from_mU")
|
||||
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
|
||||
T = self.temperature_from_mass_internal_energy(m, U)
|
||||
p = self.pressure(m, T, V)
|
||||
|
||||
@@ -5,6 +5,8 @@ from app.simulation.core.errors import RecoverableTrialStateError
|
||||
from dataclasses import dataclass
|
||||
from math import acos, cos, isfinite, log, pi, sqrt
|
||||
|
||||
from app.simulation.performance import profile_property
|
||||
|
||||
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
|
||||
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
|
||||
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
|
||||
@@ -97,6 +99,11 @@ class PengRobinsonFluid:
|
||||
) -> float:
|
||||
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
|
||||
|
||||
@profile_property(
|
||||
"pressure_from_molar_volume",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
|
||||
self._validate_temperature(temperature)
|
||||
if molar_volume <= self.b_parameter:
|
||||
@@ -107,11 +114,21 @@ class PengRobinsonFluid:
|
||||
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
|
||||
return repulsive - attractive
|
||||
|
||||
@profile_property(
|
||||
"pressure_from_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_from_density(self, temperature: float, density: float) -> float:
|
||||
if density <= 0.0:
|
||||
raise ValueError("Density must be positive.")
|
||||
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
|
||||
|
||||
@profile_property(
|
||||
"pressure_temperature_derivative_at_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_temperature_derivative_at_density(
|
||||
self,
|
||||
temperature: float,
|
||||
@@ -132,6 +149,11 @@ class PengRobinsonFluid:
|
||||
- self.attractive_parameter_temperature_derivative(temperature) / denominator
|
||||
)
|
||||
|
||||
@profile_property(
|
||||
"pressure_density_derivative_at_temperature",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def pressure_density_derivative_at_temperature(
|
||||
self,
|
||||
temperature: float,
|
||||
@@ -165,6 +187,11 @@ class PengRobinsonFluid:
|
||||
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
|
||||
return A, B
|
||||
|
||||
@profile_property(
|
||||
"compressibility_roots",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
|
||||
A, B = self.reduced_parameters(pressure, temperature)
|
||||
coefficients = (
|
||||
@@ -178,6 +205,11 @@ class PengRobinsonFluid:
|
||||
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
|
||||
return physical_roots
|
||||
|
||||
@profile_property(
|
||||
"compressibility_factor",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def compressibility_factor(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -193,6 +225,11 @@ class PengRobinsonFluid:
|
||||
return roots[-1]
|
||||
raise ValueError(f"Unsupported phase selector: {phase!r}")
|
||||
|
||||
@profile_property(
|
||||
"molar_volume",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def molar_volume(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -202,6 +239,7 @@ class PengRobinsonFluid:
|
||||
z = self.compressibility_factor(pressure, temperature, phase=phase)
|
||||
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
|
||||
|
||||
@profile_property("density", layer="kernel", minimum_mode="audit")
|
||||
def density(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -210,6 +248,11 @@ class PengRobinsonFluid:
|
||||
) -> float:
|
||||
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
|
||||
|
||||
@profile_property(
|
||||
"residual_specific_enthalpy",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def residual_specific_enthalpy(
|
||||
self,
|
||||
pressure: float,
|
||||
@@ -239,6 +282,11 @@ class PengRobinsonFluid:
|
||||
)
|
||||
return residual_molar_enthalpy / self.molar_mass
|
||||
|
||||
@profile_property(
|
||||
"residual_specific_internal_energy_at_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def residual_specific_internal_energy_at_density(
|
||||
self,
|
||||
temperature: float,
|
||||
@@ -268,6 +316,11 @@ class PengRobinsonFluid:
|
||||
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
|
||||
return residual_molar_internal_energy / self.molar_mass
|
||||
|
||||
@profile_property(
|
||||
"residual_isochoric_heat_capacity_at_density",
|
||||
layer="kernel",
|
||||
minimum_mode="audit",
|
||||
)
|
||||
def residual_isochoric_heat_capacity_at_density(
|
||||
self,
|
||||
temperature: float,
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROFILE_MODE",
|
||||
"PerformanceTrace",
|
||||
"performance_span",
|
||||
"profile_phase",
|
||||
"profile_property",
|
||||
"profile_run",
|
||||
"record_property_iterations",
|
||||
]
|
||||
@@ -14,6 +14,7 @@ from app.simulation.components.amesim.flow.pipes import (
|
||||
)
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.ports import PortState, VariableRole
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
@@ -1286,6 +1287,7 @@ class PressureFlowSolver:
|
||||
),
|
||||
}
|
||||
|
||||
@profile_phase("simulation.pressure_flow", minimum_mode="audit")
|
||||
def solve(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
@@ -36,6 +37,7 @@ class PneumaticVolumeResolver:
|
||||
result[second] = first
|
||||
return result
|
||||
|
||||
@profile_phase("simulation.pneumatic_volume", minimum_mode="audit")
|
||||
def solve(self) -> PneumaticVolumeDiagnostics:
|
||||
for component in self.network.components.values():
|
||||
for definition in component.active_port_definitions:
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
@@ -47,6 +48,7 @@ class SignalResolver:
|
||||
]
|
||||
self.last_diagnostics: SignalSolveDiagnostics | None = None
|
||||
|
||||
@profile_phase("simulation.signal", minimum_mode="audit")
|
||||
def solve(self, time: float) -> SignalSolveDiagnostics:
|
||||
for component in self.network.components.values():
|
||||
signal_output_values = getattr(component, "signal_output_values", None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.simulation.core.errors import RecoverableTrialStateError
|
||||
from app.simulation.performance import profile_phase
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
@@ -1018,6 +1019,7 @@ def _integrate_scipy_stepwise(
|
||||
)
|
||||
|
||||
|
||||
@profile_phase("simulation.integration")
|
||||
def integrate_ode(
|
||||
rhs: Callable[[float, list[float]], list[float]],
|
||||
initial_state: list[float],
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.performance import profile_phase
|
||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||
|
||||
|
||||
@@ -83,14 +84,33 @@ class StreamResolver:
|
||||
)
|
||||
return values
|
||||
|
||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||
def _refresh_dynamic_components(
|
||||
self,
|
||||
components: list[DynamicComponent],
|
||||
) -> None:
|
||||
for component in components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
|
||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||
def _refresh_stream_components(
|
||||
self,
|
||||
connected: dict[str, dict[str, float]],
|
||||
) -> None:
|
||||
for component in self.network.components.values():
|
||||
if isinstance(component, DynamicComponent):
|
||||
component.refresh_thermodynamic_ports()
|
||||
else:
|
||||
component.update_stream_outflows(connected[component.name])
|
||||
|
||||
@profile_phase("simulation.stream", minimum_mode="audit")
|
||||
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||
dynamic_components = [
|
||||
component
|
||||
for component in self.network.components.values()
|
||||
if isinstance(component, DynamicComponent)
|
||||
]
|
||||
for component in dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
self._refresh_dynamic_components(dynamic_components)
|
||||
|
||||
max_delta = 0.0
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
@@ -100,11 +120,7 @@ class StreamResolver:
|
||||
for port_name, port in component.ports.items()
|
||||
}
|
||||
connected = self.connected_enthalpies()
|
||||
for component in self.network.components.values():
|
||||
if isinstance(component, DynamicComponent):
|
||||
component.refresh_thermodynamic_ports()
|
||||
else:
|
||||
component.update_stream_outflows(connected[component.name])
|
||||
self._refresh_stream_components(connected)
|
||||
|
||||
deltas = [
|
||||
abs(port.h_outflow - previous[(component.name, port_name)])
|
||||
|
||||
+155
-130
@@ -7,6 +7,7 @@ from typing import Literal
|
||||
|
||||
from app.simulation.core.base import DynamicComponent
|
||||
from app.simulation.core.metadata import ResultVariableMetadata
|
||||
from app.simulation.performance import performance_span, profile_phase
|
||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||
from app.simulation.solvers.mechanical import (
|
||||
MechanicalConstraintGroup,
|
||||
@@ -337,6 +338,7 @@ def simulation_sample_times(
|
||||
class GenericFluidSystem:
|
||||
"""Topology-driven, semi-explicit fluid simulation for registered components."""
|
||||
|
||||
@profile_phase("simulation.system_construction")
|
||||
def __init__(self, network: SimulationNetwork) -> None:
|
||||
issues = simulation_preparation_issues(network)
|
||||
if issues:
|
||||
@@ -465,14 +467,18 @@ class GenericFluidSystem:
|
||||
"colorGroupCount": group_count,
|
||||
}
|
||||
|
||||
@profile_phase(
|
||||
"simulation.closure",
|
||||
minimum_mode="audit",
|
||||
reset_property_shadow=True,
|
||||
)
|
||||
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
|
||||
signal = self.signal_resolver.solve(time)
|
||||
self.signal_propagation_count += signal.propagated
|
||||
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
|
||||
pneumatic_volume = self.pneumatic_volume_resolver.solve()
|
||||
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
|
||||
for component in self.dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
self._refresh_dynamic_components()
|
||||
algebraic = self.pressure_flow_solver.solve(
|
||||
effort_variables=("p",),
|
||||
)
|
||||
@@ -549,18 +555,31 @@ class GenericFluidSystem:
|
||||
)
|
||||
return connected_h
|
||||
|
||||
@profile_phase("simulation.refresh", minimum_mode="audit")
|
||||
def _refresh_dynamic_components(self) -> None:
|
||||
for component in self.dynamic_components:
|
||||
component.refresh_thermodynamic_ports()
|
||||
|
||||
@profile_phase("simulation.derivatives", minimum_mode="audit")
|
||||
def _state_derivatives(
|
||||
self,
|
||||
connected_h: dict[str, dict[str, float]],
|
||||
) -> list[float]:
|
||||
return self.pneumatic_storage_reducer.coupled_derivatives(
|
||||
self.mechanical_state_reducer.state_derivatives(connected_h)
|
||||
)
|
||||
|
||||
def consistent_initial_state_vector(self, time: float = 0.0) -> list[float]:
|
||||
state = self.initial_state_vector()
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state(time)
|
||||
return state
|
||||
|
||||
@profile_phase("simulation.rhs", minimum_mode="audit")
|
||||
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||
self.apply_state_vector(state_vector)
|
||||
connected_h = self._close_current_state(_time)
|
||||
return self.pneumatic_storage_reducer.coupled_derivatives(
|
||||
self.mechanical_state_reducer.state_derivatives(connected_h)
|
||||
)
|
||||
return self._state_derivatives(connected_h)
|
||||
|
||||
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
||||
for component in self.network.components.values():
|
||||
@@ -603,20 +622,26 @@ class GenericFluidSystem:
|
||||
progress_callback(last_reported_progress, phase)
|
||||
|
||||
report_progress(0.0, "initializing", force=True)
|
||||
integration_config = config
|
||||
if isinstance(config.atol, (int, float)):
|
||||
integration_config = replace(
|
||||
config,
|
||||
atol=self.mechanical_state_reducer.absolute_tolerances(
|
||||
float(config.atol)
|
||||
),
|
||||
with performance_span("simulation.sample_initialization"):
|
||||
integration_config = config
|
||||
if isinstance(config.atol, (int, float)):
|
||||
integration_config = replace(
|
||||
config,
|
||||
atol=self.mechanical_state_reducer.absolute_tolerances(
|
||||
float(config.atol)
|
||||
),
|
||||
)
|
||||
t_eval = simulation_sample_times(config, sample_step)
|
||||
signal_event_times = self.signal_resolver.event_times(
|
||||
config.t_start,
|
||||
config.t_stop,
|
||||
)
|
||||
initial_state = self.consistent_initial_state_vector(config.t_start)
|
||||
jac_sparsity = (
|
||||
self.jacobian_sparsity()
|
||||
if integration_config.method in {"BDF", "Radau"}
|
||||
else None
|
||||
)
|
||||
t_eval = simulation_sample_times(config, sample_step)
|
||||
signal_event_times = self.signal_resolver.event_times(
|
||||
config.t_start,
|
||||
config.t_stop,
|
||||
)
|
||||
initial_state = self.consistent_initial_state_vector(config.t_start)
|
||||
report_progress(0.0, "integrating", force=True)
|
||||
duration = config.t_stop - config.t_start
|
||||
furthest_solver_time = config.t_start
|
||||
@@ -651,11 +676,7 @@ class GenericFluidSystem:
|
||||
if self.mechanical_state_reducer.has_state_events
|
||||
else None
|
||||
),
|
||||
jac_sparsity=(
|
||||
self.jacobian_sparsity()
|
||||
if integration_config.method in {"BDF", "Radau"}
|
||||
else None
|
||||
),
|
||||
jac_sparsity=jac_sparsity,
|
||||
)
|
||||
if isinstance(solution, ODESolution):
|
||||
run_status: SimulationRunStatus = solution.status
|
||||
@@ -719,112 +740,116 @@ class GenericFluidSystem:
|
||||
for segment in solver_segment_diagnostics
|
||||
)
|
||||
|
||||
series: dict[str, list[float]] = {"time": []}
|
||||
postprocessing_error: Exception | None = None
|
||||
self.mechanical_state_reducer.reset_constraint_modes()
|
||||
for time_index in range(len(times)):
|
||||
if (
|
||||
run_status == "completed"
|
||||
and cancel_check is not None
|
||||
and cancel_check()
|
||||
):
|
||||
run_status = "cancelled"
|
||||
result_message = "Simulation was stopped while preparing partial results."
|
||||
break
|
||||
state = [
|
||||
float(solution.y[state_index][time_index])
|
||||
for state_index in range(len(solution.y))
|
||||
]
|
||||
try:
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state(times[time_index])
|
||||
self._append_current_state(series)
|
||||
series["time"].append(times[time_index])
|
||||
except Exception as exc:
|
||||
run_status = "failed"
|
||||
result_message = str(exc)
|
||||
postprocessing_error = exc
|
||||
break
|
||||
if len(series["time"]) < 2:
|
||||
if postprocessing_error is not None:
|
||||
raise postprocessing_error
|
||||
if integration_error is not None:
|
||||
raise integration_error
|
||||
with performance_span("simulation.postprocessing"):
|
||||
series: dict[str, list[float]] = {"time": []}
|
||||
postprocessing_error: Exception | None = None
|
||||
self.mechanical_state_reducer.reset_constraint_modes()
|
||||
for time_index in range(len(times)):
|
||||
if (
|
||||
run_status == "completed"
|
||||
and cancel_check is not None
|
||||
and cancel_check()
|
||||
):
|
||||
run_status = "cancelled"
|
||||
result_message = (
|
||||
"Simulation was stopped while preparing partial results."
|
||||
)
|
||||
break
|
||||
state = [
|
||||
float(solution.y[state_index][time_index])
|
||||
for state_index in range(len(solution.y))
|
||||
]
|
||||
try:
|
||||
self.apply_state_vector(state)
|
||||
self._close_current_state(times[time_index])
|
||||
self._append_current_state(series)
|
||||
series["time"].append(times[time_index])
|
||||
except Exception as exc:
|
||||
run_status = "failed"
|
||||
result_message = str(exc)
|
||||
postprocessing_error = exc
|
||||
break
|
||||
if len(series["time"]) < 2:
|
||||
if postprocessing_error is not None:
|
||||
raise postprocessing_error
|
||||
if integration_error is not None:
|
||||
raise integration_error
|
||||
|
||||
final = {
|
||||
key: values[-1]
|
||||
for key, values in series.items()
|
||||
if key != "time" and values
|
||||
}
|
||||
diagnostics = {
|
||||
"integration": {
|
||||
"method": integration_config.method,
|
||||
"jacobianSparsity": jacobian_diagnostics,
|
||||
"segmentCount": len(solver_segment_diagnostics),
|
||||
"segments": solver_segment_diagnostics,
|
||||
"totals": solver_totals,
|
||||
},
|
||||
"pressureFlow": {
|
||||
"solveCount": self.algebraic_solve_count,
|
||||
"maxScaledResidual": self.max_algebraic_residual,
|
||||
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
|
||||
"last": (
|
||||
self.pressure_flow_solver.last_diagnostics.as_dict()
|
||||
if self.pressure_flow_solver.last_diagnostics is not None
|
||||
else None
|
||||
with performance_span("simulation.result_assembly"):
|
||||
final = {
|
||||
key: values[-1]
|
||||
for key, values in series.items()
|
||||
if key != "time" and values
|
||||
}
|
||||
diagnostics = {
|
||||
"integration": {
|
||||
"method": integration_config.method,
|
||||
"jacobianSparsity": jacobian_diagnostics,
|
||||
"segmentCount": len(solver_segment_diagnostics),
|
||||
"segments": solver_segment_diagnostics,
|
||||
"totals": solver_totals,
|
||||
},
|
||||
"pressureFlow": {
|
||||
"solveCount": self.algebraic_solve_count,
|
||||
"maxScaledResidual": self.max_algebraic_residual,
|
||||
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
|
||||
"last": (
|
||||
self.pressure_flow_solver.last_diagnostics.as_dict()
|
||||
if self.pressure_flow_solver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stream": {
|
||||
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||
"maxThermofluidIterations": self.max_thermofluid_iterations,
|
||||
"last": (
|
||||
self.stream_resolver.last_diagnostics.as_dict()
|
||||
if self.stream_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"signal": {
|
||||
"propagations": self.signal_propagation_count,
|
||||
"eventTimes": list(signal_event_times),
|
||||
"last": (
|
||||
self.signal_resolver.last_diagnostics.as_dict()
|
||||
if self.signal_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"pneumaticVolume": {
|
||||
"propagations": self.pneumatic_volume_propagation_count,
|
||||
"last": (
|
||||
self.pneumatic_volume_resolver.last_diagnostics.as_dict()
|
||||
if self.pneumatic_volume_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stateCount": len(initial_state),
|
||||
"sampleCount": len(series["time"]),
|
||||
}
|
||||
variables = tuple(
|
||||
variable
|
||||
for variable in self.network.result_variable_metadata()
|
||||
if variable.key in series
|
||||
)
|
||||
report_progress(
|
||||
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||
"complete" if run_status == "completed" else run_status,
|
||||
force=True,
|
||||
)
|
||||
return GenericSimulationResult(
|
||||
success=run_status == "completed" and bool(solution.success),
|
||||
status=run_status,
|
||||
message=result_message,
|
||||
simulated_until=(
|
||||
float(series["time"][-1])
|
||||
if series["time"]
|
||||
else float(config.t_start)
|
||||
),
|
||||
},
|
||||
"stream": {
|
||||
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||
"maxThermofluidIterations": self.max_thermofluid_iterations,
|
||||
"last": (
|
||||
self.stream_resolver.last_diagnostics.as_dict()
|
||||
if self.stream_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"signal": {
|
||||
"propagations": self.signal_propagation_count,
|
||||
"eventTimes": list(signal_event_times),
|
||||
"last": (
|
||||
self.signal_resolver.last_diagnostics.as_dict()
|
||||
if self.signal_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"pneumaticVolume": {
|
||||
"propagations": self.pneumatic_volume_propagation_count,
|
||||
"last": (
|
||||
self.pneumatic_volume_resolver.last_diagnostics.as_dict()
|
||||
if self.pneumatic_volume_resolver.last_diagnostics is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"stateCount": len(initial_state),
|
||||
"sampleCount": len(series["time"]),
|
||||
}
|
||||
variables = tuple(
|
||||
variable
|
||||
for variable in self.network.result_variable_metadata()
|
||||
if variable.key in series
|
||||
)
|
||||
report_progress(
|
||||
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||
"complete" if run_status == "completed" else run_status,
|
||||
force=True,
|
||||
)
|
||||
return GenericSimulationResult(
|
||||
success=run_status == "completed" and bool(solution.success),
|
||||
status=run_status,
|
||||
message=result_message,
|
||||
simulated_until=(
|
||||
float(series["time"][-1])
|
||||
if series["time"]
|
||||
else float(config.t_start)
|
||||
),
|
||||
requested_stop_time=float(config.t_stop),
|
||||
variables=variables,
|
||||
series=series,
|
||||
final=final,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_stop_time=float(config.t_stop),
|
||||
variables=variables,
|
||||
series=series,
|
||||
final=final,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
Reference in new issue
Block a user