优化仿真求解性能并修复流量闭合问题(初版)

This commit is contained in:
ljz committed 2026-08-16 17:46:05 +08:00
1 parent 57b459bc72
commit 5332a788f3
55 files changed
+8973 -549

No files matched your search

+37 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable, Iterator, Mapping
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
from contextlib import asynccontextmanager
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -22,6 +23,7 @@ from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.simulation.performance import performance_span, profile_phase, profile_run
from app.simulation.property_cache import property_cache_run
from app.system_xml import (
SystemXmlDocument,
SystemXmlValidationReport,
@@ -35,7 +37,19 @@ if TYPE_CHECKING:
from app.simulation.systems.network import SimulationNetwork
app = FastAPI(title="System Simulation ReactFlow App")
@asynccontextmanager
async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]:
from app.simulation.warmup import warm_up_simulation_runtime
application.state.simulation_warmup = warm_up_simulation_runtime().as_dict()
yield
app = FastAPI(
title="System Simulation ReactFlow App",
lifespan=_app_lifespan,
)
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
SYSTEM_XML_SCHEMA_VERSION = "3"
@@ -668,14 +682,25 @@ def run_system_xml_simulation(
progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None,
) -> dict[str, object]:
with profile_run() as trace:
result = _run_system_xml_simulation_profiled(
xml_bytes,
progress_callback,
cancel_check,
)
with property_cache_run() as property_cache:
with profile_run() as trace:
result = _run_system_xml_simulation_profiled(
xml_bytes,
progress_callback,
cancel_check,
)
performance = trace.snapshot()
if performance.get("mode") == "audit" and property_cache is not None:
cache_info = property_cache.info()
performance["propertyCache"] = {
"hits": cache_info.hits,
"misses": cache_info.misses,
"maxEntriesPerCache": cache_info.max_entries_per_cache,
"cacheCount": cache_info.cache_count,
"currentEntries": cache_info.current_entries,
"evictions": cache_info.evictions,
}
if performance.get("mode") != "off":
diagnostics = dict(result.get("diagnostics", {}))
diagnostics["performance"] = performance
@@ -766,6 +791,9 @@ def _run_system_xml_simulation_profiled(
},
) from exc
except AlgebraicSolveError as exc:
algebraic_diagnostics = exc.diagnostics.as_dict()
algebraic_diagnostics["scopeKind"] = exc.scope_kind
algebraic_diagnostics["scopeComponents"] = list(exc.scope_components)
raise HTTPException(
status_code=422,
detail={
@@ -778,7 +806,7 @@ def _run_system_xml_simulation_profiled(
"message": str(exc),
}
],
"diagnostics": exc.diagnostics.as_dict(),
"diagnostics": algebraic_diagnostics,
},
) from exc
except StreamSolveError as exc:
+14
View File
@@ -66,6 +66,16 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
明显更高。最终优化收益必须在 `off` 下复测。
Peng–Robinson 氦气的高开销物性默认使用一次仿真内独立的精确 LRU 缓存;不同
仿真任务不会共享条目,仿真结束后自动释放。可在启动进程前设置
`SIMULATIONAPP_PROPERTY_CACHE=off` 做数值和性能 A/B,正常运行保持默认 `on`。
缓存只复用完全相同的输入,不做四舍五入或容差匹配。
FastAPI worker 默认在 lifespan 启动阶段预热 SciPy 积分、非线性求解、稀疏
Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行业务模型,也不
写入文件;如需诊断冷启动,可设置 `SIMULATIONAPP_WARMUP=off`。每个 worker 都会
独立暖机一次。
```powershell
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
--mode audit --warmups 1 --runs 3 `
@@ -73,6 +83,10 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
--output app/data/performance-evaluations/helium-step.json
```
缓存关闭对照可在同一命令中增加 `--disable-property-cache`。缓存容量、命中、
未命中和驱逐数会在 audit 响应的
`diagnostics.performance.propertyCache` 中返回。
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。
+6 -24
View File
@@ -58,21 +58,6 @@ def _load_factory_xml(specification: str) -> bytes:
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."""
@@ -98,15 +83,12 @@ def _run_case(
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')}")
@@ -118,8 +100,6 @@ def _run_case(
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)
@@ -190,9 +170,9 @@ def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
)
parser.add_argument(
"--cold-property-cache",
"--disable-property-cache",
action="store_true",
help="Clear the two helium property LRU caches before every warmup and measured run.",
help="Disable the run-local exact property cache for an A/B comparison.",
)
parser.add_argument(
"--allow-failures",
@@ -213,6 +193,9 @@ def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
def main(argv: list[str] | None = None) -> int:
arguments = _parse_arguments(argv)
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = (
"off" if arguments.disable_property_cache else "on"
)
cases: list[tuple[str, bytes]] = []
for raw_case in arguments.xml:
@@ -226,7 +209,7 @@ def main(argv: list[str] | None = None) -> int:
"generatedAt": datetime.now(UTC).isoformat(),
"profileMode": arguments.mode,
"cancellableSolverPath": not arguments.direct_path,
"coldPropertyCache": bool(arguments.cold_property_cache),
"propertyCacheEnabled": not arguments.disable_property_cache,
"allowFailures": bool(arguments.allow_failures),
"warmups": arguments.warmups,
"runs": arguments.runs,
@@ -242,7 +225,6 @@ def main(argv: list[str] | None = None) -> int:
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
@@ -19,6 +19,7 @@ class AmesimPnpl01(AlgebraicComponent):
MODEL_TYPE = "amesim_pnpl01"
MODEL_VERSION = "0.1.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
PARAMETERS = ()
RESULT_VARIABLES = ()
@@ -58,6 +58,7 @@ class AmesimPnor001(AlgebraicComponent):
MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -462,6 +463,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
MODEL_TYPE = "amesim_pnvo001_fixed"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PORTS = (
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
@@ -698,7 +700,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
def mass_flow(self, p_2: float, p_3: float) -> float:
if (
isclose(p_2, p_3, rel_tol=1.0e-7, abs_tol=1.0e-9)
isclose(p_2, p_3, rel_tol=0.0, abs_tol=1.0e-8)
or self.effective_area == 0.0
):
return 0.0
@@ -885,6 +887,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
MODEL_TYPE = "amesim_pnvo001"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -49,6 +49,7 @@ class AmesimPnl00r(AlgebraicComponent):
MODEL_TYPE = "amesim_pnl00r"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -283,7 +284,7 @@ class AmesimPnl00r(AlgebraicComponent):
return 0.5 * (lower + upper)
def mass_flow(self, p_1: float, p_2: float) -> float:
if isclose(p_1, p_2, rel_tol=1.0e-7, abs_tol=1.0e-9):
if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8):
return 0.0
pressure_difference = p_1 - p_2
upstream_pressure = max(p_1, p_2, 1.0)
@@ -910,6 +911,7 @@ class AmesimPnl0002(AmesimPnl0001):
MODEL_TYPE = "amesim_pnl0002"
MODEL_VERSION = "0.6.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -1396,7 +1398,7 @@ class AmesimPnl0003(DynamicComponent):
port_1 = self._properties(self.state_1)
port_2 = self._properties(self.state_2)
pressure_difference = port_1.p - port_2.p
if isclose(port_1.p, port_2.p, rel_tol=1.0e-7, abs_tol=1.0e-9):
if isclose(port_1.p, port_2.p, rel_tol=0.0, abs_tol=1.0e-8):
return 0.0
upstream = port_1 if pressure_difference > 0.0 else port_2
magnitude = self._mass_flow_for_pressure_drop(
@@ -18,6 +18,7 @@ class _AmesimPneumaticNode(AlgebraicComponent):
balance, matching the AMESim dh2 causality.
"""
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
REFERENCE_PORT = "port_2"
def __init__(self, name: str) -> None:
@@ -122,6 +123,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
MODEL_TYPE = "amesim_pn3node2"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -158,6 +160,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
MODEL_TYPE = "amesim_p4node2"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -28,6 +28,7 @@ class AmesimPnrp17(AlgebraicComponent):
MODEL_TYPE = "amesim_pnrp17"
MODEL_VERSION = "0.1.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.mechanical_translational("port_2"),
@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import lru_cache
from typing import ClassVar
from app.simulation.core.errors import RecoverableTrialStateError
@@ -13,6 +12,7 @@ from app.simulation.core.medium import (
)
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.performance import profile_property, record_property_iterations
from app.simulation.property_cache import cache_property_calculation
@dataclass(frozen=True)
@@ -71,6 +71,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
return self.cv
@profile_property("density")
@cache_property_calculation("density")
def density(self, p: float, T: float) -> float:
return self.fluid.density(p, T)
@@ -137,6 +138,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
return factor, exponent
@profile_property("isentropic_density_pressure_factor")
@cache_property_calculation("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor(
self,
p: float,
@@ -205,8 +207,8 @@ 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)
@profile_property("temperature_from_pressure_enthalpy")
@cache_property_calculation("temperature_from_pressure_enthalpy")
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
temperature = max(self.temperature_from_enthalpy(h), 2.2)
for _iteration in range(16):
@@ -240,8 +242,8 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
)
return self.temperature_from_internal_energy(U / m)
@profile_property("properties_from_mU", track_cache=True)
@lru_cache(maxsize=8192)
@profile_property("properties_from_mU")
@cache_property_calculation("properties_from_mU")
def properties_from_mU(
self,
m: float,
@@ -16,6 +16,7 @@ class Orifice(AlgebraicComponent):
MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -16,6 +16,7 @@ class ResistivePipe(AlgebraicComponent):
MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -14,6 +14,7 @@ class Tee(AlgebraicComponent):
MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
+7
View File
@@ -21,6 +21,13 @@ if TYPE_CHECKING:
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
# ``True`` means that pressure/flow residuals read values written by
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
# ``False`` is an explicit promise that those residuals are independent of
# stream propagation. ``None`` keeps custom components conservative: when
# they override either stream hook, the closure planner retains the legacy
# full-network thermofluid fixed point.
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
+24
View File
@@ -654,6 +654,29 @@ def record_property_iterations(
)
def record_property_cache(operation: str, *, hit: bool) -> None:
"""Record one run-local property-cache lookup in audit mode."""
trace = _CURRENT_TRACE.get()
if trace is None or trace.mode != "audit":
return
for frame in reversed(_ACTIVE_SPANS.get()):
if (
frame.property_key is not None
and frame.property_operation == operation
):
layer, medium, _unused_operation = frame.property_key.split("|", 2)
trace._record_cache(
frame.property_key,
operation=operation,
layer=layer,
medium=medium,
hits=1 if hit else 0,
misses=0 if hit else 1,
)
return
__all__ = [
"PROFILE_MODE",
"PerformanceTrace",
@@ -661,5 +684,6 @@ __all__ = [
"profile_phase",
"profile_property",
"profile_run",
"record_property_cache",
"record_property_iterations",
]
+234
View File
@@ -0,0 +1,234 @@
"""Run-local, exact-key cache for expensive thermodynamic calculations.
The cache is deliberately bound to one simulation through ``ContextVar``.
That keeps concurrent runs isolated and releases all cached states when the
run finishes. Keys use the original Python values with no rounding or
tolerance-based reuse that could flatten numerical residuals seen by ODE and
nonlinear solvers.
"""
from __future__ import annotations
from collections.abc import Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from functools import lru_cache, wraps
import os
from typing import ParamSpec, TypeVar
from app.simulation.performance import PROFILE_MODE, record_property_cache
_P = ParamSpec("_P")
_R = TypeVar("_R")
DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192
def _read_cache_enabled() -> bool:
raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower()
if raw_value in {"", "1", "true", "yes", "on"}:
return True
if raw_value in {"0", "false", "no", "off"}:
return False
raise ValueError(
"SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0."
)
PROPERTY_CACHE_ENABLED = _read_cache_enabled()
@dataclass(frozen=True)
class PropertyCacheInfo:
hits: int
misses: int
max_entries_per_cache: int
cache_count: int
current_entries: int
evictions: int
class SimulationPropertyCache:
"""Bounded C-level LRUs owned by one simulation run."""
def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None:
if max_entries <= 0:
raise ValueError("Property cache max_entries must be positive.")
self.max_entries_per_cache = int(max_entries)
self._functions: dict[
tuple[str, int, Callable[..., object]],
Callable[..., object],
] = {}
self._failed_misses: dict[
tuple[str, int, Callable[..., object]],
int,
] = {}
self._owners: dict[int, tuple[object, int]] = {}
self._next_owner_token = 0
def owner_token(self, owner: object) -> int:
"""Return a stable identity token and retain its owner for this run."""
identity = id(owner)
existing = self._owners.get(identity)
if existing is not None and existing[0] is owner:
return existing[1]
self._next_owner_token += 1
self._owners[identity] = (owner, self._next_owner_token)
return self._next_owner_token
def get_or_compute(
self,
operation: str,
owner: object,
function: Callable[..., _R],
args: tuple[object, ...],
kwargs: dict[str, object],
) -> _R:
cache_key = (operation, self.owner_token(owner), function)
cached_function = self._functions.get(cache_key)
if cached_function is None:
@lru_cache(maxsize=self.max_entries_per_cache, typed=True)
def invoke(*cached_args: object, **cached_kwargs: object) -> _R:
return function(owner, *cached_args, **cached_kwargs)
cached_function = invoke
self._functions[cache_key] = cached_function
if PROFILE_MODE != "audit":
try:
return cached_function(*args, **kwargs)
except Exception:
self._failed_misses[cache_key] = (
self._failed_misses.get(cache_key, 0) + 1
)
raise
before = cached_function.cache_info() # type: ignore[attr-defined]
try:
value = cached_function(*args, **kwargs)
except Exception:
self._failed_misses[cache_key] = (
self._failed_misses.get(cache_key, 0) + 1
)
raise
finally:
after = cached_function.cache_info() # type: ignore[attr-defined]
hit = after.hits > before.hits
record_property_cache(operation, hit=hit)
return value
def info(self) -> PropertyCacheInfo:
cache_infos = {
key: cached.cache_info() # type: ignore[attr-defined]
for key, cached in self._functions.items()
}
return PropertyCacheInfo(
hits=sum(info.hits for info in cache_infos.values()),
misses=sum(info.misses for info in cache_infos.values()),
max_entries_per_cache=self.max_entries_per_cache,
cache_count=len(self._functions),
current_entries=sum(info.currsize for info in cache_infos.values()),
evictions=sum(
max(
0,
info.misses
- self._failed_misses.get(key, 0)
- info.currsize,
)
for key, info in cache_infos.items()
),
)
_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar(
"simulation_property_cache",
default=None,
)
def current_property_cache() -> SimulationPropertyCache | None:
return _CURRENT_PROPERTY_CACHE.get()
@contextmanager
def property_cache_run(
*,
max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES,
) -> Generator[SimulationPropertyCache | None, None, None]:
"""Bind a fresh cache to one top-level simulation run.
Nested uses reuse the existing cache so lower-level simulation helpers can
safely opt in without replacing the cache created by the API entry point.
"""
existing = _CURRENT_PROPERTY_CACHE.get()
if existing is not None:
yield existing
return
if not PROPERTY_CACHE_ENABLED:
yield None
return
cache = SimulationPropertyCache(max_entries=max_entries)
token = _CURRENT_PROPERTY_CACHE.set(cache)
try:
yield cache
finally:
_CURRENT_PROPERTY_CACHE.reset(token)
def cache_property_calculation(
operation: str,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Cache one pure property calculation with hashable arguments per run."""
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not PROPERTY_CACHE_ENABLED:
return function
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
cache = _CURRENT_PROPERTY_CACHE.get()
if cache is None:
return function(*args, **kwargs)
owner = args[0] if args else function
return cache.get_or_compute(
operation,
owner,
function,
tuple(args[1:] if args else ()),
dict(kwargs),
)
return wrapper
return decorate
def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]:
"""Ensure a simulation entry point has a run-local cache."""
if not PROPERTY_CACHE_ENABLED:
return function
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
with property_cache_run():
return function(*args, **kwargs)
return wrapper
__all__ = [
"DEFAULT_PROPERTY_CACHE_MAX_ENTRIES",
"PROPERTY_CACHE_ENABLED",
"PropertyCacheInfo",
"SimulationPropertyCache",
"cache_property_calculation",
"current_property_cache",
"property_cache_run",
"with_property_cache",
]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+45 -19
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from app.simulation.core.base import Component
from app.simulation.core.ports import PortState
from app.simulation.performance import profile_phase
from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -19,35 +21,62 @@ class PneumaticVolumeDiagnostics:
}
@dataclass(frozen=True)
class _PneumaticVolumeConnectionBinding:
connected_endpoint: Endpoint
connected_port: PortState
class PneumaticVolumeResolver:
"""Propagate AMESim pneumatic external-volume connector variables."""
def __init__(self, network: SimulationNetwork) -> None:
self.network = network
self._pneumatic_ports = tuple(
component.get_port(definition.name)
for component in network.components.values()
for definition in component.active_port_definitions
if definition.kind == "physical" and definition.domain == "pneumatic"
)
self._output_components = tuple(
component
for component in network.components.values()
if type(component).pneumatic_volume_outputs
is not Component.pneumatic_volume_outputs
)
self._connected_endpoint = self._build_connection_map()
self.last_diagnostics: PneumaticVolumeDiagnostics | None = None
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
result: dict[Endpoint, Endpoint] = {}
def _build_connection_map(
self,
) -> dict[Endpoint, _PneumaticVolumeConnectionBinding]:
result: dict[Endpoint, _PneumaticVolumeConnectionBinding] = {}
for connection in self.network.connections:
if connection.kind != "physical" or connection.domain != "pneumatic":
continue
first, second = connection.endpoints
result[first] = second
result[second] = first
result[first] = _PneumaticVolumeConnectionBinding(
connected_endpoint=second,
connected_port=self.network.components[second.component].get_port(
second.port
),
)
result[second] = _PneumaticVolumeConnectionBinding(
connected_endpoint=first,
connected_port=self.network.components[first.component].get_port(
first.port
),
)
return result
@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:
if definition.kind == "physical" and definition.domain == "pneumatic":
port = component.get_port(definition.name)
port.volume = 0.0
port.volume_flow = 0.0
for port in self._pneumatic_ports:
port.volume = 0.0
port.volume_flow = 0.0
outputs: dict[Endpoint, tuple[float, float]] = {}
for component in self.network.components.values():
for component in self._output_components:
for port_name, raw_values in component.pneumatic_volume_outputs().items():
port = component.get_port(port_name)
definition = port.definition
@@ -73,18 +102,15 @@ class PneumaticVolumeResolver:
propagated = 0
for endpoint, values in outputs.items():
connected = self._connected_endpoint.get(endpoint)
if connected is None:
binding = self._connected_endpoint.get(endpoint)
if binding is None:
continue
if connected in outputs:
if binding.connected_endpoint in outputs:
raise ValueError(
"A pneumatic connection cannot contain two external-volume "
f"sources: {endpoint} and {connected}."
f"sources: {endpoint} and {binding.connected_endpoint}."
)
connected_port = self.network.components[connected.component].get_port(
connected.port
)
connected_port.volume, connected_port.volume_flow = values
binding.connected_port.volume, binding.connected_port.volume_flow = values
propagated += 1
diagnostics = PneumaticVolumeDiagnostics(
+54 -20
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Protocol
from typing import Callable, Protocol
from app.simulation.core.base import Component
from app.simulation.core.ports import PortState
from app.simulation.performance import profile_phase
from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -38,31 +40,56 @@ class SignalSolveDiagnostics:
return {"propagated": self.propagated}
@dataclass(frozen=True)
class _SignalOutputBinding:
component: Component
evaluate: Callable[[float], dict[str, float]]
@dataclass(frozen=True)
class _SignalConnectionBinding:
source: PortState
target: PortState
class SignalResolver:
"""Propagate scalar signal connections from output ports to input ports."""
def __init__(self, network: SimulationNetwork) -> None:
self.network = network
self._connections = [
connection for connection in network.connections if connection.kind == "signal"
]
self._output_bindings = tuple(
_SignalOutputBinding(component=component, evaluate=evaluate)
for component in network.components.values()
if (evaluate := getattr(component, "signal_output_values", None)) is not None
)
self._event_sources = tuple(
(component.name, source_event_times)
for component in network.components.values()
if (
source_event_times := getattr(
component,
"signal_event_times",
None,
)
)
is not None
)
self._connections = tuple(
self._connection_binding(connection.endpoints)
for connection in network.connections
if connection.kind == "signal"
)
self.last_diagnostics: SignalSolveDiagnostics | None = None
@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)
if signal_output_values is None:
continue
for port_name, value in signal_output_values(time).items():
component.get_port(port_name).signal = float(value)
for binding in self._output_bindings:
for port_name, value in binding.evaluate(time).items():
binding.component.get_port(port_name).signal = float(value)
propagated = 0
for connection in self._connections:
source, target = self._source_target(connection.endpoints)
source_port = self.network.components[source.component].get_port(source.port)
target_port = self.network.components[target.component].get_port(target.port)
target_port.signal = source_port.signal
for binding in self._connections:
binding.target.signal = binding.source.signal
propagated += 1
diagnostics = SignalSolveDiagnostics(propagated=propagated)
@@ -86,15 +113,12 @@ class SignalResolver:
return ()
events: set[float] = set()
for component in self.network.components.values():
source_event_times = getattr(component, "signal_event_times", None)
if source_event_times is None:
continue
for component_name, source_event_times in self._event_sources:
for raw_time in source_event_times(start, stop):
event_time = float(raw_time)
if not isfinite(event_time):
raise ValueError(
f"Signal event time from component '{component.name}' must be finite."
f"Signal event time from component '{component_name}' must be finite."
)
if start < event_time < stop:
events.add(event_time)
@@ -109,3 +133,13 @@ class SignalResolver:
if second_port.definition is not None and second_port.definition.nominal_role == "output":
return second, first
raise ValueError("Signal connection must contain one output endpoint.")
def _connection_binding(
self,
endpoints: tuple[Endpoint, Endpoint],
) -> _SignalConnectionBinding:
source, target = self._source_target(endpoints)
return _SignalConnectionBinding(
source=self.network.components[source.component].get_port(source.port),
target=self.network.components[target.component].get_port(target.port),
)
+6 -2
View File
@@ -807,9 +807,13 @@ def _integrate_scipy_stepwise(
segment_accepted_steps += 1
step_end_time = float(solver.t)
step_end_state = [float(value) for value in solver.y]
crosses_sample = (
sample_index < len(sample_times)
and sample_times[sample_index] <= step_end_time
)
dense_output = (
solver.dense_output()
if sample_times or state_transition_handler is not None
if crosses_sample or state_transition_handler is not None
else None
)
@@ -918,11 +922,11 @@ def _integrate_scipy_stepwise(
else last_accepted_time
)
if sample_times:
assert dense_output is not None
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= last_accepted_time
):
assert dense_output is not None
sample_time = float(sample_times[sample_index])
sample_state = [
float(value) for value in dense_output(sample_time)
+74 -45
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import DynamicComponent
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.ports import PortState
from app.simulation.performance import profile_phase
from app.simulation.systems.network import Endpoint, SimulationNetwork
from app.simulation.systems.network import SimulationNetwork
class StreamSolveError(RuntimeError):
@@ -27,6 +28,14 @@ class StreamSolveDiagnostics:
}
@dataclass(frozen=True)
class _StreamConnectionBinding:
component_name: str
port_name: str
connected_component: Component
connected_port: PortState
class StreamResolver:
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
@@ -40,28 +49,59 @@ class StreamResolver:
self.network = network
self.relative_tolerance = relative_tolerance
self.max_iterations = max_iterations
self._connected_endpoint = self._build_connection_map()
self._components = tuple(network.components.values())
self._dynamic_components = tuple(
component
for component in self._components
if isinstance(component, DynamicComponent)
)
self._non_dynamic_components = tuple(
component
for component in self._components
if not isinstance(component, DynamicComponent)
)
self._ports = tuple(
(component.name, port_name, port)
for component in self._components
for port_name, port in component.ports.items()
)
self._connection_bindings = self._build_connection_bindings()
self.last_diagnostics: StreamSolveDiagnostics | None = None
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
result: dict[Endpoint, Endpoint] = {}
def _build_connection_bindings(self) -> tuple[_StreamConnectionBinding, ...]:
result: list[_StreamConnectionBinding] = []
for connection in self.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
result[first] = second
result[second] = first
return result
first_component = self.network.components[first.component]
second_component = self.network.components[second.component]
result.append(
_StreamConnectionBinding(
component_name=first.component,
port_name=first.port,
connected_component=second_component,
connected_port=second_component.get_port(second.port),
)
)
result.append(
_StreamConnectionBinding(
component_name=second.component,
port_name=second.port,
connected_component=first_component,
connected_port=first_component.get_port(first.port),
)
)
return tuple(result)
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
values: dict[str, dict[str, float]] = {
component.name: {} for component in self.network.components.values()
component.name: {} for component in self._components
}
for endpoint, connected in self._connected_endpoint.items():
connected_port = self.network.components[connected.component].get_port(
connected.port
for binding in self._connection_bindings:
values[binding.component_name][binding.port_name] = (
binding.connected_port.h_outflow
)
values[endpoint.component][endpoint.port] = connected_port.h_outflow
return values
def connected_temperature_reference_enthalpies(
@@ -70,26 +110,21 @@ class StreamResolver:
"""Return connector references used for upstream temperature only."""
values: dict[str, dict[str, float]] = {
component.name: {} for component in self.network.components.values()
component.name: {} for component in self._components
}
for endpoint, connected in self._connected_endpoint.items():
connected_component = self.network.components[connected.component]
connected_port = connected_component.get_port(connected.port)
values[endpoint.component][endpoint.port] = float(
for binding in self._connection_bindings:
values[binding.component_name][binding.port_name] = float(
getattr(
connected_component,
binding.connected_component,
"temperature_reference_h",
connected_port.h_outflow,
binding.connected_port.h_outflow,
)
)
return values
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(
self,
components: list[DynamicComponent],
) -> None:
for component in components:
def _refresh_dynamic_components(self) -> None:
for component in self._dynamic_components:
component.refresh_thermodynamic_ports()
@profile_phase("simulation.refresh", minimum_mode="audit")
@@ -97,40 +132,34 @@ class StreamResolver:
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])
for component in self._non_dynamic_components:
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)
]
self._refresh_dynamic_components(dynamic_components)
def solve(
self,
*,
dynamic_ports_are_current: bool = False,
) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
if not dynamic_ports_are_current:
self._refresh_dynamic_components()
max_delta = 0.0
for iteration in range(1, self.max_iterations + 1):
previous = {
(component.name, port_name): port.h_outflow
for component in self.network.components.values()
for port_name, port in component.ports.items()
(component_name, port_name): port.h_outflow
for component_name, port_name, port in self._ports
}
connected = self.connected_enthalpies()
self._refresh_stream_components(connected)
deltas = [
abs(port.h_outflow - previous[(component.name, port_name)])
for component in self.network.components.values()
for port_name, port in component.ports.items()
abs(port.h_outflow - previous[(component_name, port_name)])
for component_name, port_name, port in self._ports
]
magnitudes = [
abs(port.h_outflow)
for component in self.network.components.values()
for port in component.ports.values()
for _component_name, _port_name, port in self._ports
]
max_delta = max(deltas, default=0.0)
scale = max(magnitudes + [1.0])
+576 -22
View File
@@ -5,10 +5,13 @@ from dataclasses import dataclass, replace
from math import floor, isfinite
from typing import Literal
from app.simulation.core.base import DynamicComponent
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.core.ports import PortState
from app.simulation.performance import performance_span, profile_phase
from app.simulation.property_cache import with_property_cache
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver
from app.simulation.solvers.mechanical import (
MechanicalConstraintGroup,
MechanicalStateReducer,
@@ -29,6 +32,25 @@ SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@dataclass(frozen=True)
class _ThermofluidClosurePlan:
"""Static execution data for one compiled network.
The first pressure-flow solve remains global. Later fixed-point passes only
need the physical islands whose constitutive equations read stream-derived
enthalpy. An unclassified custom stream component deliberately falls back
to the original global solve.
"""
physical_ports: tuple[PortState, ...]
global_component_group: tuple[str, ...]
secondary_pressure_solvers: tuple[PressureFlowSolver, ...]
secondary_component_groups: tuple[tuple[str, ...], ...]
uses_conservative_global_solver: bool
conservative_fallback_reason: str | None
secondary_block_solvers: tuple[StreamPressureBlockSolver, ...] = ()
@dataclass(frozen=True)
class SimulationPreparationIssue:
code: str
@@ -357,15 +379,271 @@ class GenericFluidSystem:
self.pneumatic_volume_resolver = PneumaticVolumeResolver(network)
self.signal_resolver = SignalResolver(network)
self.stream_resolver = StreamResolver(network)
self._thermofluid_closure_plan = self._build_thermofluid_closure_plan()
self.algebraic_solve_count = 0
self.algebraic_seeded_solve_count = 0
self.algebraic_nonlinear_solve_count = 0
self.algebraic_optimizer_evaluation_count = 0
self.algebraic_residual_evaluation_count = 0
self.algebraic_block_fallback_count = 0
self.algebraic_dense_fallback_count = 0
self.thermofluid_pressure_pass_count = 0
self.max_algebraic_residual = 0.0
self.max_algebraic_evaluations = 0
self.max_algebraic_residual_evaluations = 0
self._last_algebraic_diagnostics = None
self._last_algebraic_scope: tuple[str, ...] = ()
self.max_stream_iterations = 0
self.max_thermofluid_iterations = 0
self.signal_propagation_count = 0
self.pneumatic_volume_propagation_count = 0
self._jacobian_sparsity = None
def _request_causal_residual_audit(self) -> None:
"""Make topology or mode boundaries verify the next causal closure."""
self.pressure_flow_solver.request_causal_audit()
for block_solver in (
self._thermofluid_closure_plan.secondary_block_solvers
):
block_solver.request_causal_audit()
@staticmethod
def _overrides_stream_update(component: Component) -> bool:
component_type = type(component)
return (
component_type.update_stream_outflows
is not Component.update_stream_outflows
or component_type.update_flow_temperature_references
is not Component.update_flow_temperature_references
)
def _physical_component_groups(self) -> tuple[tuple[str, ...], ...]:
"""Return physical islands in component insertion order."""
physical_names = tuple(
component.name
for component in self.network.components.values()
if any(
definition.kind == "physical"
for definition in component.active_port_definitions
)
)
adjacency = {name: set() for name in physical_names}
for connection in self.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
adjacency[first.component].add(second.component)
adjacency[second.component].add(first.component)
groups: list[tuple[str, ...]] = []
visited: set[str] = set()
for root in physical_names:
if root in visited:
continue
members = {root}
pending = [root]
visited.add(root)
while pending:
current = pending.pop()
for neighbor in adjacency[current]:
if neighbor in visited:
continue
visited.add(neighbor)
members.add(neighbor)
pending.append(neighbor)
groups.append(tuple(name for name in physical_names if name in members))
return tuple(groups)
def _network_for_component_group(
self,
component_names: tuple[str, ...],
all_physical_names: frozenset[str],
) -> SimulationNetwork:
if frozenset(component_names) == all_physical_names:
return self.network
selected = frozenset(component_names)
subnetwork = SimulationNetwork(
name=f"{self.network.name}:thermofluid:{len(component_names)}"
)
for component in self.network.components.values():
if component.name in selected:
subnetwork.add_component(component)
subnetwork.connections.extend(
connection
for connection in self.network.connections
if connection.kind == "physical"
and connection.endpoint_a.component in selected
and connection.endpoint_b.component in selected
)
return subnetwork
def _pressure_solver_for_component_group(
self,
component_names: tuple[str, ...],
all_physical_names: frozenset[str],
) -> PressureFlowSolver:
subnetwork = self._network_for_component_group(
component_names,
all_physical_names,
)
if subnetwork is self.network:
return self.pressure_flow_solver
return PressureFlowSolver(
subnetwork,
residual_tolerance=self.pressure_flow_solver.residual_tolerance,
max_evaluations=self.pressure_flow_solver.max_evaluations,
scope_kind="physicalIsland",
)
def _build_thermofluid_closure_plan(self) -> _ThermofluidClosurePlan:
physical_ports = tuple(
component.get_port(definition.name)
for component in self.network.components.values()
for definition in component.active_port_definitions
if definition.kind == "physical"
)
physical_groups = self._physical_component_groups()
all_physical_names = frozenset(
name for group in physical_groups for name in group
)
all_physical_order = tuple(
name for group in physical_groups for name in group
)
sensitive_names: set[str] = set()
has_unclassified_stream_component = False
has_invalid_dependency_declaration = False
for name in all_physical_names:
component = self.network.components[name]
# Only an exact-class declaration opts into pruning. A custom
# subclass cannot accidentally inherit a purity promise after
# changing its stream hook or constitutive equations.
declared = type(component).__dict__.get(
"PRESSURE_FLOW_DEPENDS_ON_STREAM"
)
if declared is True:
sensitive_names.add(name)
elif declared is False:
continue
elif declared is None and self._overrides_stream_update(component):
# Preserve the exact legacy behavior for custom components that
# receive stream values but have not declared equation purity.
has_unclassified_stream_component = True
elif declared is not None:
has_invalid_dependency_declaration = True
if has_invalid_dependency_declaration:
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="invalidDependencyDeclaration",
)
if has_unclassified_stream_component:
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="unclassifiedStreamComponent",
)
component_names = set(self.network.components)
compiled_equations = self.pressure_flow_solver.equation_templates
for equation in compiled_equations:
if equation.owner != "component":
continue
referenced_components = {
parts[0]
for variable in equation.variables
if len(parts := variable.rsplit(".", 2)) == 3
and parts[0] in component_names
}
if referenced_components - {equation.owner_id}:
# Catalog equations are component-local and connectors carry
# cross-component constraints. A custom residual may violate
# that convention, so retain the unsplit global problem.
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="crossComponentEquation",
)
for group in physical_groups:
selected = frozenset(group)
connection_ids = {
connection.id
for connection in self.network.connections
if connection.kind == "physical"
and connection.endpoint_a.component in selected
and connection.endpoint_b.component in selected
}
unknown_count = sum(
unknown.component in selected
for unknown in self.pressure_flow_solver.unknowns
)
equation_count = sum(
(
equation.owner == "component"
and equation.owner_id in selected
)
or (
equation.owner == "connection"
and equation.owner_id in connection_ids
)
for equation in compiled_equations
)
if unknown_count != equation_count:
# The full network can be square even when two disconnected
# rectangular islands happen to cancel each other's equation
# count. Preserve the original global least-squares problem in
# that unusual case rather than changing its solution space.
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="nonSquarePhysicalIsland",
)
coupled_groups = tuple(
group for group in physical_groups if sensitive_names.intersection(group)
)
secondary_pressure_solvers = tuple(
self._pressure_solver_for_component_group(group, all_physical_names)
for group in coupled_groups
)
secondary_block_solvers = tuple(
StreamPressureBlockSolver(
pressure_solver,
tuple(name for name in group if name in sensitive_names),
)
for pressure_solver, group in zip(
secondary_pressure_solvers,
coupled_groups,
)
)
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=secondary_pressure_solvers,
secondary_component_groups=coupled_groups,
uses_conservative_global_solver=False,
conservative_fallback_reason=None,
secondary_block_solvers=secondary_block_solvers,
)
def initial_state_vector(self) -> list[float]:
return self.pneumatic_storage_reducer.synchronize_state_vector(
self.mechanical_state_reducer.initial_state_vector(),
@@ -377,6 +655,129 @@ class GenericFluidSystem:
self.pneumatic_storage_reducer.synchronize_state_vector(values)
)
@staticmethod
def _entry_has_pneumatic_state(entry: object) -> bool:
"""Return whether one reduced ODE entry owns pneumatic state.
Mechanical constraint groups are synthetic state owners. Every other
entry is a dynamic component, so its active port metadata is the
topology-level way to classify it without depending on model names.
"""
if isinstance(entry, MechanicalConstraintGroup):
return False
return any(
definition.kind == "physical" and definition.domain == "pneumatic"
for definition in entry.active_port_definitions
)
def _add_pneumatic_volume_state_dependencies(
self,
dependencies: list[set[int]],
entries: tuple[object, ...],
owner_by_component: dict[str, int],
) -> None:
"""Close the cross-domain dependency hidden by external volume ports.
A pneumatic-volume source such as a piston writes swept volume from
mechanical coordinates into a connected storage component before the
pressure-flow closure. The ordinary physical-path walk intentionally
stops at a storage state. Consequently, a second storage connected to
that chamber can depend on the piston even though the path crosses the
chamber state, and that derivative was previously omitted from the BDF
sparsity pattern.
Reuse the resolver's compiled output/connection plan to locate each
receiving storage. Mechanical states already found from that receiver,
the volume source's own ODE state (when it has one), and pneumatic states
whose local closure reaches the receiver form one conservative
cross-domain dependency set. Add it in both directions. If executable
custom/source metadata cannot bound those drivers, use a dense pattern.
"""
resolver = self.pneumatic_volume_resolver
pneumatic_entries = tuple(
self._entry_has_pneumatic_state(entry) for entry in entries
)
all_entry_indexes = set(range(len(entries)))
def use_conservative_dense_pattern() -> None:
for entry_dependencies in dependencies:
entry_dependencies.update(all_entry_indexes)
for component in resolver._output_components:
# ``pneumatic_volume_outputs`` is executable code rather than an
# equation-level dependency declaration. Catalog components with
# no directed signal input can be bounded by their own ODE state
# and the mechanical states already connected through topology.
# Custom/output components with an external signal driver keep the
# implicit integrator safe by disabling sparsity for this system.
if (
not type(component).__module__.startswith(
"app.simulation.components."
)
or any(
(
definition.kind == "signal"
and definition.nominal_role == "input"
)
or (
definition.kind == "physical"
and definition.domain
not in {"pneumatic", "mechanical"}
)
for definition in component.active_port_definitions
)
):
use_conservative_dense_pattern()
return
source_index = owner_by_component.get(component.name)
receiver_indexes: set[int] = set()
for definition in component.active_port_definitions:
if (
definition.kind != "physical"
or definition.domain != "pneumatic"
):
continue
binding = resolver._connected_endpoint.get(
Endpoint(component.name, definition.name)
)
if binding is None:
continue
receiver_index = owner_by_component.get(
binding.connected_endpoint.component
)
if receiver_index is not None and pneumatic_entries[receiver_index]:
receiver_indexes.add(receiver_index)
for receiver_index in receiver_indexes:
driver_indexes = {
entry_index
for entry_index in dependencies[receiver_index]
if isinstance(entries[entry_index], MechanicalConstraintGroup)
}
if source_index is not None:
driver_indexes.add(source_index)
if not driver_indexes:
use_conservative_dense_pattern()
return
coupled_pneumatic_indexes = {
entry_index
for entry_index, is_pneumatic in enumerate(pneumatic_entries)
if is_pneumatic
and (
entry_index == receiver_index
or receiver_index in dependencies[entry_index]
)
}
for pneumatic_index in coupled_pneumatic_indexes:
dependencies[pneumatic_index].update(driver_indexes)
for driver_index in driver_indexes:
dependencies[driver_index].update(
coupled_pneumatic_indexes
)
def _build_jacobian_sparsity(self):
"""Build a conservative state dependency graph for implicit solvers.
@@ -431,6 +832,12 @@ class GenericFluidSystem:
pending.append(neighbour)
dependencies.append(found)
self._add_pneumatic_volume_state_dependencies(
dependencies,
entries,
owner_by_component,
)
offsets = [0]
for state_size in entry_sizes:
offsets.append(offsets[-1] + state_size)
@@ -479,10 +886,12 @@ class GenericFluidSystem:
pneumatic_volume = self.pneumatic_volume_resolver.solve()
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
self._refresh_dynamic_components()
algebraic = self.pressure_flow_solver.solve(
initial_algebraic = self.pressure_flow_solver.solve(
effort_variables=("p",),
)
algebraic_diagnostics = [initial_algebraic]
pressure_flow_solve_count = 1
self.thermofluid_pressure_pass_count += 1
# Some constitutive flow laws recover their upstream temperature from
# connected stream enthalpy, while junction stream mixing itself depends
@@ -490,19 +899,25 @@ class GenericFluidSystem:
# leaves that two-way coupling to the next RHS call, making the ODE RHS
# depend on evaluation history and corrupting finite-difference
# Jacobians. Close both layers to one fixed point inside this call.
physical_ports = tuple(
port
for component in self.network.components.values()
for definition in component.active_port_definitions
if definition.kind == "physical"
for port in (component.get_port(definition.name),)
)
# The compiled closure plan keeps custom stream-aware components on the
# legacy global path. For catalog models, only stream-sensitive physical
# islands are revisited; independent islands keep the first solve.
closure_plan = self._thermofluid_closure_plan
self._last_algebraic_diagnostics = initial_algebraic
self._last_algebraic_scope = closure_plan.global_component_group
physical_ports = closure_plan.physical_ports
secondary_pressure_solvers = closure_plan.secondary_pressure_solvers
secondary_block_solvers = closure_plan.secondary_block_solvers
connected_h: dict[str, dict[str, float]] = {}
stream_diagnostics = []
max_coupling_iterations = 25
flow_relative_tolerance = 1.0e-12
for coupling_iteration in range(1, max_coupling_iterations + 1):
previous_flows = tuple(port.m_flow for port in physical_ports)
stream, connected_h = self.stream_resolver.solve()
stream, connected_h = self.stream_resolver.solve(
dynamic_ports_are_current=True,
)
stream_diagnostics.append(stream)
temperature_reference_h = (
self.stream_resolver.connected_temperature_reference_enthalpies()
)
@@ -511,12 +926,57 @@ class GenericFluidSystem:
component.update_flow_temperature_references(
temperature_reference_h[component.name]
)
algebraic = self.pressure_flow_solver.solve(
effort_variables=(
("p",) if pressure_flow_solve_count == 0 else ()
),
if secondary_pressure_solvers:
self.thermofluid_pressure_pass_count += 1
block_scale_context = (
self.pressure_flow_solver.scale_context()
if any(
solver is not self.pressure_flow_solver
for solver in secondary_pressure_solvers
)
else None
)
pressure_flow_solve_count += 1
if (
secondary_block_solvers
and not closure_plan.uses_conservative_global_solver
):
# Stream propagation only invalidates equations that explicitly
# consume the new enthalpy/temperature references. Re-solve the
# exact equation/unknown blocks containing those equations; the
# first global pass above remains the causalization boundary for
# mechanics, contact, and all stream-independent pneumatic blocks.
for block_solver in secondary_block_solvers:
block_result = block_solver.solve(
scale_context=block_scale_context,
)
# One public secondary closure is one logical solve. The
# block solver folds every local attempt and a possible
# accepted global fallback into this single diagnostic, so
# evaluations and blockFallbackUsed are counted exactly
# once here rather than once per internal equation block.
(algebraic,) = block_result.diagnostics
(scope,) = block_result.scopes
algebraic_diagnostics.append(algebraic)
self._last_algebraic_diagnostics = algebraic
self._last_algebraic_scope = scope
pressure_flow_solve_count += 1
else:
for pressure_solver, component_group in zip(
secondary_pressure_solvers,
closure_plan.secondary_component_groups,
):
algebraic = pressure_solver.solve(
effort_variables=(),
scale_context=(
block_scale_context
if pressure_solver is not self.pressure_flow_solver
else None
),
)
algebraic_diagnostics.append(algebraic)
self._last_algebraic_diagnostics = algebraic
self._last_algebraic_scope = component_group
pressure_flow_solve_count += 1
current_flows = tuple(port.m_flow for port in physical_ports)
flow_scale = max(
[abs(value) for value in (*previous_flows, *current_flows)] + [1.0]
@@ -528,7 +988,10 @@ class GenericFluidSystem:
),
default=0.0,
)
if max_flow_delta <= flow_relative_tolerance * flow_scale:
if (
not secondary_pressure_solvers
or max_flow_delta <= flow_relative_tolerance * flow_scale
):
break
else:
raise ThermofluidClosureError(
@@ -541,17 +1004,40 @@ class GenericFluidSystem:
)
self.mechanical_state_reducer.update_constraint_accelerations()
self.algebraic_solve_count += pressure_flow_solve_count
seeded_count = sum(
item.jacobian_mode == "seeded" for item in algebraic_diagnostics
)
self.algebraic_seeded_solve_count += seeded_count
self.algebraic_nonlinear_solve_count += (
len(algebraic_diagnostics) - seeded_count
)
self.algebraic_optimizer_evaluation_count += sum(
item.evaluations for item in algebraic_diagnostics
)
self.algebraic_residual_evaluation_count += sum(
item.residual_evaluations for item in algebraic_diagnostics
)
self.algebraic_block_fallback_count += sum(
item.block_fallback_used for item in algebraic_diagnostics
)
self.algebraic_dense_fallback_count += sum(
item.dense_fallback_used for item in algebraic_diagnostics
)
self.max_algebraic_residual = max(
self.max_algebraic_residual,
algebraic.max_scaled_residual,
*(item.max_scaled_residual for item in algebraic_diagnostics),
)
self.max_algebraic_evaluations = max(
self.max_algebraic_evaluations,
algebraic.evaluations,
*(item.evaluations for item in algebraic_diagnostics),
)
self.max_algebraic_residual_evaluations = max(
self.max_algebraic_residual_evaluations,
*(item.residual_evaluations for item in algebraic_diagnostics),
)
self.max_stream_iterations = max(
self.max_stream_iterations,
stream.iterations,
*(item.iterations for item in stream_diagnostics),
)
return connected_h
@@ -588,6 +1074,7 @@ class GenericFluidSystem:
f"{component.name}.{relative_key}", []
).append(value)
@with_property_cache
def simulate(
self,
config: SolveIVPConfig,
@@ -645,6 +1132,7 @@ class GenericFluidSystem:
report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start
furthest_solver_time = config.t_start
next_signal_audit_index = 0
def report_solver_time(time: float) -> None:
nonlocal furthest_solver_time
@@ -657,10 +1145,23 @@ class GenericFluidSystem:
report_progress(time_fraction, "integrating")
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
nonlocal next_signal_audit_index
while (
next_signal_audit_index < len(signal_event_times)
and float(time) >= signal_event_times[next_signal_audit_index]
):
self._request_causal_residual_audit()
next_signal_audit_index += 1
if cancel_check is None:
report_solver_time(time)
return self.rhs(time, state_vector)
def handle_state_transition(*args):
transition = self.mechanical_state_reducer.state_transition(*args)
if transition is not None:
self._request_causal_residual_audit()
return transition
solution = integrate_ode(
rhs=monitored_rhs,
initial_state=initial_state,
@@ -672,7 +1173,7 @@ class GenericFluidSystem:
),
breakpoints=signal_event_times,
state_transition_handler=(
self.mechanical_state_reducer.state_transition
handle_state_transition
if self.mechanical_state_reducer.has_state_events
else None
),
@@ -791,13 +1292,66 @@ class GenericFluidSystem:
},
"pressureFlow": {
"solveCount": self.algebraic_solve_count,
"seededSolveCount": self.algebraic_seeded_solve_count,
"nonlinearSolveCount": self.algebraic_nonlinear_solve_count,
"fastPathHitRate": (
self.algebraic_seeded_solve_count
/ self.algebraic_solve_count
if self.algebraic_solve_count
else 0.0
),
"optimizerEvaluationCount": (
self.algebraic_optimizer_evaluation_count
),
"residualEvaluationCount": (
self.algebraic_residual_evaluation_count
),
"blockFallbackCount": self.algebraic_block_fallback_count,
"denseFallbackCount": self.algebraic_dense_fallback_count,
"closurePassCount": self.thermofluid_pressure_pass_count,
"secondaryPhysicalIslandCount": len(
self._thermofluid_closure_plan.secondary_pressure_solvers
),
"secondaryBlockCount": sum(
len(solver.blocks)
for solver in self._thermofluid_closure_plan.secondary_block_solvers
if solver.available
),
"secondaryUnknownCount": sum(
len(block.unknowns)
for solver in self._thermofluid_closure_plan.secondary_block_solvers
if solver.available
for block in solver.blocks
),
"equationBlockFallbackReasons": [
solver.fallback_reason
for solver in self._thermofluid_closure_plan.secondary_block_solvers
if solver.fallback_reason is not None
],
"usesConservativeGlobalCoupling": (
self._thermofluid_closure_plan.uses_conservative_global_solver
),
"couplingPlanFallbackReason": (
self._thermofluid_closure_plan.conservative_fallback_reason
),
"maxScaledResidual": self.max_algebraic_residual,
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
"maxResidualEvaluationsPerSolve": (
self.max_algebraic_residual_evaluations
),
"lastScope": list(self._last_algebraic_scope),
"last": (
self.pressure_flow_solver.last_diagnostics.as_dict()
if self.pressure_flow_solver.last_diagnostics is not None
self._last_algebraic_diagnostics.as_dict()
if self._last_algebraic_diagnostics is not None
else None
),
"causalExecution": (
self.pressure_flow_solver.causal_execution_diagnostics()
),
"secondaryCausalExecution": [
solver.causal_execution_diagnostics()
for solver in self._thermofluid_closure_plan.secondary_block_solvers
],
},
"stream": {
"maxIterationsPerSolve": self.max_stream_iterations,
+161
View File
@@ -0,0 +1,161 @@
"""Process-local warm-up for the numerical simulation runtime."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import logging
import os
from threading import Lock
from time import perf_counter
from typing import Literal
LOGGER = logging.getLogger(__name__)
WarmupStatus = Literal["completed", "failed", "disabled"]
@dataclass(frozen=True)
class SimulationWarmupReport:
status: WarmupStatus
duration_ms: float
error: str | None = None
def as_dict(self) -> dict[str, object]:
return asdict(self)
_WARMUP_LOCK = Lock()
_WARMUP_REPORT: SimulationWarmupReport | None = None
def simulation_warmup_enabled() -> bool:
raw_value = os.getenv("SIMULATIONAPP_WARMUP", "on").strip().lower()
if raw_value in {"", "1", "true", "yes", "on"}:
return True
if raw_value in {"0", "false", "no", "off"}:
return False
raise ValueError(
"SIMULATIONAPP_WARMUP must be one of: on, off, true, false, 1, 0."
)
def _run_numerical_warmup() -> None:
"""Exercise only in-memory SciPy paths used by real simulations."""
import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau, solve_ivp
from scipy.optimize import brentq, least_squares
from scipy.optimize._numdiff import group_columns
from scipy.sparse import csc_matrix, csr_matrix
# Importing these classes is intentional even though the micro solve below
# uses BDF: the stepwise solver selects them dynamically at runtime.
solver_types = (BDF, DOP853, LSODA, RK23, RK45, Radau)
if len(solver_types) != 6:
raise RuntimeError("SciPy solver warm-up did not load every supported method.")
sparsity = csc_matrix(np.array([[1.0]], dtype=float))
groups = group_columns(sparsity)
if groups.shape != (1,):
raise RuntimeError("SciPy Jacobian grouping warm-up returned an invalid shape.")
integration = solve_ivp(
lambda _time, state: -state,
(0.0, 1.0e-4),
np.array([1.0], dtype=float),
method="BDF",
t_eval=np.array([0.0, 1.0e-4], dtype=float),
jac_sparsity=sparsity,
rtol=1.0e-6,
atol=1.0e-9,
)
if not integration.success or not np.isfinite(integration.y).all():
raise RuntimeError("SciPy integration warm-up did not complete successfully.")
algebraic_sparsity = csr_matrix(np.eye(2, dtype=bool))
algebraic = least_squares(
lambda state: np.array(
[state[0] - 1.0, state[1] - 2.0],
dtype=float,
),
np.array([0.5, 0.5], dtype=float),
bounds=(
np.array([0.0, 0.0], dtype=float),
np.array([3.0, 3.0], dtype=float),
),
jac_sparsity=algebraic_sparsity,
tr_solver="lsmr",
)
if (
not algebraic.success
or not np.isfinite(algebraic.x).all()
or not np.allclose(algebraic.x, np.array([1.0, 2.0]), atol=1.0e-8)
):
raise RuntimeError("SciPy algebraic warm-up did not complete successfully.")
root = brentq(lambda value: value - 0.5, 0.0, 1.0)
if abs(root - 0.5) > 1.0e-12:
raise RuntimeError("SciPy scalar root warm-up returned an invalid result.")
# Compile the cached v3 XSD through the same public validation path. The
# intentionally incomplete document is never accepted or persisted.
from app.system_xml import validate_system_xml_document
validate_system_xml_document(b"<System/>")
def warm_up_simulation_runtime() -> SimulationWarmupReport:
"""Warm one worker exactly once, returning a startup diagnostic report.
Ordinary warm-up failures are reported but do not prevent the editor and
non-simulation APIs from starting. ``MemoryError`` remains fatal because
continuing a worker under memory exhaustion is unsafe.
"""
global _WARMUP_REPORT
with _WARMUP_LOCK:
if _WARMUP_REPORT is not None:
return _WARMUP_REPORT
if not simulation_warmup_enabled():
_WARMUP_REPORT = SimulationWarmupReport(
status="disabled",
duration_ms=0.0,
)
return _WARMUP_REPORT
started = perf_counter()
try:
_run_numerical_warmup()
except MemoryError:
raise
except Exception as exc:
_WARMUP_REPORT = SimulationWarmupReport(
status="failed",
duration_ms=(perf_counter() - started) * 1000.0,
error=f"{type(exc).__name__}: {exc}",
)
LOGGER.exception("Simulation runtime warm-up failed; startup will continue.")
else:
_WARMUP_REPORT = SimulationWarmupReport(
status="completed",
duration_ms=(perf_counter() - started) * 1000.0,
)
LOGGER.info(
"Simulation runtime warm-up completed in %.1f ms.",
_WARMUP_REPORT.duration_ms,
)
return _WARMUP_REPORT
def _reset_simulation_warmup_for_tests() -> None:
global _WARMUP_REPORT
with _WARMUP_LOCK:
_WARMUP_REPORT = None
__all__ = [
"SimulationWarmupReport",
"simulation_warmup_enabled",
"warm_up_simulation_runtime",
]
+35
View File
@@ -111,6 +111,7 @@ app/simulation/components/experimental/junctions/tee.py
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
@@ -153,6 +154,18 @@ def create(
- `pressure_flow_equation_residuals()`。
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
实现 `update_stream_outflows()` 或 `update_flow_temperature_references()` 的公开模型,
还应在该公开类自身显式声明 `PRESSURE_FLOW_DEPENDS_ON_STREAM`:构成压力/流量方程
会读取这些 hook 写入的焓或温度引用时设为 `True`,否则设为 `False`。省略声明、
声明非法值或由自定义子类仅继承父类声明时,求解器会保守使用全网热流闭合;不要
为了获得分块加速而错误声明 `False`。
`pressure_flow_equation_residuals()` 返回的每条 `EquationResidual.variables` 必须完整
列出该残差实际读取的全部代数端口量(`p/m_flow/x/v/f`),不能只写“主要变量”。
求解器会用这份声明编译稀疏 Jacobian 和独立方程块;漏写依赖可能让有限差分方向
不完整。仓库内置组件会接受结构与数值依赖回归,外部自定义组件当前仍保守使用
全网 dense 回退,直到具备同等的依赖验证边界。
### 6.2 `ThermodynamicVolumeComponent`
适用于包含质量和能量状态的气体容腔,例如:
@@ -406,6 +419,27 @@ def create(
- 必要正则化必须有物理解释,并通过边界测试保护。
- 不得用画布坐标、连接线方向或组件名称决定方程。
### 12.1 可因果执行的残差语义
后端只会对经过结构门控的内置模型启用完全因果执行。除完整声明
`variables` 外,这些模型还必须遵守以下可执行语义:
- `role="effort", relation="state"` 的残差写成
`端口 effort - 状态给定值`,被约束的端口量系数必须为 `+1`。
- `role="effort", relation="equal"` 的残差写成两个同类 effort 的差。
- `role="flow", relation="sumToZero"` 按“流入组件为正”的约定求和,待消元
flow 的系数必须为 `+1`。
- `role="flow", relation="constitutive"` 写成
`待消元 flow - 本构计算值`,待消元 flow 的系数必须为 `+1`。
- `pressure_flow_equation_values()` 必须是无副作用的只读计算,返回顺序和长度
必须与 `pressure_flow_equation_residuals()` 的编译结果永久一致;不得在求残差时
修改端口、状态或活动集缓存。
求解器仍会在首次闭合、离散事件之后和固定周期执行完整残差审计。结构不满足、
运行时覆盖不完整或审计不通过时,会立即熔断到原有残差/非线性求解路径。现场诊断
时可在启动进程前设置 `SIMULATION_CAUSAL_FAST_PATH=0`,一键关闭该优化而不改变模型
文件。
动态模型还必须:
- 状态向量长度稳定。
@@ -435,6 +469,7 @@ from app.simulation.core.ports import PortDefinition
class ExampleRestriction(AlgebraicComponent):
MODEL_TYPE = "example_restriction"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
+178 -12
View File
@@ -2,16 +2,22 @@
> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。
> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。
> 2026-08-16 已按本报告建议实现“仿真内独立物性缓存”“worker 启动暖机”、高刚度试探压力边界修复、方程关联块闭合、代数稀疏回退、外部 volume 跨域 ODE Jacobian 修正和 dense output 惰性构造;原始基线数据保留用于对照,当前大型 XML 复验见第 11 节。
## 1. 结论
1. **压力—流量闭合是当前首要热点。** 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加。
1. **压力—流量闭合是原始基线的首要热点。** 2026-08-15 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加;2026-08-16 已完成方程块与稀疏首轮,当前现状见第 11 节。
2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。
3. **现有两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些缓存已经存在于评估基线,本次没有新增或改变缓存算法。
3. **评估基线已有的两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些数据描述 2026-08-15 的原始基线,后续实现见第 9 节。
4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。
5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。
6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。
7. **用户提供的 demo XML 尚不能形成完整性能样本。** 它仍在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;深度埋点确认错误发生在压力—流量闭合。本批只记录失败路径,没有顺带改变求解器容错行为。
7. **用户提供的高刚度 XML 已能完成 10 s 仿真。** 原始基线在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;原因是压力优化下界为 1 Pa,排除了 RK45 合法产生的、仍严格大于 0 Pa 的亚帕试探值。2026-08-16 将优化器压力下界放宽到 0 Pa 后,构成方程仍要求压力严格为正,完整 RK45 仿真通过且没有触发可恢复重试。
8. **闭合不再固定执行第二次全网压力求解,也不再把一个大物理岛等同于一个求解块。** 每次闭合仍先保证全网成立;stream 更新后,只重算声明为 stream-sensitive 的方程—未知量关联块。物理连通岛只是安全范围,当前 `secondaryBlockCount` 是真实方程块数;无法安全分类的自定义模型会保守回退原全网路径。
9. **历史物理岛版收益取决于模型拓扑。** `off` 模式配对测试中,空气链、空气分支、氦气阶跃、机械接触和高刚度短算例分别改善 7.9%、6.5%、0.6%、21.4% 和 14.6%。这些数据保留作纵向基线,但该版已由方程关联块实现取代。
10. **大型分支 XML 的 `0.69 s` 现象已定位并完整跑通。** 输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量、74 个 ODE 状态。根因是外部 volume 跨域耦合在 ODE Jacobian 依赖图中漏 12 个实测显著项,而不是线程死锁;修正后结构由 1092 非零/27 色变为 1284 非零/31 色。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009。
11. **代数非线性回退已有可信声明图上的稀疏保护链。** 先求本轮未闭合方程块的 union sparse;失败恢复原始 `x0` 后做 global sparse,再失败才做 dense。受控扰动微基准在相同 `max_nfev=20` 下把真实残差回调由 3796 降至 164、墙钟约 7.357 s 降至 0.634 s(约 11.6 倍);活动接触或不可信声明仍走兼容 dense 路径。
12. **首轮其他优化均按适用范围解释。** worker 暖机已覆盖 sparse `least_squares` 的 LSMR 路径;dense output 仅在跨采样点或需要状态事件时构造,但本次大型 XML 含状态事件,因此没有本案收益。机械 `atol` 的 `1e-12→1e-10` A/B 约快 16%,但会改变机械误差合同,未采用;外层 thermofluid 流量固定点相对容差的 `1e-12→1e-9` A/B 反而增加 BDF 步数并改变轨迹,也未采用。
## 2. 埋点实现与污染控制
@@ -72,7 +78,7 @@
| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% |
| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 |
当前热流耦合不是旧文档所写的固定 2~3 次压力求解。每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点;也就是说理论上最多 26 次。本次四个算例的平均压力求解次数/闭合分别为 2.00、2.15、2.30 和 2.00。优化时应编译依赖/脏标记并减少不必要的全网 pass,但不能直接删除第二轮,否则会重新引入求值历史依赖并破坏有限差分 Jacobian。
这是 2026-08-15 原始基线的闭合数据:当时每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点,理论上最多 26 次;四个算例平均为 2.00、2.15、2.30 和 2.00 次/闭合。随后 2026-08-16 的第一版先把后续重算缩到 stream-sensitive 物理连通岛;该历史版本又被当前方程关联块版取代。当前做法是在安全物理范围内只重算敏感方程实际关联的块,没有敏感块时不强制第二次压力求解。这样裁剪的是无效重算,不是删除真实耦合。
## 5. 物性调用与缓存结果
@@ -83,9 +89,9 @@
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 |
暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当前精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
在原始基线中,暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当时的进程级精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,200 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,712 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。
@@ -99,21 +105,181 @@ audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调
| `mechanical_contact` | 51.4 KiB | 0.62 ms |
| `helium_long` | 507.4 KiB | 18.48 ms |
用户 demo 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;这与此前定位的低压试探态越过 `least_squares` 初值边界一致。由于没有到达 10 s 终点,不能把其 1.08 s 失败耗时当作完整模型性能。
用户高刚度 XML 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。原始 audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;其 1.08 s 只代表历史失败路径,不能当作完整模型性能。允许严格正的亚帕试探压力后,同一模型已完成 10 s,当前完整性能结果见第 10 节。
## 7. 后续优化顺序
1. **先优化压力流量执行计划。** 继续预编译组件/连接残差归属、显式赋值顺序和热流耦合脏标记;增加快路径命中率、非线性 `nfev` 累计耗时,区分“全网扫描慢”与“非线性迭代慢”。
2. **再减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,合并等熵因子与密度读取;沿用精确键、有界容量和按仿真隔离原则。现有 LRU 已带来约 8% 的短算例收益,不应回退。
3. **处理冷启动。** 若首请求延迟重要,可在 worker 启动时显式导入 SciPy 求解模块或运行一个极小、无业务副作用的预热模型;不要把约 0.65 s 冷启动归因到每次仿真。
1. **[2026-08-16 已落实方程块与稀疏首轮] 优化压力流量执行计划。** 压力/流量方程、显式赋值、热流依赖和方程—未知量关联图已预编译;每轮 stream 更新后只重算敏感方程块。非线性时先做可信未闭合块的 union sparse,失败从原 `x0` 做 global sparse→dense;自定义、活动接触或结构不安全的网络保守回退兼容路径。后续仍可评估 equality group 真正消元和解析 Jacobian。
2. **[2026-08-16 已落实] 减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,并缓存相同输入的密度和等熵因子;沿用精确键、有界容量和按仿真隔离原则。
3. **[2026-08-16 已落实] 处理冷启动。** worker 在 FastAPI lifespan 中完成无业务副作用的 SciPy/XSD 微型暖机后再接收请求;不要把约 0.65 s 冷启动归因到每次仿真。
4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。
5. **把 demo 数值容错作为独立修复。** 统一压力可行域与初值投影、将试探态错误转为可恢复拒步,并在闭合前刷新外部容积缓存;该改动需要单独回归,不能混入性能优化提交。
5. **[2026-08-16 已落实] 修复高刚度 XML 的压力试探边界。** 优化器允许严格正的亚帕试探值,物理构成方程仍拒绝零压和负压;该模型已用 10 s RK45 回归验证,不需要把这类合法试探误报为可恢复拒步。
6. **[2026-08-16 已落实] 补全外部 volume 的跨域 ODE Jacobian 依赖。** 机械位移写入气室容积后,气动储能与机械力平衡必须在状态稀疏图中双向关联;大型分支 XML 据此完整跑通。机械 `atol` 放宽虽有约 16% 的单次改善但改变精度合同;flow 固定点容差放宽反而增加步数,均未采用。
7. **[2026-08-16 已落实] dense output 惰性构造。** 仅当已接受步跨越下一样本或需要状态事件时构造插值;含状态事件的模型每步仍需要,不将其宣传为大型分支 XML 的收益来源。
## 8. 本次评估边界
- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。
- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。
- 当前 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告均为单任务运行。多个 audit 仿真线程同时调用同一缓存时,阶段/物性调用仍按 trace 隔离,但缓存命中差值可能交错,不能用来做并发结论。
- 原始基线的 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告当时均为单任务运行。该并发统计限制已由第 9 节的仿真内独立缓存消除。
- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。
- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。
- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。
## 9. 2026-08-16 缓存与启动暖机复验
原先两个函数级 LRU 会在 Python 进程内跨仿真共享条目。现已改为每次仿真通过
`ContextVar` 创建独立缓存,并在运行结束后整体释放;并发任务不会共享缓存或
命中统计。每个“物性操作 + 介质实例”使用独立的 C 层有界 LRU,默认上限为
8192 项。当前只缓存四条有明确重复收益的氦气路径:密度、等熵密度—压力因子、
`properties_from_mU` 和 `temperature_from_pressure_enthalpy`。
同一个高压氦气阶跃算例的冷缓存 audit 结果为:
| 操作 | 调用 | 命中 / 未命中 | 命中率 |
| --- | ---: | ---: | ---: |
| `density` | 578 | 403 / 175 | 69.7% |
| `isentropic_density_pressure_factor` | 398 | 310 / 88 | 77.9% |
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% |
四项合计 2,869 次命中、435 次未命中、435 个最终条目,未发生驱逐。PR 三次根
计算从原审计的 1,712 次降到 689 次。关闭埋点、各自预热 5 次并测量 21 次时,
缓存开启/完全关闭的墙钟中位数在本机分别为 71.6 ms 和 154.0 ms。这个对比表示
“四项缓存整体”相对“完全不缓存”的收益,不能误解为相对旧版两个 LRU 又提升
53.5%。缓存开关两次运行的完整 `series` 和 `final` SHA-256 一致。
10 s、501 个输出点的氦气均压算例单次复验中,缓存开启和关闭分别用时
14.18 s 与 26.64 s;开启时命中 400,571 次、未命中 96,427 次。四个缓存均达到
各自 8192 项上限,共发生 63,659 次 LRU 驱逐,但仍完成到 10 s。该长算例每种
配置只测了一次,只能说明容量上限确实生效且仍有收益,不能作为稳定百分比承诺。
worker 暖机只执行内存中的一维 BDF、`least_squares`、`brentq`、稀疏 Jacobian
分组和 XSD 编译,不运行用户模型、不写文件、不填充氦气业务缓存。当前暖机还显式
覆盖携带 `jac_sparsity`、使用 sparse LSMR trust-region 子问题的代数路径,避免真实
用户任务第一次触发该 SciPy 分支时再承担惰性初始化。三个新进程的
中位数为:不暖机首个仿真 709.1 ms;启动暖机本身 637.1 ms;暖机后的首个仿真
69.8 ms,同进程第二次约 68~74 ms。也就是说总初始化成本没有消失,而是被
移到服务宣告就绪之前。
## 10. 2026-08-16 历史物理岛版闭合复验
> 本节保留方程关联块实现之前的物理岛版数据,用于纵向对照。它已不是当前执行计划;当前结果见第 11 节。
该轮把 signal 源/连接、stream 组件/端口/连接以及气动外部 volume 组件/连接的
静态查找移到系统构造阶段;运行时仍按每个状态执行实际传播和热力刷新。压力流量
闭合先进行一次全网求解,再根据预编译的依赖声明,只对 stream-sensitive 的物理
连通块做后续固定点重算。对未声明依赖的自定义 stream 组件、跨组件方程或非方阵
物理岛,执行计划保守回退到原全网求解,不以性能换取模型兼容性。
该历史版本的 `off` 模式配对结果如下。表中百分比是同一模型、同一数值配置下的墙钟改善,适合
判断优化方向,不是跨机器速度承诺:
| 算例 | 历史物理岛版相对原全网闭合的改善 |
| --- | ---: |
| `air_chain` | 7.9% |
| `air_branched` | 6.5% |
| `helium_step` | 0.6% |
| `mechanical_contact` | 21.4% |
| high-stiffness short | 14.6% |
`helium_step` 在该历史版本里只有一个需要在 stream 后继续求解的敏感物理岛,因此 0.6% 的改善处于
小幅范围;不能用其他无敏感岛模型的收益夸大氦气模型的效果。积分完成后的后处理
也没有跳过闭合:每个输出采样点仍重新应用状态、执行完整 `_close_current_state()`
并提取结果,只是闭合内部使用同一安全执行计划。
完整 high-stiffness 10 s 算例的历史 `off` 基线约为 28.126 s;本轮全部改动后的
多次运行中位数为 13.694 s。这个跨版本对比同时包含本轮多项改动,不能把差额全部
归因于物理岛裁剪。为了单独核对当时的闭合计划,在同版代码上做 optimized/forced-global
对照,墙钟分别为 14.676 s 和 16.929 s,完整 `series` 完全一致;这组受控对比才
直接反映该模型的执行计划收益。
当前诊断已经进一步消除旧命名歧义:`secondaryPhysicalIslandCount` 表示安全分类的
物理范围,`secondaryBlockCount` 表示方程—未知量关联图中的真实块数,
`secondaryUnknownCount` 表示这些方程块合计未知量。`solveCount` 统计实际求解器调用,
`closurePassCount` 单列发生过压力求解的闭合 pass;`last` 中还包含
`residualEvaluations`、`jacobianMode`、dense/方程块回退状态。不能再把
`secondaryBlockCount` 解释为物理岛数。
## 11. 2026-08-16 大型分支 XML 与方程块首轮复验
验证输入 `test_mql-full-branches-01-04.xml` 的 SHA-256 为
`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`。模型规模如下:
| 项目 | 数量 |
| --- | ---: |
| 组件 | 98 |
| ODE 状态 | 74 |
| 压力/流量/机械代数未知量与方程 | 472 / 472 |
| 代数声明图结构非零 | 919(约 0.413%) |
| 全部独立代数方程块 | 58 |
| stream 后续敏感方程块 | 9,合计 192 个未知量 |
### 11.1 `0.69 s` 慢区的根因与完整结果
旧代码在 `0.69 s` 左右不是线程死锁:它仍会缓慢前进,但 BDF 在刚性变化区大量
缩步、重建有限差分 Jacobian 和执行 LU。定位出的结构错误是气动外部 volume
跨域耦合没有完整进入 ODE 状态依赖图。机械位置先写入气室容积,气室压力又反馈到
机械力平衡;旧图只沿普通物理端口追踪,漏掉这条闭环中的 12 个实测显著导数项。
修正后,状态稀疏图由 1092 个非零项、27 个颜色组变为 1284 个非零项、31 个颜色组。
颜色数增加是因为补上了真实依赖,并非回退到更差算法;完整 Jacobian 让 BDF 少走
错误 Newton 方向和重复试步。功能收口过程中的较早阶段测量为 92.187 s;最终稳定
代码连续三次完整 `0~0.81 s` 分别用时 79.049 s、74.658 s 和 85.103 s,数值工作量一致:
| 指标 | 结果 |
| --- | ---: |
| `nfev` | 3393 |
| `njev` | 226 |
| `nlu` | 667 |
| 接受步 | 1009 |
| 求解器启动次数 | 3 |
| 压力流量求解 | 28008,全部 seeded |
| 方程块/dense 非线性回退 | 0 / 0 |
| 三次 full-response 规范 JSON SHA-256 | `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f` |
| `physical-solution-v1` SHA-256 | `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7` |
这里的 full-response 哈希覆盖完整响应,所以诊断字段增删也会改变它。为稳定比较物理
结果,`physical-solution-v1` 只把 `{status, simulatedUntil, requestedStopTime, series,
final}` 投影为待哈希对象;schema 名只是外部标签,不进入对象。两种口径都使用
`json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 后计算 SHA-256。
旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,响应结构不同,
不作为最终结果,也不能与当前口径直接比较。
容差 A/B 必须分开解释:机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次测试约快
16%,但会改变机械状态与事件的误差合同,当前未采用;外层 thermofluid 流量固定点
相对容差从 `1e-12` 放宽到 `1e-9` 后,BDF 内部步数反而增加并改变积分轨迹,也未
采用。完整成功来自依赖图修正和闭合优化,不是牺牲积分精度或闭合精度。
### 11.2 stream 方程块与受控 A/B
第一次压力流量求解仍承担“全网必须成立”的语义;但可信声明图允许它在非线性时只把
本轮未闭合的独立方程块合并成一个 union sparse 问题,而不是固定构造 472 变量的
dense 问题。stream 更新后的固定点进一步只处理 9 个敏感方程块、合计 192 个未知量,
不再因为机械总线把拓扑连成一个大物理岛,就重复求解全部 472 个未知量。
在相同当前代码、相同 `0~0.01 s` 区间做 optimized/forced-global 配对,墙钟分别为
16.200 s 和 18.584 s,物理解与 `series` 逐值一致。这组对照隔离的是后续 stream
闭合作用域;它不包含完整 `0.81 s` 慢区的全部收益,不能与最终完整运行直接换算百分比。
### 11.3 非线性稀疏回退与其他首轮项
全局非线性回退当前采用兼容保护链:可信声明图先求未闭合方程块的 union sparse;
若块解失败,先把所有共享端口未知量恢复到原始 `x0`,再做 global sparse;若 sparse
仍未达到既有残差合同,再次从原 `x0` 做 global dense。活动接触会改变坐标/活动集,
不可信自定义声明也可能漏依赖,这两类不冒险使用静态稀疏图,继续走 dense 兼容路径。
受控扰动微基准在相同 `max_nfev=20` 下得到:
| 路径 | 真实残差回调 | 墙钟 |
| --- | ---: | ---: |
| dense | 3796 | 7.357 s |
| sparse | 164 | 0.634 s |
同一评估预算下约为 11.6 倍的回退成本改善;两条路径在 20 次优化器评估内都没有收敛,
所以这是“数值 Jacobian 试算成本”微基准,不是整体仿真加速承诺。诊断用
`residualEvaluations` 记录真实残差回调,避免仅看 SciPy `nfev` 漏掉内部差分调用。
worker 暖机现已覆盖带 `jac_sparsity` 的 sparse LSMR `least_squares` 路径。逐步积分的
dense output 也改为只在当前步跨越下一采样点或需要状态事件定位时构造;本 XML 含
状态事件,所以每步仍需要插值,这项优化对最终 79.049 s/74.658 s/85.103 s 复验没有收益。
+92 -37
View File
@@ -1,6 +1,6 @@
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
> 调研基线:2026-08-15(System XML v3 迁移后),依据当前仓库代码、配置、说明文档、测试与阶段埋点。
> 调研基线:2026-08-15(System XML v3 迁移后);2026-08-16 已补充压力边界、预编译闭合执行计划、方程关联图分块、代数稀疏回退、ODE Jacobian 修正和实测复验的当前状态。
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。机器相关的实测结果单独见[仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)。
## 0. 三分钟读懂
@@ -74,12 +74,15 @@
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
4. **[已实现] 每次完整闭合先调用 1 次压力流量求解,再执行最多 25 轮 `stream → pressure-flow` 固定点。** 因而每次闭合至少 2 次、理论上最多 26 次压力求解;本次代表算例平均为 2.00~2.30 次。积分结束后,每个输出采样点又执行一次完整闭合并提取结果。
5. **[已实现] 代数求解已有因果化快路径。** 压力流量求解器预编译相等组与显式流量计划,种子残差足够小时不调用非线性优化;否则对全局未知向量调用 SciPy `least_squares`,当前未提供解析 Jacobian 或 `jac_sparsity`。
4. **[已优化] 每次完整闭合仍先做 1 次语义上的全网压力流量求解,再传播 stream;后续固定点只重算 stream-sensitive 方程实际关联的方程块。** 物理连通岛现在只是安全分类的第一层,真正执行的 `secondaryBlockCount` 是方程—未知量关联图中的块数;无法可信分类的自定义或异常结构仍保守回退原全网路径。积分结束后,每个输出采样点仍执行一次完整闭合并提取结果。
5. **[已实现] 代数求解已有因果化快路径、方程块和稀疏 `least_squares` 回退。** 对可信内置声明,求解器从方程—未知量关联图编译 `jac_sparsity`,先把本轮未闭合的独立方程块合并成一个 union sparse 问题;若失败,恢复到同一个原始 `x0`,再执行全局 sparse,仍失败才执行兼容的 dense 路径。活动接触或不可信自定义声明不会冒险稀疏化,保留 dense 兼容路径。当前没有解析 Jacobian,但不再是“完全未提供 `jac_sparsity`”。
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
7. **[推断] 优化应分两条线:**
- 单算例速度:减少闭合 pass、代数未知量与残差装配,缓存热物性,改善外层积分尺度/Jacobian,减少后处理重算;
- 服务吞吐与资源稳定性:有界进程 worker、结果分块/按需返回、任务表主动清理和前端结果存储降副本。
8. **[已修复] 高刚度 XML 不再因合法亚帕试探压力失败。** 压力优化下界由 1 Pa 放宽到 0 Pa,允许严格正的亚帕试探值,同时构成方程仍拒绝零压和负压;同一 10 s RK45 算例已完成且没有可恢复重试。
9. **[历史实测] 闭合裁剪的收益随拓扑明显变化。** 已被方程块版取代的物理岛版短算例 `off` 配对改善为 0.6%~21.4%;其 high-stiffness 10 s optimized/forced-global 对照为 14.676 s/16.929 s,完整 `series` 一致。数据保留作纵向基线,不能当作当前方程块版结果,也不能把其他拓扑的收益外推给单一氦气敏感岛。
10. **[实测] 98 组件的大型分支 XML 在 `0.69 s` 附近的长时间停留不是死锁。** 根因是气动外部 volume 跨域耦合在 ODE 有限差分 Jacobian 依赖图中漏了 12 个实测显著项,BDF 因此反复做小步和 Jacobian/LU 工作;修正后结构非零数由 1092 增至 1284、颜色组由 27 增至 31。最终稳定代码连续三次完整 `0~0.81 s` 用时 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`、接受步 1009;stream 后续闭合为 9 个方程块、合计 192 个未知量。
## 2. 证据标签与范围
@@ -182,18 +185,21 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
### 4.3 压力流量因果化与非线性回退
`PressureFlowSolver` 初始化时预编译 effort 等值组、显式流/力赋值计划和未知量布局(`app/simulation/solvers/algebraic.py:86-106, 637-729`)。每次 `solve()`:
`PressureFlowSolver` 初始化时预编译方程模板、effort 等值组、显式流/力赋值计划、未知量布局,以及方程声明到未知量的静态关联图。热流闭合先按物理连通关系划定安全范围,再在可信内置模型中按这张关联图拆成更细的方程块。每次 `solve()`:
1. 从上一解与当前动态状态播种端口未知量;
2. 传播状态拥有的压力、位移和速度;
3. 执行可显式求值的构成关系与守恒关系;
4. 处理单边接触约束;
5. 若缩放后残差不超过 `1e-7`,直接返回且 `evaluations=0`;
6. 否则调用 `scipy.optimize.least_squares`。
6. 否则找出残差未闭合或种子不可行的方程块,把这些互相独立的块合并成一次 sparse `least_squares`;
7. 方程块失败时恢复原始 `x0`,执行全局 sparse `least_squares`;全局 sparse 仍失败时再次恢复同一个 `x0`,最后执行 dense 兼容回退。
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`,没有传入解析 Jacobian 或 `jac_sparsity`(`app/simulation/solvers/algebraic.py:771-968`)。
非线性回退当前参数为 `x_scale="jac"`、`ftol=xtol=gtol=1e-10`、`max_nfev=500`。可信结构会传入由声明图编译的 `jac_sparsity`;活动单边接触会改变坐标和活动集,自定义组件也可能没有完整依赖声明,因此这些情况继续使用 dense 路径。稀疏/分块是加速层,不会把失败候选带入兼容回退。诊断中的 `evaluations` 是 SciPy 报告的优化器 `nfev`,`residualEvaluations` 才包含数值 Jacobian 在内的真实残差回调数,并通过 `jacobianMode`、`denseFallbackUsed` 和块回退字段说明实际路径。
**[推断]** 即使快速路径经常命中,固定的多次全网扫描、残差重建和热物性刷新仍会发生;一旦回退到有限差分 `least_squares`,成本会随全局未知量数量快速上升。
**[当前边界]** 第一次压力流量求解仍要保证全网所有方程成立,但“全网语义”不再等于“总是把全部未知量交给一个 dense 优化器”:可信声明图可只求本轮未闭合方程块的 union。后续 stream 固定点则进一步只重算敏感方程块。保守条件会恢复全网 sparse→dense 行为;没有可信依赖声明或活动接触时,仍可能直接恢复 dense,成本会随未知量数快速上升。
在用户提供的 `test_mql-full-branches-01-04.xml`(SHA-256 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`)中,472×472 代数关联矩阵只有 919 个结构非零项,密度约 0.413%,可拆为 58 个独立方程块。一个受控扰动实验在相同 `max_nfev=20` 下,dense 与 sparse 分别触发 3796 和 164 次真实残差回调,墙钟约为 7.357 s 和 0.634 s,即同一预算下约 11.6 倍的单次回退成本改善;这项实验只衡量回退开销,不代表两条路径在 20 次评估内已经收敛。
## 5. 每次导数计算的代数闭合
@@ -205,25 +211,25 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
- 气体携带的能量按实际流向交给下游;
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。
代码目前不是一次对完,而是先建立初始压力解,再让 stream 焓和压力—流量相互迭代到同一个固定点。这样做是为了让当前 RHS 不依赖上一次调用留下的焓/流量历史,并保持有限差分 Jacobian 可重复。
代码先建立全网初始压力解,再传播 stream 焓;只有组件方程明确依赖 stream 的范围,才让 stream 焓和其方程—未知量关联块继续迭代到同一个固定点。这样既让当前 RHS 不依赖上一次调用留下的焓/流量历史、保持有限差分 Jacobian 可重复,又避免无关方程重复对账。物理岛仍用于确认范围不会跨越未声明边界,但不再等同于实际求解块。
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py`:
| 顺序 | 操作 | 目的 |
| ---: | --- | --- |
| 1 | `SignalResolver.solve(time)` | 更新时间信号源并从 output 传播到 input |
| 1 | `SignalResolver.solve(time)` | 用构造时预绑定的信号源和连接更新时间信号 |
| 2 | 传播机械 `x/v` 等值关系 | 把当前机械状态同步到刚性连接端口 |
| 3 | `PneumaticVolumeResolver.solve()` | 沿气动连接传播外部 `volume/volume_flow` |
| 3 | `PneumaticVolumeResolver.solve()` | 用预绑定的气动端口、输出组件和连接传播外部 `volume/volume_flow` |
| 4 | 刷新动态组件热力端口 | 由当前 `m/U/V` 和最新体积恢复压力、温度、焓 |
| 5 | 第一次 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 |
| 6 | 最多 25 轮 `StreamResolver.solve()` 后再求压力流量 | 让焓、温度引用和构成流量同时收敛;按流量变化判停 |
| 5 | 第一次全网 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 |
| 6 | `StreamResolver.solve()`,必要时最多 25 轮重算敏感方程块 | 用预绑定的组件、端口和连接传播焓;让焓、温度引用和敏感构成流量同时收敛 |
| 7 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
随后 `rhs()` 才收集各动态组件的导数。
因此每次闭合至少有 **2 次**、最多有 **26 次**压力流量求解。外部容积传播会影响初始热力状态,但不再用“有没有容积传播”直接决定固定次数。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代;外层热流固定点最多 25 轮,两层上限不能混为一个数。
因此每次闭合固定有 **1 次全网语义**的压力流量求解;没有敏感方程块时,stream 传播一次后结束,不再有第二次压力求解。若有 `B` 个敏感方程块,每一轮外层热流固定点会把相应块作为一个 union 快路径求解,最多 25 轮;保守回退时恢复原全网求解器。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代,外层热流固定点最多 25 轮,两层上限不能混为一个数。上述大型 XML 的敏感 union 是 9 个方程块、合计 192 个未知量,而不是其单一巨大物理连通岛中的全部 472 个未知量。
**[发现]** 这套顺序仍没有按模型实际能力完全裁剪。例如没有信号、没有外部容积源或 stream 不反向影响构成关系的网络,仍经过对应全网 pass。是否能安全删去某一 pass 必须由依赖关系、脏标记和回归测试决定,不能只凭某个算例结果不变。
signal、stream 和外部 volume resolver 已把静态组件列表、端口引用和连接绑定预编译到系统构造阶段;运行时仍执行真实的信号赋值、焓迭代、volume 传播和动态热力刷新。闭合计划只在组件以布尔能力声明明确说明压力流量方程是否依赖 stream、且方程变量声明可信时裁剪;未分类自定义 stream 组件、非法声明、跨组件残差或局部非方阵都会保守回退全网。这个边界避免把“少扫描”误做成“少算物理关系”。
## 6. 初始化、积分参数与推进方式
@@ -252,7 +258,7 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
| `rtol` | 通用 XML 路径硬编码 `1e-6` | 外层 ODE 相对误差;用户不可配置 |
| `atol` | `SolveIVPConfig` 标量默认 `1e-8` | 同时用于不同量纲的全部状态;用户不可配置 |
| `atol` | `SolveIVPConfig` 标量默认 `1e-8`;机械状态收紧到 `min(default, 1e-12)` | 热力状态使用默认值,机械速度/位置使用更紧的分量容差;用户不可配置 |
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
| 代数最大评估 | `500` | 单次 `least_squares` 上限 |
@@ -276,13 +282,29 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
1. 检查取消;
2. 推进一步;
3. 构造 dense output,并插入跨越到的 `t_eval` 样本;
3. 仅当本步跨越下一个 `t_eval` 样本或需要机械状态事件检测时构造 dense output,并插入样本;
4. 检测状态事件;
5. 报告进度;
6. 必要时重建求解器。
**[已优化]** 没有跨采样点、也没有状态事件时,不再为每个已接受步无条件构造插值对象。该优化对稀疏采样、无机械事件的模型有收益;用户大型分支 XML 含机械状态事件,所以仍必须在每步保留用于事件定位的 dense output,本轮实测中这项优化对该 XML 没有收益。
Peng–Robinson 试探状态越界会抛 `RecoverableTrialStateError`;代码回到最后已接受状态,将 `max_step` 减半,最多重试 16 次(`app/simulation/solvers/solver.py:528-914`)。
高刚度 XML 的历史失败不是“所有亚帕压力都不物理”,而是优化器原 1 Pa 下界过严:RK45 的内部自适应试探会短暂给出仍严格大于 0 Pa、但小于 1 Pa 的压力种子。当前 `PRESSURE_LOWER_BOUND_PA=0.0`,合法正压试探交回 RK45 自身判断,零压/负压仍由构成方程拒绝。该模型 10 s RK45 已通过且可恢复重试数为 0,因此这里没有用异常重启掩盖真实模型错误。
### 6.4 大型分支 XML 的 `0.69 s` 慢区
用户 XML 包含 98 个组件、472 个代数未知量和 74 个 ODE 状态。旧代码不是在 `0.69 s` 死锁,而是 BDF 到达这一刚性变化区后开始大量缩步、重建有限差分 Jacobian 并做 LU 分解。定位发现,气动外部 volume resolver 把机械位移写入气室容积,形成“机械位置 → 气室热力/压力 → 气动力”的跨域闭环;旧 ODE 稀疏依赖图只沿普通物理端口追踪,漏掉了这条外部 volume 边的 12 个实测显著导数项。
修正采用保守的双向跨域依赖:接收外部容积的气动储能状态依赖相关机械状态,机械力平衡也依赖被耦合的气动状态。结构非零数因此由 1092 增至 1284,有限差分颜色组由 27 增至 31。颜色组稍多不是退化:旧图更小是因为漏项,给 BDF 的 Jacobian 数值不完整,导致后续重复试步的总成本更高。
功能收口过程中的较早阶段测量为 92.187 s;最终稳定代码连续三次完整运行到 `0.81 s`,分别用时 79.049 s、74.658 s 和 85.103 s。三次积分统计均为 `nfev=3393`、`njev=226`、`nlu=667`、接受步 1009、求解器启动 3 次;压力流量求解 28008 次,全部由 seeded 快路径满足残差合同,没有触发方程块或 dense 非线性回退。
三次完整响应按规范 JSON 序列化后的 SHA-256 均为 `454cd11aece1c4a2296a88e2c1dd592eeace28565e342235fb7a7df34de5b18f`。为避免诊断字段增删造成“物理结果没变但响应哈希变化”,另定义外部标签 `physical-solution-v1`:待哈希对象只投影 `{status, simulatedUntil, requestedStopTime, series, final}`,标签本身不放入对象;用 `json.dumps(sort_keys=True,separators=(",",":"),ensure_ascii=False)` 规范化后 SHA-256 为 `04982f427867801c582fea81c6e2da0b726bd8a61d7894b311e4a807b19e89a7`。旧 `09b5c7…` 是聚合诊断和最终 union 路径收口前的 full-response 哈希,受响应结构影响,不能与当前哈希直接比较。
本轮还区分验证了两种容易混淆的“容差”。机械状态 `atol` 从 `1e-12` 放宽到 `1e-10` 的单次 A/B 约快 16%,但这会改变机械状态与事件的误差合同,当前证据不足,未采用。另一项是外层 thermofluid **流量固定点**相对容差从 `1e-12` 放宽到 `1e-9`;它反而增加 BDF 内部步数并改变积分轨迹,也未采用。这里的正式修复是补全依赖图,不是通过放宽精度或闭合容差掩盖问题。
仓库有固定 RK4 回退,但通用压力流量求解器本身依赖 SciPy;因此它不能被视为一般流体网络在无 SciPy 环境下的完整替代方案。
## 7. 事件、取消与停止
@@ -317,18 +339,20 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
**[推断]** 采样密集或结果变量多时,这会形成明显的第二计算阶段;此时内存中还保留积分状态矩阵,CPU 与内存峰值可能重叠。
**[当前边界]** 本轮优化没有复用积分期间的闭合快照,也没有跳过后处理对账。每个输出点仍执行完整 `_close_current_state()`;变化仅在于该闭合内部使用同一套预编译绑定和敏感方程块执行计划。
### 8.2 当前返回的诊断
**[已实现]** 结果包含:
- 状态数、采样数;
- 压力流量 `solveCount`、最大残差、最大单次评估数;
- 压力流量 `solveCount`、`closurePassCount`、`secondaryPhysicalIslandCount`、真实方程 `secondaryBlockCount`、`secondaryUnknownCount`、保守回退原因、最大残差、最大单次评估数和最后求解作用域;
- stream 最大迭代数;
- 停止状态及部分错误上下文。
**[已实现]** 积分诊断已经包含分段及汇总的 `nfev/njev/nlu`、已接受步、求解器启动、状态迁移和可恢复重试数。设置 `SIMULATIONAPP_PROFILE=standard|audit` 后,响应还会加入分阶段墙钟时间;audit 进一步记录物性调用、精确重复、缓存命中和逆解迭代。
**[发现]** `_close_current_state()` 中局部变量 `algebraic` 会被后续 pass 覆盖;最大残差/评估统计只采集每次闭合最后一次压力求解,而 `solveCount` 才累计了全部调用。仍缺少压力快路径命中率/累计 `nfev`、峰值 RSS、任务队列深度等服务级指标。结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。
**[已修复]** 闭合现在聚合所有实际压力求解的最大残差和最大单次评估数,`last` 与 `lastScope` 指向最后一个真实求解作用域,不再被一个未执行或较早 pass 的局部变量覆盖。`solveCount` 统计求解器调用次数,`closurePassCount` 单列发生过压力求解的固定点 pass;`secondaryPhysicalIslandCount` 只表示安全分类得到的物理范围,`secondaryBlockCount` 明确表示方程关联块数,不能再把两者混称为“块”。代数诊断还返回真实 `residualEvaluations`、`jacobianMode`、dense/方程块回退状态。子作用域失败时,API 返回 `scopeKind` 和 `scopeComponents`。仍缺少峰值 RSS、任务队列深度等服务级指标;结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。
## 9. 求解时前后端交流
@@ -435,16 +459,35 @@ O(组件 + 连接 + 代数结构)
| 热点 | 代码证据 | 影响范围 | 判断 |
| --- | --- | --- | --- |
| 每次闭合执行 2~26 次压力流量求解 | `generic.py` 的热流固定点 | 每个 RHS、初始化、每个结果采样点 | [实测] 代表气动算例平均 2.00~2.30 次;audit 包含时间占 71%~85% |
| stream 每轮复制/扫描并重复刷新 | `stream.py:29-119` | 每个闭合,最多 100 轮 | [已实现];网络越大越明显 |
| 非线性回退用全局有限差分 least-squares | `algebraic.py:927-947` | 快速路径失效时 | [已实现];大非线性网络潜在陡增 |
| 外层刚性积分器看不到显式稀疏 Jacobian | `solver.py:528-1009` | BDF/Radau 的每步/Newton | [已实现] |
| 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [实测] 闭合内精确重复率 82%~97%;PR 氦气缓存端到端收益约 8% |
| 每次闭合的压力流量重算 | `generic.py` 的预编译热流闭合计划 | 每个 RHS、初始化、每个结果采样点 | [已优化] 固定 1 次全网语义初解;后续只重算 stream-sensitive 方程关联块,不再把整个敏感物理岛重复求解 |
| stream 每轮复制/比较焓并刷新 | `stream.py` | 每个闭合,最多 100 轮 | [静态预编译已完成] 组件、端口和连接已预绑定;每轮必要的数值复制、比较和刷新仍保留 |
| 非线性回退使用有限差分 least-squares | `algebraic.py` | 快速路径失效时 | [首轮已优化] 可信声明图先做未闭合方程块 union sparse;失败恢复 `x0` 后做 global sparse→dense;接触/不可信结构保留 dense |
| 外层 ODE Jacobian 稀疏依赖图 | `generic.py`、`solver.py` | BDF/Radau 的每步/Newton | [已修复] 已传 `jac_sparsity`;补上外部 volume 的跨域双向依赖,用户 XML 为 1284 非零/31 色 |
| 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [已优化] 2026-08-16 已加入仿真隔离的四项氦气精确 LRU;代表算例 2,869/435 次命中/未命中,PR 三次根调用由 1,712 降至 689 |
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
| 每个已接受步构造 dense output | `solver.py:528-914` | 流式逐步路径 | [已实现];无跨样本/事件时可能浪费 |
| dense output 插值对象 | `solver.py` | 流式逐步路径 | [已优化] 仅跨样本或需要状态事件时构造;含状态事件的用户 XML 每步仍需要,因此无本案收益 |
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
| 完整结果单行 JSON 与前端多副本 | `main.py:825-840`、`App.tsx:8872-8958` | 大输出 | [已实现] 内存/网络热点 |
下面是 2026-08-16 **物理岛版首轮实现的历史 `off` 基线**。这些数据仍可说明旧执行计划相对更早“每轮全网”的收益,但该实现已由方程关联块版取代,不能把表中的“块”解释为当前 `secondaryBlockCount`:
| 算例 | 墙钟改善 |
| --- | ---: |
| `air_chain` | 7.9% |
| `air_branched` | 6.5% |
| `helium_step` | 0.6% |
| `mechanical_contact` | 21.4% |
| high-stiffness short | 14.6% |
完整 high-stiffness 10 s 的历史基线约 28.126 s,本轮全部改动后的中位数为
13.694 s;这个跨版本差额不能归到某一项优化。当前代码上单独强制恢复全网后续
闭合的受控对照为 optimized 14.676 s、forced-global 16.929 s,完整 `series`
一致。`helium_step` 只有一个仍需重算的敏感岛,改善仅 0.6%;这说明收益取决于
可跳过多少无关网络,不能宣称单一氦岛也有两位数提升。后处理的逐采样点完整闭合
仍然保留。
当前方程关联块版另用用户大型分支 XML 做了受控短区间 A/B:输入 SHA-256 为 `2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`,含 98 个组件、472 个代数未知量和 74 个 ODE 状态。stream 后续求解的 9 个方程块合计 192 个未知量;`0~0.01 s` optimized 与 forced-global 分别为 16.200 s 和 18.584 s,物理解与 `series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s、74.658 s 和 85.103 s,积分统计均为 `nfev/njev/nlu=3393/226/667`。短区间 A/B 只归因于后续 stream 闭合作用域,完整运行同时包含 Jacobian 修正和最终执行路径收口,二者不能混算成一个百分比。
## 13. 优化建议排序
以下按**预期综合收益**排序;同档位优先低风险、低难度项。排序同时参考代码结构和 2026-08-15 的阶段/物性实测,但尚未覆盖大规模拓扑与多任务吞吐。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
@@ -455,8 +498,8 @@ O(组件 + 连接 + 代数结构)
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
| ---: | --- | --- | --- |
| 1 | 少做重复“瞬时对账” | 当前每次闭合做初始压力求解和热流固定点,实测压力流量层最热 | 少做一轮可能漏掉真实耦合,必须按组件依赖和脏标记裁剪 |
| 2 | 先整理方程,再求解 | 合并重复未知量,把关联较弱的方程分组;大模型回退迭代时收益很高 | 连接、接触和跨域活塞会让分组出错 |
| 1 | 少做重复“瞬时对账” | [方程块首轮已完成] 初解保持全网语义,后续只重算 stream-sensitive 的关联方程块 | 自定义/异常结构必须继续保守回退,不能漏掉真实耦合 |
| 2 | 先整理方程,再求解 | [稀疏首轮已完成] 可信声明图将未闭合块合并求解;大模型回退时减少数值 Jacobian 试算 | 接触活动集和不可信自定义声明必须走兼容回退 |
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
| 5 | 只计算、保存和传输需要的曲线 | 采样多、变量多时,可同时减少后处理、内存和网络开销 | 会改变默认结果合同,需要保留完整模式 |
@@ -466,28 +509,30 @@ O(组件 + 连接 + 代数结构)
| 排名 | 建议 | 主要收益对象 | 预期收益 | 风险 | 实施难度 |
| ---: | --- | --- | --- | --- | --- |
| 1 | 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:无信号不扫信号、无外部容积源不传播;仅在 stream 或体积确实使构成关系变脏时追加压力求解。保留可收敛的耦合迭代上限。 | 单算例 | 高;覆盖每个 RHS 和每个后处理点 | 中:错误裁剪会破坏耦合一致性 | 中 |
| 2 | 强化代数结构消元:合并 equality group 中重复未知量,按方程关联图分块,预编译残差/尺度;为非线性回退提供解析或稀疏 Jacobian/`jac_sparsity`。跨域活塞应按方程关联而非仅按物理域分块。 | 单算例、大网络 | 很高,尤其 least-squares 回退时 | 高:影响收敛与接触约束 | 高 |
| 3 | 为 BDF/Radau 提供状态缩放、分量级 `atol` 和 Jacobian 稀疏结构;允许有边界地配置 `rtol/atol`,依据物理时标选择 `max_step`,不要简单全局放宽容差。 | 单算例、刚性网络 | 中到高 | 中高:会改变误差轨迹/事件时刻 | 中高 |
| 4 | 在单次闭包内缓存热物性结果,并预计算 dynamic components、signal sources、stream components、端口引用和结果访问器;状态或体积变化时严格失效。 | 单算例 | 中到高,热力网络可能高 | 中:缓存失效错误会污染物理结果 | 中 |
| 1 | [方程块首轮已完成] 将 `_close_current_state` 编译为按能力/依赖启用的执行计划:signal/stream/volume 预绑定;首次压力求解保留全网语义,仅在 stream 确实使构成关系变脏时重算相关方程关联块,并保留全网回退和耦合迭代上限。 | 单算例 | 历史物理岛版实测 0.6%~21.4%;大型 XML 方程块版短区间 16.200 s 对 18.584 s | 中:错误裁剪会破坏耦合一致性,需持续回归自定义模型 | 已完成首轮 |
| 2 | [稀疏首轮已完成] 可信方程声明图已用于 union block sparse 和 global sparse→dense 回退;后续继续评估 equality group 真正消元、解析 Jacobian,以及活动接触的安全分块。 | 单算例、大网络 | 扰动实验同预算残差回调 3796→164,约 11.6 倍;实际收益取决于是否触发非线性回退 | 高:接触活动集与错误声明会影响收敛 | 首轮已完成,继续深化 |
| 3 | [依赖图已修复] BDF/Radau 已使用状态 `jac_sparsity`,外部 volume 跨域漏边已补;后续再评估状态缩放和可配置分量级 `rtol/atol`,不要简单全局放宽容差。 | 单算例、刚性网络 | 大型 XML 已从 `0.69 s` 慢区定位并完整跑通;机械 `atol` A/B 虽约快 16%但改变精度合同,flow 固定点放宽则增加步数,均未采用 | 中高:会改变误差轨迹/事件时刻 | 稀疏图首轮完成,缩放待评估 |
| 4 | [已完成] 氦气高成本物性已按单次仿真做精确、有界缓存;dynamic components 及 signal/stream/volume 的静态组件、端口和连接也已预绑定。 | 单算例 | 已取得可见收益,且减少固定拓扑的重复查找 | 中:缓存失效错误会污染物理结果 | 已完成 |
| 5 | 改造结果选择和后处理:允许选择变量、采样/降采样;避免对不需要的变量和时间点执行完整闭合,必要时复用积分期间已接受的闭合快照。 | 单算例、内存 | 长仿真/多变量时高 | 中:结果合同与复用精度 | 中高 |
| 6 | 引入有界作业队列和固定大小的进程 worker;统一让同步端点也进入执行器,并设置最大并发、排队长度和结果尺寸。 | 吞吐、稳定性 | 高;单任务速度通常不变 | 中高:跨进程取消和序列化 | 高 |
| 7 | 进度与结果解耦:NDJSON 只发送进度和 `resultId`,结果按变量/时间块压缩下载或外部存储;前端改用 TypedArray/IndexedDB,图表先降采样。 | 内存、网络、UI | 大结果时高 | 中:需要版本化协议 | 中高 |
| 8 | 主动定时清理任务表,限制任务数/结果字节;成功交付后只保留摘要或引用。将无界进度队列改为“最新进度槽 + 不可丢终态槽”。 | 稳定性 | 中到高 | 低中 | 低中 |
| 9 | 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;记录并优化事件重启。大量周期 UD00 事件采用惰性调度。 | 单算例、事件密集模型 | 中 | 低到中 | 低到中 |
| 9 | [首轮已完成] 只在当前步跨越下一采样点或需要状态事件检测时构造 dense output;后续记录并优化事件重启,大量周期 UD00 事件再评估惰性调度。 | 单算例、事件密集模型 | 无事件且采样稀疏时可减少插值对象;本次含状态事件 XML 无收益 | 低到中 | 首轮已完成 |
| 10 | CSV 在浏览器直接生成或按 `resultId` 服务端流式生成,避免全量 series 重新上传与 `StringIO` 全量复制。 | 内存、网络 | 中 | 低 | 低中 |
| 11 | 用共享 Schema/OpenAPI 生成前后端事件类型,修正 `complete/completed`;停滞依据服务端活动计数/已接受步时间戳并允许按模型调节。 | 可靠性、减少误杀重算 | 中 | 低 | 低中 |
| 12 | 长期评估支持稀疏残差/Jacobian 的 DAE 求解器,将外层 ODE 与内层代数 least-squares 统一成状态—代数系统。 | 复杂大模型 | 潜在很高 | 很高:架构与验证成本大 | 很高 |
结果访问器和每轮剩余临时容器不属于第 4 项已经完成的 signal/stream/volume 静态预绑定;前者应结合第 5 项后处理改造单独基准,不把尚未实测的小项混入已完成收益。
### 13.2 推荐落地顺序
低侵入阶段/物性观测、积分计数和代表算例首轮基准已经落地,但不把“加指标”误列为直接加速。下一步建议:
1. 给压力流量层补快路径命中、累计非线性 `nfev` 和残差装配时间,继续拆解本次确认的首要热点;
2. 用同一套基准对执行计划裁剪、组件级精确物性复用做 `off` 模式 A/B;
1. 继续汇总压力流量快路径命中、非线性 `nfev`、真实 `residualEvaluations` 和残差装配时间;单次诊断已能区分 block sparse、global sparse 和 dense 回退;
2. 用同一套基准把方程声明图 A/B 扩展到更多自定义组件、活动接触和保守回退路径;大型 98 组件 XML 与首轮短算例已经覆盖可信内置路径;
3. 补 1/8/32 单元规模曲线、1/2/4 并发吞吐和峰值 RSS;
4. 记录状态/结果数组字节、任务队列深度;结果 JSON 字节可继续由基准工具测量;
5. 再决定代数分块/Jacobian、结果按需计算和进程 worker 的实施深度。
5. 再决定 equality group 真正消元/解析 Jacobian、结果按需计算和进程 worker 的实施深度。
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
@@ -497,7 +542,12 @@ O(组件 + 连接 + 代数结构)
- System XML 校验、拓扑编译和通用半显式 ODE/代数求解主链。
- 气动、机械和信号的专用闭合顺序。
- 压力流量显式因果化快路径与 `least_squares` 回退。
- 压力流量显式因果化快路径,以及可信声明图上的 union block sparse、global sparse→dense `least_squares` 兼容回退;失败候选不会污染原始 `x0`。
- signal/stream/外部 volume 静态绑定,以及“全网语义初解 + stream-sensitive 方程块重算 + 保守全网回退”的闭合执行计划。
- 明确区分物理岛、方程块和方程块未知量的诊断,并记录实际残差回调、Jacobian 模式、最后作用域和子块失败作用域。
- BDF/Radau 状态 `jac_sparsity`,以及外部 volume 跨域双向依赖修正。
- dense output 按采样跨越/状态事件惰性构造;状态事件模型仍保持每步插值能力。
- 允许严格正亚帕试探压力的高刚度 RK45 路径;零压和负压仍不接受。
- 自适应积分、输出采样、信号断点、机械端挡、协作取消和部分结果。
- NDJSON 长响应、心跳、取消端点、异常恢复轮询和任务状态表。
- 单 Uvicorn worker、每任务 daemon 线程、完整终态结果驻留与浏览器多副本行为。
@@ -511,8 +561,11 @@ O(组件 + 连接 + 代数结构)
### 已有初步实测、仍需扩大样本
- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,现有 PR 缓存有可见端到端收益。
- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,仿真隔离的四项 PR 氦气缓存已取得可见端到端收益。
- 长氦气代表算例的积分阶段占约 90.5%,后处理约 5%。
- 物理岛版闭合执行计划的历史短算例配对收益为 0.6%~21.4%;完整 high-stiffness optimized/forced-global 历史对照为 14.676 s/16.929 s,数值序列一致。这些基线保留用于纵向比较,但已不是当前方程块实现。
- 当前大型分支 XML 的 stream 后续闭合为 9 个方程块/192 个未知量;`0~0.01 s` optimized/forced-global 为 16.200 s/18.584 s 且物理解/`series` 逐值一致。最终稳定代码完整 `0~0.81 s` 连续三次为 79.049 s/74.658 s/85.103 s,积分统计一致。
- 代数 sparse 扰动实验将真实残差回调由 3796 降至 164(约 11.6 倍耗时改善);它是回退微基准,不能外推为所有仿真的整体加速倍数。
- 上述结论仍需在更大拓扑、更多真实工程和固定硬件环境复测。
### 推断及必须继续实测
@@ -529,12 +582,14 @@ O(组件 + 连接 + 代数结构)
| JSON/XML 网络编译 | `app/main.py` | `compile_reactflow_network()`、`compile_system_xml_network()`、`_compile_solver_network()` |
| XML v3 校验/解析 | `app/system_xml.py`、`schemas/system-simulation-v3.xsd` | `SystemXmlDocument`、`validate_system_xml_document()` |
| 通用系统准备与仿真 | `app/simulation/systems/generic.py:93-474` | `GenericFluidSystem`、`_close_current_state()` |
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py:86-968` | `PressureFlowSolver.solve()` |
| 压力流量代数闭合 | `app/simulation/solvers/algebraic.py` | `PressureFlowSolver.solve()`、方程关联图、sparse→dense 回退 |
| stream 方程块闭合 | `app/simulation/solvers/algebraic_blocks.py` | `StreamPressureBlockSolver` |
| stream 焓 | `app/simulation/solvers/stream.py:29-119` | `StreamResolver.solve()` |
| 标量信号 | `app/simulation/solvers/signal.py:40-109` | `SignalResolver`、`signal_event_times()` |
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
| 启动暖机 | `app/simulation/warmup.py` | BDF、稀疏分组、`least_squares` sparse LSMR 路径 |
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
+987 -262
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
import { Background, BackgroundVariant } from "@xyflow/react";
export const CANVAS_GRID_SIZE = 18;
export type CanvasGridVisibility = {
lines: boolean;
dots: boolean;
};
type CanvasGridBackgroundProps = {
visibility: CanvasGridVisibility;
};
export function CanvasGridBackground({
visibility,
}: CanvasGridBackgroundProps) {
return (
<>
{visibility.lines ? (
<Background
color="#d6e0ea"
gap={CANVAS_GRID_SIZE * 5}
id="major-grid-lines"
lineWidth={1}
variant={BackgroundVariant.Lines}
/>
) : null}
{visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_GRID_SIZE}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null}
</>
);
}
+5 -2
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { memo, useLayoutEffect, useRef, useState } from "react";
import {
AmesimPnpl01Symbol,
@@ -665,7 +665,7 @@ export function componentSymbolVariantKey(
return definition.variantKey?.(parameters) ?? "";
}
export function ComponentSymbol({
function ComponentSymbolImpl({
symbol,
surface,
className,
@@ -752,3 +752,6 @@ export function ComponentSymbol({
</span>
);
}
export const ComponentSymbol = memo(ComponentSymbolImpl);
ComponentSymbol.displayName = "ComponentSymbol";
+37 -8
View File
@@ -20,15 +20,19 @@ import {
X,
} from "lucide-react";
import {
Background,
ConnectionLineType,
ConnectionMode,
Controls,
ReactFlow,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from "@xyflow/react";
import {
CanvasGridBackground,
type CanvasGridVisibility,
} from "./CanvasGridBackground";
import { hasComponentSymbol } from "./ComponentSymbol";
import {
WorkspaceViewTabs,
type WorkspaceView,
@@ -52,6 +56,7 @@ type ResultNodeData = {
label: string;
componentType: string;
modelType: string;
symbol?: string;
ports: ResultPortDefinition[];
parameters: Record<string, number | string>;
parameterUnits?: Record<string, string>;
@@ -170,6 +175,7 @@ type SimulationResultFile = {
type SimulationResultsViewProps = {
snapshot: SimulationResultsSnapshot;
gridVisibility: CanvasGridVisibility;
nodeTypes: NodeTypes;
onViewChange: (view: WorkspaceView) => void;
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
@@ -335,10 +341,12 @@ const RESULT_UNIT_OPTIONS: Record<string, ResultUnitOption[]> = {
export function SimulationResultsView({
snapshot,
gridVisibility,
nodeTypes,
onViewChange,
onSnapshotLoad,
}: SimulationResultsViewProps) {
const reactFlow = useReactFlow();
const resultStatus = simulationResultStatus(snapshot.result);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null,
@@ -467,7 +475,15 @@ export function SimulationResultsView({
() =>
snapshot.project.nodes.map((node) => ({
...node,
className: "results-readonly-node",
className: [
"results-readonly-node",
hasComponentSymbol(node.data.symbol ?? node.data.componentType) ||
hasComponentSymbol(node.data.modelType)
? "component-icon-node"
: "",
]
.filter(Boolean)
.join(" "),
connectable: false,
draggable: false,
focusable: true,
@@ -1134,7 +1150,23 @@ export function SimulationResultsView({
<section className="results-system-panel">
<div className="results-section-heading">
<strong>系统图</strong>
<span>{snapshot.project.nodes.length} 个组件</span>
<div className="results-system-heading-tools">
<span>{snapshot.project.nodes.length} 个组件</span>
<button
aria-label="适应系统图窗口"
onClick={() => {
void reactFlow.fitView({
duration: 180,
minZoom: 0.1,
padding: 0.18,
});
}}
title="适应窗口"
type="button"
>
<Scaling aria-hidden="true" size={14} strokeWidth={1.8} />
</button>
</div>
</div>
<div className="results-system-canvas">
<ReactFlow
@@ -1166,10 +1198,7 @@ export function SimulationResultsView({
fitOnMount
requestKey={snapshot.id}
/>
<Background gap={18} />
<Controls
fitViewOptions={{ duration: 180, minZoom: 0.1, padding: 0.18 }}
/>
<CanvasGridBackground visibility={gridVisibility} />
</ReactFlow>
</div>
</section>
+38
View File
@@ -2138,6 +2138,32 @@ textarea {
font-size: 11px;
}
.results-system-heading-tools {
display: flex;
align-items: center;
gap: 8px;
}
.results-system-heading-tools button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
place-items: center;
color: #526172;
background: transparent;
cursor: pointer;
}
.results-system-heading-tools button:hover,
.results-system-heading-tools button:focus-visible {
border-color: #c5cfda;
color: #1d6fb8;
background: #ffffff;
}
.results-chart-heading {
position: relative;
z-index: 25;
@@ -2265,6 +2291,18 @@ textarea {
cursor: pointer;
}
.results-system-canvas .react-flow__node.component-icon-node,
.results-system-canvas .react-flow__node.component-icon-node .sim-node {
pointer-events: none !important;
}
.results-system-canvas
.react-flow__node.component-icon-node
.node-symbol-envelope {
cursor: pointer;
pointer-events: auto;
}
.results-system-canvas .react-flow__handle {
cursor: default;
pointer-events: none;
@@ -2902,6 +2902,8 @@ test("恢复工程时采用当前目录端口并明确丢弃已删除端口的
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/");
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
@@ -3214,6 +3216,39 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expect(signalInput).toHaveCSS("opacity", "0");
await expect(signalOutput).toHaveCSS("pointer-events", "none");
await expect(signalInput).toHaveCSS("pointer-events", "none");
const renderedEdges = page.locator(".flow-canvas .react-flow__edge");
const clickEdgeMiddle = async (edgeIndex: number) => {
const point = await renderedEdges
.nth(edgeIndex)
.locator(".react-flow__edge-interaction")
.evaluate((path: SVGPathElement) => {
const localPoint = path.getPointAtLength(path.getTotalLength() / 2);
const matrix = path.getScreenCTM();
if (!matrix) {
throw new Error("Unable to resolve the edge screen position");
}
const screenPoint = new DOMPoint(
localPoint.x,
localPoint.y,
).matrixTransform(matrix);
return { x: screenPoint.x, y: screenPoint.y };
});
await page.mouse.click(point.x, point.y);
};
await clickEdgeMiddle(0);
await expect(renderedEdges).toHaveCount(2);
await expect(renderedEdges.nth(0)).toHaveClass(
/selected|editor-edge-selected/,
);
await clickEdgeMiddle(1);
await expect(renderedEdges).toHaveCount(2);
await expect(renderedEdges.nth(1)).toHaveClass(
/selected|editor-edge-selected/,
);
await expect(page.getByText("System Simulation Flow", { exact: true })).toBeVisible();
expect(pageErrors).toEqual([]);
});
test("端口图标随画布缩小且物理端口始终保持标准圆形", async ({ page }) => {
+231 -2
View File
@@ -6,9 +6,9 @@ import {
wideProject,
} from "./fixtures";
async function readModelingViewport(page: Page) {
async function readViewport(page: Page, canvasSelector: string) {
return page
.locator(".flow-canvas .react-flow__viewport")
.locator(`${canvasSelector} .react-flow__viewport`)
.evaluate((viewport) => {
const values =
getComputedStyle(viewport)
@@ -22,6 +22,10 @@ async function readModelingViewport(page: Page) {
});
}
async function readModelingViewport(page: Page) {
return readViewport(page, ".flow-canvas");
}
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
@@ -257,10 +261,24 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
window.localStorage.setItem(
"system-simulation-flow:canvas-grid-visibility",
JSON.stringify({ lines: true, dots: false }),
);
}, { project: wideProject, snapshot: resultSnapshot });
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
),
).toHaveCount(0);
const freeModelingHandle = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
);
@@ -271,6 +289,24 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
),
).toHaveCount(0);
await expect(
page.locator(".results-system-canvas .react-flow__controls"),
).toHaveCount(0);
const fitSystemViewButton = page.getByRole("button", {
name: "适应系统图窗口",
exact: true,
});
await expect(fitSystemViewButton).toBeVisible();
await expect(
page.locator(".results-system-canvas .react-flow__attribution"),
).toHaveCount(0);
@@ -278,6 +314,49 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
const resultPane = page.locator(".results-system-canvas .react-flow__pane");
const resultPaneBox = await resultPane.boundingBox();
expect(resultPaneBox).not.toBeNull();
const fittedResultViewport = await readViewport(
page,
".results-system-canvas",
);
await page.mouse.move(
resultPaneBox!.x + resultPaneBox!.width / 2,
resultPaneBox!.y + resultPaneBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
resultPaneBox!.x + resultPaneBox!.width / 2 + 180,
resultPaneBox!.y + resultPaneBox!.height / 2 + 90,
{ steps: 5 },
);
await page.mouse.up();
await expect
.poll(async () => {
const viewport = await readViewport(page, ".results-system-canvas");
return Math.hypot(
viewport.x - fittedResultViewport.x,
viewport.y - fittedResultViewport.y,
);
})
.toBeGreaterThan(100);
const pannedResultViewport = await readViewport(
page,
".results-system-canvas",
);
await fitSystemViewButton.click();
await expect
.poll(async () => {
const viewport = await readViewport(page, ".results-system-canvas");
return Math.hypot(
viewport.x - pannedResultViewport.x,
viewport.y - pannedResultViewport.y,
);
})
.toBeGreaterThan(100);
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await page.getByRole("tab", { name: "建模" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
await expect(freeModelingHandle).toHaveAttribute(
@@ -354,3 +433,153 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
}
});
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
page,
}) => {
await prepareApp(page);
const pneumaticPort = (name: string, side: "left" | "right") => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
});
const pnplNode = (
id: string,
position: { x: number; y: number },
rotation: 0 | 90 | 180 | 270,
) => ({
id,
type: "simulationComponent",
position,
data: {
label: id,
componentType: "amesim_pnpl01",
modelType: "amesim_pnpl01",
modelVersion: "0.1.0",
ports: [pneumaticPort("port_1", "left")],
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation,
mirrored: false,
},
});
const targetNodeId = "amesim_pnch012_2";
const overlapProject = {
...wideProject,
name: "overlapped-symbols",
nodes: [
{
id: "generic_sensor_0",
type: "simulationComponent",
position: { x: 360, y: 390 },
data: {
label: "generic_sensor_0",
componentType: "generic_sensor",
modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
ports: [],
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation: 0,
mirrored: false,
},
},
{
id: targetNodeId,
type: "simulationComponent",
position: { x: 569.2129060235693, y: 515.6448805401301 },
data: {
label: targetNodeId,
componentType: "amesim_pnch012",
modelType: "amesim_pnch012",
modelVersion: "0.1.0",
ports: [
pneumaticPort("port_1", "left"),
pneumaticPort("port_2", "right"),
pneumaticPort("port_3", "left"),
pneumaticPort("port_4", "right"),
],
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation: 0,
mirrored: false,
},
},
pnplNode(
"amesim_pnpl01_7",
{ x: 535.6129060235693, y: 523.6448805401301 },
180,
),
pnplNode(
"amesim_pnpl01_8",
{ x: 587.2129060235693, y: 555.2448805401301 },
90,
),
pnplNode(
"amesim_pnpl01_9",
{ x: 618.8129060235693, y: 523.6448805401301 },
0,
),
],
edges: [],
};
const overlapSnapshot = {
...resultSnapshot,
id: "overlapped-symbols-result",
project: overlapProject,
};
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, overlapSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(
page,
".results-system-canvas",
overlapProject.nodes.length,
);
const targetNode = page.locator(
`.results-system-canvas .react-flow__node[data-id="${targetNodeId}"]`,
);
const targetEnvelope = targetNode.locator('[data-symbol-envelope="true"]');
await expect(targetNode).toHaveClass(/component-icon-node/);
await expect(targetNode).toHaveCSS("pointer-events", "none");
await expect(targetEnvelope).toHaveCSS("pointer-events", "auto");
const pane = page.locator(".results-system-canvas .react-flow__pane");
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
await page.mouse.click(paneBox!.x + 6, paneBox!.y + 6);
await expect(targetNode).not.toHaveClass(/selected/);
const envelopeBox = await targetEnvelope.boundingBox();
expect(envelopeBox).not.toBeNull();
const targetPoint = {
x: envelopeBox!.x + envelopeBox!.width / 2,
y: envelopeBox!.y + envelopeBox!.height / 2,
};
const hitNodeId = await page.evaluate(({ x, y }) => {
const element = document.elementFromPoint(x, y);
return element?.closest<HTMLElement>(".react-flow__node")?.dataset.id ?? null;
}, targetPoint);
expect(hitNodeId).toBe(targetNodeId);
await page.mouse.click(targetPoint.x, targetPoint.y);
await expect(targetNode).toHaveClass(/selected/);
await expect(
page.locator(
'.results-system-canvas .react-flow__node[data-id^="amesim_pnpl01_"].selected',
),
).toHaveCount(0);
});
+325
View File
@@ -0,0 +1,325 @@
<?xml version="1.0" encoding="UTF-8"?>
<System name="demo-system" schemaVersion="3" unitSystem="SI">
<Simulation tStart="0" tStop="10" sampleStep="0.02" maxStep="0.02" method="RK45"/>
<Components>
<Component id="amesim_pnch023_1" type="amesim_pnch023" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="cvol" value="0.057"/>
<Parameter name="kth" value="0"/>
<Parameter name="sth" value="0.1"/>
<Parameter name="extemp" value="293.15"/>
<Parameter name="p0" value="15300000"/>
<Parameter name="T0" value="293.15"/>
</Component>
<Component id="amesim_pnvo001_1" type="amesim_pnvo001" modelVersion="0.2.0">
<Parameter name="gi" value="1"/>
<Parameter name="cq" value="0.45"/>
<Parameter name="area0" value="0.0000785"/>
<Parameter name="Cv" value="0.5"/>
<Parameter name="Kv" value="0.4"/>
<Parameter name="flowset" value="1"/>
<Parameter name="opening0" value="0"/>
</Component>
<Component id="amesim_step0_1" type="amesim_step0" modelVersion="0.1.0">
<Parameter name="initial" value="0"/>
<Parameter name="final" value="1"/>
<Parameter name="time" value="0.04"/>
</Component>
<Component id="amesim_pnch012_1" type="amesim_pnch012" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="cvol0" value="0.015"/>
<Parameter name="kth" value="1500"/>
<Parameter name="sth" value="0.7"/>
<Parameter name="extemp" value="293.15"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="293.15"/>
<Parameter name="vol1" value="0"/>
<Parameter name="vol2" value="0"/>
<Parameter name="vol3" value="0"/>
<Parameter name="vol4" value="0"/>
<Parameter name="dvol1" value="0"/>
<Parameter name="dvol2" value="0"/>
<Parameter name="dvol3" value="0"/>
<Parameter name="dvol4" value="0"/>
</Component>
<Component id="amesim_pnpl01_2" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_3" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_4" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_ud00_1" type="amesim_ud00" modelVersion="0.2.0">
<Parameter name="tstart" value="0"/>
<Parameter name="start1" value="100000000000000000"/>
<Parameter name="end1" value="100000000000000000"/>
<Parameter name="t1" value="0.8"/>
<Parameter name="start2" value="49000"/>
<Parameter name="end2" value="49000"/>
<Parameter name="t2" value="10"/>
<Parameter name="start3" value="1"/>
<Parameter name="end3" value="1"/>
<Parameter name="t3" value="0"/>
<Parameter name="start4" value="1"/>
<Parameter name="end4" value="1"/>
<Parameter name="t4" value="0"/>
<Parameter name="start5" value="1"/>
<Parameter name="end5" value="1"/>
<Parameter name="t5" value="0"/>
<Parameter name="start6" value="1"/>
<Parameter name="end6" value="1"/>
<Parameter name="t6" value="0"/>
<Parameter name="start7" value="1"/>
<Parameter name="end7" value="1"/>
<Parameter name="t7" value="0"/>
<Parameter name="start8" value="1"/>
<Parameter name="end8" value="1"/>
<Parameter name="t8" value="0"/>
<Parameter name="nstages" value="2"/>
<Parameter name="iscyclic" value="0"/>
</Component>
<Component id="amesim_forc_1" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="amesim_pnrp17_1" type="amesim_pnrp17" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="dp" value="0.2"/>
<Parameter name="dr" value="0.001"/>
<Parameter name="x0" value="0"/>
</Component>
<Component id="amesim_helium_medium_1" type="amesim_helium_medium" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="property_model" value="0"/>
</Component>
<Component id="amesim_pnch012_2" type="amesim_pnch012" modelVersion="0.1.0">
<Parameter name="gi" value="1"/>
<Parameter name="cvol0" value="0.015"/>
<Parameter name="kth" value="1500"/>
<Parameter name="sth" value="0.7"/>
<Parameter name="extemp" value="293.15"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="293.15"/>
<Parameter name="vol1" value="0"/>
<Parameter name="vol2" value="0"/>
<Parameter name="vol3" value="0"/>
<Parameter name="vol4" value="0"/>
<Parameter name="dvol1" value="0"/>
<Parameter name="dvol2" value="0"/>
<Parameter name="dvol3" value="0"/>
<Parameter name="dvol4" value="0"/>
</Component>
<Component id="amesim_pnpl01_6" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_7" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_8" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_pnpl01_9" type="amesim_pnpl01" modelVersion="0.1.0"/>
<Component id="amesim_mecmas21_2" type="amesim_mecmas21" modelVersion="0.2.0">
<Parameter name="mass" value="50"/>
<Parameter name="fstick" value="0"/>
<Parameter name="fcoul" value="0"/>
<Parameter name="rvisc" value="0"/>
<Parameter name="wind" value="0"/>
<Parameter name="dvel" value="0.000001"/>
<Parameter name="restdvel" value="0.000001"/>
<Parameter name="restcoeff" value="0.65"/>
<Parameter name="astrib" value="0.001"/>
<Parameter name="xmin" value="-1"/>
<Parameter name="Kbmin" value="1000000000"/>
<Parameter name="Dbmin" value="10000"/>
<Parameter name="Pdmin" value="0.0001"/>
<Parameter name="xmax" value="0.8"/>
<Parameter name="Kbmax" value="1000000000"/>
<Parameter name="Dbmax" value="10000"/>
<Parameter name="Pdmax" value="0.0001"/>
<Parameter name="theta" value="0"/>
<Parameter name="useFriction" value="1"/>
<Parameter name="stoptype" value="4"/>
<Parameter name="discContactOption" value="1"/>
<Parameter name="strib" value="1"/>
<Parameter name="frictionType" value="1"/>
<Parameter name="v0" value="0"/>
<Parameter name="x0" value="0"/>
</Component>
<Component id="amesim_f000_1" type="amesim_f000" modelVersion="0.1.0"/>
<Component id="amesim_f000_2" type="amesim_f000" modelVersion="0.1.0"/>
<Component id="amesim_lstp00a_1" type="amesim_lstp00a" modelVersion="0.2.0">
<Parameter name="na" value="10"/>
<Parameter name="gap0" value="0"/>
<Parameter name="kcont" value="100000000000"/>
<Parameter name="G" value="85700000000"/>
<Parameter name="sdiam" value="0.02"/>
<Parameter name="wdiam" value="0.002"/>
<Parameter name="rcont" value="100000000000"/>
<Parameter name="Pdis" value="1e-7"/>
<Parameter name="stiffmode" value="1"/>
<Parameter name="discContactOption" value="1"/>
</Component>
<Component id="amesim_forc_2" type="amesim_forc" modelVersion="0.2.0">
<Parameter name="direction" value="1"/>
</Component>
<Component id="amesim_ud00_2" type="amesim_ud00" modelVersion="0.2.0">
<Parameter name="tstart" value="0"/>
<Parameter name="start1" value="1000000000000"/>
<Parameter name="end1" value="1000000000000"/>
<Parameter name="t1" value="0.8"/>
<Parameter name="start2" value="0"/>
<Parameter name="end2" value="0"/>
<Parameter name="t2" value="10"/>
<Parameter name="start3" value="1"/>
<Parameter name="end3" value="1"/>
<Parameter name="t3" value="0"/>
<Parameter name="start4" value="1"/>
<Parameter name="end4" value="1"/>
<Parameter name="t4" value="0"/>
<Parameter name="start5" value="1"/>
<Parameter name="end5" value="1"/>
<Parameter name="t5" value="0"/>
<Parameter name="start6" value="1"/>
<Parameter name="end6" value="1"/>
<Parameter name="t6" value="0"/>
<Parameter name="start7" value="1"/>
<Parameter name="end7" value="1"/>
<Parameter name="t7" value="0"/>
<Parameter name="start8" value="1"/>
<Parameter name="end8" value="1"/>
<Parameter name="t8" value="0"/>
<Parameter name="nstages" value="2"/>
<Parameter name="iscyclic" value="0"/>
</Component>
<Component id="amesim_mecmas21_5" type="amesim_mecmas21" modelVersion="0.2.0">
<Parameter name="mass" value="170000"/>
<Parameter name="fstick" value="0"/>
<Parameter name="fcoul" value="0"/>
<Parameter name="rvisc" value="0"/>
<Parameter name="wind" value="0"/>
<Parameter name="dvel" value="0.000001"/>
<Parameter name="restdvel" value="0.000001"/>
<Parameter name="restcoeff" value="0.65"/>
<Parameter name="astrib" value="0.001"/>
<Parameter name="xmin" value="0"/>
<Parameter name="Kbmin" value="1000000000"/>
<Parameter name="Dbmin" value="10000"/>
<Parameter name="Pdmin" value="0.0001"/>
<Parameter name="xmax" value="0.37"/>
<Parameter name="Kbmax" value="1000000000"/>
<Parameter name="Dbmax" value="10000"/>
<Parameter name="Pdmax" value="0.0001"/>
<Parameter name="theta" value="0"/>
<Parameter name="useFriction" value="1"/>
<Parameter name="stoptype" value="1"/>
<Parameter name="discContactOption" value="1"/>
<Parameter name="strib" value="1"/>
<Parameter name="frictionType" value="1"/>
<Parameter name="v0" value="0"/>
<Parameter name="x0" value="0"/>
</Component>
<Component id="amesim_mecmas21_7" type="amesim_mecmas21" modelVersion="0.2.0">
<Parameter name="mass" value="90000"/>
<Parameter name="fstick" value="0"/>
<Parameter name="fcoul" value="0"/>
<Parameter name="rvisc" value="0"/>
<Parameter name="wind" value="0"/>
<Parameter name="dvel" value="0.000001"/>
<Parameter name="restdvel" value="0.000001"/>
<Parameter name="restcoeff" value="0.65"/>
<Parameter name="astrib" value="0.001"/>
<Parameter name="xmin" value="-0.72"/>
<Parameter name="Kbmin" value="1000000000"/>
<Parameter name="Dbmin" value="10000"/>
<Parameter name="Pdmin" value="0.0001"/>
<Parameter name="xmax" value="0"/>
<Parameter name="Kbmax" value="1000000000"/>
<Parameter name="Dbmax" value="10000"/>
<Parameter name="Pdmax" value="0.0001"/>
<Parameter name="theta" value="0"/>
<Parameter name="useFriction" value="1"/>
<Parameter name="stoptype" value="1"/>
<Parameter name="discContactOption" value="1"/>
<Parameter name="strib" value="1"/>
<Parameter name="frictionType" value="1"/>
<Parameter name="v0" value="0"/>
<Parameter name="x0" value="0"/>
</Component>
</Components>
<Connections>
<Connection id="edge-amesim_pnch023_1-port_2-amesim_pnvo001_1-port_2-1786524999267">
<Endpoint component="amesim_pnch023_1" port="port_2"/>
<Endpoint component="amesim_pnvo001_1" port="port_2"/>
</Connection>
<Connection id="edge-amesim_pnvo001_1-port_3-amesim_pnch012_1-port_1-1786525009973">
<Endpoint component="amesim_pnvo001_1" port="port_3"/>
<Endpoint component="amesim_pnch012_1" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_2-port_1-amesim_pnch012_1-port_2-1786525035706-0">
<Endpoint component="amesim_pnpl01_2" port="port_1"/>
<Endpoint component="amesim_pnch012_1" port="port_2"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_3-port_1-amesim_pnch012_1-port_3-1786525054054-0">
<Endpoint component="amesim_pnpl01_3" port="port_1"/>
<Endpoint component="amesim_pnch012_1" port="port_3"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_4-port_1-amesim_pnch012_1-port_4-1786525061349-0">
<Endpoint component="amesim_pnpl01_4" port="port_1"/>
<Endpoint component="amesim_pnch012_1" port="port_4"/>
</Connection>
<Connection id="edge-amesim_step0_1-out-amesim_pnvo001_1-res-1786525074494">
<Endpoint component="amesim_step0_1" port="out"/>
<Endpoint component="amesim_pnvo001_1" port="res"/>
</Connection>
<Connection id="edge-amesim_ud00_1-out-amesim_forc_1-res-1786525088412">
<Endpoint component="amesim_ud00_1" port="out"/>
<Endpoint component="amesim_forc_1" port="res"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_6-port_1-amesim_pnch023_1-port_1-1786525164428-0">
<Endpoint component="amesim_pnpl01_6" port="port_1"/>
<Endpoint component="amesim_pnch023_1" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_7-port_1-amesim_pnch012_2-port_1-1786525181969-0">
<Endpoint component="amesim_pnpl01_7" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_8-port_1-amesim_pnch012_2-port_4-1786525185493-0">
<Endpoint component="amesim_pnpl01_8" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_4"/>
</Connection>
<Connection id="edge-contact-amesim_pnpl01_9-port_1-amesim_pnch012_2-port_2-1786525188482-0">
<Endpoint component="amesim_pnpl01_9" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_2"/>
</Connection>
<Connection id="edge-contact-amesim_f000_1-port_1-amesim_mecmas21_2-port_2-1786525202665-0">
<Endpoint component="amesim_f000_1" port="port_1"/>
<Endpoint component="amesim_mecmas21_2" port="port_2"/>
</Connection>
<Connection id="edge-amesim_mecmas21_2-port_1-amesim_pnrp17_1-port_2-1786525203778">
<Endpoint component="amesim_mecmas21_2" port="port_1"/>
<Endpoint component="amesim_pnrp17_1" port="port_2"/>
</Connection>
<Connection id="edge-amesim_pnrp17_1-port_5-amesim_lstp00a_1-port_1-1786525213548">
<Endpoint component="amesim_pnrp17_1" port="port_5"/>
<Endpoint component="amesim_lstp00a_1" port="port_1"/>
</Connection>
<Connection id="edge-amesim_ud00_2-out-amesim_forc_2-res-1786525226761">
<Endpoint component="amesim_ud00_2" port="out"/>
<Endpoint component="amesim_forc_2" port="res"/>
</Connection>
<Connection id="edge-contact-amesim_pnrp17_1-port_4-amesim_f000_2-port_1-1786525729832-0">
<Endpoint component="amesim_pnrp17_1" port="port_4"/>
<Endpoint component="amesim_f000_2" port="port_1"/>
</Connection>
<Connection id="edge-contact-amesim_pnrp17_1-port_1-amesim_pnch012_2-port_3-1786525729832-1">
<Endpoint component="amesim_pnrp17_1" port="port_1"/>
<Endpoint component="amesim_pnch012_2" port="port_3"/>
</Connection>
<Connection id="edge-amesim_forc_1-port_2-amesim_mecmas21_7-port_2-1786525915163">
<Endpoint component="amesim_forc_1" port="port_2"/>
<Endpoint component="amesim_mecmas21_7" port="port_2"/>
</Connection>
<Connection id="edge-amesim_mecmas21_7-port_1-amesim_pnrp17_1-port_3-1786525916652">
<Endpoint component="amesim_mecmas21_7" port="port_1"/>
<Endpoint component="amesim_pnrp17_1" port="port_3"/>
</Connection>
<Connection id="edge-amesim_mecmas21_5-port_1-amesim_forc_2-port_2-1786525922010">
<Endpoint component="amesim_mecmas21_5" port="port_1"/>
<Endpoint component="amesim_forc_2" port="port_2"/>
</Connection>
<Connection id="edge-amesim_lstp00a_1-port_2-amesim_mecmas21_5-port_2-1786525924224">
<Endpoint component="amesim_lstp00a_1" port="port_2"/>
<Endpoint component="amesim_mecmas21_5" port="port_2"/>
</Connection>
</Connections>
</System>
+21 -18
View File
@@ -13,6 +13,7 @@ from app.simulation.components.amesim.media.mediums import (
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.peng_robinson import HELIUM_PR
from app.simulation.property_cache import property_cache_run
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
from tests.test_generic_system_xml_simulation import component_node
from tests.test_system_xml_protocol import physical_port
@@ -93,17 +94,18 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
delta=1.0e-8,
)
medium.temperature_from_pressure_enthalpy.cache_clear()
first = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_first = medium.temperature_from_pressure_enthalpy.cache_info()
second = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_second = medium.temperature_from_pressure_enthalpy.cache_info()
with property_cache_run() as cache:
assert cache is not None
first = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_first = cache.info()
second = medium.temperature_from_pressure_enthalpy(
pressure,
transport_enthalpy,
)
after_second = cache.info()
self.assertEqual(second, first)
self.assertEqual(after_first.misses, 1)
self.assertEqual(after_second.hits, 1)
@@ -146,13 +148,14 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
temperature,
)
first = medium.properties_from_mU(mass, internal_energy, volume)
second = medium.properties_from_mU(mass, internal_energy, volume)
changed = medium.properties_from_mU(
mass,
internal_energy,
volume * 1.01,
)
with property_cache_run():
first = medium.properties_from_mU(mass, internal_energy, volume)
second = medium.properties_from_mU(mass, internal_energy, volume)
changed = medium.properties_from_mU(
mass,
internal_energy,
volume * 1.01,
)
self.assertIs(second, first)
self.assertIsNot(changed, first)
@@ -241,6 +241,85 @@ class AmesimPnl0003ComponentTests(unittest.TestCase):
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None:
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = AmesimPnl0003(
"below",
self.medium,
p1_0=base_pressure + 0.99 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
above = AmesimPnl0003(
"above",
self.medium,
p1_0=base_pressure + 1.01 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
reverse = AmesimPnl0003(
"reverse",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure + 0.99 * former_threshold,
).resistance_mass_flow()
equal = AmesimPnl0003(
"equal",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure,
).resistance_mass_flow()
small = AmesimPnl0003(
"small",
self.medium,
p1_0=15.3e6 + 0.1,
p2_0=15.3e6,
).resistance_mass_flow()
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(equal, 0.0)
self.assertGreater(small, 0.0)
def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None:
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = AmesimPnl0003(
"below",
self.medium,
p1_0=base_pressure + 0.99 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
above = AmesimPnl0003(
"above",
self.medium,
p1_0=base_pressure + 1.01 * former_threshold,
p2_0=base_pressure,
).resistance_mass_flow()
reverse = AmesimPnl0003(
"reverse",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure + 0.99 * former_threshold,
).resistance_mass_flow()
equal = AmesimPnl0003(
"equal",
self.medium,
p1_0=base_pressure,
p2_0=base_pressure,
).resistance_mass_flow()
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(equal, 0.0)
def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None:
pipe = AmesimPnl0003("pnl_3", self.medium)
pipe.properties_1()
+33
View File
@@ -59,6 +59,39 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0)
self.assertGreater(pipe.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0)
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0)
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0)
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
@@ -64,6 +64,23 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase):
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse)
def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
base_pressure = 8.0e6
former_threshold = base_pressure * 1.0e-7
below = valve.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure)
above = valve.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure)
reverse = valve.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold)
self.assertGreater(below, 0.0)
self.assertGreater(above, below)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9)
self.assertLess(abs(above - below), abs(above) * 0.05)
self.assertEqual(valve.mass_flow(base_pressure, base_pressure), 0.0)
self.assertGreater(valve.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0)
def test_mass_flow_uses_connected_enthalpy_from_the_upstream_side(self) -> None:
valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5)
hot_h = self.medium.specific_enthalpy(600.0)
@@ -162,6 +162,8 @@ class ContactSolverCausalizationTests(unittest.TestCase):
self.assertTrue(diagnostics.success, diagnostics.message)
self.assertGreater(diagnostics.evaluations, 0)
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertFalse(diagnostics.dense_fallback_used)
self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3)
self.assertAlmostEqual(load.mechanical.f, -41.0, delta=1.0e-3)
self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3)
+175
View File
@@ -12,6 +12,36 @@ from app.simulation.solvers.solver import (
)
def _counting_fixed_step_solver(
step_size: float,
dense_output_times: list[float],
):
import numpy as np
class FixedStepSolver:
def __init__(self, _fun, t0, y0, t_bound, **_kwargs):
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
self.nfev = 0
self.njev = 0
self.nlu = 0
def step(self):
self.t = min(self.t + step_size, self.t_bound)
self.y = np.asarray([self.t], dtype=float)
if self.t >= self.t_bound:
self.status = "finished"
return None
def dense_output(self):
dense_output_times.append(self.t)
return lambda time: np.asarray([float(time)], dtype=float)
return FixedStepSolver
class IntegrateOdeTests(unittest.TestCase):
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
@@ -159,6 +189,151 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertEqual(result.status, "cancelled")
self.assertEqual(result.t, [0.0])
def test_stepwise_solvers_build_dense_output_only_when_crossing_a_sample(
self,
) -> None:
import scipy.integrate
for method in ("BDF", "Radau", "RK45"):
with self.subTest(method=method):
dense_output_times: list[float] = []
fixed_step_solver = _counting_fixed_step_solver(
0.2,
dense_output_times,
)
with patch.object(scipy.integrate, method, fixed_step_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method=method,
max_step=1.0,
),
t_eval=[0.0, 0.75, 1.0],
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertEqual(result.t, [0.0, 0.75, 1.0])
self.assertEqual(result.y, [[0.0, 0.75, 1.0]])
self.assertEqual(dense_output_times, [0.8, 1.0])
self.assertEqual(
result.solver_segments[0].accepted_step_count,
5,
)
def test_stepwise_solvers_keep_dense_output_for_state_event_detection(
self,
) -> None:
import scipy.integrate
for method in ("BDF", "Radau", "RK45"):
with self.subTest(method=method):
dense_output_times: list[float] = []
inspected_steps = 0
fixed_step_solver = _counting_fixed_step_solver(
0.25,
dense_output_times,
)
def inspect_state_event(
previous_time,
_previous_state,
current_time,
_current_state,
dense_state,
):
nonlocal inspected_steps
inspected_steps += 1
midpoint = 0.5 * (previous_time + current_time)
self.assertAlmostEqual(dense_state(midpoint)[0], midpoint)
return None
with patch.object(scipy.integrate, method, fixed_step_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method=method,
max_step=1.0,
),
t_eval=[0.0, 1.0],
state_transition_handler=inspect_state_event,
)
self.assertTrue(result.success, result.message)
self.assertEqual(len(dense_output_times), 4)
self.assertEqual(inspected_steps, 4)
def test_stepwise_dense_output_preserves_adjacent_float_samples(self) -> None:
import scipy.integrate
dense_output_times: list[float] = []
adjacent_time = math.nextafter(0.5, math.inf)
half_interval_solver = _counting_fixed_step_solver(
0.5,
dense_output_times,
)
with patch.object(scipy.integrate, "RK45", half_interval_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="RK45",
max_step=1.0,
),
t_eval=[0.0, 0.5, adjacent_time, 1.0],
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertEqual(result.t, [0.0, 0.5, adjacent_time, 1.0])
self.assertEqual(result.y[0], result.t)
self.assertEqual(dense_output_times, [0.5, 1.0])
def test_dense_output_pruning_preserves_cancelled_partial_samples(self) -> None:
import scipy.integrate
cancellation_requested = False
dense_output_times: list[float] = []
fixed_step_solver = _counting_fixed_step_solver(
0.2,
dense_output_times,
)
def request_cancel(time: float) -> None:
nonlocal cancellation_requested
cancellation_requested = time >= 0.4
with patch.object(scipy.integrate, "BDF", fixed_step_solver):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
t_eval=[0.0, 0.3, 0.8, 1.0],
cancel_check=lambda: cancellation_requested,
accepted_step_callback=request_cancel,
)
self.assertFalse(result.success)
self.assertEqual(result.status, "cancelled")
self.assertEqual(result.t, [0.0, 0.3, 0.4])
self.assertEqual(result.y, [[0.0, 0.3, 0.4]])
self.assertEqual(dense_output_times, [0.4])
def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None:
import scipy.integrate
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
from collections.abc import Mapping
from types import SimpleNamespace
import unittest
import numpy as np
from scipy.integrate._ivp.common import num_jac
from app.main import ReactFlowProjectPayload, compile_reactflow_network
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.base import DynamicComponent
from app.simulation.core.ports import PortDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import Endpoint
from tests.test_amesim_pnrp17_xml import pnrp17_coupled_project
from tests.test_generic_system_xml_simulation import component_node, physical_edge
from tests.test_system_xml_protocol import physical_port
class _DynamicVolumeSource(DynamicComponent):
PORTS = (PortDefinition.pneumatic("port"),)
state_size = 1
def __init__(self, name: str) -> None:
super().__init__(name)
self.state = 0.25
self.port = self.register_declared_port("port")
def get_state_vector(self) -> list[float]:
return [self.state]
def set_state_vector(self, values: list[float]) -> None:
self.state = float(values[0])
def refresh_thermodynamic_ports(self):
return None
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
return [0.0]
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
return {"port": (self.state, 0.0)}
class _TrustedDynamicVolumeSource(_DynamicVolumeSource):
pass
_TrustedDynamicVolumeSource.__module__ = "app.simulation.components.synthetic"
class _UntrustedDynamicVolumeSource(_DynamicVolumeSource):
pass
def cross_domain_storage_project() -> ReactFlowProjectPayload:
"""PNL storage -> variable chamber -> pneumatic piston -> two masses."""
base = pnrp17_coupled_project()
nodes = [node.model_dump() for node in base.nodes]
nodes.append(
component_node(
"line_storage",
"amesim_pnl0001",
[
physical_port("port_1", "bidirectional", "left"),
physical_port("port_2", "bidirectional", "right"),
],
{
"diam": 0.01,
"le": 1.0,
"rr": 1.0e-5,
"k": 1.35,
"kth": 0.0,
"extemp": 300.0,
"gi": 0.0,
"mode": 2.0,
"p0": 200000.0,
"T0": 300.0,
},
)
)
edges = [
edge.model_dump()
for edge in base.edges
if edge.id != "edge-boundary-1"
]
edges.extend(
(
physical_edge(
"edge-chamber-line",
"chamber_1",
"port_1",
"line_storage",
"port_1",
),
physical_edge(
"edge-line-boundary",
"line_storage",
"port_2",
"boundary_1",
"port_1",
),
)
)
return ReactFlowProjectPayload(
projectSchemaVersion=base.projectSchemaVersion,
name="cross-domain-jacobian-sparsity",
nodes=nodes,
edges=edges,
simulation=base.simulation.model_dump(),
)
def state_slices(system: GenericFluidSystem) -> dict[str, slice]:
result: dict[str, slice] = {}
cursor = 0
for entry in system.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
entry_size = 2
names = tuple(component.name for component in entry.components)
else:
entry_size = entry.state_size
names = (entry.name,)
state_slice = slice(cursor, cursor + entry_size)
for name in names:
result[name] = state_slice
cursor += entry_size
return result
class GenericJacobianSparsityTests(unittest.TestCase):
def setUp(self) -> None:
self.system = GenericFluidSystem(
compile_reactflow_network(cross_domain_storage_project())
)
def test_external_volume_connects_mechanical_and_nearby_storage_states(self) -> None:
slices = state_slices(self.system)
pattern = self.system.jacobian_sparsity().toarray().astype(bool)
line_states = range(
slices["line_storage"].start,
slices["line_storage"].stop,
)
mechanical_states = [
state_index
for name in ("piston_mass", "cylinder_mass")
for state_index in range(slices[name].start, slices[name].stop)
]
self.assertTrue(
pattern[np.ix_(tuple(line_states), tuple(mechanical_states))].all()
)
self.assertTrue(
pattern[np.ix_(tuple(mechanical_states), tuple(line_states))].all()
)
def _apply_dynamic_volume_dependency_probe(
self,
source: _DynamicVolumeSource,
) -> list[set[int]]:
medium = IdealGasMedium()
receiver = Cylinder("receiver", medium, V=0.1, p0=200_000.0)
remote = Tank("remote", medium, V=0.1, p0=100_000.0)
system = GenericFluidSystem.__new__(GenericFluidSystem)
system.pneumatic_volume_resolver = SimpleNamespace(
_output_components=(source,),
_connected_endpoint={
Endpoint(source.name, "port"): SimpleNamespace(
connected_endpoint=Endpoint("receiver", "port_b")
)
},
)
dependencies = [
{0, 1},
{0, 1},
{1, 2},
]
system._add_pneumatic_volume_state_dependencies(
dependencies,
(source, receiver, remote),
{"source": 0, "receiver": 1, "remote": 2},
)
return dependencies
def test_dynamic_volume_source_ode_state_drives_remote_pneumatic_state(
self,
) -> None:
dependencies = self._apply_dynamic_volume_dependency_probe(
_TrustedDynamicVolumeSource("source")
)
self.assertIn(0, dependencies[2])
self.assertIn(2, dependencies[0])
def test_untrusted_volume_source_disables_ode_jacobian_sparsity(self) -> None:
dependencies = self._apply_dynamic_volume_dependency_probe(
_UntrustedDynamicVolumeSource("source")
)
self.assertEqual(dependencies, [{0, 1, 2}] * 3)
def test_dense_numerical_jacobian_has_no_significant_entry_outside_pattern(
self,
) -> None:
state = np.asarray(self.system.consistent_initial_state_vector(0.0))
slices = state_slices(self.system)
# Move one piston face away from the zero-volume reference so the
# chamber/line flow has a measurable local volume derivative. This is
# an operating-point probe only; the state remains well inside the
# chamber's positive total-volume domain.
state[slices["piston_mass"].stop - 1] = 1.0e-3
def evaluate_one(values: np.ndarray) -> np.ndarray:
return np.asarray(
self.system.rhs(0.0, [float(value) for value in values])
)
def evaluate(_time: float, values: np.ndarray) -> np.ndarray:
if values.ndim == 1:
return evaluate_one(values)
return np.column_stack(
[evaluate_one(values[:, index]) for index in range(values.shape[1])]
)
derivative = evaluate(0.0, state)
absolute_tolerance = np.asarray(
self.system.mechanical_state_reducer.absolute_tolerances(1.0e-8)
)
dense_jacobian, _factor = num_jac(
evaluate,
0.0,
state,
derivative,
absolute_tolerance / 1.0e-6,
None,
None,
)
pattern = self.system.jacobian_sparsity().toarray().astype(bool)
missed = np.abs(np.asarray(dense_jacobian)) * ~pattern
column_scale = np.maximum(
1.0,
np.max(np.abs(np.asarray(dense_jacobian)), axis=0),
)
self.assertFalse(
np.any(missed > 1.0e-6 * column_scale[np.newaxis, :]),
f"maximum omitted derivative was {float(np.max(missed))}",
)
line_rows = range(
slices["line_storage"].start,
slices["line_storage"].stop,
)
piston_columns = range(
slices["piston_mass"].start,
slices["piston_mass"].stop,
)
self.assertGreater(
float(
np.max(
np.abs(
np.asarray(dense_jacobian)[
np.ix_(tuple(line_rows), tuple(piston_columns))
]
)
)
),
1.0,
)
if __name__ == "__main__":
unittest.main()
+14 -3
View File
@@ -298,9 +298,20 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
)
]
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
self.assertLess(
result.diagnostics["pressureFlow"]["maxScaledResidual"],
1e-7,
pressure_flow = result.diagnostics["pressureFlow"]
self.assertLess(pressure_flow["maxScaledResidual"], 1e-7)
self.assertEqual(
pressure_flow["solveCount"],
pressure_flow["seededSolveCount"]
+ pressure_flow["nonlinearSolveCount"],
)
self.assertAlmostEqual(
pressure_flow["fastPathHitRate"],
pressure_flow["seededSolveCount"] / pressure_flow["solveCount"],
)
self.assertGreaterEqual(
pressure_flow["residualEvaluationCount"],
pressure_flow["optimizerEvaluationCount"],
)
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
@@ -0,0 +1,74 @@
from __future__ import annotations
import hashlib
import math
from pathlib import Path
import unittest
from app.main import run_system_xml_simulation
FIXTURE_PATH = (
Path(__file__).resolve().parent
/ "fixtures"
/ "high_stiffness_explicit_rk45.xml"
)
ORIGINAL_XML_SHA256 = (
"27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5"
)
def short_explicit_rk45_xml() -> bytes:
original = FIXTURE_PATH.read_bytes()
canonical = original.replace(b"\r\n", b"\n").rstrip(b"\n")
if hashlib.sha256(canonical).hexdigest() != ORIGINAL_XML_SHA256:
raise AssertionError("The high-stiffness RK45 fixture differs from the user XML.")
if original.count(b'tStop="10"') != 1:
raise AssertionError("Expected one original simulation stop time.")
if original.count(b'sampleStep="0.02"') != 1:
raise AssertionError("Expected one original simulation sample step.")
return original.replace(
b'tStop="10"',
b'tStop="0.005"',
1,
).replace(
b'sampleStep="0.02"',
b'sampleStep="0.001"',
1,
)
class HighStiffnessExplicitRk45RegressionTests(unittest.TestCase):
def test_short_user_model_crosses_the_early_stiff_limit_event(self) -> None:
result = run_system_xml_simulation(
short_explicit_rk45_xml(),
cancel_check=lambda: False,
)
self.assertTrue(result["success"], result["message"])
self.assertEqual(result["status"], "completed")
self.assertFalse(result["partial"])
self.assertEqual(result["simulatedUntil"], 0.005)
self.assertNotIn("provided bounds", result["message"])
integration = result["diagnostics"]["integration"]
totals = integration["totals"]
self.assertEqual(integration["method"], "RK45")
self.assertGreaterEqual(totals["stateTransitionCount"], 1)
self.assertGreaterEqual(totals["solverStartCount"], 2)
series = result["series"]
times = series["time"]
self.assertEqual(times[0], 0.0)
self.assertEqual(times[-1], 0.005)
self.assertTrue(
all(first < second for first, second in zip(times, times[1:]))
)
self.assertTrue(any(0.0 < time < 0.001 for time in times))
for values in series.values():
self.assertEqual(len(values), len(times))
self.assertTrue(all(math.isfinite(value) for value in values))
if __name__ == "__main__":
unittest.main()
+12
View File
@@ -6,6 +6,7 @@ from app.simulation.benchmark_performance import (
_duration_summary,
_load_factory_xml,
_named_value,
_parse_arguments,
_serialize_result_event,
)
@@ -53,6 +54,17 @@ class PerformanceBenchmarkToolTests(unittest.TestCase):
self.assertIn(b'"event":"result"', payload)
self.assertIn(b'"result":{"success":true', payload)
def test_cache_ab_flag_is_explicit(self) -> None:
arguments = _parse_arguments(
[
"--factory",
"sample=tests.test_performance_benchmark:sample_xml_factory",
"--disable-property-cache",
]
)
self.assertTrue(arguments.disable_property_cache)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,278 @@
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network
from app.simulation.solvers.algebraic import (
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
)
from app.simulation.systems.generic import GenericFluidSystem
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
from tests.test_generic_system_xml_simulation import chain_project
def _system(project) -> GenericFluidSystem:
return GenericFluidSystem(compile_reactflow_network(project))
class PressureFlowCausalExecutionTests(unittest.TestCase):
def test_strict_causal_rhs_matches_environment_disabled_legacy_bitwise(
self,
) -> None:
optimized = _system(high_pressure_helium_step_project())
with patch.dict(
os.environ,
{CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE: "0"},
):
legacy = _system(high_pressure_helium_step_project())
optimized_state = optimized.initial_state_vector()
legacy_state = legacy.initial_state_vector()
for time in (0.0, 0.041, 0.8):
optimized_derivative = optimized.rhs(time, optimized_state)
legacy_derivative = legacy.rhs(time, legacy_state)
self.assertEqual(optimized_derivative, legacy_derivative)
self.assertEqual(
tuple(
unknown.read()
for unknown in optimized.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in legacy.pressure_flow_solver.unknowns
),
)
global_diagnostics = (
optimized.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(global_diagnostics["eligible"])
self.assertGreater(global_diagnostics["fastSolveCount"], 0)
self.assertGreaterEqual(
global_diagnostics["fullResidualAuditCount"],
1,
)
secondary = (
optimized._thermofluid_closure_plan.secondary_block_solvers[0]
)
secondary_diagnostics = secondary.causal_execution_diagnostics()
self.assertTrue(secondary_diagnostics["eligible"])
self.assertGreater(secondary_diagnostics["fastSolveCount"], 0)
legacy_diagnostics = (
legacy.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertFalse(legacy_diagnostics["enabled"])
self.assertEqual(
legacy_diagnostics["disabledReason"],
"disabledByEnvironment",
)
self.assertEqual(legacy_diagnostics["fastSolveCount"], 0)
def test_multiple_effort_anchors_conservatively_keep_legacy_path(self) -> None:
system = _system(chain_project())
diagnostics = (
system.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertFalse(diagnostics["eligible"])
self.assertFalse(diagnostics["enabled"])
self.assertEqual(
diagnostics["fallbackReason"],
"effortGroupDoesNotHaveOneAnchor",
)
def test_runtime_flow_coverage_failure_fuses_to_verified_legacy_path(
self,
) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
original = solver._solve_explicit_flow_unknowns
def hide_coverage(*args, **kwargs):
original(*args, **kwargs)
return set()
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
side_effect=hide_coverage,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_periodic_audit_failure_disables_fast_path_before_fallback(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
solver._causal_audit_interval = 0
original_values = solver._pressure_flow_equation_values
call_count = 0
def one_bad_audit_value():
nonlocal call_count
call_count += 1
values = original_values()
if call_count != 1:
return values
return (values[0] + 1.0, *values[1:])
with patch.object(
solver,
"_pressure_flow_equation_values",
side_effect=one_bad_audit_value,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalResidualAuditFailed",
)
self.assertEqual(execution["auditFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_requested_audit_interrupts_periodic_fast_sequence(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
system.rhs(0.0, state)
before = solver.causal_execution_diagnostics()
self.assertEqual(before["fullResidualAuditCount"], 1)
self.assertEqual(before["fastSolveCount"], 1)
solver.request_causal_audit()
system.rhs(0.0, state)
after = solver.causal_execution_diagnostics()
self.assertEqual(after["fullResidualAuditCount"], 2)
self.assertEqual(after["fastSolveCount"], 1)
def test_fast_solve_skips_the_full_residual_evaluator(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
with patch.object(
solver,
"_pressure_flow_equation_values",
wraps=solver._pressure_flow_equation_values,
) as evaluate_all:
system.rhs(0.0, state)
evaluate_all.assert_not_called()
self.assertTrue(solver.last_diagnostics.causal_fast_path_used)
self.assertFalse(
solver.last_diagnostics.residual_verified_this_solve
)
def test_nonfinite_explicit_assignment_fuses_and_verifies_same_solve(
self,
) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
original = solver._evaluate_explicit_flow_stage
call_count = 0
def one_nonfinite_assignment(stage):
nonlocal call_count
call_count += 1
values = original(stage)
if call_count != 1:
return values
return (float("nan"), *values[1:])
with patch.object(
solver,
"_evaluate_explicit_flow_stage",
side_effect=one_nonfinite_assignment,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_nonpositive_pressure_fuses_and_verifies_same_solve(self) -> None:
system = _system(high_pressure_helium_step_project())
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
original = solver._solve_explicit_flow_unknowns
pressure = next(
unknown for unknown in solver.unknowns if unknown.variable == "p"
)
def make_pressure_invalid(*args, **kwargs):
seeded = original(*args, **kwargs)
pressure.write(-1.0)
return seeded
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
side_effect=make_pressure_invalid,
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
self.assertGreater(pressure.read(), 0.0)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_default_periodic_audit_runs_after_sixty_four_fast_solves(self) -> None:
system = _system(zero_force_mass_project())
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
for _iteration in range(64):
system.rhs(0.0, state)
before_boundary = solver.causal_execution_diagnostics()
self.assertEqual(before_boundary["auditInterval"], 64)
self.assertEqual(before_boundary["fastSolveCount"], 64)
self.assertEqual(before_boundary["fullResidualAuditCount"], 1)
system.rhs(0.0, state)
after_boundary = solver.causal_execution_diagnostics()
self.assertEqual(after_boundary["fastSolveCount"], 64)
self.assertEqual(after_boundary["fullResidualAuditCount"], 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,287 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from scipy.optimize import least_squares as scipy_least_squares
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.components.amesim.mechanical.translational import (
AmesimF000,
AmesimForc,
AmesimLstp00a,
AmesimMecmas21,
)
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.systems.network import SimulationNetwork
class _CustomPnl0002(AmesimPnl0002):
"""External subclass: structural declarations alone are not a purity promise."""
def _branched_solver(
*,
custom_pipe: bool = False,
include_contact: bool = False,
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
medium = IdealGasMedium()
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
right = Tank("right", medium, V=0.1, p0=100_000.0)
pipe_type = _CustomPnl0002 if custom_pipe else AmesimPnl0002
pipe = pipe_type("pipe", medium, p0=300_000.0, T0=300.0)
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
plug = AmesimPnpl01("isolated_plug")
network = SimulationNetwork("nonlinear-equation-blocks")
for component in (left, right, pipe, isolated, plug):
network.add_component(component)
network.connect("left", "port_b", "pipe", "port_1")
network.connect("pipe", "port_2", "right", "port_a")
network.connect("isolated", "port_b", "isolated_plug", "port_1")
if include_contact:
force = AmesimForc("contact_force")
force.res.signal = 40.0
contact = AmesimLstp00a(
"contact",
medium,
gap0=0.0,
kcont=1.0e11,
rcont=0.0,
Pdis=1.0e-7,
discContactOption=1.0,
)
mass = AmesimMecmas21(
"mass",
medium,
mass=2.0,
useFriction=1.0,
x0=1.0e9,
)
zero = AmesimF000("zero")
for component in (force, contact, mass, zero):
network.add_component(component)
network.connect("contact_force", "port_2", "contact", "port_1")
network.connect("contact", "port_2", "mass", "port_1")
network.connect("mass", "port_2", "zero", "port_1")
for component in network.dynamic_components():
component.refresh_thermodynamic_ports()
solver = PressureFlowSolver(network)
solver.solve()
return solver, pipe
def _damage_flows_after_seed(
solver: PressureFlowSolver,
pipe: AmesimPnl0002,
ports: tuple[str, ...],
*,
snapshots: list[tuple[float, ...]] | None = None,
) -> None:
original = solver._solve_explicit_flow_unknowns
def damaged(*args, **kwargs):
result = original(*args, **kwargs)
for index, port_name in enumerate(ports, start=1):
port = pipe.get_port(port_name)
port.m_flow += index * 0.01
if snapshots is not None:
snapshots.append(tuple(unknown.read() for unknown in solver.unknowns))
return result
solver._solve_explicit_flow_unknowns = damaged
class PressureFlowEquationBlockTests(unittest.TestCase):
def test_pnl0002_owner_is_safely_split_across_two_blocks(self) -> None:
solver, _pipe = _branched_solver()
pipe_rows = {
index
for index, equation in enumerate(solver.equation_templates)
if equation.owner == "component" and equation.owner_id == "pipe"
}
owner_blocks = [
block
for block in solver.equation_blocks
if pipe_rows.intersection(block.equation_indices)
]
self.assertTrue(solver.equation_blocks_are_trusted)
self.assertEqual(len(pipe_rows), 2)
self.assertEqual(len(owner_blocks), 2)
self.assertTrue(
set(owner_blocks[0].unknown_indices).isdisjoint(
owner_blocks[1].unknown_indices
)
)
def test_only_bad_block_uses_scoped_sparse_residual(self) -> None:
optimized, optimized_pipe = _branched_solver()
dense, dense_pipe = _branched_solver()
_damage_flows_after_seed(optimized, optimized_pipe, ("port_1",))
_damage_flows_after_seed(dense, dense_pipe, ("port_1",))
dense._equation_blocks_are_trusted = False
dense._jacobian_sparsity_is_trusted = False
block_diagnostics = optimized.solve()
dense_diagnostics = dense.solve()
self.assertEqual(block_diagnostics.jacobian_mode, "blockSparse")
self.assertEqual(block_diagnostics.nonlinear_block_count, 1)
self.assertEqual(block_diagnostics.nonlinear_block_unknown_count, 4)
self.assertFalse(block_diagnostics.block_fallback_used)
self.assertLess(
block_diagnostics.residual_evaluations,
dense_diagnostics.residual_evaluations,
)
for block_unknown, dense_unknown in zip(
optimized.unknowns,
dense.unknowns,
):
self.assertAlmostEqual(
block_unknown.read(),
dense_unknown.read(),
delta=1.0e-6 * max(abs(dense_unknown.read()), 1.0),
)
def test_multiple_bad_blocks_are_solved_in_one_union_call(self) -> None:
solver, pipe = _branched_solver()
_damage_flows_after_seed(solver, pipe, ("port_1", "port_2"))
with patch(
"scipy.optimize.least_squares",
wraps=scipy_least_squares,
) as least_squares:
diagnostics = solver.solve()
self.assertEqual(least_squares.call_count, 1)
self.assertEqual(diagnostics.jacobian_mode, "blockSparse")
self.assertEqual(diagnostics.nonlinear_block_count, 2)
self.assertEqual(diagnostics.nonlinear_block_unknown_count, 8)
subset_pattern = least_squares.call_args.kwargs["jac_sparsity"]
self.assertEqual(subset_pattern.shape, (8, 8))
self.assertLess(subset_pattern.nnz, solver.jacobian_sparsity.nnz)
def test_failed_local_candidate_restores_global_seed_before_fallback(
self,
) -> None:
solver, pipe = _branched_solver()
seeded_snapshots: list[tuple[float, ...]] = []
_damage_flows_after_seed(
solver,
pipe,
("port_1",),
snapshots=seeded_snapshots,
)
call_count = 0
def fail_block_then_solve_global(fun, x0, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
self.assertEqual(x0.shape, (4,))
return SimpleNamespace(
x=x0 + 123.0,
success=False,
status=-1,
message="forced block failure",
nfev=1,
)
self.assertEqual(x0.shape, (len(solver.unknowns),))
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
seeded_snapshots[-1],
)
return scipy_least_squares(fun, x0, **kwargs)
with patch(
"scipy.optimize.least_squares",
side_effect=fail_block_then_solve_global,
):
diagnostics = solver.solve()
self.assertGreaterEqual(call_count, 2)
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.block_fallback_used)
self.assertEqual(diagnostics.nonlinear_block_count, 1)
self.assertEqual(
diagnostics.block_fallback_reason,
"blockResidualNotConverged",
)
def test_block_memory_error_restores_seed_and_remains_fatal(self) -> None:
solver, pipe = _branched_solver()
seeded_snapshots: list[tuple[float, ...]] = []
_damage_flows_after_seed(
solver,
pipe,
("port_1",),
snapshots=seeded_snapshots,
)
with patch(
"scipy.optimize.least_squares",
side_effect=MemoryError("forced allocation failure"),
):
with self.assertRaises(MemoryError):
solver.solve()
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
seeded_snapshots[-1],
)
def test_custom_component_keeps_the_legacy_global_path(self) -> None:
solver, pipe = _branched_solver(custom_pipe=True)
_damage_flows_after_seed(solver, pipe, ("port_1",))
diagnostics = solver.solve()
self.assertFalse(solver.equation_blocks_are_trusted)
self.assertEqual(
solver.equation_blocks_fallback_reason,
"untrustedCustomComponent",
)
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertTrue(diagnostics.block_fallback_used)
self.assertEqual(
diagnostics.block_fallback_reason,
"untrustedCustomComponent",
)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"untrustedCustomComponent",
)
def test_active_contact_conservatively_keeps_global_dense_fallback(self) -> None:
solver, pipe = _branched_solver(include_contact=True)
_damage_flows_after_seed(solver, pipe, ("port_1",))
diagnostics = solver.solve()
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertTrue(diagnostics.block_fallback_used)
self.assertEqual(
diagnostics.block_fallback_reason,
"activeCausalContact",
)
contact = solver.network.components["contact"]
self.assertIsNotNone(contact._causal_penetration)
self.assertAlmostEqual(contact.contact_force, 40.0, places=7)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"activeSetCausalizationRequired",
)
if __name__ == "__main__":
unittest.main()
@@ -44,13 +44,19 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
chamber.refresh_thermodynamic_ports()
pipe.refresh_thermodynamic_ports()
result = PressureFlowSolver(network).solve()
solver = PressureFlowSolver(network)
result = solver.solve()
self.assertTrue(result.success)
self.assertGreater(orifice.port_2.p, pipe.port_2.p)
self.assertLess(orifice.port_2.p, chamber.port_2.p)
self.assertAlmostEqual(orifice.port_2.p, pipe.port_1.p)
self.assertAlmostEqual(-orifice.port_2.m_flow, pipe.port_1.m_flow)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"specialSeriesPressureSeed",
)
def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None:
medium = IdealGasMedium()
@@ -63,13 +69,19 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
network.add_component(plug)
network.connect("resistance", "port_2", "closed", "port_1")
result = PressureFlowSolver(network).solve()
solver = PressureFlowSolver(network)
result = solver.solve()
self.assertTrue(result.success)
self.assertEqual(result.evaluations, 0)
self.assertAlmostEqual(pipe.port_2.p, pipe.port_1.p)
self.assertEqual(pipe.port_1.m_flow, 0.0)
self.assertEqual(pipe.port_2.m_flow, 0.0)
self.assertFalse(solver.causal_fast_path_eligible)
self.assertEqual(
solver.causal_execution_diagnostics()["fallbackReason"],
"specialClosedResistancePressureSeed",
)
@staticmethod
def _near_equal_pressure_network() -> tuple[
@@ -338,6 +350,48 @@ class PressureFlowSolverInitializationTests(unittest.TestCase):
self.assertIsNotNone(solver.last_diagnostics)
self.assertFalse(solver.last_diagnostics.success)
def test_positive_sub_pascal_seed_uses_the_exact_pressure_without_optimizer(
self,
) -> None:
medium = IdealGasMedium()
pressure = 0.43301848566882734
temperature = pressure / medium.R_gas
tank = Tank("tank", medium, V=1.0)
plug = AmesimPnpl01("plug")
network = SimulationNetwork("positive-sub-pascal-pressure")
network.add_component(tank)
network.add_component(plug)
network.connect("tank", "port_a", "plug", "port_1")
tank.state = VolumeState(
m=1.0,
U=medium.specific_internal_energy(temperature),
)
tank.refresh_thermodynamic_ports()
self.assertAlmostEqual(
tank.port_a.p,
pressure,
delta=pressure * 1.0e-11,
)
solver = PressureFlowSolver(network, max_evaluations=10)
with patch(
"scipy.optimize.least_squares",
side_effect=AssertionError(
"A finite positive pressure seed must stay on the fast path."
),
) as least_squares:
diagnostics = solver.solve()
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.evaluations, 0)
self.assertAlmostEqual(
tank.port_a.p,
pressure,
delta=pressure * 1.0e-11,
)
least_squares.assert_not_called()
if __name__ == "__main__":
unittest.main()
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import numpy as np
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.systems.network import SimulationNetwork
class _IncompleteDependencyComponent(AlgebraicComponent):
"""Deliberately violate the residual dependency authoring contract."""
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
def __init__(self, name: str) -> None:
super().__init__(name)
self.port_1 = self.register_declared_port("port_1")
self.port_1.p = 90_000.0
self.port_1.m_flow = 1.0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
return self.port_1.p - 100_000.0, self.port_1.m_flow
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:pressure",
owner="component",
owner_id=self.name,
relation="state",
# The live residual reads p, but the declaration omits it on
# purpose. A zero structural row must disable sparse FD.
variables=(),
role="effort",
value=self.port_1.p - 100_000.0,
),
EquationResidual(
id=f"{self.name}:flow",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.m_flow",),
role="flow",
value=self.port_1.m_flow,
),
)
def _storage_boundary_solver(name: str) -> PressureFlowSolver:
medium = IdealGasMedium()
storage = Cylinder(f"{name}_storage", medium, V=0.1, p0=500_000.0)
boundary = AmesimPnpl01(f"{name}_boundary")
network = SimulationNetwork(name)
network.add_component(storage)
network.add_component(boundary)
network.connect(storage.name, "port_b", boundary.name, "port_1")
storage.refresh_thermodynamic_ports()
boundary.port_1.m_flow = 1.0
return PressureFlowSolver(network)
class PressureFlowSolverSparsityTests(unittest.TestCase):
def test_sparse_and_dense_paths_produce_the_same_solution(self) -> None:
sparse_solver = _storage_boundary_solver("sparse")
dense_solver = _storage_boundary_solver("dense")
dense_solver._jacobian_sparsity_is_trusted = False
with patch.object(
sparse_solver,
"_solve_explicit_flow_unknowns",
return_value=set(),
):
sparse = sparse_solver.solve()
with patch.object(
dense_solver,
"_solve_explicit_flow_unknowns",
return_value=set(),
):
dense = dense_solver.solve()
self.assertEqual(sparse.jacobian_mode, "blockSparse")
self.assertEqual(sparse.nonlinear_block_count, 1)
self.assertLess(
sparse.nonlinear_block_unknown_count,
len(sparse_solver.unknowns),
)
self.assertEqual(dense.jacobian_mode, "dense")
self.assertFalse(sparse.dense_fallback_used)
self.assertFalse(dense.dense_fallback_used)
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
self.assertLess(sparse.residual_evaluations, dense.residual_evaluations)
sparse_values = {
unknown.id.split("_", maxsplit=1)[-1]: unknown.read()
for unknown in sparse_solver.unknowns
}
dense_values = {
unknown.id.split("_", maxsplit=1)[-1]: unknown.read()
for unknown in dense_solver.unknowns
}
self.assertEqual(sparse_values.keys(), dense_values.keys())
for variable_id in sparse_values:
self.assertAlmostEqual(
sparse_values[variable_id],
dense_values[variable_id],
delta=1.0e-9,
)
self.assertEqual(
sparse.as_dict()["residualEvaluations"],
sparse.residual_evaluations,
)
def test_incomplete_dependency_metadata_uses_dense_finite_differences(
self,
) -> None:
component = _IncompleteDependencyComponent("incomplete")
network = SimulationNetwork("incomplete-dependency")
network.add_component(component)
solver = PressureFlowSolver(network)
diagnostics = solver.solve()
self.assertFalse(solver.jacobian_sparsity_is_trusted)
self.assertEqual(
solver.jacobian_sparsity_fallback_reason,
"equationWithoutDeclaredUnknown",
)
self.assertEqual(diagnostics.jacobian_mode, "dense")
self.assertFalse(diagnostics.dense_fallback_used)
self.assertAlmostEqual(component.port_1.p, 100_000.0, places=6)
self.assertAlmostEqual(component.port_1.m_flow, 0.0, places=12)
def test_failed_sparse_candidate_restarts_dense_from_original_x0(self) -> None:
solver = _storage_boundary_solver("retry")
# This regression specifically exercises the final whole-network
# sparse -> dense compatibility fallback, not the preceding block
# optimization.
solver._equation_blocks_are_trusted = False
original_x0: list[np.ndarray] = []
calls: list[str] = []
flow_indices = tuple(
index
for index, unknown in enumerate(solver.unknowns)
if unknown.variable == "m_flow"
)
def fake_least_squares(_fun, x0, **kwargs):
if "jac_sparsity" in kwargs:
calls.append("sparse")
original_x0.append(x0.copy())
failed = x0.copy()
failed[list(flow_indices)] = 123.0
return SimpleNamespace(
x=failed,
success=False,
status=-1,
message="forced sparse failure",
nfev=3,
)
calls.append("dense")
np.testing.assert_array_equal(x0, original_x0[0])
solved = x0.copy()
solved[list(flow_indices)] = 0.0
return SimpleNamespace(
x=solved,
success=True,
status=1,
message="dense fallback solved",
nfev=4,
)
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
return_value=set(),
), patch(
"scipy.optimize.least_squares",
side_effect=fake_least_squares,
):
diagnostics = solver.solve()
self.assertEqual(calls, ["sparse", "dense"])
self.assertTrue(diagnostics.success)
self.assertEqual(diagnostics.evaluations, 7)
self.assertEqual(diagnostics.jacobian_mode, "sparseThenDense")
self.assertTrue(diagnostics.dense_fallback_used)
for index in flow_indices:
self.assertAlmostEqual(solver.unknowns[index].read(), 0.0, places=12)
def test_compiled_pattern_matches_declared_storage_boundary_structure(
self,
) -> None:
solver = _storage_boundary_solver("pattern")
self.assertTrue(solver.jacobian_sparsity_is_trusted)
self.assertEqual(solver.jacobian_sparsity.shape, (4, 4))
self.assertEqual(solver.jacobian_sparsity.nnz, 6)
if __name__ == "__main__":
unittest.main()
@@ -6,17 +6,12 @@ from unittest.mock import Mock, patch
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.property_cache import property_cache_run
class PropertyPerformanceInstrumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = AmesimHeliumPengRobinsonMedium()
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def tearDown(self) -> None:
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
@@ -30,14 +25,15 @@ class PropertyPerformanceInstrumentationTests(unittest.TestCase):
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
second = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
with property_cache_run():
first = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
second = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
self.assertEqual(second, first)
record_iterations.assert_called_once()
@@ -60,8 +56,17 @@ class PropertyPerformanceInstrumentationTests(unittest.TestCase):
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.properties_from_mU(mass, internal_energy, volume)
second = self.medium.properties_from_mU(mass, internal_energy, volume)
with property_cache_run():
first = self.medium.properties_from_mU(
mass,
internal_energy,
volume,
)
second = self.medium.properties_from_mU(
mass,
internal_energy,
volume,
)
self.assertIs(second, first)
record_iterations.assert_called_once()
@@ -166,6 +166,77 @@ class SimulationPerformancePipelineTests(unittest.TestCase):
self.assertGreater(len(snapshot["properties"]), 0)
self.assertGreater(snapshot["propertyOutermostNs"], 0)
def test_audit_property_totals_match_run_local_cache(self) -> None:
script = textwrap.dedent(
"""
import json
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.performance import profile_run
from app.simulation.property_cache import property_cache_run
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
volume = 0.057
with property_cache_run() as cache:
assert cache is not None
with profile_run() as trace:
density = medium.density(pressure, temperature)
medium.density(pressure, temperature)
medium.isentropic_density_pressure_factor(
pressure, temperature, 1.0e6
)
medium.isentropic_density_pressure_factor(
pressure, temperature, 1.0e6
)
enthalpy = medium.specific_enthalpy_at_pressure(
pressure, temperature
)
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
medium.temperature_from_pressure_enthalpy(pressure, enthalpy)
mass = density * volume
energy = mass * medium.specific_internal_energy_at_pressure(
pressure, temperature
)
medium.properties_from_mU(mass, energy, volume)
medium.properties_from_mU(mass, energy, volume)
cache_info = cache.info()
snapshot = trace.snapshot()
cached_properties = [
metrics
for metrics in snapshot["properties"].values()
if metrics["cacheLookups"] > 0
]
print(json.dumps({
"cacheHits": cache_info.hits,
"cacheMisses": cache_info.misses,
"propertyHits": sum(item["cacheHits"] for item in cached_properties),
"propertyMisses": sum(item["cacheMisses"] for item in cached_properties),
}))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "audit"
environment["SIMULATIONAPP_PROPERTY_CACHE"] = "on"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
totals = json.loads(completed.stdout)
self.assertGreater(totals["cacheHits"], 0)
self.assertGreater(totals["cacheMisses"], 0)
self.assertEqual(totals["cacheHits"], totals["propertyHits"])
self.assertEqual(totals["cacheMisses"], totals["propertyMisses"])
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from math import nextafter
import os
from pathlib import Path
import subprocess
import sys
import textwrap
from threading import Barrier
import unittest
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
from app.simulation.property_cache import (
cache_property_calculation,
current_property_cache,
property_cache_run,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class SimulationPropertyCacheTests(unittest.TestCase):
def test_reuses_only_exact_inputs(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
pressure = 15.3e6
temperature = 293.15
with property_cache_run() as cache:
assert cache is not None
first = medium.density(pressure, temperature)
repeated = medium.density(pressure, temperature)
changed = medium.density(nextafter(pressure, float("inf")), temperature)
info = cache.info()
self.assertEqual(repeated, first)
self.assertNotEqual(changed, first)
self.assertEqual(info.hits, 1)
self.assertEqual(info.misses, 2)
self.assertEqual(info.current_entries, 2)
def test_failed_calculations_are_not_cached(self) -> None:
class FailingProperty:
def __init__(self) -> None:
self.calls = 0
@cache_property_calculation("failure")
def calculate(self, value: float) -> float:
self.calls += 1
raise ValueError(f"invalid {value}")
owner = FailingProperty()
with property_cache_run() as cache:
assert cache is not None
for _ in range(2):
with self.assertRaisesRegex(ValueError, "invalid"):
owner.calculate(1.0)
info = cache.info()
self.assertEqual(owner.calls, 2)
self.assertEqual(info.hits, 0)
self.assertEqual(info.misses, 2)
self.assertEqual(info.current_entries, 0)
def test_lru_capacity_is_bounded_and_oldest_entry_is_evicted(self) -> None:
class CachedProperty:
def __init__(self) -> None:
self.calls = 0
@cache_property_calculation("bounded")
def calculate(self, value: int) -> int:
self.calls += 1
return value * 10
owner = CachedProperty()
with property_cache_run(max_entries=2) as cache:
assert cache is not None
owner.calculate(1)
owner.calculate(2)
owner.calculate(1)
owner.calculate(3)
second = owner.calculate(2)
info = cache.info()
self.assertEqual(second, 20)
self.assertEqual(owner.calls, 4)
self.assertEqual(info.current_entries, 2)
self.assertEqual(info.evictions, 2)
def test_each_run_gets_an_independent_cache_and_releases_context(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
with property_cache_run() as first_cache:
assert first_cache is not None
medium.density(100_000.0, 300.0)
medium.density(100_000.0, 300.0)
first_info = first_cache.info()
self.assertIsNone(current_property_cache())
with property_cache_run() as second_cache:
assert second_cache is not None
medium.density(100_000.0, 300.0)
second_info = second_cache.info()
self.assertIsNot(first_cache, second_cache)
self.assertEqual(first_info.hits, 1)
self.assertEqual(second_info.hits, 0)
self.assertEqual(second_info.misses, 1)
self.assertIsNone(current_property_cache())
def test_async_tasks_do_not_share_run_local_caches(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
async def exercise(pressure: float) -> tuple[int, int, int]:
with property_cache_run() as cache:
assert cache is not None
await asyncio.sleep(0)
medium.density(pressure, 300.0)
medium.density(pressure, 300.0)
info = cache.info()
return id(cache), info.hits, info.misses
async def run_both() -> list[tuple[int, int, int]]:
return list(
await asyncio.gather(
exercise(100_000.0),
exercise(200_000.0),
)
)
results = asyncio.run(run_both())
self.assertNotEqual(results[0][0], results[1][0])
self.assertEqual(results[0][1:], (1, 1))
self.assertEqual(results[1][1:], (1, 1))
def test_worker_threads_do_not_share_run_local_caches(self) -> None:
medium = AmesimHeliumPengRobinsonMedium()
barrier = Barrier(2)
def exercise(pressure: float) -> tuple[int, int, int]:
with property_cache_run() as cache:
assert cache is not None
barrier.wait(timeout=5.0)
medium.density(pressure, 300.0)
medium.density(pressure, 300.0)
info = cache.info()
return id(cache), info.hits, info.misses
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(
exercise,
(100_000.0, 200_000.0),
)
)
self.assertNotEqual(results[0][0], results[1][0])
self.assertEqual(results[0][1:], (1, 1))
self.assertEqual(results[1][1:], (1, 1))
def test_cache_on_and_off_produce_identical_helium_results(self) -> None:
script = textwrap.dedent(
"""
import hashlib
import json
from app.main import build_reactflow_system_xml, run_system_xml_simulation
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
result = run_system_xml_simulation(
build_reactflow_system_xml(high_pressure_helium_step_project())
)
assert result["success"], result["message"]
payload = json.dumps(
{
"status": result["status"],
"series": result["series"],
"final": result["final"],
},
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
print(hashlib.sha256(payload).hexdigest())
"""
)
hashes: list[str] = []
for enabled in ("on", "off"):
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "off"
environment["SIMULATIONAPP_PROPERTY_CACHE"] = enabled
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
hashes.append(completed.stdout.strip())
self.assertEqual(hashes[0], hashes[1])
if __name__ == "__main__":
unittest.main()
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import os
import unittest
from unittest.mock import patch
from app.main import _app_lifespan, app
from app.simulation.warmup import (
SimulationWarmupReport,
_run_numerical_warmup,
_reset_simulation_warmup_for_tests,
warm_up_simulation_runtime,
)
class SimulationWarmupTests(unittest.TestCase):
def setUp(self) -> None:
_reset_simulation_warmup_for_tests()
def tearDown(self) -> None:
_reset_simulation_warmup_for_tests()
def test_warmup_runs_only_once_per_process(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch("app.simulation.warmup._run_numerical_warmup") as run_warmup,
):
first = warm_up_simulation_runtime()
second = warm_up_simulation_runtime()
self.assertIs(second, first)
self.assertEqual(first.status, "completed")
run_warmup.assert_called_once_with()
def test_disabled_warmup_does_not_touch_numerical_runtime(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "off"}),
patch("app.simulation.warmup._run_numerical_warmup") as run_warmup,
):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "disabled")
run_warmup.assert_not_called()
def test_regular_failure_is_reported_without_blocking_startup(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_numerical_warmup",
side_effect=RuntimeError("broken warmup"),
),
self.assertLogs("app.simulation.warmup", level="ERROR"),
):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "failed")
self.assertIn("broken warmup", report.error or "")
def test_memory_error_remains_fatal(self) -> None:
with (
patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}),
patch(
"app.simulation.warmup._run_numerical_warmup",
side_effect=MemoryError("out of memory"),
),
self.assertRaises(MemoryError),
):
warm_up_simulation_runtime()
def test_lifespan_stores_warmup_report_before_serving(self) -> None:
report = SimulationWarmupReport(status="completed", duration_ms=12.5)
async def enter_lifespan() -> None:
with patch(
"app.simulation.warmup.warm_up_simulation_runtime",
return_value=report,
) as warmup:
async with _app_lifespan(app):
self.assertEqual(
app.state.simulation_warmup,
report.as_dict(),
)
warmup.assert_called_once_with()
asyncio.run(enter_lifespan())
def test_real_numerical_warmup_completes(self) -> None:
with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}):
report = warm_up_simulation_runtime()
self.assertEqual(report.status, "completed", report.error)
self.assertGreater(report.duration_ms, 0.0)
def test_numerical_warmup_exercises_sparse_lsmr_algebraic_path(self) -> None:
import scipy.optimize
actual_least_squares = scipy.optimize.least_squares
optimizer_calls: list[dict[str, object]] = []
def recording_least_squares(*args, **kwargs):
optimizer_calls.append(dict(kwargs))
return actual_least_squares(*args, **kwargs)
with patch.object(
scipy.optimize,
"least_squares",
recording_least_squares,
):
_run_numerical_warmup()
self.assertEqual(len(optimizer_calls), 1)
call = optimizer_calls[0]
self.assertEqual(call["tr_solver"], "lsmr")
sparsity = call["jac_sparsity"]
self.assertEqual(sparsity.shape, (2, 2))
self.assertEqual(sparsity.nnz, 2)
if __name__ == "__main__":
unittest.main()
+486
View File
@@ -0,0 +1,486 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.components.amesim.mechanical.translational import (
AmesimLstp00a,
)
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import (
AlgebraicSolveDiagnostics,
PressureFlowSolver,
)
from app.simulation.solvers.algebraic_blocks import (
StreamPressureBlockSolver,
_BlockSolveAttempt,
)
from app.simulation.systems.network import SimulationNetwork
def _pnl0002_solver(
*,
include_unselected_island: bool = False,
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
medium = IdealGasMedium()
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
right = Tank("right", medium, V=0.1, p0=100_000.0)
pipe = AmesimPnl0002(
"pipe",
medium,
p0=300_000.0,
T0=300.0,
)
network = SimulationNetwork("pnl0002-equation-blocks")
for component in (left, right, pipe):
network.add_component(component)
network.connect("left", "port_b", "pipe", "port_1")
network.connect("pipe", "port_2", "right", "port_a")
if include_unselected_island:
isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0)
plug = AmesimPnpl01("isolated_plug")
network.add_component(isolated)
network.add_component(plug)
network.connect("isolated", "port_b", "isolated_plug", "port_1")
for component in network.dynamic_components():
component.refresh_thermodynamic_ports()
solver = PressureFlowSolver(network)
solver.solve()
return solver, pipe
class StreamPressureBlockSolverTests(unittest.TestCase):
def test_pnl0002_uses_two_blocks_with_one_shared_component(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
self.assertTrue(block_solver.available, block_solver.fallback_reason)
self.assertEqual(len(block_solver.blocks), 2)
self.assertTrue(
all("pipe" in block.scope_components for block in block_solver.blocks)
)
pipe_component_evaluations = [
evaluation
for block in block_solver.blocks
for evaluation in block.component_evaluations
if getattr(evaluation.evaluate, "__self__", None) is pipe
]
self.assertEqual(len(pipe_component_evaluations), 2)
self.assertTrue(
{unknown.id for unknown in block_solver.blocks[0].unknowns}.isdisjoint(
unknown.id for unknown in block_solver.blocks[1].unknowns
)
)
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
self.assertEqual(len(result.diagnostics), 1)
self.assertAlmostEqual(
pipe.port_1.m_flow,
pipe.port_mass_flow(
pipe.port_1.p,
pipe.properties().p,
pipe.properties().T,
port_name="port_1",
),
places=12,
)
self.assertAlmostEqual(
pipe.port_2.m_flow,
pipe.port_mass_flow(
pipe.port_2.p,
pipe.properties().p,
pipe.properties().T,
port_name="port_2",
),
places=12,
)
def test_selected_block_seeding_preserves_every_unselected_unknown(self) -> None:
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
unselected = tuple(
unknown
for unknown in solver.unknowns
if unknown.component in {"isolated", "isolated_plug"}
)
for index, unknown in enumerate(unselected, start=1):
unknown.write(10_000.0 * index)
expected = tuple(unknown.read() for unknown in unselected)
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
self.assertEqual(
tuple(unknown.read() for unknown in unselected),
expected,
)
def test_causal_secondary_pressure_mutation_fuses_to_verified_path(self) -> None:
solver, _pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
first = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(first.used_global_fallback)
expected_pressures = tuple(
unknown.read()
for unknown in block_solver._selected_unknowns
if unknown.variable == "p"
)
original_seed = block_solver._seed_selected_blocks
mutated_pressure = next(
unknown
for unknown in block_solver._selected_unknowns
if unknown.variable == "p"
)
def seed_then_mutate(entry_values):
seeded = original_seed(entry_values)
mutated_pressure.write(mutated_pressure.read() + 10_000.0)
return seeded
with patch.object(
block_solver,
"_seed_selected_blocks",
side_effect=seed_then_mutate,
):
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
self.assertTrue(result.diagnostics[0].residual_verified_this_solve)
actual_pressures = tuple(
unknown.read()
for unknown in block_solver._selected_unknowns
if unknown.variable == "p"
)
for expected, actual in zip(expected_pressures, actual_pressures):
self.assertAlmostEqual(
actual,
expected,
delta=1.0e-12 * max(abs(expected), 1.0),
)
execution = block_solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"causalSecondaryRuntimeGateFailed",
)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_sparse_block_reports_actual_residual_evaluations(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
pipe.port_1.m_flow += 0.01
with patch.object(
block_solver,
"_seed_selected_blocks",
return_value=None,
):
result = block_solver.solve(scale_context=solver.scale_context())
sparse = result.diagnostics[0]
self.assertEqual(sparse.jacobian_mode, "blockSparse")
self.assertGreater(sparse.evaluations, 0)
self.assertGreater(sparse.residual_evaluations, sparse.evaluations)
self.assertFalse(sparse.dense_fallback_used)
def test_failed_sparse_block_restores_its_original_unknowns(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block = block_solver.blocks[0]
pipe.port_1.m_flow += 0.01
expected = tuple(unknown.read() for unknown in block.unknowns)
def failed_sparse(_fun, x0, **_kwargs):
return SimpleNamespace(
x=x0 + 123.0,
success=False,
status=-1,
message="forced sparse block failure",
nfev=1,
)
with patch("scipy.optimize.least_squares", side_effect=failed_sparse):
attempt = block_solver._solve_block(
block,
solver.scale_context(),
)
self.assertIsNone(attempt.diagnostics)
self.assertEqual(attempt.optimizer_evaluations, 1)
self.assertEqual(
tuple(unknown.read() for unknown in block.unknowns),
expected,
)
def test_block_failure_restores_full_snapshot_before_global_fallback(self) -> None:
solver, pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
pipe.port_1.m_flow += 0.01
solver.network.components["isolated_plug"].port_1.p = 12_345.0
expected = tuple(unknown.read() for unknown in solver.unknowns)
original_global_solve = solver.solve
def checked_global_solve(*args, **kwargs):
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
return original_global_solve(*args, **kwargs)
def failed_block(block, _scale_context, **_kwargs):
for unknown in block.unknowns:
unknown.write(unknown.read() + 321.0)
return _BlockSolveAttempt(
diagnostics=None,
optimizer_evaluations=2,
residual_evaluations=7,
failure_reason="blockResidualNotConverged",
)
with patch.object(
block_solver,
"_seed_selected_blocks",
return_value=None,
), patch.object(
block_solver,
"_solve_block",
side_effect=failed_block,
), patch.object(
solver,
"solve",
side_effect=checked_global_solve,
) as global_solve:
result = block_solver.solve(scale_context=solver.scale_context())
global_solve.assert_called_once()
self.assertTrue(result.used_global_fallback)
self.assertEqual(len(result.diagnostics), 1)
fallback_diagnostics = result.diagnostics[0]
self.assertIn(
fallback_diagnostics.jacobian_mode,
{
"seeded",
"sparse",
"dense",
"sparseThenDense",
"blockSparse",
},
)
self.assertGreaterEqual(
fallback_diagnostics.residual_evaluations,
fallback_diagnostics.evaluations,
)
self.assertGreaterEqual(fallback_diagnostics.evaluations, 2)
self.assertGreaterEqual(fallback_diagnostics.residual_evaluations, 7)
self.assertTrue(fallback_diagnostics.block_fallback_used)
self.assertIn(
"blockResidualNotConverged",
fallback_diagnostics.block_fallback_reason or "",
)
def test_seed_exception_restores_snapshot_before_global_fallback(self) -> None:
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
expected = tuple(unknown.read() for unknown in solver.unknowns)
original_global_solve = solver.solve
def broken_seed(_entry_values) -> None:
solver.unknowns[0].write(solver.unknowns[0].read() + 123_456.0)
raise ValueError("forced seed failure")
def checked_global_solve(*args, **kwargs):
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
return original_global_solve(*args, **kwargs)
with patch.object(
block_solver,
"_seed_selected_blocks",
side_effect=broken_seed,
), patch.object(
solver,
"solve",
side_effect=checked_global_solve,
) as global_solve:
result = block_solver.solve(scale_context=solver.scale_context())
global_solve.assert_called_once()
self.assertTrue(result.used_global_fallback)
def test_failed_global_fallback_does_not_leak_candidate_state(self) -> None:
solver, _pipe = _pnl0002_solver(include_unselected_island=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
expected = tuple(unknown.read() for unknown in solver.unknowns)
def failed_block(block, _scale_context, **_kwargs):
for unknown in block.unknowns:
unknown.write(unknown.read() + 123.0)
return _BlockSolveAttempt(
diagnostics=None,
optimizer_evaluations=1,
residual_evaluations=4,
failure_reason="blockResidualNotConverged",
)
def failed_global_solve(*_args, **_kwargs):
for unknown in solver.unknowns:
unknown.write(unknown.read() - 456.0)
raise RuntimeError("forced global fallback failure")
with patch.object(
block_solver,
"_seed_selected_blocks",
return_value=None,
), patch.object(
block_solver,
"_seeded_diagnostics",
return_value=None,
), patch.object(
block_solver,
"_solve_block",
side_effect=failed_block,
), patch.object(
solver,
"solve",
side_effect=failed_global_solve,
):
with self.assertRaisesRegex(
RuntimeError,
"forced global fallback failure",
):
block_solver.solve(scale_context=solver.scale_context())
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
def test_local_to_global_fallback_aggregates_attempt_chain_once(self) -> None:
solver, _pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
accepted_global = AlgebraicSolveDiagnostics(
success=True,
message="accepted global result",
evaluations=3,
pressure_scale=400_000.0,
flow_scale=0.01,
max_scaled_residual=0.25,
max_raw_residual=5.0,
residual_evaluations=11,
jacobian_mode="dense",
)
failed_local = _BlockSolveAttempt(
diagnostics=None,
optimizer_evaluations=2,
residual_evaluations=7,
failure_reason="blockResidualNotConverged",
)
with patch.object(
block_solver,
"_seeded_diagnostics",
return_value=None,
), patch.object(
block_solver,
"_solve_block",
return_value=failed_local,
), patch.object(
solver,
"solve",
return_value=accepted_global,
) as global_solve:
result = block_solver.solve(scale_context=solver.scale_context())
global_solve.assert_called_once()
self.assertTrue(result.used_global_fallback)
self.assertEqual(len(result.diagnostics), 1)
aggregate = result.diagnostics[0]
self.assertEqual(aggregate.evaluations, 5)
self.assertEqual(aggregate.residual_evaluations, 18)
self.assertEqual(aggregate.jacobian_mode, "dense")
self.assertEqual(aggregate.max_scaled_residual, 0.25)
self.assertTrue(aggregate.block_fallback_used)
self.assertEqual(
aggregate.block_fallback_reason,
"blockResidualNotConverged",
)
def test_failed_global_fallback_restores_lstp_causal_cache_for_base_errors(
self,
) -> None:
class ForcedFatalError(BaseException):
pass
for error_type in (MemoryError, ForcedFatalError):
with self.subTest(error_type=error_type.__name__):
solver, _pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.fallback_reason = "forcedUntrustedStructure"
contact = AmesimLstp00a(
"contact",
IdealGasMedium(),
gap0=0.0,
kcont=1.0e6,
rcont=0.0,
Pdis=1.0e-6,
discContactOption=1.0,
)
contact.port_1.x = 1.25
contact.port_2.x = 1.0
contact.port_1.v = 0.5
contact.port_2.v = -0.25
contact.set_causal_contact(penetration=0.25, force=12.0)
solver._causal_contact_components = (contact,)
expected_unknowns = tuple(
unknown.read() for unknown in solver.unknowns
)
causal_names = tuple(
name
for name in vars(contact)
if name.startswith("_causal_")
)
expected_causal = tuple(
getattr(contact, name) for name in causal_names
)
expected_last = solver.last_diagnostics
def failed_global_solve(*_args, **_kwargs):
solver.unknowns[0].write(
solver.unknowns[0].read() + 123_456.0
)
contact.clear_causal_contact()
solver.last_diagnostics = None
raise error_type("forced global fallback failure")
with patch.object(
solver,
"solve",
side_effect=failed_global_solve,
):
with self.assertRaises(error_type):
block_solver.solve(scale_context=solver.scale_context())
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected_unknowns,
)
self.assertEqual(
tuple(getattr(contact, name) for name in causal_names),
expected_causal,
)
self.assertIs(solver.last_diagnostics, expected_last)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,178 @@
from __future__ import annotations
from collections.abc import Mapping
import unittest
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
from app.simulation.core.ports import PortDefinition
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import SimulationNetwork
class _CountingDynamicAnchor(DynamicComponent):
PORTS = (PortDefinition.pneumatic("port"),)
def __init__(
self,
name: str,
*,
enthalpy: float,
temperature_reference_h: float,
) -> None:
super().__init__(name)
self.enthalpy = enthalpy
self.temperature_reference_h = temperature_reference_h
self.refresh_count = 0
self.port = self.register_declared_port("port")
self.port.h_outflow = -1.0
def make_ports_current(self) -> None:
self.port.h_outflow = self.enthalpy
def get_state_vector(self) -> list[float]:
return [0.0, 0.0]
def set_state_vector(self, values: list[float]) -> None:
if len(values) != self.state_size:
raise ValueError("Unexpected test state size.")
def refresh_thermodynamic_ports(self) -> None:
self.refresh_count += 1
self.make_ports_current()
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
return [0.0, 0.0]
class _PassThrough(AlgebraicComponent):
PORTS = (
PortDefinition.pneumatic("left"),
PortDefinition.pneumatic("right"),
)
def __init__(self, name: str, update_log: list[str]) -> None:
super().__init__(name)
self.left = self.register_declared_port("left")
self.right = self.register_declared_port("right")
self.update_log = update_log
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.update_log.append(self.name)
self.left.h_outflow = connected_h["right"]
self.right.h_outflow = connected_h["left"]
def _build_chain() -> tuple[
SimulationNetwork,
_CountingDynamicAnchor,
_PassThrough,
_PassThrough,
_CountingDynamicAnchor,
list[str],
]:
update_log: list[str] = []
left = _CountingDynamicAnchor(
"left_anchor",
enthalpy=100.0,
temperature_reference_h=1_100.0,
)
first = _PassThrough("first", update_log)
second = _PassThrough("second", update_log)
right = _CountingDynamicAnchor(
"right_anchor",
enthalpy=400.0,
temperature_reference_h=1_400.0,
)
network = SimulationNetwork("stream-chain")
for component in (left, first, second, right):
network.add_component(component)
network.connect("left_anchor", "port", "first", "left")
network.connect("first", "right", "second", "left")
network.connect("second", "right", "right_anchor", "port")
return network, left, first, second, right, update_log
class StreamResolverExecutionPlanTests(unittest.TestCase):
def test_standalone_solve_refreshes_each_dynamic_exactly_once(self) -> None:
network, left, _first, _second, right, _update_log = _build_chain()
diagnostics, _connected = StreamResolver(network).solve()
self.assertTrue(diagnostics.converged)
self.assertEqual(left.refresh_count, 1)
self.assertEqual(right.refresh_count, 1)
def test_current_dynamic_ports_skip_refresh(self) -> None:
network, left, _first, _second, right, _update_log = _build_chain()
left.make_ports_current()
right.make_ports_current()
diagnostics, connected = StreamResolver(network).solve(
dynamic_ports_are_current=True
)
self.assertTrue(diagnostics.converged)
self.assertEqual(left.refresh_count, 0)
self.assertEqual(right.refresh_count, 0)
self.assertEqual(connected["first"], {"left": 100.0, "right": 400.0})
def test_multiple_iterations_do_not_repeat_dynamic_refresh(self) -> None:
network, left, _first, _second, right, update_log = _build_chain()
diagnostics, _connected = StreamResolver(network).solve()
self.assertGreater(diagnostics.iterations, 1)
self.assertEqual(left.refresh_count, 1)
self.assertEqual(right.refresh_count, 1)
self.assertEqual(
update_log,
["first", "second"] * diagnostics.iterations,
)
def test_precompiled_bindings_preserve_outputs_and_references(self) -> None:
default_network, default_left, default_first, default_second, default_right, _ = (
_build_chain()
)
current_network, current_left, current_first, current_second, current_right, _ = (
_build_chain()
)
current_left.make_ports_current()
current_right.make_ports_current()
default_resolver = StreamResolver(default_network)
current_resolver = StreamResolver(current_network)
default_diagnostics, default_connected = default_resolver.solve()
current_diagnostics, current_connected = current_resolver.solve(
dynamic_ports_are_current=True
)
self.assertEqual(default_diagnostics, current_diagnostics)
self.assertEqual(default_connected, current_connected)
self.assertEqual(
(
default_left.port.h_outflow,
default_first.left.h_outflow,
default_first.right.h_outflow,
default_second.left.h_outflow,
default_second.right.h_outflow,
default_right.port.h_outflow,
),
(
current_left.port.h_outflow,
current_first.left.h_outflow,
current_first.right.h_outflow,
current_second.left.h_outflow,
current_second.right.h_outflow,
current_right.port.h_outflow,
),
)
references = default_resolver.connected_temperature_reference_enthalpies()
self.assertEqual(references["first"]["left"], 1_100.0)
self.assertEqual(references["second"]["right"], 1_400.0)
if __name__ == "__main__":
unittest.main()
+642
View File
@@ -0,0 +1,642 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import replace
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network, compile_system_xml_network
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.orifices import (
AmesimPnor001,
AmesimPnvo001FixedOpening,
)
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl00r,
AmesimPnl0001,
AmesimPnl0002,
)
from app.simulation.components.amesim.storage.chambers import AmesimPnch023
from app.simulation.components.experimental.flow.orifice import Orifice
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import (
AlgebraicSolveDiagnostics,
AlgebraicSolveError,
)
from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import (
GenericFluidSystem,
simulation_preparation_issues,
)
from app.simulation.systems.network import SimulationNetwork
from app.system_xml import validate_system_xml_document
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
from tests.test_amesim_mechanical_xml import elastic_contact_project
from tests.test_generic_system_xml_simulation import chain_project
from tests.test_high_stiffness_explicit_rk45 import short_explicit_rk45_xml
class _UnclassifiedCustomOrifice(Orifice):
"""A custom subclass must not inherit the catalog purity declaration."""
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
super().update_stream_outflows(connected_h)
class _InvalidDependencyDeclarationOrifice(Orifice):
PRESSURE_FLOW_DEPENDS_ON_STREAM = 1
class _CountingCylinder(Cylinder):
def __init__(self, *args, **kwargs) -> None:
self.refresh_count = 0
super().__init__(*args, **kwargs)
def refresh_thermodynamic_ports(self):
self.refresh_count += 1
return super().refresh_thermodynamic_ports()
def _three_component_network(
middle: Orifice,
*,
prefix: str = "independent",
) -> tuple[SimulationNetwork, _CountingCylinder, Tank]:
medium = IdealGasMedium()
source = _CountingCylinder(
f"{prefix}_source",
medium,
V=0.02,
p0=500_000.0,
T0=320.0,
)
sink = Tank(
f"{prefix}_sink",
medium,
V=0.05,
p0=100_000.0,
T0=290.0,
)
network = SimulationNetwork(prefix)
for component in (source, middle, sink):
network.add_component(component)
network.connect(source.name, "port_b", middle.name, "port_a")
network.connect(middle.name, "port_b", sink.name, "port_a")
return network, source, sink
def _mixed_island_network(
*,
independent_pressure: float = 450_000.0,
) -> SimulationNetwork:
medium = IdealGasMedium()
sensitive_source = Cylinder(
"sensitive_source",
medium,
V=0.02,
p0=600_000.0,
T0=350.0,
)
sensitive_sink = Tank(
"sensitive_sink",
medium,
V=0.05,
p0=100_000.0,
T0=280.0,
)
valve = AmesimPnvo001FixedOpening(
"sensitive_valve",
medium,
area0=1.0e-5,
opening=0.8,
)
independent_source = Cylinder(
"independent_source",
medium,
V=0.02,
p0=independent_pressure,
T0=310.0,
)
independent_sink = Tank(
"independent_sink",
medium,
V=0.05,
p0=120_000.0,
T0=295.0,
)
independent_orifice = Orifice("independent_orifice", K=2.0e-5)
network = SimulationNetwork("mixed-islands")
for component in (
sensitive_source,
sensitive_sink,
valve,
independent_source,
independent_sink,
independent_orifice,
):
network.add_component(component)
network.connect("sensitive_source", "port_b", "sensitive_valve", "port_2")
network.connect("sensitive_valve", "port_3", "sensitive_sink", "port_a")
network.connect("independent_source", "port_b", "independent_orifice", "port_a")
network.connect("independent_orifice", "port_b", "independent_sink", "port_a")
return network
def _special_seed_islands_network() -> SimulationNetwork:
medium = IdealGasMedium()
series_source = AmesimPnch023("series_source", medium, p0=15.3e6)
series_source_plug = AmesimPnpl01("series_source_plug")
series_orifice = AmesimPnor001("series_orifice", medium)
series_pipe = AmesimPnl0001("series_pipe", medium, p0=14.0e6)
series_pipe_plug = AmesimPnpl01("series_pipe_plug")
closed_pipe = AmesimPnl0002("closed_pipe", medium, p0=200_000.0)
closed_pipe_left = AmesimPnpl01("closed_pipe_left")
closed_pipe_right = AmesimPnpl01("closed_pipe_right")
resistance_source = Cylinder(
"resistance_source",
medium,
V=0.02,
p0=500_000.0,
T0=310.0,
)
resistance = AmesimPnl00r("resistance", medium)
resistance_plug = AmesimPnpl01("resistance_plug")
network = SimulationNetwork("special-seed-islands")
for component in (
series_source,
series_source_plug,
series_orifice,
series_pipe,
series_pipe_plug,
closed_pipe,
closed_pipe_left,
closed_pipe_right,
resistance_source,
resistance,
resistance_plug,
):
network.add_component(component)
network.connect("series_source_plug", "port_1", "series_source", "port_1")
network.connect("series_source", "port_2", "series_orifice", "port_1")
network.connect("series_orifice", "port_2", "series_pipe", "port_1")
network.connect("series_pipe", "port_2", "series_pipe_plug", "port_1")
network.connect("closed_pipe_left", "port_1", "closed_pipe", "port_1")
network.connect("closed_pipe", "port_2", "closed_pipe_right", "port_1")
network.connect("resistance_source", "port_b", "resistance", "port_1")
network.connect("resistance", "port_2", "resistance_plug", "port_1")
return network
def _force_legacy_global_coupling(system: GenericFluidSystem) -> None:
plan = system._thermofluid_closure_plan
system._thermofluid_closure_plan = replace(
plan,
secondary_pressure_solvers=(system.pressure_flow_solver,),
secondary_component_groups=(plan.global_component_group,),
uses_conservative_global_solver=True,
)
class ThermofluidClosurePlanTests(unittest.TestCase):
def test_independent_network_solves_pressure_once_and_refreshes_once(self) -> None:
network, source, _sink = _three_component_network(
Orifice("independent_orifice", K=1.0e-5)
)
system = GenericFluidSystem(network)
source.refresh_count = 0
system.consistent_initial_state_vector()
self.assertEqual(system._thermofluid_closure_plan.secondary_pressure_solvers, ())
self.assertEqual(system.algebraic_solve_count, 1)
self.assertEqual(system.thermofluid_pressure_pass_count, 1)
self.assertEqual(source.refresh_count, 1)
def test_mixed_network_revisits_only_the_stream_sensitive_island(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
plan = system._thermofluid_closure_plan
self.assertFalse(plan.uses_conservative_global_solver)
self.assertEqual(len(plan.secondary_pressure_solvers), 1)
self.assertEqual(
set(plan.secondary_component_groups[0]),
{"sensitive_source", "sensitive_sink", "sensitive_valve"},
)
self.assertEqual(
set(plan.secondary_pressure_solvers[0].network.components),
set(plan.secondary_component_groups[0]),
)
self.assertNotIn(
"independent_orifice",
plan.secondary_pressure_solvers[0].network.components,
)
state = system.consistent_initial_state_vector()
first = system.rhs(0.0, state)
second = system.rhs(0.0, state)
for first_value, second_value in zip(first, second):
self.assertAlmostEqual(first_value, second_value, delta=1.0e-9)
def test_secondary_attempt_chain_is_counted_as_one_logical_solve(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
aggregate = AlgebraicSolveDiagnostics(
success=True,
message="accepted global fallback",
evaluations=5,
pressure_scale=600_000.0,
flow_scale=0.01,
max_scaled_residual=0.2,
max_raw_residual=2.0,
residual_evaluations=18,
jacobian_mode="dense",
block_fallback_used=True,
block_fallback_reason="blockResidualNotConverged",
)
fake_result = StreamBlockSolveResult(
diagnostics=(aggregate,),
scopes=(tuple(system.network.components),),
used_global_fallback=True,
)
initial_diagnostics: list[AlgebraicSolveDiagnostics] = []
original_initial_solve = system.pressure_flow_solver.solve
def recorded_initial_solve(*args, **kwargs):
result = original_initial_solve(*args, **kwargs)
initial_diagnostics.append(result)
return result
with patch.object(
system.pressure_flow_solver,
"solve",
side_effect=recorded_initial_solve,
), patch.object(secondary, "solve", return_value=fake_result):
system.consistent_initial_state_vector()
self.assertEqual(len(initial_diagnostics), 1)
initial = initial_diagnostics[0]
self.assertEqual(system.algebraic_solve_count, 2)
self.assertEqual(
system.algebraic_block_fallback_count,
int(initial.block_fallback_used) + 1,
)
self.assertEqual(
system.algebraic_optimizer_evaluation_count,
initial.evaluations + 5,
)
self.assertEqual(
system.algebraic_residual_evaluation_count,
initial.residual_evaluations + 18,
)
def test_secondary_island_failure_reports_its_physical_scope(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
plan = system._thermofluid_closure_plan
secondary = plan.secondary_pressure_solvers[0]
def failed_result(_fun, x0, **_kwargs):
return SimpleNamespace(
x=x0.copy(),
success=False,
status=-1,
message="forced secondary-island failure",
nfev=1,
)
with patch.object(
secondary,
"_solve_explicit_flow_unknowns",
return_value=None,
), patch("scipy.optimize.least_squares", side_effect=failed_result):
with self.assertRaises(AlgebraicSolveError) as raised:
secondary.solve(effort_variables=())
self.assertEqual(raised.exception.scope_kind, "physicalIsland")
self.assertEqual(
raised.exception.scope_components,
plan.secondary_component_groups[0],
)
def test_secondary_islands_preserve_special_pressure_seed_plans(self) -> None:
network = _special_seed_islands_network()
system = GenericFluidSystem(network)
plan = system._thermofluid_closure_plan
solvers = {
frozenset(solver.network.components): solver
for solver in plan.secondary_pressure_solvers
}
series_solver = solvers[
frozenset(
{
"series_source",
"series_source_plug",
"series_orifice",
"series_pipe",
"series_pipe_plug",
}
)
]
closed_pipe_solver = solvers[
frozenset(
{"closed_pipe", "closed_pipe_left", "closed_pipe_right"}
)
]
resistance_solver = solvers[
frozenset(
{
"resistance_source",
"resistance",
"resistance_plug",
}
)
]
self.assertEqual(len(series_solver._pnor_pnl0001_series_plan), 1)
self.assertEqual(len(closed_pipe_solver._closed_resistance_pressure_plan), 2)
self.assertEqual(len(resistance_solver._closed_resistance_pressure_plan), 1)
closed_pipe = network.components["closed_pipe"]
expected_pressure = closed_pipe.properties().p
closed_pipe.port_1.p = 10_000.0
closed_pipe.port_2.p = 20_000.0
network.components["closed_pipe_left"].port_1.p = 30_000.0
network.components["closed_pipe_right"].port_1.p = 40_000.0
closed_pipe_solver._seed_closed_resistance_pressures()
self.assertAlmostEqual(closed_pipe.port_1.p, expected_pressure)
self.assertAlmostEqual(closed_pipe.port_2.p, expected_pressure)
self.assertAlmostEqual(
network.components["closed_pipe_left"].port_1.p,
expected_pressure,
)
self.assertAlmostEqual(
network.components["closed_pipe_right"].port_1.p,
expected_pressure,
)
def test_unclassified_custom_stream_component_uses_legacy_global_solver(self) -> None:
network, _source, _sink = _three_component_network(
_UnclassifiedCustomOrifice("custom_orifice", K=1.0e-5),
prefix="custom",
)
system = GenericFluidSystem(network)
plan = system._thermofluid_closure_plan
self.assertTrue(plan.uses_conservative_global_solver)
self.assertEqual(plan.secondary_pressure_solvers, (system.pressure_flow_solver,))
self.assertEqual(plan.secondary_component_groups, (plan.global_component_group,))
def test_invalid_dependency_declaration_uses_legacy_global_solver(self) -> None:
network, _source, _sink = _three_component_network(
_InvalidDependencyDeclarationOrifice("invalid_orifice", K=1.0e-5),
prefix="invalid",
)
plan = GenericFluidSystem(network)._thermofluid_closure_plan
self.assertTrue(plan.uses_conservative_global_solver)
self.assertEqual(
plan.conservative_fallback_reason,
"invalidDependencyDeclaration",
)
def test_non_square_physical_island_metadata_forces_global_fallback(self) -> None:
system = GenericFluidSystem(_mixed_island_network())
templates = list(system.pressure_flow_solver.equation_templates)
moved = next(
index
for index, equation in enumerate(templates)
if equation.owner == "component"
and equation.owner_id == "independent_orifice"
)
equation = templates[moved]
templates[moved] = replace(
equation,
owner_id="sensitive_valve",
variables=tuple(
variable.replace("independent_orifice", "sensitive_valve")
for variable in equation.variables
),
)
system.pressure_flow_solver._equation_templates = tuple(templates)
plan = system._build_thermofluid_closure_plan()
self.assertTrue(plan.uses_conservative_global_solver)
self.assertEqual(
plan.conservative_fallback_reason,
"nonSquarePhysicalIsland",
)
def test_pruned_and_legacy_chain_results_are_numerically_equivalent(self) -> None:
config = SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
)
optimized = GenericFluidSystem(compile_reactflow_network(chain_project()))
legacy = GenericFluidSystem(compile_reactflow_network(chain_project()))
_force_legacy_global_coupling(legacy)
optimized_result = optimized.simulate(config, sample_step=0.005)
legacy_result = legacy.simulate(config, sample_step=0.005)
self.assertTrue(optimized_result.success)
self.assertTrue(legacy_result.success)
self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys())
for key, optimized_values in optimized_result.series.items():
legacy_values = legacy_result.series[key]
self.assertEqual(len(optimized_values), len(legacy_values), key)
for optimized_value, legacy_value in zip(
optimized_values,
legacy_values,
):
self.assertAlmostEqual(
optimized_value,
legacy_value,
delta=1.0e-11 * max(abs(legacy_value), 1.0),
msg=key,
)
self.assertEqual(optimized_result.final.keys(), legacy_result.final.keys())
for key, optimized_value in optimized_result.final.items():
legacy_value = legacy_result.final[key]
self.assertAlmostEqual(
optimized_value,
legacy_value,
delta=1.0e-11 * max(abs(legacy_value), 1.0),
msg=key,
)
self.assertLess(optimized.algebraic_solve_count, legacy.algebraic_solve_count)
def test_stream_dependent_rhs_is_history_independent_after_other_trial(self) -> None:
network_a = compile_reactflow_network(high_pressure_helium_step_project())
network_fresh = compile_reactflow_network(high_pressure_helium_step_project())
system = GenericFluidSystem(network_a)
fresh = GenericFluidSystem(network_fresh)
state = system.consistent_initial_state_vector()
fresh_state = fresh.consistent_initial_state_vector()
perturbed = list(state)
perturbed[0] *= 1.000001
perturbed[1] *= 0.999999
first = system.rhs(0.041, state)
system.rhs(0.041, perturbed)
repeated = system.rhs(0.041, state)
reference = fresh.rhs(0.041, fresh_state)
for expected, actual in zip(first, repeated):
self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0))
for expected, actual in zip(reference, repeated):
self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0))
def test_block_solve_reuses_global_scales_from_extreme_other_island(self) -> None:
optimized = GenericFluidSystem(
_mixed_island_network(independent_pressure=1.0e10)
)
legacy = GenericFluidSystem(
_mixed_island_network(independent_pressure=1.0e10)
)
_force_legacy_global_coupling(legacy)
optimized_state = optimized.initial_state_vector()
legacy_state = legacy.initial_state_vector()
optimized_rhs = optimized.rhs(0.0, optimized_state)
legacy_rhs = legacy.rhs(0.0, legacy_state)
for expected, actual in zip(legacy_rhs, optimized_rhs):
self.assertAlmostEqual(
actual,
expected,
delta=1.0e-10 * max(abs(expected), 1.0),
)
self.assertLessEqual(
optimized.max_algebraic_residual,
max(legacy.max_algebraic_residual, 1.0e-14),
)
def test_high_stiffness_contact_island_matches_legacy_global_closure(self) -> None:
report = validate_system_xml_document(short_explicit_rk45_xml())
self.assertTrue(report.valid)
assert report.document is not None
document = report.document
optimized = GenericFluidSystem(compile_system_xml_network(document))
legacy = GenericFluidSystem(compile_system_xml_network(document))
_force_legacy_global_coupling(legacy)
config = SolveIVPConfig(
t_start=document.simulation.t_start,
t_stop=document.simulation.t_stop,
method=document.simulation.method,
rtol=1.0e-6,
max_step=document.simulation.max_step,
)
optimized_result = optimized.simulate(
config,
sample_step=document.simulation.sample_step,
)
legacy_result = legacy.simulate(
config,
sample_step=document.simulation.sample_step,
)
self.assertTrue(optimized_result.success)
self.assertTrue(legacy_result.success)
optimized_totals = optimized_result.diagnostics["integration"]["totals"]
legacy_totals = legacy_result.diagnostics["integration"]["totals"]
self.assertEqual(
optimized_totals["stateTransitionCount"],
legacy_totals["stateTransitionCount"],
)
self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys())
for key, optimized_values in optimized_result.series.items():
legacy_values = legacy_result.series[key]
self.assertEqual(len(optimized_values), len(legacy_values), key)
for optimized_value, legacy_value in zip(
optimized_values,
legacy_values,
):
self.assertAlmostEqual(
optimized_value,
legacy_value,
delta=2.0e-7 * max(abs(legacy_value), 1.0),
msg=key,
)
def test_secondary_fluid_island_does_not_clear_active_contact_state(self) -> None:
network = compile_reactflow_network(elastic_contact_project())
medium = IdealGasMedium()
source = Cylinder(
"separate_source",
medium,
V=0.02,
p0=600_000.0,
T0=350.0,
)
sink = Tank(
"separate_sink",
medium,
V=0.05,
p0=100_000.0,
T0=280.0,
)
valve = AmesimPnvo001FixedOpening(
"separate_valve",
medium,
area0=1.0e-5,
opening=0.8,
)
for component in (source, sink, valve):
network.add_component(component)
network.connect("separate_source", "port_b", "separate_valve", "port_2")
network.connect("separate_valve", "port_3", "separate_sink", "port_a")
self.assertEqual(simulation_preparation_issues(network), ())
system = GenericFluidSystem(network)
contact = network.components["contact_1"]
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
original_solve = secondary.solve
observed: list[tuple[float | None, ...]] = []
def checked_solve(*, scale_context=None):
if contact._causal_penetration is None:
contact.set_causal_contact(penetration=1.0e-4, force=10.0)
before = (
contact._causal_penetration,
contact._causal_contact_force,
contact._causal_port_1_x,
contact._causal_port_2_x,
contact._causal_port_1_v,
contact._causal_port_2_v,
)
result = original_solve(scale_context=scale_context)
after = (
contact._causal_penetration,
contact._causal_contact_force,
contact._causal_port_1_x,
contact._causal_port_2_x,
contact._causal_port_1_v,
contact._causal_port_2_v,
)
self.assertEqual(after, before)
observed.append(before)
return result
secondary.solve = checked_solve
system.consistent_initial_state_vector()
self.assertTrue(observed)
self.assertIsNotNone(observed[0][0])
self.assertIsNotNone(observed[0][1])
if __name__ == "__main__":
unittest.main()