增加可选性能埋点并完成物性效率评估

This commit is contained in:
ljz committed 2026-08-16 17:46:04 +08:00
1 parent 4e0b9fd8cc
commit 57b459bc72
21 files changed
+2029 -179

No files matched your search

+18
View File
@@ -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 件重点工作,现在的状态如下:
+262
View File
@@ -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,
+10
View File
@@ -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)
+53
View File
@@ -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,
+665
View File
@@ -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",
]
+2
View File
@@ -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:
+2
View File
@@ -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)
+2
View File
@@ -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],
+23 -7
View File
@@ -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
View File
@@ -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,
)