diff --git a/app/main.py b/app/main.py index 07ef9bd..f84afc8 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/app/simulation/README.md b/app/simulation/README.md index b0235e3..257008a 100644 --- a/app/simulation/README.md +++ b/app/simulation/README.md @@ -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)。 diff --git a/app/simulation/benchmark_performance.py b/app/simulation/benchmark_performance.py index 616005f..e249fb6 100644 --- a/app/simulation/benchmark_performance.py +++ b/app/simulation/benchmark_performance.py @@ -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 diff --git a/app/simulation/components/amesim/boundary/sources.py b/app/simulation/components/amesim/boundary/sources.py index 00c569a..44242a8 100644 --- a/app/simulation/components/amesim/boundary/sources.py +++ b/app/simulation/components/amesim/boundary/sources.py @@ -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 = () diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py index 5538774..b537427 100644 --- a/app/simulation/components/amesim/flow/orifices.py +++ b/app/simulation/components/amesim/flow/orifices.py @@ -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"), diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index cff5720..c9c1b88 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -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( diff --git a/app/simulation/components/amesim/junctions/nodes.py b/app/simulation/components/amesim/junctions/nodes.py index 125f81a..be21525 100644 --- a/app/simulation/components/amesim/junctions/nodes.py +++ b/app/simulation/components/amesim/junctions/nodes.py @@ -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"), diff --git a/app/simulation/components/amesim/mechanical/pistons.py b/app/simulation/components/amesim/mechanical/pistons.py index 0d2456b..a018dc4 100644 --- a/app/simulation/components/amesim/mechanical/pistons.py +++ b/app/simulation/components/amesim/mechanical/pistons.py @@ -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"), diff --git a/app/simulation/components/amesim/media/mediums.py b/app/simulation/components/amesim/media/mediums.py index 70583b2..75880ad 100644 --- a/app/simulation/components/amesim/media/mediums.py +++ b/app/simulation/components/amesim/media/mediums.py @@ -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, diff --git a/app/simulation/components/experimental/flow/orifice.py b/app/simulation/components/experimental/flow/orifice.py index 9984ba5..a2e285e 100644 --- a/app/simulation/components/experimental/flow/orifice.py +++ b/app/simulation/components/experimental/flow/orifice.py @@ -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"), diff --git a/app/simulation/components/experimental/flow/resistive_pipe.py b/app/simulation/components/experimental/flow/resistive_pipe.py index 9d62d0d..52bec37 100644 --- a/app/simulation/components/experimental/flow/resistive_pipe.py +++ b/app/simulation/components/experimental/flow/resistive_pipe.py @@ -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"), diff --git a/app/simulation/components/experimental/junctions/tee.py b/app/simulation/components/experimental/junctions/tee.py index 6912530..33a5a7d 100644 --- a/app/simulation/components/experimental/junctions/tee.py +++ b/app/simulation/components/experimental/junctions/tee.py @@ -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"), diff --git a/app/simulation/core/base.py b/app/simulation/core/base.py index e73252e..ce5941b 100644 --- a/app/simulation/core/base.py +++ b/app/simulation/core/base.py @@ -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, ...]] = () diff --git a/app/simulation/performance.py b/app/simulation/performance.py index 4cfa9a8..d09a703 100644 --- a/app/simulation/performance.py +++ b/app/simulation/performance.py @@ -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", ] diff --git a/app/simulation/property_cache.py b/app/simulation/property_cache.py new file mode 100644 index 0000000..830fce2 --- /dev/null +++ b/app/simulation/property_cache.py @@ -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", +] diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py index 50b4920..95f83d2 100644 --- a/app/simulation/solvers/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -1,9 +1,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from math import expm1, isfinite, log, sqrt +import os from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 from app.simulation.components.amesim.flow.orifices import AmesimPnor001 @@ -18,10 +19,36 @@ from app.simulation.performance import profile_phase from app.simulation.systems.network import SimulationNetwork +# The pneumatic constitutive laws require strictly positive absolute pressure. +# Keep the optimizer's open lower bound at zero: adaptive explicit integrators +# can legitimately probe positive sub-pascal trial states before rejecting a +# high-stiffness step. A 1 Pa bound made those otherwise valid seeds fail in +# SciPy before the residuals were evaluated. +PRESSURE_LOWER_BOUND_PA = 0.0 + + +CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_FAST_PATH" +CAUSAL_FAST_PATH_AUDIT_INTERVAL = 64 + + +def _causal_fast_path_environment_enabled() -> bool: + value = os.getenv(CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE, "1") + return value.strip().lower() not in {"0", "false", "no", "off"} + + class AlgebraicSolveError(RuntimeError): - def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None: + def __init__( + self, + message: str, + diagnostics: "AlgebraicSolveDiagnostics", + *, + scope_kind: str = "network", + scope_components: tuple[str, ...] = (), + ) -> None: super().__init__(message) self.diagnostics = diagnostics + self.scope_kind = scope_kind + self.scope_components = scope_components @dataclass(frozen=True) @@ -78,6 +105,16 @@ class EquationScalePlan: class EffortAnchor: unknown: AlgebraicUnknown evaluate: Callable[[], float] + equation_id: str + + +@dataclass(frozen=True) +class CausalEffortAssignment: + """One uniquely state-anchored effort equality group.""" + + variable: str + members: tuple[AlgebraicUnknown, ...] + anchor: EffortAnchor @dataclass(frozen=True) @@ -93,6 +130,54 @@ class ComponentEquationEvaluation: templates: tuple[EquationResidual, ...] +@dataclass(frozen=True) +class AlgebraicEquationBlock: + """One connected component of the equation/unknown incidence graph.""" + + unknown_indices: tuple[int, ...] + equation_indices: tuple[int, ...] + + +@dataclass(frozen=True) +class ScopedComponentEquationEvaluation: + evaluate: Callable[[], tuple[float, ...]] + targets: tuple[tuple[int, int, str], ...] + + +@dataclass(frozen=True) +class ScopedConnectionEquationEvaluation: + target: int + evaluate: Callable[[], float] + + +@dataclass(frozen=True) +class AlgebraicEquationSubset: + """Compiled residual evaluation for a union of independent blocks.""" + + unknown_indices: tuple[int, ...] + equation_indices: tuple[int, ...] + component_evaluations: tuple[ScopedComponentEquationEvaluation, ...] + connection_evaluations: tuple[ScopedConnectionEquationEvaluation, ...] + jacobian_sparsity: object + + def equation_values(self) -> tuple[float, ...]: + values: list[float | None] = [None] * len(self.equation_indices) + for evaluation in self.component_evaluations: + component_values = evaluation.evaluate() + for target, source, equation_id in evaluation.targets: + if source >= len(component_values): + raise RuntimeError( + "Compiled algebraic equation disappeared at runtime: " + f"{equation_id}." + ) + values[target] = float(component_values[source]) + for evaluation in self.connection_evaluations: + values[evaluation.target] = float(evaluation.evaluate()) + if any(value is None for value in values): + raise RuntimeError("Scoped algebraic evaluation returned no value.") + return tuple(float(value) for value in values) + + @dataclass(frozen=True) class PnorPnl0001SeriesBinding: orifice: AmesimPnor001 @@ -135,6 +220,15 @@ class AlgebraicSolveDiagnostics: flow_scale: float max_scaled_residual: float max_raw_residual: float + residual_evaluations: int = 0 + jacobian_mode: str = "seeded" + dense_fallback_used: bool = False + nonlinear_block_count: int = 0 + nonlinear_block_unknown_count: int = 0 + block_fallback_used: bool = False + block_fallback_reason: str | None = None + residual_verified_this_solve: bool = True + causal_fast_path_used: bool = False def as_dict(self) -> dict[str, object]: return { @@ -145,6 +239,15 @@ class AlgebraicSolveDiagnostics: "flowScale": self.flow_scale, "maxScaledResidual": self.max_scaled_residual, "maxRawResidual": self.max_raw_residual, + "residualEvaluations": self.residual_evaluations, + "jacobianMode": self.jacobian_mode, + "denseFallbackUsed": self.dense_fallback_used, + "nonlinearBlockCount": self.nonlinear_block_count, + "nonlinearBlockUnknownCount": self.nonlinear_block_unknown_count, + "blockFallbackUsed": self.block_fallback_used, + "blockFallbackReason": self.block_fallback_reason, + "residualVerifiedThisSolve": self.residual_verified_this_solve, + "causalFastPathUsed": self.causal_fast_path_used, } @@ -157,10 +260,12 @@ class PressureFlowSolver: *, residual_tolerance: float = 1e-7, max_evaluations: int = 500, + scope_kind: str = "network", ) -> None: self.network = network self.residual_tolerance = residual_tolerance self.max_evaluations = max_evaluations + self.scope_kind = scope_kind self.unknowns = self._build_unknowns() self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns} self._unknowns_by_variable = { @@ -225,8 +330,630 @@ class PressureFlowSolver: self._equation_scale_plans = self._build_equation_scale_plans( self._equation_templates ) + ( + self._jacobian_sparsity, + self._jacobian_sparsity_is_trusted, + self._jacobian_sparsity_fallback_reason, + ) = self._build_jacobian_sparsity() + self._equation_evaluation_locations = ( + self._build_equation_evaluation_locations() + ) + ( + self._equation_blocks, + self._equation_blocks_are_trusted, + self._equation_blocks_fallback_reason, + ) = self._build_equation_blocks() + ( + self._causal_effort_plan_by_variable, + self._causal_flow_unknown_ids, + self._causal_flow_equation_ids, + self._causal_fast_path_eligible, + self._causal_fast_path_fallback_reason, + ) = self._build_causal_execution_plan() + self._causal_fast_path_environment_enabled = ( + _causal_fast_path_environment_enabled() + ) + self._causal_runtime_disabled_reason: str | None = None + self._causal_audit_interval = CAUSAL_FAST_PATH_AUDIT_INTERVAL + self._causal_audit_required = True + self._causal_solves_since_audit = 0 + self._causal_fast_solve_count = 0 + self._causal_full_residual_audit_count = 0 + self._causal_audit_failure_count = 0 + self._causal_legacy_fallback_count = 0 + self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None self.last_diagnostics: AlgebraicSolveDiagnostics | None = None + @property + def equation_templates(self) -> tuple[EquationResidual, ...]: + """Immutable equation metadata compiled for this solver.""" + + return self._equation_templates + + @property + def jacobian_sparsity(self): + """Compiled equation/unknown dependency pattern for finite differences.""" + + return self._jacobian_sparsity + + @property + def jacobian_sparsity_is_trusted(self) -> bool: + """Whether every algebraic row and column has declared structure.""" + + return self._jacobian_sparsity_is_trusted + + @property + def jacobian_sparsity_fallback_reason(self) -> str | None: + """Reason sparse finite differences are disabled for this solver.""" + + return self._jacobian_sparsity_fallback_reason + + @property + def equation_blocks(self) -> tuple[AlgebraicEquationBlock, ...]: + """Trusted connected blocks in the algebraic incidence graph.""" + + return self._equation_blocks + + @property + def equation_blocks_are_trusted(self) -> bool: + """Whether scoped nonlinear fallback is safe for this network.""" + + return self._equation_blocks_are_trusted + + @property + def equation_blocks_fallback_reason(self) -> str | None: + """Reason nonlinear block pruning is disabled for this network.""" + + return self._equation_blocks_fallback_reason + + @property + def causal_fast_path_eligible(self) -> bool: + """Whether the compiled equations form one strictly causal program.""" + + return self._causal_fast_path_eligible + + @property + def causal_fast_path_enabled(self) -> bool: + """Whether new solves may currently use the causal fast path.""" + + return ( + self._causal_fast_path_environment_enabled + and self._causal_fast_path_eligible + and self._causal_runtime_disabled_reason is None + ) + + def causal_execution_diagnostics(self) -> dict[str, object]: + disabled_reason = self._causal_runtime_disabled_reason + if not self._causal_fast_path_environment_enabled: + disabled_reason = "disabledByEnvironment" + elif not self._causal_fast_path_eligible: + disabled_reason = self._causal_fast_path_fallback_reason + last_verified = self._causal_last_verified_diagnostics + return { + "eligible": self._causal_fast_path_eligible, + "enabled": self.causal_fast_path_enabled, + "fallbackReason": self._causal_fast_path_fallback_reason, + "disabledReason": disabled_reason, + "fastSolveCount": self._causal_fast_solve_count, + "fullResidualAuditCount": self._causal_full_residual_audit_count, + "auditFailureCount": self._causal_audit_failure_count, + "legacyFallbackCount": self._causal_legacy_fallback_count, + "auditInterval": self._causal_audit_interval, + "solvesSinceAudit": self._causal_solves_since_audit, + "lastVerifiedMaxScaledResidual": ( + last_verified.max_scaled_residual + if last_verified is not None + else None + ), + } + + def request_causal_audit(self) -> None: + """Require the next eligible solve to verify every residual.""" + + self._causal_audit_required = True + + def _disable_causal_fast_path(self, reason: str) -> None: + if self._causal_runtime_disabled_reason is None: + self._causal_runtime_disabled_reason = reason + self._causal_audit_required = True + + def _build_causal_execution_plan( + self, + ) -> tuple[ + dict[str, tuple[CausalEffortAssignment, ...]], + frozenset[str], + frozenset[str], + bool, + str | None, + ]: + """Prove that effort propagation plus staged flow assignment is complete. + + The fast path is deliberately narrower than ordinary sparse fallback. + It accepts only audited built-in residual contracts, one state anchor per + effort equality tree, and a flow plan whose dependency stages never use + the arbitrary cycle-breaking seed retained by the compatibility solver. + """ + + empty = ({}, frozenset(), frozenset()) + + def failed(reason: str): + return (*empty, False, reason) + + if self.scope_kind != "network": + return failed("nonGlobalAlgebraicScope") + if not self._jacobian_sparsity_is_trusted: + return failed( + self._jacobian_sparsity_fallback_reason + or "untrustedAlgebraicStructure" + ) + if self._unilateral_contact_plan: + return failed("activeSetCausalizationRequired") + if self._closed_resistance_pressure_plan: + return failed("specialClosedResistancePressureSeed") + if self._pnor_pnl0001_series_plan: + return failed("specialSeriesPressureSeed") + if len(self._unknowns_by_id) != len(self.unknowns): + return failed("duplicateAlgebraicUnknown") + if len({equation.id for equation in self._equation_templates}) != len( + self._equation_templates + ): + return failed("duplicateAlgebraicEquation") + + effort_unknown_ids = frozenset( + unknown.id for unknown in self.unknowns if unknown.role == "effort" + ) + effort_groups = tuple( + group + for variable_groups in self._effort_groups.values() + for group in variable_groups + ) + effort_member_ids = frozenset( + unknown.id for group in effort_groups for unknown in group.members + ) + if effort_member_ids != effort_unknown_ids: + return failed("incompleteEffortGroupCoverage") + if any(len(group.anchors) != 1 for group in effort_groups): + return failed("effortGroupDoesNotHaveOneAnchor") + + group_by_unknown_id = { + unknown.id: group + for group in effort_groups + for unknown in group.members + } + state_equations_by_group = {id(group): 0 for group in effort_groups} + equal_equations_by_group = {id(group): 0 for group in effort_groups} + effort_equations = tuple( + equation + for equation in self._equation_templates + if equation.role == "effort" + ) + for equation in effort_equations: + referenced_ids = tuple( + dict.fromkeys( + variable + for variable in equation.variables + if variable in effort_unknown_ids + ) + ) + if equation.relation == "state": + if len(referenced_ids) != 1: + return failed("invalidEffortStateEquation") + group = group_by_unknown_id[referenced_ids[0]] + if equation.id != group.anchors[0].equation_id: + return failed("effortAnchorEquationMismatch") + state_equations_by_group[id(group)] += 1 + continue + if equation.relation == "equal": + if len(referenced_ids) != 2: + return failed("invalidEffortEqualityEquation") + first_group = group_by_unknown_id[referenced_ids[0]] + second_group = group_by_unknown_id[referenced_ids[1]] + if first_group is not second_group: + return failed("crossGroupEffortEquality") + equal_equations_by_group[id(first_group)] += 1 + continue + return failed("unsupportedEffortEquationRelation") + for group in effort_groups: + if state_equations_by_group[id(group)] != 1: + return failed("invalidEffortStateEquationCount") + if equal_equations_by_group[id(group)] != len(group.members) - 1: + return failed("effortEqualityGroupIsNotATree") + + effort_plan_by_variable = { + variable: tuple( + CausalEffortAssignment( + variable=variable, + members=group.members, + anchor=group.anchors[0], + ) + for group in self._effort_groups[variable] + ) + for variable in ("p", "x", "v") + } + + flow_unknown_ids = frozenset( + unknown.id for unknown in self.unknowns if unknown.role == "flow" + ) + flow_equations = tuple( + equation + for equation in self._equation_templates + if equation.role == "flow" + ) + if any( + equation.relation not in {"constitutive", "sumToZero"} + for equation in flow_equations + ): + return failed("unsupportedFlowEquationRelation") + if len(effort_equations) + len(flow_equations) != len( + self._equation_templates + ): + return failed("unsupportedAlgebraicEquationRole") + + equation_by_id = { + equation.id: equation for equation in self._equation_templates + } + assigned_unknown_ids: list[str] = [] + assigned_equation_ids: list[str] = [] + seeded_unknown_ids: set[str] = set() + for stage in self._explicit_flow_plan: + stage_unknown_ids: set[str] = set() + for assignment in stage.assignments: + equation = equation_by_id.get(assignment.equation_id) + if equation is None or equation.role != "flow": + return failed("unknownExplicitFlowEquation") + dependency_ids = { + unknown.id + for unknown in self._flow_unknowns_for_equation(equation) + } + if dependency_ids - seeded_unknown_ids != {assignment.unknown.id}: + return failed("cyclicExplicitFlowPlan") + if ( + assignment.unknown.id in seeded_unknown_ids + or assignment.unknown.id in stage_unknown_ids + ): + return failed("duplicateExplicitFlowAssignment") + stage_unknown_ids.add(assignment.unknown.id) + assigned_unknown_ids.append(assignment.unknown.id) + assigned_equation_ids.append(assignment.equation_id) + seeded_unknown_ids.update(stage_unknown_ids) + + flow_equation_ids = frozenset( + equation.id for equation in flow_equations + ) + if ( + frozenset(assigned_unknown_ids) != flow_unknown_ids + or len(assigned_unknown_ids) != len(flow_unknown_ids) + ): + return failed("incompleteExplicitFlowCoverage") + if ( + frozenset(assigned_equation_ids) != flow_equation_ids + or len(assigned_equation_ids) != len(flow_equation_ids) + ): + return failed("incompleteExplicitFlowEquationCoverage") + return ( + effort_plan_by_variable, + flow_unknown_ids, + flow_equation_ids, + True, + None, + ) + + def _execute_causal_effort_plan( + self, + variables: tuple[str, ...], + ) -> bool: + for variable in variables: + assignments = self._causal_effort_plan_by_variable.get(variable) + if assignments is None: + return False + for assignment in assignments: + anchor = assignment.anchor + target = anchor.unknown.read() - anchor.evaluate() + if not isfinite(target) or ( + variable == "p" and target <= PRESSURE_LOWER_BOUND_PA + ): + return False + for unknown in assignment.members: + unknown.write(target) + return True + + def _causal_audit_is_due(self) -> bool: + return ( + self._causal_audit_required + or self._causal_last_verified_diagnostics is None + or self._causal_solves_since_audit >= self._causal_audit_interval + ) + + def _record_causal_audit( + self, + diagnostics: AlgebraicSolveDiagnostics, + ) -> None: + self._causal_full_residual_audit_count += 1 + self._causal_solves_since_audit = 0 + self._causal_audit_required = False + self._causal_last_verified_diagnostics = diagnostics + + def _causal_fast_diagnostics( + self, + ) -> AlgebraicSolveDiagnostics: + verified = self._causal_last_verified_diagnostics + if verified is None: + raise RuntimeError("Causal execution has no verified residual baseline.") + return replace( + verified, + message=( + "Compiled causal pressure-flow program completed; residuals " + "reuse the latest full audit." + ), + evaluations=0, + residual_evaluations=0, + dense_fallback_used=False, + nonlinear_block_count=0, + nonlinear_block_unknown_count=0, + block_fallback_used=False, + block_fallback_reason=None, + residual_verified_this_solve=False, + causal_fast_path_used=True, + ) + + def _build_jacobian_sparsity(self): + """Compile the residual dependency contract into one CSR pattern. + + Component authoring requires ``EquationResidual.variables`` to list + every algebraic port value read by the residual. A missing row/column + or a reference to a physical algebraic variable that is not present in + this solver makes that contract structurally incomplete, so nonlinear + fallback stays on the legacy dense finite-difference path. + """ + + try: + from scipy.sparse import csr_matrix + except ImportError as exc: + raise RuntimeError( + "Topology-driven simulation requires SciPy; install requirements.txt." + ) from exc + + unknown_indices = { + unknown.id: index for index, unknown in enumerate(self.unknowns) + } + row_indices: list[int] = [] + column_indices: list[int] = [] + declared_algebraic_names = {"p", "m_flow", "x", "v", "f"} + unresolved_algebraic_reference = False + for row_index, equation in enumerate(self._equation_templates): + for variable in equation.variables: + unknown = self._unknowns_by_id.get(variable) + if unknown is not None: + row_indices.append(row_index) + column_indices.append(unknown_indices[unknown.id]) + continue + parts = variable.rsplit(".", 2) + if len(parts) == 3 and parts[-1] in declared_algebraic_names: + unresolved_algebraic_reference = True + + pattern = csr_matrix( + ( + [True] * len(row_indices), + (row_indices, column_indices), + ), + shape=(len(self._equation_templates), len(self.unknowns)), + dtype=bool, + ) + if unresolved_algebraic_reference: + return pattern, False, "unresolvedAlgebraicVariable" + if pattern.shape[0] and any(pattern.getnnz(axis=1) == 0): + return pattern, False, "equationWithoutDeclaredUnknown" + if pattern.shape[1] and any(pattern.getnnz(axis=0) == 0): + return pattern, False, "unknownWithoutDeclaredEquation" + if any( + not type(item.component).__module__.startswith( + "app.simulation.components." + ) + for item in self._component_equation_plan + ): + # Built-in component declarations are covered by the repository's + # structural and numerical dependency tests. An external component + # may omit a read while still leaving every row/column non-empty, + # which cannot be detected from shape checks alone. Keep such + # networks on the original dense finite-difference contract. + return pattern, False, "untrustedCustomComponent" + return pattern, True, None + + def _build_equation_evaluation_locations( + self, + ) -> tuple[tuple[str, object, int], ...]: + locations: list[tuple[str, object, int]] = [] + for component_plan in self._component_equation_plan: + locations.extend( + ("component", component_plan, local_index) + for local_index, _template in enumerate(component_plan.templates) + ) + locations.extend( + ("connection", connection_plan, 0) + for connection_plan in self._connection_equation_plan + ) + if len(locations) != len(self._equation_templates): + raise RuntimeError("Compiled algebraic evaluation plan is inconsistent.") + return tuple(locations) + + def _build_equation_blocks( + self, + ) -> tuple[tuple[AlgebraicEquationBlock, ...], bool, str | None]: + """Partition a trusted square incidence graph into exact blocks. + + A scoped optimizer calls component equation evaluators directly. Keep + that optimization limited to the audited component package: an + external/custom component may have undeclared reads or evaluation side + effects even when its structural metadata happens to look complete. + """ + + if not self._jacobian_sparsity_is_trusted: + return ( + (), + False, + self._jacobian_sparsity_fallback_reason + or "untrustedAlgebraicStructure", + ) + if any( + not type(item.component).__module__.startswith( + "app.simulation.components." + ) + for item in self._component_equation_plan + ): + return (), False, "untrustedCustomComponent" + + unknown_index_by_id = { + unknown.id: index for index, unknown in enumerate(self.unknowns) + } + if len(unknown_index_by_id) != len(self.unknowns): + return (), False, "duplicateAlgebraicUnknown" + + equation_unknowns: list[tuple[int, ...]] = [] + equations_by_unknown: list[list[int]] = [ + [] for _unknown in self.unknowns + ] + for equation_index, equation in enumerate(self._equation_templates): + dependencies = tuple( + dict.fromkeys( + unknown_index_by_id[variable] + for variable in equation.variables + if variable in unknown_index_by_id + ) + ) + if not dependencies: + return (), False, "equationWithoutDeclaredUnknown" + equation_unknowns.append(dependencies) + for unknown_index in dependencies: + equations_by_unknown[unknown_index].append(equation_index) + if any(not attached for attached in equations_by_unknown): + return (), False, "unknownWithoutDeclaredEquation" + + blocks: list[AlgebraicEquationBlock] = [] + visited_unknowns: set[int] = set() + visited_equations: set[int] = set() + for root_unknown in range(len(self.unknowns)): + if root_unknown in visited_unknowns: + continue + block_unknowns: set[int] = set() + block_equations: set[int] = set() + pending_unknowns = [root_unknown] + while pending_unknowns: + unknown_index = pending_unknowns.pop() + if unknown_index in visited_unknowns: + continue + visited_unknowns.add(unknown_index) + block_unknowns.add(unknown_index) + for equation_index in equations_by_unknown[unknown_index]: + if equation_index not in visited_equations: + visited_equations.add(equation_index) + block_equations.add(equation_index) + for dependency in equation_unknowns[equation_index]: + if dependency not in visited_unknowns: + pending_unknowns.append(dependency) + unknown_indices = tuple(sorted(block_unknowns)) + equation_indices = tuple(sorted(block_equations)) + if len(unknown_indices) != len(equation_indices): + return (), False, "nonSquareEquationBlock" + blocks.append( + AlgebraicEquationBlock( + unknown_indices=unknown_indices, + equation_indices=equation_indices, + ) + ) + if len(visited_equations) != len(self._equation_templates): + return (), False, "unreachableEquationBlock" + return tuple(blocks), True, None + + def _compile_equation_subset( + self, + blocks: tuple[AlgebraicEquationBlock, ...], + ) -> AlgebraicEquationSubset: + """Compile one batched residual for the union of independent blocks.""" + + equation_indices = tuple( + sorted( + equation_index + for block in blocks + for equation_index in block.equation_indices + ) + ) + unknown_indices = tuple( + sorted( + unknown_index + for block in blocks + for unknown_index in block.unknown_indices + ) + ) + equation_target = { + equation_index: target + for target, equation_index in enumerate(equation_indices) + } + component_targets: dict[int, list[tuple[int, int, str]]] = {} + component_plans: dict[int, ComponentEquationEvaluation] = {} + connection_evaluations: list[ScopedConnectionEquationEvaluation] = [] + for equation_index in equation_indices: + kind, evaluation_plan, source = self._equation_evaluation_locations[ + equation_index + ] + target = equation_target[equation_index] + if kind == "component": + key = id(evaluation_plan) + component_plan = evaluation_plan + component_plans[key] = component_plan + component_targets.setdefault(key, []).append( + ( + target, + source, + self._equation_templates[equation_index].id, + ) + ) + else: + connection_plan = evaluation_plan + connection_evaluations.append( + ScopedConnectionEquationEvaluation( + target=target, + evaluate=connection_plan.evaluate, + ) + ) + component_evaluations = tuple( + ScopedComponentEquationEvaluation( + evaluate=component_plans[key].evaluate, + targets=tuple(targets), + ) + for key, targets in component_targets.items() + ) + jacobian_sparsity = self._jacobian_sparsity[ + list(equation_indices), : + ][:, list(unknown_indices)].tocsr() + if ( + any(jacobian_sparsity.getnnz(axis=1) == 0) + or any(jacobian_sparsity.getnnz(axis=0) == 0) + ): + raise RuntimeError("Scoped algebraic Jacobian is structurally empty.") + return AlgebraicEquationSubset( + unknown_indices=unknown_indices, + equation_indices=equation_indices, + component_evaluations=component_evaluations, + connection_evaluations=tuple(connection_evaluations), + jacobian_sparsity=jacobian_sparsity, + ) + + def scale_context(self) -> dict[str, float]: + """Capture global normalization data for a compatible block solve.""" + + scales = self._scales() + positive_pressures = [ + unknown.read() + for unknown in self._unknowns_by_variable["p"] + if unknown.read() > 0.0 + ] + scales["fallback_pressure"] = ( + sum(positive_pressures) / len(positive_pressures) + if positive_pressures + else scales["p"] + ) + return scales + def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]: unknowns: list[AlgebraicUnknown] = [] for component in self.network.components.values(): @@ -371,6 +1098,7 @@ class PressureFlowSolver: EffortAnchor( unknown=unknown, evaluate=self._equation_value_reader(equation), + equation_id=equation.id, ) ) @@ -1292,6 +2020,7 @@ class PressureFlowSolver: self, *, effort_variables: tuple[str, ...] = ("p", "x", "v"), + scale_context: Mapping[str, float] | None = None, ) -> AlgebraicSolveDiagnostics: try: import numpy as np @@ -1301,18 +2030,68 @@ class PressureFlowSolver: "Topology-driven simulation requires SciPy; install requirements.txt." ) from exc + causal_candidate = ( + self.causal_fast_path_enabled + and effort_variables == ("p",) + and scale_context is None + ) + causal_audit_due = ( + self._causal_audit_is_due() if causal_candidate else False + ) for component in self._causal_contact_components: component.clear_causal_contact() - self._seed_equal_efforts(effort_variables) + if causal_candidate: + causal_efforts_are_valid = self._execute_causal_effort_plan( + effort_variables + ) + if not causal_efforts_are_valid: + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path("nonFiniteCausalEffortAnchor") + causal_candidate = False + causal_audit_due = False + self._seed_equal_efforts(effort_variables) + else: + self._seed_equal_efforts(effort_variables) self._seed_closed_resistance_pressures() self._seed_pnor_pnl0001_series_pressures() - self._solve_explicit_flow_unknowns() + seeded_flow_ids = self._solve_explicit_flow_unknowns() contact_bindings = self._seed_unilateral_contacts() if contact_bindings: - self._solve_explicit_flow_unknowns(("f",)) + seeded_flow_ids.update(self._solve_explicit_flow_unknowns(("f",))) self._refresh_unilateral_contacts(contact_bindings) - scales = self._scales() + if causal_candidate: + causal_unknowns_are_feasible = ( + not contact_bindings + and seeded_flow_ids == self._causal_flow_unknown_ids + and all( + isfinite(unknown.read()) + and ( + unknown.variable != "p" + or unknown.read() > PRESSURE_LOWER_BOUND_PA + ) + for unknown in self.unknowns + ) + ) + if not causal_unknowns_are_feasible: + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path("causalRuntimeGateFailed") + causal_candidate = False + causal_audit_due = False + elif not causal_audit_due: + diagnostics = self._causal_fast_diagnostics() + self._causal_fast_solve_count += 1 + self._causal_solves_since_audit += 1 + self.last_diagnostics = diagnostics + return diagnostics + scales = ( + { + name: float(scale_context[name]) + for name in ("p", "m_flow", "x", "v", "f") + } + if scale_context is not None + else self._scales() + ) pressure_scale = scales["p"] flow_scale = scales["m_flow"] seeded_values = self._pressure_flow_equation_values() @@ -1361,14 +2140,24 @@ class PressureFlowSolver: ] seeded_unknowns_are_feasible = all( isfinite(value) - and (unknown.variable != "p" or value >= 1.0) + and ( + unknown.variable != "p" + or value > PRESSURE_LOWER_BOUND_PA + ) for unknown, value in seeded_unknown_values ) - if ( + seeded_state_is_valid = ( seeded_unknowns_are_feasible and all(isfinite(value) for value in seeded_scaled) and seeded_max_scaled_residual <= self.residual_tolerance - ): + ) + if causal_candidate and causal_audit_due and not seeded_state_is_valid: + self._causal_audit_failure_count += 1 + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path("causalResidualAuditFailed") + causal_candidate = False + causal_audit_due = False + if seeded_state_is_valid: diagnostics = AlgebraicSolveDiagnostics( success=True, message="Seeded pressure-flow state satisfies the residual tolerance.", @@ -1381,6 +2170,8 @@ class PressureFlowSolver: default=0.0, ), ) + if causal_candidate and causal_audit_due: + self._record_causal_audit(diagnostics) self.last_diagnostics = diagnostics return diagnostics @@ -1402,9 +2193,13 @@ class PressureFlowSolver: if unknown.read() > 0.0 ] fallback_pressure = ( - sum(positive_pressures) / len(positive_pressures) - if positive_pressures - else pressure_scale + float(scale_context["fallback_pressure"]) + if scale_context is not None + else ( + sum(positive_pressures) / len(positive_pressures) + if positive_pressures + else pressure_scale + ) ) # A causal contact retains its small relative penetration around the @@ -1427,7 +2222,11 @@ class PressureFlowSolver: ) lower = np.asarray( [ - 1.0 / pressure_scale if unknown.variable == "p" else -np.inf + ( + PRESSURE_LOWER_BOUND_PA / pressure_scale + if unknown.variable == "p" + else -np.inf + ) for unknown in self.unknowns ] ) @@ -1437,7 +2236,11 @@ class PressureFlowSolver: for unknown, value in zip(self.unknowns, values): unknown.write(float(value) * variable_scale(unknown)) + residual_evaluations = 0 + def scaled_residuals(values): + nonlocal residual_evaluations + residual_evaluations += 1 assign(values) self._refresh_unilateral_contacts(contact_bindings) equation_values = self._pressure_flow_equation_values() @@ -1449,34 +2252,310 @@ class PressureFlowSolver: dtype=float, ) - result = least_squares( - scaled_residuals, - x0, - bounds=(lower, upper), - x_scale="jac", - ftol=1e-10, - xtol=1e-10, - gtol=1e-10, - max_nfev=self.max_evaluations, + optimizer_arguments = { + "bounds": (lower, upper), + "x_scale": "jac", + "ftol": 1e-10, + "xtol": 1e-10, + "gtol": 1e-10, + "max_nfev": self.max_evaluations, + } + + def evaluate_result(result): + assign(result.x) + self._refresh_unilateral_contacts(contact_bindings) + current_equation_values = self._pressure_flow_equation_values() + current_scaled = [ + abs(value / scale) + for value, scale in zip( + current_equation_values, + equation_scales, + ) + ] + current_max_scaled_residual = max(current_scaled, default=0.0) + residuals_converged = ( + all(isfinite(value) for value in current_scaled) + and current_max_scaled_residual <= self.residual_tolerance + ) + optimizer_status_is_acceptable = ( + bool(result.success) or int(result.status) == 0 + ) + return ( + residuals_converged and optimizer_status_is_acceptable, + current_equation_values, + current_max_scaled_residual, + ) + + nonlinear_blocks: tuple[AlgebraicEquationBlock, ...] = () + nonlinear_block_unknown_count = 0 + block_fallback_used = False + block_fallback_reason: str | None = None + total_optimizer_evaluations = 0 + + if ( + self._equation_blocks_are_trusted + and self._jacobian_sparsity_is_trusted + ): + infeasible_unknown_indices = { + index + for index, (unknown, value) in enumerate(seeded_unknown_values) + if not isfinite(value) + or ( + unknown.variable == "p" + and value <= PRESSURE_LOWER_BOUND_PA + ) + } + nonlinear_blocks = tuple( + block + for block in self._equation_blocks + if any( + not isfinite(seeded_scaled[equation_index]) + or seeded_scaled[equation_index] > self.residual_tolerance + for equation_index in block.equation_indices + ) + or any( + unknown_index in infeasible_unknown_indices + for unknown_index in block.unknown_indices + ) + ) + nonlinear_block_unknown_count = sum( + len(block.unknown_indices) for block in nonlinear_blocks + ) + else: + block_fallback_reason = ( + self._equation_blocks_fallback_reason + or self._jacobian_sparsity_fallback_reason + or "untrustedAlgebraicStructure" + ) + + if contact_bindings: + # Contact activity is the stronger runtime reason even when its + # causal projection also makes the static graph appear rectangular. + block_fallback_reason = "activeCausalContact" + + block_subset: AlgebraicEquationSubset | None = None + if nonlinear_blocks: + if contact_bindings: + # Contact causalization mutates coordinates and component-local + # active-set caches during a residual call. The first version + # deliberately retains the proven global dense path whenever a + # contact is active; independent-contact pruning can be added + # only with an explicit side-effect dependency contract. + block_fallback_reason = "activeCausalContact" + elif nonlinear_block_unknown_count >= len(self.unknowns): + block_fallback_reason = "fullScopeNonlinearBlock" + else: + try: + block_subset = self._compile_equation_subset( + nonlinear_blocks + ) + except MemoryError: + raise + except Exception as exc: + block_fallback_reason = ( + f"blockCompilationFailed:{type(exc).__name__}" + ) + + if block_subset is not None: + subset_unknowns = tuple( + self.unknowns[index] + for index in block_subset.unknown_indices + ) + subset_x0 = x0[list(block_subset.unknown_indices)] + subset_lower = lower[list(block_subset.unknown_indices)] + subset_upper = upper[list(block_subset.unknown_indices)] + subset_equation_scales = tuple( + equation_scales[index] + for index in block_subset.equation_indices + ) + + def assign_subset(values) -> None: + for unknown, value in zip(subset_unknowns, values): + unknown.write(float(value) * variable_scale(unknown)) + + def scaled_subset_residuals(values): + nonlocal residual_evaluations + residual_evaluations += 1 + assign_subset(values) + return np.asarray( + [ + value / scale + for value, scale in zip( + block_subset.equation_values(), + subset_equation_scales, + ) + ], + dtype=float, + ) + + block_result = None + try: + block_result = least_squares( + scaled_subset_residuals, + subset_x0, + bounds=(subset_lower, subset_upper), + jac_sparsity=block_subset.jacobian_sparsity, + x_scale="jac", + ftol=1e-10, + xtol=1e-10, + gtol=1e-10, + max_nfev=self.max_evaluations, + ) + total_optimizer_evaluations += int(block_result.nfev) + assign_subset(block_result.x) + block_equation_values = self._pressure_flow_equation_values() + block_scaled = [ + abs(value / scale) + for value, scale in zip( + block_equation_values, + equation_scales, + ) + ] + block_unknowns_are_feasible = all( + isfinite(unknown.read()) + and ( + unknown.variable != "p" + or unknown.read() > PRESSURE_LOWER_BOUND_PA + ) + for unknown in self.unknowns + ) + block_success = ( + block_unknowns_are_feasible + and all(isfinite(value) for value in block_scaled) + and max(block_scaled, default=0.0) + <= self.residual_tolerance + and ( + bool(block_result.success) + or int(block_result.status) == 0 + ) + ) + if block_success: + diagnostics = AlgebraicSolveDiagnostics( + success=True, + message=str(block_result.message), + evaluations=total_optimizer_evaluations, + pressure_scale=pressure_scale, + flow_scale=flow_scale, + max_scaled_residual=max(block_scaled, default=0.0), + max_raw_residual=max( + (abs(value) for value in block_equation_values), + default=0.0, + ), + residual_evaluations=residual_evaluations, + jacobian_mode="blockSparse", + dense_fallback_used=False, + nonlinear_block_count=len(nonlinear_blocks), + nonlinear_block_unknown_count=( + nonlinear_block_unknown_count + ), + block_fallback_used=False, + block_fallback_reason=None, + ) + self.last_diagnostics = diagnostics + return diagnostics + block_fallback_reason = ( + "blockResidualNotConverged" + if block_result is not None + else "blockOptimizerFailed" + ) + except MemoryError: + assign(x0) + self._refresh_unilateral_contacts(contact_bindings) + raise + except Exception as exc: + block_fallback_reason = ( + f"blockSolveFailed:{type(exc).__name__}" + ) + except BaseException: + assign(x0) + self._refresh_unilateral_contacts(contact_bindings) + raise + + # A scoped solve is strictly an optimization. Its candidate must + # never seed the compatibility fallback, including candidates from + # blocks that converged before another block failed. + block_fallback_used = True + assign(x0) + self._refresh_unilateral_contacts(contact_bindings) + elif seeded_max_scaled_residual > self.residual_tolerance or not ( + seeded_unknowns_are_feasible + and all(isfinite(value) for value in seeded_scaled) + ): + block_fallback_used = True + + sparse_is_safe = ( + self._jacobian_sparsity_is_trusted + and not contact_bindings + and self._jacobian_sparsity.nnz > 0 ) - assign(result.x) - self._refresh_unilateral_contacts(contact_bindings) - equation_values = self._pressure_flow_equation_values() - scaled = [ - abs(value / scale) - for value, scale in zip(equation_values, equation_scales) - ] - max_scaled_residual = max(scaled, default=0.0) - residuals_converged = ( - all(isfinite(value) for value in scaled) - and max_scaled_residual <= self.residual_tolerance - ) - optimizer_status_is_acceptable = bool(result.success) or int(result.status) == 0 - success = residuals_converged and optimizer_status_is_acceptable + dense_fallback_used = False + result = None + success = False + equation_values = seeded_values + max_scaled_residual = seeded_max_scaled_residual + if sparse_is_safe: + try: + sparse_result = least_squares( + scaled_residuals, + x0, + jac_sparsity=self._jacobian_sparsity, + **optimizer_arguments, + ) + except (ArithmeticError, RuntimeError, ValueError): + sparse_result = None + if sparse_result is not None: + total_optimizer_evaluations += int(sparse_result.nfev) + try: + ( + success, + equation_values, + max_scaled_residual, + ) = evaluate_result(sparse_result) + except (ArithmeticError, RuntimeError, ValueError): + success = False + result = sparse_result + + if not success: + # The sparse LSMR path is an optimization, not a new numerical + # contract. Restart the legacy dense solve from the exact + # original seed; a failed sparse candidate must not influence + # the fallback result through shared PortState objects. + dense_fallback_used = True + assign(x0) + self._refresh_unilateral_contacts(contact_bindings) + result = least_squares( + scaled_residuals, + x0, + **optimizer_arguments, + ) + total_optimizer_evaluations += int(result.nfev) + ( + success, + equation_values, + max_scaled_residual, + ) = evaluate_result(result) + jacobian_mode = ( + "sparseThenDense" if dense_fallback_used else "sparse" + ) + else: + result = least_squares( + scaled_residuals, + x0, + **optimizer_arguments, + ) + total_optimizer_evaluations = int(result.nfev) + ( + success, + equation_values, + max_scaled_residual, + ) = evaluate_result(result) + jacobian_mode = "dense" + + assert result is not None diagnostics = AlgebraicSolveDiagnostics( success=success, message=str(result.message), - evaluations=int(result.nfev), + evaluations=total_optimizer_evaluations, pressure_scale=pressure_scale, flow_scale=flow_scale, max_scaled_residual=max_scaled_residual, @@ -1484,11 +2563,20 @@ class PressureFlowSolver: (abs(value) for value in equation_values), default=0.0, ), + residual_evaluations=residual_evaluations, + jacobian_mode=jacobian_mode, + dense_fallback_used=dense_fallback_used, + nonlinear_block_count=len(nonlinear_blocks), + nonlinear_block_unknown_count=nonlinear_block_unknown_count, + block_fallback_used=block_fallback_used, + block_fallback_reason=block_fallback_reason, ) self.last_diagnostics = diagnostics if not success: raise AlgebraicSolveError( "Pressure-flow equations did not converge to the requested tolerance.", diagnostics, + scope_kind=self.scope_kind, + scope_components=tuple(self.network.components), ) return diagnostics diff --git a/app/simulation/solvers/algebraic_blocks.py b/app/simulation/solvers/algebraic_blocks.py new file mode 100644 index 0000000..83325ee --- /dev/null +++ b/app/simulation/solvers/algebraic_blocks.py @@ -0,0 +1,1313 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from math import isfinite + +from app.simulation.core.equations import EquationResidual +from app.simulation.solvers.algebraic import ( + PRESSURE_LOWER_BOUND_PA, + AlgebraicSolveDiagnostics, + AlgebraicUnknown, + ExplicitFlowStage, + PressureFlowSolver, +) + + +@dataclass(frozen=True) +class StreamBlockSolveResult: + diagnostics: tuple[AlgebraicSolveDiagnostics, ...] + scopes: tuple[tuple[str, ...], ...] + used_global_fallback: bool + + +@dataclass(frozen=True) +class _BlockSolveAttempt: + diagnostics: AlgebraicSolveDiagnostics | None + optimizer_evaluations: int + residual_evaluations: int + failure_reason: str | None = None + + +@dataclass(frozen=True) +class _MutableAlgebraicStateSnapshot: + unknown_values: tuple[tuple[AlgebraicUnknown, float], ...] + causal_attributes: tuple[ + tuple[object, tuple[tuple[str, object], ...]], ... + ] + last_diagnostics: AlgebraicSolveDiagnostics | None + + @classmethod + def capture( + cls, + solver: PressureFlowSolver, + ) -> _MutableAlgebraicStateSnapshot: + causal_attribute_names = ( + "_causal_penetration", + "_causal_contact_force", + "_causal_port_1_x", + "_causal_port_2_x", + "_causal_port_1_v", + "_causal_port_2_v", + ) + + def component_causal_attributes( + component: object, + ) -> tuple[tuple[str, object], ...]: + names = dict.fromkeys( + [ + name + for name in getattr(component, "__dict__", {}) + if name.startswith("_causal_") + ] + + [ + name + for name in causal_attribute_names + if hasattr(component, name) + ] + ) + return tuple((name, getattr(component, name)) for name in names) + + return cls( + unknown_values=tuple( + (unknown, unknown.read()) for unknown in solver.unknowns + ), + causal_attributes=tuple( + ( + component, + component_causal_attributes(component), + ) + for component in solver._causal_contact_components + ), + last_diagnostics=solver.last_diagnostics, + ) + + def restore(self, solver: PressureFlowSolver) -> None: + for unknown, value in self.unknown_values: + unknown.write(value) + for component, attributes in self.causal_attributes: + original_names = {name for name, _value in attributes} + for name in tuple(getattr(component, "__dict__", {})): + if name.startswith("_causal_") and name not in original_names: + delattr(component, name) + for name, value in attributes: + setattr(component, name, value) + solver.last_diagnostics = self.last_diagnostics + + +@dataclass(frozen=True) +class _ScopedComponentEvaluation: + evaluate: Callable[[], tuple[float, ...]] + targets: tuple[tuple[int, int, str], ...] + + +@dataclass(frozen=True) +class _ScopedConnectionEvaluation: + target: int + evaluate: Callable[[], float] + equation_id: str + + +@dataclass(frozen=True) +class _EquationScaleSpec: + direct_scale: str | None + variable_names: tuple[str, ...] + + +def _scoped_equation_values( + equation_count: int, + component_evaluations: tuple[_ScopedComponentEvaluation, ...], + connection_evaluations: tuple[_ScopedConnectionEvaluation, ...], + zero_equation_ids: frozenset[str] = frozenset(), +) -> tuple[float, ...]: + values: list[float | None] = [None] * equation_count + for evaluation in component_evaluations: + if all( + equation_id in zero_equation_ids + for _target, _source, equation_id in evaluation.targets + ): + for target, _source, _equation_id in evaluation.targets: + values[target] = 0.0 + continue + component_values = evaluation.evaluate() + for target, source, equation_id in evaluation.targets: + if equation_id in zero_equation_ids: + values[target] = 0.0 + continue + if source >= len(component_values): + raise RuntimeError( + "Compiled algebraic equation disappeared at runtime: " + f"{equation_id}." + ) + values[target] = float(component_values[source]) + for evaluation in connection_evaluations: + values[evaluation.target] = ( + 0.0 + if evaluation.equation_id in zero_equation_ids + else float(evaluation.evaluate()) + ) + if any(value is None for value in values): + raise RuntimeError("Scoped algebraic evaluation returned no value.") + return tuple(float(value) for value in values) + + +@dataclass(frozen=True) +class _StreamAlgebraicBlock: + unknowns: tuple[AlgebraicUnknown, ...] + equations: tuple[EquationResidual, ...] + component_evaluations: tuple[_ScopedComponentEvaluation, ...] + connection_evaluations: tuple[_ScopedConnectionEvaluation, ...] + explicit_flow_plan: tuple[ExplicitFlowStage, ...] + scope_components: tuple[str, ...] + jacobian_entries: tuple[tuple[int, int], ...] + equation_scale_specs: tuple[_EquationScaleSpec, ...] + + def equation_values( + self, + zero_equation_ids: frozenset[str] = frozenset(), + ) -> tuple[float, ...]: + return _scoped_equation_values( + len(self.equations), + self.component_evaluations, + self.connection_evaluations, + zero_equation_ids, + ) + + +@dataclass(frozen=True) +class _SelectedEquationEvaluation: + equations: tuple[EquationResidual, ...] + component_evaluations: tuple[_ScopedComponentEvaluation, ...] + connection_evaluations: tuple[_ScopedConnectionEvaluation, ...] + block_ranges: tuple[tuple[int, int], ...] + equation_scale_specs: tuple[_EquationScaleSpec, ...] + scope_components: tuple[str, ...] + + def equation_values( + self, + zero_equation_ids: frozenset[str] = frozenset(), + ) -> tuple[float, ...]: + return _scoped_equation_values( + len(self.equations), + self.component_evaluations, + self.connection_evaluations, + zero_equation_ids, + ) + + +class StreamPressureBlockSolver: + """Re-close only equation blocks whose flow laws consume stream values. + + The primary pressure-flow solve remains global and performs all mechanical + contact and effort causalization. Stream propagation can only invalidate + equations owned by components that explicitly declare a stream dependency. + For trusted built-in components, the declared ``EquationResidual.variables`` + graph identifies the complete square blocks that must be revisited. Any + structural ambiguity keeps the legacy full-scope secondary solve. + """ + + def __init__( + self, + pressure_flow_solver: PressureFlowSolver, + sensitive_components: tuple[str, ...], + ) -> None: + self.pressure_flow_solver = pressure_flow_solver + self.sensitive_components = sensitive_components + self.fallback_reason: str | None = None + self.blocks = self._build_blocks() + self._selected_unknowns = tuple( + unknown for block in self.blocks for unknown in block.unknowns + ) + self._selected_unknown_ids = frozenset( + unknown.id for unknown in self._selected_unknowns + ) + self._selected_flow_unknowns = tuple( + unknown + for block in self.blocks + for unknown in block.unknowns + if unknown.variable == "m_flow" + ) + self._selected_explicit_flow_plan = tuple( + pressure_flow_solver._compile_explicit_flow_stage( + tuple( + assignment + for assignment in stage.assignments + if assignment.unknown.id in self._selected_unknown_ids + ) + ) + for stage in pressure_flow_solver._explicit_flow_plan + ) + self._selected_equation_evaluation = ( + self._compile_selected_equation_evaluation() + if self.blocks + else None + ) + ( + self._entry_mutated_unknowns, + self._unselected_seed_restore_positions, + ) = self._compile_mutation_snapshot_plan() + ( + self._causal_fast_path_eligible, + self._causal_fast_path_fallback_reason, + self._causal_expected_flow_equation_ids, + self._causal_effort_entry_positions, + ) = self._build_causal_execution_plan() + self._causal_runtime_disabled_reason: str | None = None + self._causal_audit_required = True + self._causal_solves_since_audit = 0 + self._causal_fast_solve_count = 0 + self._causal_full_residual_audit_count = 0 + self._causal_audit_failure_count = 0 + self._causal_legacy_fallback_count = 0 + self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None + + @property + def available(self) -> bool: + return bool(self.blocks) and self.fallback_reason is None + + @property + def causal_fast_path_enabled(self) -> bool: + return ( + self._causal_fast_path_eligible + and self._causal_runtime_disabled_reason is None + and self.pressure_flow_solver.causal_fast_path_enabled + ) + + def request_causal_audit(self) -> None: + self._causal_audit_required = True + + def causal_execution_diagnostics(self) -> dict[str, object]: + parent = self.pressure_flow_solver.causal_execution_diagnostics() + disabled_reason = self._causal_runtime_disabled_reason + if not bool(parent["enabled"]): + disabled_reason = str(parent["disabledReason"] or "parentCausalPathDisabled") + elif not self._causal_fast_path_eligible: + disabled_reason = self._causal_fast_path_fallback_reason + verified = self._causal_last_verified_diagnostics + return { + "eligible": self._causal_fast_path_eligible, + "enabled": self.causal_fast_path_enabled, + "fallbackReason": self._causal_fast_path_fallback_reason, + "disabledReason": disabled_reason, + "fastSolveCount": self._causal_fast_solve_count, + "fullResidualAuditCount": self._causal_full_residual_audit_count, + "auditFailureCount": self._causal_audit_failure_count, + "legacyFallbackCount": self._causal_legacy_fallback_count, + "auditInterval": self.pressure_flow_solver._causal_audit_interval, + "solvesSinceAudit": self._causal_solves_since_audit, + "lastVerifiedMaxScaledResidual": ( + verified.max_scaled_residual if verified is not None else None + ), + } + + def _disable_causal_fast_path(self, reason: str) -> None: + if self._causal_runtime_disabled_reason is None: + self._causal_runtime_disabled_reason = reason + self._causal_audit_required = True + + def _build_causal_execution_plan( + self, + ) -> tuple[ + bool, + str | None, + frozenset[str], + tuple[tuple[AlgebraicUnknown, int], ...], + ]: + def failed(reason: str): + return False, reason, frozenset(), () + + solver = self.pressure_flow_solver + if not self.available: + return failed(self.fallback_reason or "streamBlockUnavailable") + if not solver.causal_fast_path_eligible: + parent_reason = solver.causal_execution_diagnostics()["fallbackReason"] + return failed(str(parent_reason or "parentCausalPathIneligible")) + if solver._closed_resistance_pressure_plan: + return failed("specialClosedResistancePressureSeed") + if solver._pnor_pnl0001_series_plan: + return failed("specialSeriesPressureSeed") + selected = self._selected_equation_evaluation + if selected is None: + return failed("missingSelectedEquationEvaluation") + if any( + unknown.variable not in {"p", "m_flow"} + for unknown in self._selected_unknowns + ): + return failed("unsupportedSelectedUnknown") + + assignments = tuple( + assignment + for stage in self._selected_explicit_flow_plan + for assignment in stage.assignments + ) + assignment_unknown_ids = tuple( + assignment.unknown.id for assignment in assignments + ) + selected_flow_unknown_ids = frozenset( + unknown.id for unknown in self._selected_flow_unknowns + ) + if ( + frozenset(assignment_unknown_ids) != selected_flow_unknown_ids + or len(assignment_unknown_ids) != len(selected_flow_unknown_ids) + ): + return failed("incompleteSelectedFlowCoverage") + + assignment_equation_ids = tuple( + assignment.equation_id for assignment in assignments + ) + flow_equation_ids = frozenset( + equation.id for equation in selected.equations if equation.role == "flow" + ) + if ( + frozenset(assignment_equation_ids) != flow_equation_ids + or len(assignment_equation_ids) != len(flow_equation_ids) + ): + return failed("incompleteSelectedFlowEquationCoverage") + if any( + equation.role != "effort" + for equation in selected.equations + if equation.id not in flow_equation_ids + ): + return failed("selectedResidualIsNotEffortOnly") + + entry_position_by_id = { + unknown.id: position + for position, unknown in enumerate(self._entry_mutated_unknowns) + } + effort_positions: list[tuple[AlgebraicUnknown, int]] = [] + for unknown in self._selected_unknowns: + if unknown.variable != "p": + continue + position = entry_position_by_id.get(unknown.id) + if position is None: + return failed("missingEffortMutationSnapshot") + effort_positions.append((unknown, position)) + return ( + True, + None, + flow_equation_ids, + tuple(effort_positions), + ) + + def _causal_audit_is_due(self) -> bool: + return ( + self._causal_audit_required + or self._causal_last_verified_diagnostics is None + or self._causal_solves_since_audit + >= self.pressure_flow_solver._causal_audit_interval + ) + + def _record_causal_audit( + self, + diagnostics: AlgebraicSolveDiagnostics, + ) -> None: + self._causal_full_residual_audit_count += 1 + self._causal_solves_since_audit = 0 + self._causal_audit_required = False + self._causal_last_verified_diagnostics = diagnostics + + def _causal_fast_diagnostics( + self, + scale_context: Mapping[str, float], + ) -> AlgebraicSolveDiagnostics: + verified = self._causal_last_verified_diagnostics + if verified is None: + raise RuntimeError("Stream causal execution has no residual audit.") + return replace( + verified, + message=( + "Compiled causal stream-pressure block completed; residuals " + "reuse the latest full audit." + ), + evaluations=0, + pressure_scale=float(scale_context["p"]), + flow_scale=float(scale_context["m_flow"]), + residual_evaluations=0, + dense_fallback_used=False, + nonlinear_block_count=0, + nonlinear_block_unknown_count=0, + block_fallback_used=False, + block_fallback_reason=None, + residual_verified_this_solve=False, + causal_fast_path_used=True, + ) + + @staticmethod + def _equation_scale_spec(equation: EquationResidual) -> _EquationScaleSpec: + variable_names = tuple( + variable.rsplit(".", 1)[-1] for variable in equation.variables + ) + if equation.role == "flow": + return _EquationScaleSpec("m_flow", variable_names) + if equation.role == "effort" or "p" in variable_names: + return _EquationScaleSpec("p", variable_names) + return _EquationScaleSpec(None, variable_names) + + def _build_blocks(self) -> tuple[_StreamAlgebraicBlock, ...]: + solver = self.pressure_flow_solver + if not solver.jacobian_sparsity_is_trusted: + self.fallback_reason = ( + solver.jacobian_sparsity_fallback_reason + or "untrustedAlgebraicStructure" + ) + return () + unknowns = solver.unknowns + equations = solver.equation_templates + unknown_index_by_id = { + unknown.id: index for index, unknown in enumerate(unknowns) + } + equation_unknowns: list[tuple[int, ...]] = [] + equations_by_unknown: list[list[int]] = [[] for _unknown in unknowns] + for equation_index, equation in enumerate(equations): + dependencies = tuple( + dict.fromkeys( + unknown_index_by_id[variable] + for variable in equation.variables + if variable in unknown_index_by_id + ) + ) + if not dependencies: + self.fallback_reason = "equationWithoutDeclaredUnknown" + return () + equation_unknowns.append(dependencies) + for unknown_index in dependencies: + equations_by_unknown[unknown_index].append(equation_index) + + if any(not attached for attached in equations_by_unknown): + self.fallback_reason = "unknownWithoutDeclaredEquation" + return () + + raw_blocks: list[tuple[tuple[int, ...], tuple[int, ...]]] = [] + visited_unknowns: set[int] = set() + visited_equations: set[int] = set() + for root_unknown in range(len(unknowns)): + if root_unknown in visited_unknowns: + continue + block_unknowns: set[int] = set() + block_equations: set[int] = set() + pending_unknowns = [root_unknown] + while pending_unknowns: + unknown_index = pending_unknowns.pop() + if unknown_index in visited_unknowns: + continue + visited_unknowns.add(unknown_index) + block_unknowns.add(unknown_index) + for equation_index in equations_by_unknown[unknown_index]: + if equation_index not in visited_equations: + visited_equations.add(equation_index) + block_equations.add(equation_index) + for dependency in equation_unknowns[equation_index]: + if dependency not in visited_unknowns: + pending_unknowns.append(dependency) + ordered_unknowns = tuple(sorted(block_unknowns)) + ordered_equations = tuple(sorted(block_equations)) + if len(ordered_unknowns) != len(ordered_equations): + self.fallback_reason = "nonSquareEquationBlock" + return () + raw_blocks.append((ordered_unknowns, ordered_equations)) + + if len(visited_equations) != len(equations): + self.fallback_reason = "unreachableEquationBlock" + return () + + sensitive = frozenset(self.sensitive_components) + if sensitive - set(solver.network.components): + self.fallback_reason = "unknownSensitiveComponent" + return () + component_equation_owners = { + equation.owner_id + for equation in equations + if equation.owner == "component" + } + if sensitive - component_equation_owners: + self.fallback_reason = "sensitiveComponentHasNoEquationBlock" + return () + selected = [ + block + for block in raw_blocks + if any( + equations[equation_index].owner == "component" + and equations[equation_index].owner_id in sensitive + for equation_index in block[1] + ) + ] + if not selected: + self.fallback_reason = "sensitiveComponentHasNoEquationBlock" + return () + + for unknown_indices, equation_indices in selected: + if { + unknowns[unknown_index].variable + for unknown_index in unknown_indices + } - {"p", "m_flow"}: + self.fallback_reason = "streamBlockContainsNonPneumaticUnknown" + return () + component_names = { + unknowns[unknown_index].component + for unknown_index in unknown_indices + } | { + equations[equation_index].owner_id + for equation_index in equation_indices + if equations[equation_index].owner == "component" + } + if component_names - set(solver.network.components): + self.fallback_reason = "unknownEquationOwner" + return () + if any( + not type(solver.network.components[name]).__module__.startswith( + "app.simulation.components." + ) + for name in component_names + ): + self.fallback_reason = "untrustedCustomComponent" + return () + + equation_locations: list[tuple[str, object, int]] = [] + for component_plan in solver._component_equation_plan: + equation_locations.extend( + ("component", component_plan, local_index) + for local_index, _template in enumerate(component_plan.templates) + ) + equation_locations.extend( + ("connection", connection_plan, 0) + for connection_plan in solver._connection_equation_plan + ) + if len(equation_locations) != len(equations): + self.fallback_reason = "equationEvaluationPlanMismatch" + return () + + compiled: list[_StreamAlgebraicBlock] = [] + for unknown_indices, equation_indices in selected: + block_unknowns = tuple(unknowns[index] for index in unknown_indices) + block_equations = tuple(equations[index] for index in equation_indices) + block_unknown_ids = frozenset(unknown.id for unknown in block_unknowns) + component_targets: dict[int, list[tuple[int, int, str]]] = {} + component_plans: dict[int, object] = {} + connection_evaluations: list[_ScopedConnectionEvaluation] = [] + for target, global_equation_index in enumerate(equation_indices): + kind, evaluation_plan, source = equation_locations[ + global_equation_index + ] + if kind == "component": + key = id(evaluation_plan) + component_plans[key] = evaluation_plan + component_targets.setdefault(key, []).append( + ( + target, + source, + equations[global_equation_index].id, + ) + ) + else: + connection_evaluations.append( + _ScopedConnectionEvaluation( + target=target, + evaluate=evaluation_plan.evaluate, + equation_id=equations[global_equation_index].id, + ) + ) + component_evaluations = tuple( + _ScopedComponentEvaluation( + evaluate=component_plans[key].evaluate, + targets=tuple(targets), + ) + for key, targets in component_targets.items() + ) + explicit_flow_plan = tuple( + solver._compile_explicit_flow_stage( + tuple( + assignment + for assignment in stage.assignments + if assignment.unknown.id in block_unknown_ids + ) + ) + for stage in solver._explicit_flow_plan + ) + local_unknown_index = { + unknown.id: index for index, unknown in enumerate(block_unknowns) + } + jacobian_entries = tuple( + (row, local_unknown_index[variable]) + for row, equation in enumerate(block_equations) + for variable in dict.fromkeys(equation.variables) + if variable in local_unknown_index + ) + scope_components = tuple( + dict.fromkeys( + [unknown.component for unknown in block_unknowns] + + [ + equation.owner_id + for equation in block_equations + if equation.owner == "component" + ] + ) + ) + compiled.append( + _StreamAlgebraicBlock( + unknowns=block_unknowns, + equations=block_equations, + component_evaluations=component_evaluations, + connection_evaluations=tuple(connection_evaluations), + explicit_flow_plan=explicit_flow_plan, + scope_components=scope_components, + jacobian_entries=jacobian_entries, + equation_scale_specs=tuple( + self._equation_scale_spec(equation) + for equation in block_equations + ), + ) + ) + return tuple(compiled) + + def _compile_selected_equation_evaluation( + self, + ) -> _SelectedEquationEvaluation: + equations: list[EquationResidual] = [] + equation_scale_specs: list[_EquationScaleSpec] = [] + component_targets: dict[int, list[tuple[int, int, str]]] = {} + component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {} + connection_evaluations: list[_ScopedConnectionEvaluation] = [] + block_ranges: list[tuple[int, int]] = [] + scope_components: list[str] = [] + offset = 0 + for block in self.blocks: + start = offset + equations.extend(block.equations) + equation_scale_specs.extend(block.equation_scale_specs) + scope_components.extend(block.scope_components) + for evaluation in block.component_evaluations: + key = id(evaluation.evaluate) + component_evaluators[key] = evaluation.evaluate + component_targets.setdefault(key, []).extend( + (offset + target, source, equation_id) + for target, source, equation_id in evaluation.targets + ) + connection_evaluations.extend( + _ScopedConnectionEvaluation( + target=offset + evaluation.target, + evaluate=evaluation.evaluate, + equation_id=evaluation.equation_id, + ) + for evaluation in block.connection_evaluations + ) + offset += len(block.equations) + block_ranges.append((start, offset)) + return _SelectedEquationEvaluation( + equations=tuple(equations), + component_evaluations=tuple( + _ScopedComponentEvaluation( + evaluate=component_evaluators[key], + targets=tuple(targets), + ) + for key, targets in component_targets.items() + ), + connection_evaluations=tuple(connection_evaluations), + block_ranges=tuple(block_ranges), + equation_scale_specs=tuple(equation_scale_specs), + scope_components=tuple(dict.fromkeys(scope_components)), + ) + + def _compile_mutation_snapshot_plan( + self, + ) -> tuple[ + tuple[AlgebraicUnknown, ...], + tuple[tuple[AlgebraicUnknown, int], ...], + ]: + if not self.blocks: + return (), () + solver = self.pressure_flow_solver + unknowns_by_id = {unknown.id: unknown for unknown in solver.unknowns} + special_seed_target_ids: list[str] = [] + for binding in solver._closed_resistance_pressure_plan: + special_seed_target_ids.extend( + ( + f"{binding.component.name}.{binding.port_name}.p", + f"{binding.neighbor.name}.{binding.neighbor_port}.p", + ) + ) + for binding in solver._pnor_pnl0001_series_plan: + special_seed_target_ids.extend( + ( + f"{binding.orifice.name}.{binding.orifice_port}.p", + f"{binding.pipe.name}.{binding.pipe_port}.p", + ) + ) + + entry_mutated: list[AlgebraicUnknown] = [] + seen_ids: set[str] = set() + + def append_unknown(unknown: AlgebraicUnknown | None) -> None: + if unknown is None or unknown.id in seen_ids: + return + seen_ids.add(unknown.id) + entry_mutated.append(unknown) + + for block in self.blocks: + for unknown in block.unknowns: + append_unknown(unknown) + for target_id in special_seed_target_ids: + append_unknown(unknowns_by_id.get(target_id)) + + positions = { + unknown.id: index for index, unknown in enumerate(entry_mutated) + } + unselected_seed_restore_positions = tuple( + (unknowns_by_id[target_id], positions[target_id]) + for target_id in dict.fromkeys(special_seed_target_ids) + if target_id in unknowns_by_id + and target_id not in self._selected_unknown_ids + ) + return tuple(entry_mutated), unselected_seed_restore_positions + + def _seed_selected_blocks( + self, + entry_values: tuple[float, ...], + ) -> frozenset[str]: + solver = self.pressure_flow_solver + seeded_equation_ids: set[str] = set() + try: + # Reuse the global special seed plans because they encode catalog + # behavior such as PNOR/PNL0001 series pressure initialization. + # Shared PortState objects make those helpers capable of touching + # other equation blocks, so their exact unselected pressure targets + # are restored before block residuals are evaluated. Secondary + # closure deliberately mirrors ``solver.solve(effort_variables=())``: + # the primary global solve has already propagated equal pressures. + solver._seed_closed_resistance_pressures() + solver._seed_pnor_pnl0001_series_pressures() + for unknown in self._selected_flow_unknowns: + unknown.write(0.0) + for stage in self._selected_explicit_flow_plan: + values = solver._evaluate_explicit_flow_stage(stage) + targets = tuple( + ( + assignment, + assignment.unknown.read() - value, + ) + for assignment, value in zip(stage.assignments, values) + ) + for assignment, target_value in targets: + if isfinite(target_value): + assignment.unknown.write(target_value) + seeded_equation_ids.add(assignment.equation_id) + finally: + for unknown, position in self._unselected_seed_restore_positions: + unknown.write(entry_values[position]) + return frozenset(seeded_equation_ids) + + @staticmethod + def _equation_scales_from_specs( + specs: tuple[_EquationScaleSpec, ...], + scales: Mapping[str, float], + ) -> tuple[float, ...]: + result: list[float] = [] + for spec in specs: + if spec.direct_scale is not None: + result.append(float(scales[spec.direct_scale])) + else: + result.append( + max( + [ + float(scales.get(name, 1.0)) + for name in spec.variable_names + ] + + [1.0] + ) + ) + return tuple(result) + + @classmethod + def _equation_scales( + cls, + block: _StreamAlgebraicBlock, + scales: Mapping[str, float], + ) -> tuple[float, ...]: + return cls._equation_scales_from_specs( + block.equation_scale_specs, + scales, + ) + + def _seeded_diagnostics( + self, + *, + unknowns: tuple[AlgebraicUnknown, ...], + equation_values: tuple[float, ...], + equation_scales: tuple[float, ...], + scale_context: Mapping[str, float], + message: str, + ) -> AlgebraicSolveDiagnostics | None: + scaled = tuple( + abs(value / scale) + for value, scale in zip(equation_values, equation_scales) + ) + feasible = all( + isfinite(unknown.read()) + and ( + unknown.variable != "p" + or unknown.read() > PRESSURE_LOWER_BOUND_PA + ) + for unknown in unknowns + ) + max_scaled = max(scaled, default=0.0) + if ( + not feasible + or not all(isfinite(value) for value in scaled) + or max_scaled > self.pressure_flow_solver.residual_tolerance + ): + return None + return AlgebraicSolveDiagnostics( + success=True, + message=message, + evaluations=0, + pressure_scale=float(scale_context["p"]), + flow_scale=float(scale_context["m_flow"]), + max_scaled_residual=max_scaled, + max_raw_residual=max( + (abs(value) for value in equation_values), + default=0.0, + ), + ) + + def _solve_block( + self, + block: _StreamAlgebraicBlock, + scale_context: Mapping[str, float], + *, + seeded_values: tuple[float, ...] | None = None, + equation_scales: tuple[float, ...] | None = None, + ) -> _BlockSolveAttempt: + import numpy as np + from scipy.optimize import least_squares + from scipy.sparse import csr_matrix + + if equation_scales is None: + equation_scales = self._equation_scales(block, scale_context) + if seeded_values is None: + seeded_values = block.equation_values() + solver = self.pressure_flow_solver + seeded_diagnostics = self._seeded_diagnostics( + unknowns=block.unknowns, + equation_values=seeded_values, + equation_scales=equation_scales, + scale_context=scale_context, + message=( + "Seeded stream-sensitive algebraic block satisfies the " + "residual tolerance." + ), + ) + if seeded_diagnostics is not None: + return _BlockSolveAttempt( + diagnostics=seeded_diagnostics, + optimizer_evaluations=0, + residual_evaluations=0, + ) + + unknown_scales = tuple( + float(scale_context[unknown.variable]) for unknown in block.unknowns + ) + fallback_pressure = float(scale_context["fallback_pressure"]) + x0 = np.asarray( + [ + ( + unknown.read() + if unknown.variable != "p" or unknown.read() > 0.0 + else fallback_pressure + ) + / scale + for unknown, scale in zip(block.unknowns, unknown_scales) + ], + dtype=float, + ) + lower = np.asarray( + [ + ( + PRESSURE_LOWER_BOUND_PA / float(scale_context["p"]) + if unknown.variable == "p" + else -np.inf + ) + for unknown in block.unknowns + ] + ) + upper = np.full(len(block.unknowns), np.inf) + + def assign(values) -> None: + for unknown, value, scale in zip( + block.unknowns, + values, + unknown_scales, + ): + unknown.write(float(value) * scale) + + residual_evaluations = 0 + + def scaled_residuals(values): + nonlocal residual_evaluations + residual_evaluations += 1 + assign(values) + return np.asarray( + [ + value / scale + for value, scale in zip( + block.equation_values(), + equation_scales, + ) + ], + dtype=float, + ) + + rows = [row for row, _column in block.jacobian_entries] + columns = [column for _row, column in block.jacobian_entries] + jacobian_sparsity = csr_matrix( + ( + np.ones(len(rows), dtype=bool), + (rows, columns), + ), + shape=(len(block.equations), len(block.unknowns)), + ) + if ( + any(jacobian_sparsity.getnnz(axis=1) == 0) + or any(jacobian_sparsity.getnnz(axis=0) == 0) + ): + return _BlockSolveAttempt( + diagnostics=None, + optimizer_evaluations=0, + residual_evaluations=0, + failure_reason="invalidBlockJacobianSparsity", + ) + result = None + try: + result = least_squares( + scaled_residuals, + x0, + bounds=(lower, upper), + jac_sparsity=jacobian_sparsity, + x_scale="jac", + ftol=1.0e-10, + xtol=1.0e-10, + gtol=1.0e-10, + max_nfev=solver.max_evaluations, + ) + assign(result.x) + equation_values = block.equation_values() + except MemoryError: + assign(x0) + raise + except Exception as exc: + assign(x0) + return _BlockSolveAttempt( + diagnostics=None, + optimizer_evaluations=( + int(result.nfev) if result is not None else 0 + ), + residual_evaluations=residual_evaluations, + failure_reason=f"blockSolveFailed:{type(exc).__name__}", + ) + except BaseException: + assign(x0) + raise + scaled = tuple( + abs(value / scale) + for value, scale in zip(equation_values, equation_scales) + ) + max_scaled = max(scaled, default=0.0) + success = ( + all(isfinite(value) for value in scaled) + and max_scaled <= solver.residual_tolerance + and (bool(result.success) or int(result.status) == 0) + ) + if not success: + assign(x0) + return _BlockSolveAttempt( + diagnostics=None, + optimizer_evaluations=int(result.nfev), + residual_evaluations=residual_evaluations, + failure_reason="blockResidualNotConverged", + ) + diagnostics = AlgebraicSolveDiagnostics( + success=True, + message=str(result.message), + evaluations=int(result.nfev), + pressure_scale=float(scale_context["p"]), + flow_scale=float(scale_context["m_flow"]), + max_scaled_residual=max_scaled, + max_raw_residual=max( + (abs(value) for value in equation_values), + default=0.0, + ), + residual_evaluations=residual_evaluations, + jacobian_mode="sparse", + dense_fallback_used=False, + nonlinear_block_count=1, + nonlinear_block_unknown_count=len(block.unknowns), + ) + return _BlockSolveAttempt( + diagnostics=diagnostics, + optimizer_evaluations=int(result.nfev), + residual_evaluations=residual_evaluations, + ) + + @staticmethod + def _aggregate_local_diagnostics( + diagnostics: tuple[AlgebraicSolveDiagnostics, ...], + *, + nonlinear_block_unknown_count: int, + ) -> AlgebraicSolveDiagnostics: + if len(diagnostics) == 1: + return diagnostics[0] + nonlinear = tuple( + item for item in diagnostics if item.jacobian_mode != "seeded" + ) + representative = diagnostics[-1] + return replace( + representative, + message=( + f"{len(diagnostics)} stream-sensitive algebraic blocks " + "satisfied the residual tolerance." + ), + evaluations=sum(item.evaluations for item in diagnostics), + max_scaled_residual=max( + item.max_scaled_residual for item in diagnostics + ), + max_raw_residual=max(item.max_raw_residual for item in diagnostics), + residual_evaluations=sum( + item.residual_evaluations for item in diagnostics + ), + jacobian_mode="blockSparse" if nonlinear else "seeded", + dense_fallback_used=any( + item.dense_fallback_used for item in diagnostics + ), + nonlinear_block_count=len(nonlinear), + nonlinear_block_unknown_count=( + nonlinear_block_unknown_count if nonlinear else 0 + ), + block_fallback_used=False, + block_fallback_reason=None, + ) + + def solve( + self, + *, + scale_context: Mapping[str, float] | None = None, + ) -> StreamBlockSolveResult: + solver = self.pressure_flow_solver + context = dict(scale_context or solver.scale_context()) + causal_candidate = self.causal_fast_path_enabled + causal_audit_due = ( + self._causal_audit_is_due() if causal_candidate else False + ) + entry_values = tuple( + unknown.read() for unknown in self._entry_mutated_unknowns + ) + + def restore_entry_mutations() -> None: + for unknown, value in zip( + self._entry_mutated_unknowns, + entry_values, + ): + unknown.write(value) + + def global_fallback( + *, + reason: str | None, + local_optimizer_evaluations: int = 0, + local_residual_evaluations: int = 0, + local_attempted: bool, + ) -> StreamBlockSolveResult: + restore_entry_mutations() + fallback_snapshot = _MutableAlgebraicStateSnapshot.capture(solver) + try: + fallback = solver.solve( + effort_variables=(), + scale_context=context, + ) + except BaseException: + fallback_snapshot.restore(solver) + raise + fallback_reason = reason or fallback.block_fallback_reason + if ( + reason + and fallback.block_fallback_reason + and reason != fallback.block_fallback_reason + ): + fallback_reason = ( + f"{reason};global:{fallback.block_fallback_reason}" + ) + aggregate = replace( + fallback, + evaluations=( + local_optimizer_evaluations + fallback.evaluations + ), + residual_evaluations=( + local_residual_evaluations + + fallback.residual_evaluations + ), + block_fallback_used=( + local_attempted or fallback.block_fallback_used + ), + block_fallback_reason=fallback_reason, + ) + return StreamBlockSolveResult( + diagnostics=(aggregate,), + scopes=(tuple(solver.network.components),), + used_global_fallback=True, + ) + + if not self.available: + return global_fallback( + reason=self.fallback_reason, + local_attempted=False, + ) + + try: + seeded_equation_ids = ( + self._seed_selected_blocks(entry_values) or frozenset() + ) + except MemoryError: + restore_entry_mutations() + raise + except Exception as exc: + if causal_candidate: + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path( + f"causalSecondarySeedFailed:{type(exc).__name__}" + ) + return global_fallback( + reason=f"secondarySeedFailed:{type(exc).__name__}", + local_attempted=True, + ) + except BaseException: + restore_entry_mutations() + raise + + if causal_candidate: + causal_runtime_gate_passed = ( + seeded_equation_ids == self._causal_expected_flow_equation_ids + and all( + isfinite(unknown.read()) + for unknown in self._selected_flow_unknowns + ) + and all( + unknown.read() == entry_values[position] + for unknown, position in self._causal_effort_entry_positions + ) + ) + if not causal_runtime_gate_passed: + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path("causalSecondaryRuntimeGateFailed") + causal_candidate = False + causal_audit_due = False + elif not causal_audit_due: + diagnostics = self._causal_fast_diagnostics(context) + self._causal_fast_solve_count += 1 + self._causal_solves_since_audit += 1 + return StreamBlockSolveResult( + diagnostics=(diagnostics,), + scopes=( + self._selected_equation_evaluation.scope_components, + ), + used_global_fallback=False, + ) + + selected_evaluation = self._selected_equation_evaluation + assert selected_evaluation is not None + try: + selected_values = selected_evaluation.equation_values( + seeded_equation_ids + ) + selected_scales = self._equation_scales_from_specs( + selected_evaluation.equation_scale_specs, + context, + ) + union_diagnostics = self._seeded_diagnostics( + unknowns=self._selected_unknowns, + equation_values=selected_values, + equation_scales=selected_scales, + scale_context=context, + message=( + "Seeded stream-sensitive algebraic equation blocks satisfy " + "the residual tolerance." + ), + ) + except MemoryError: + restore_entry_mutations() + raise + except Exception as exc: + if causal_candidate: + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path( + "causalSecondaryResidualEvaluationFailed:" + f"{type(exc).__name__}" + ) + return global_fallback( + reason=( + "secondaryResidualEvaluationFailed:" + f"{type(exc).__name__}" + ), + local_attempted=True, + ) + except BaseException: + restore_entry_mutations() + raise + if union_diagnostics is not None: + if causal_candidate and causal_audit_due: + self._record_causal_audit(union_diagnostics) + return StreamBlockSolveResult( + diagnostics=(union_diagnostics,), + scopes=(selected_evaluation.scope_components,), + used_global_fallback=False, + ) + if causal_candidate and causal_audit_due: + self._causal_audit_failure_count += 1 + self._causal_legacy_fallback_count += 1 + self._disable_causal_fast_path("causalSecondaryResidualAuditFailed") + causal_candidate = False + + diagnostics: list[AlgebraicSolveDiagnostics] = [] + local_optimizer_evaluations = 0 + local_residual_evaluations = 0 + nonlinear_block_unknown_count = 0 + for block, (start, stop) in zip( + self.blocks, + selected_evaluation.block_ranges, + ): + try: + attempt = self._solve_block( + block, + context, + seeded_values=selected_values[start:stop], + equation_scales=selected_scales[start:stop], + ) + except MemoryError: + restore_entry_mutations() + raise + except Exception as exc: + return global_fallback( + reason=f"secondaryBlockSolveFailed:{type(exc).__name__}", + local_optimizer_evaluations=local_optimizer_evaluations, + local_residual_evaluations=local_residual_evaluations, + local_attempted=True, + ) + except BaseException: + restore_entry_mutations() + raise + local_optimizer_evaluations += attempt.optimizer_evaluations + local_residual_evaluations += attempt.residual_evaluations + if attempt.diagnostics is None: + return global_fallback( + reason=attempt.failure_reason or "secondaryBlockSolveFailed", + local_optimizer_evaluations=local_optimizer_evaluations, + local_residual_evaluations=local_residual_evaluations, + local_attempted=True, + ) + diagnostics.append(attempt.diagnostics) + if attempt.diagnostics.jacobian_mode != "seeded": + nonlinear_block_unknown_count += len(block.unknowns) + aggregate = self._aggregate_local_diagnostics( + tuple(diagnostics), + nonlinear_block_unknown_count=nonlinear_block_unknown_count, + ) + return StreamBlockSolveResult( + diagnostics=(aggregate,), + scopes=(selected_evaluation.scope_components,), + used_global_fallback=False, + ) diff --git a/app/simulation/solvers/pneumatic_volume.py b/app/simulation/solvers/pneumatic_volume.py index cd5f78d..c48f7c7 100644 --- a/app/simulation/solvers/pneumatic_volume.py +++ b/app/simulation/solvers/pneumatic_volume.py @@ -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( diff --git a/app/simulation/solvers/signal.py b/app/simulation/solvers/signal.py index 64d5bc4..10515b1 100644 --- a/app/simulation/solvers/signal.py +++ b/app/simulation/solvers/signal.py @@ -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), + ) diff --git a/app/simulation/solvers/solver.py b/app/simulation/solvers/solver.py index 3113904..e5440ec 100644 --- a/app/simulation/solvers/solver.py +++ b/app/simulation/solvers/solver.py @@ -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) diff --git a/app/simulation/solvers/stream.py b/app/simulation/solvers/stream.py index 668f0b2..1029124 100644 --- a/app/simulation/solvers/stream.py +++ b/app/simulation/solvers/stream.py @@ -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]) diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index 189ba2c..3a92ff4 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -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, diff --git a/app/simulation/warmup.py b/app/simulation/warmup.py new file mode 100644 index 0000000..a899933 --- /dev/null +++ b/app/simulation/warmup.py @@ -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"") + + +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", +] diff --git a/docs/component-model-authoring-spec-v1.md b/docs/component-model-authoring-spec-v1.md index 7c9ce27..bef0257 100644 --- a/docs/component-model-authoring-spec-v1.md +++ b/docs/component-model-authoring-spec-v1.md @@ -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"), diff --git a/docs/仿真性能评估-2026-08-15.md b/docs/仿真性能评估-2026-08-15.md index 5e7e8ca..a22e1a9 100644 --- a/docs/仿真性能评估-2026-08-15.md +++ b/docs/仿真性能评估-2026-08-15.md @@ -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 复验没有收益。 diff --git a/docs/后端求解逻辑与效率优化调研.md b/docs/后端求解逻辑与效率优化调研.md index 8b1f41d..33d4575 100644 --- a/docs/后端求解逻辑与效率优化调研.md +++ b/docs/后端求解逻辑与效率优化调研.md @@ -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()`、取消/轮询 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 82d9046..3b91b4f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, DragEvent, + memo, useCallback, useEffect, useLayoutEffect, @@ -45,8 +46,6 @@ import { type LucideIcon, } from "lucide-react"; import { - Background, - BackgroundVariant, ConnectionLineType, ConnectionMode, Handle, @@ -61,11 +60,15 @@ import { type Connection, type Edge, type EdgeChange, + type EdgeMouseHandler, type Node as ReactFlowNode, type NodeChange, + type NodeMouseHandler, type NodeProps, type NodeTypes, + type OnConnectStart, type OnNodeDrag, + type OnSelectionChangeFunc, useEdgesState, useNodesState, useReactFlow, @@ -73,6 +76,11 @@ import { useStoreApi, useUpdateNodeInternals, } from "@xyflow/react"; +import { + CANVAS_GRID_SIZE, + CanvasGridBackground, + type CanvasGridVisibility, +} from "./CanvasGridBackground"; import { EmptySimulationResultsView, SimulationResultsView, @@ -170,8 +178,12 @@ type ContactDragSession = { contactEdgeIds: Set; disconnected: boolean; effectiveEdges: SimulationEdge[]; + lastCandidate: ContactSnapCandidate | null; + lastEvaluatedPosition: { x: number; y: number } | null; + lastSnapRadius: number; multipleNodes: boolean; nodeId: string; + snapSearchContext: ContactSnapSearchContext | null; startPosition: { x: number; y: number }; }; @@ -447,6 +459,7 @@ type EditorSnapshot = { nodes: SimulationNode[]; edges: SimulationEdge[]; projectName: string; + signature: string; simulationConfig: SimulationConfig; }; @@ -469,6 +482,28 @@ type PendingPasteMiddlePointer = { moved: boolean; }; +type RenderedNodeCacheEntry = { + presentationContext: object; + presentationSignature: string; + renderedNode: SimulationNode; + sourceNode: SimulationNode; +}; + +type CachedNodePortGeometry = { + mirrored: boolean; + placementByPort: Map; + positionX: number; + positionY: number; + rotation: number; + sourceData: SimulationNode["data"]; +}; + +type RenderedEdgeCacheEntry = { + presentationSignature: string; + renderedEdge: SimulationEdge; + sourceEdge: SimulationEdge; +}; + type ModelingPaneLayout = { paletteWidth: number; propertiesWidth: number; @@ -488,11 +523,6 @@ type ModelingPaneResizeState = { startSize: number; }; -type CanvasGridVisibility = { - lines: boolean; - dots: boolean; -}; - type PaletteIconPreview = { definition: ComponentDefinition; left: number; @@ -511,7 +541,15 @@ const PALETTE_ICON_PREVIEW_DELAY_MS = 500; const CONSOLE_VIEWPORT_MARGIN = 8; const SIMULATION_STREAM_IDLE_TIMEOUT_MS = 30_000; const SIMULATION_SOLVER_STALL_TIMEOUT_MS = 60_000; -const GRID_SIZE = 18; +const GRID_SIZE = CANVAS_GRID_SIZE; +const MODELING_DEFAULT_EDGE_OPTIONS = { + interactionWidth: 28, + type: CONTACT_AWARE_EDGE_TYPE, +}; +const MODELING_DELETE_KEYS = ["Backspace", "Delete"]; +const MODELING_PAN_ON_DRAG = [1]; +const MODELING_PRO_OPTIONS = { hideAttribution: true }; +const MODELING_SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE]; const MODELING_PANE_SPLITTER_SIZE = 7; const MIN_PALETTE_WIDTH = 180; const MAX_PALETTE_WIDTH = 420; @@ -729,6 +767,19 @@ function measuredSymbolEnvelope( }; } +const SYMBOL_ENVELOPE_CACHE_LIMIT = 512; +const symbolEnvelopeCache = new Map(); + +function cacheSymbolEnvelope(key: string, envelope: SymbolEnvelope) { + if (!symbolEnvelopeCache.has(key) && symbolEnvelopeCache.size >= SYMBOL_ENVELOPE_CACHE_LIMIT) { + const oldestKey = symbolEnvelopeCache.keys().next().value; + if (typeof oldestKey === "string") { + symbolEnvelopeCache.delete(oldestKey); + } + } + symbolEnvelopeCache.set(key, envelope); +} + function defaultModelingPaneLayout(): ModelingPaneLayout { return { paletteWidth: 230, @@ -1511,7 +1562,14 @@ function assignMissingAmesimGasDefinitionIndices( }; } -function SimulationComponentNode({ id, data, selected }: NodeProps) { +type SimulationComponentNodeProps = NodeProps; + +function SimulationComponentNodeImpl({ + id, + data, + isConnectable, + selected, +}: SimulationComponentNodeProps) { const updateNodeInternals = useUpdateNodeInternals(); const viewportZoom = useStore((flowState) => flowState.transform[2]); const symbolContainerRef = useRef(null); @@ -1521,18 +1579,41 @@ function SimulationComponentNode({ id, data, selected }: NodeProps(() => - defaultSymbolEnvelope(layout), + const layout = useMemo( + () => componentSymbolLayout(symbol, data.parameters), + [data.parameters, symbol], ); - const displayedPorts = displayedPortsForNodeData(data); - const portPlacements = transformedPortPlacements( - displayedPorts, - rotation, - mirrored, - layout, + const renderedSize = nodeFrameDimensions(layout, rotation); + const layoutSignature = useMemo( + () => + `${layout.viewBox.x}:${layout.viewBox.y}:${layout.viewBox.width}:${layout.viewBox.height}:${layout.symbolSize.width}:${layout.symbolSize.height}:${layout.nodeSize.width}:${layout.nodeSize.height}:${JSON.stringify(layout.portAnchors ?? {})}`, + [layout], + ); + const symbolVariantKey = useMemo( + () => componentSymbolVariantKey(symbol, data.parameters), + [data.parameters, symbol], + ); + const symbolEnvelopeCacheKey = `${symbol}|${symbolVariantKey}|${layoutSignature}`; + const nodeInternalsGeometryKey = `${layoutSignature}|${rotation}|${mirrored ? 1 : 0}`; + const previousNodeInternalsGeometryKeyRef = useRef(nodeInternalsGeometryKey); + const [symbolEnvelope, setSymbolEnvelope] = useState( + () => + symbolEnvelopeCache.get(symbolEnvelopeCacheKey) ?? + defaultSymbolEnvelope(layout), + ); + const displayedPorts = useMemo( + () => displayedPortsForNodeData(data), + [data.componentType, data.modelType, data.parameters, data.ports], + ); + const portPlacements = useMemo( + () => + transformedPortPlacements( + displayedPorts, + rotation, + mirrored, + layout, + ), + [displayedPorts, layout, mirrored, rotation], ); const visiblePortIndexPlacements = data.hideConnectedPortIndexLabels ? portPlacements.filter( @@ -1547,7 +1628,6 @@ function SimulationComponentNode({ id, data, selected }: NodeProps 0 ? 1 / viewportZoom : 1; const nodeLayoutStyle = { width: renderedSize.width, @@ -1562,6 +1642,11 @@ function SimulationComponentNode({ id, data, selected }: NodeProps( ".component-symbol-svg", ); @@ -1569,6 +1654,7 @@ function SimulationComponentNode({ id, data, selected }: NodeProps currentEnvelope.left === nextEnvelope.left && currentEnvelope.top === nextEnvelope.top && @@ -1577,11 +1663,15 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { + if (previousNodeInternalsGeometryKeyRef.current === nodeInternalsGeometryKey) { + return; + } + previousNodeInternalsGeometryKeyRef.current = nodeInternalsGeometryKey; updateNodeInternals(id); - }, [id, layoutSignature, mirrored, rotation, updateNodeInternals]); + }, [id, nodeInternalsGeometryKey, updateNodeInternals]); return (
+ previous.id === next.id && + previous.data === next.data && + previous.isConnectable === next.isConnectable && + previous.selected === next.selected, +); +SimulationComponentNode.displayName = "SimulationComponentNode"; + function normalizeNodeRotation(value: unknown): NodeRotation { return value === 90 || value === 180 || value === 270 ? value : 0; } @@ -2135,6 +2240,53 @@ type ContactSnapPair = ContactSnapMatch & { targetPortOrder: number; }; +type ContactSnapPlacement = ReturnType[number]; + +type ContactSnapIndexedTarget = { + key: string; + placement: ContactSnapPlacement; + targetNode: SimulationNode; + targetNodeOrder: number; + targetPortOrder: number; +}; + +type ContactSnapSearchContext = { + buckets: Map; + cellSize: number; + draggedNodeId: string; + occupiedPorts: Set; + targetByPortKey: Map; +}; + +function contactSnapCell(value: number, cellSize: number) { + return Math.floor(value / cellSize); +} + +function contactSnapBucketKey(cellX: number, cellY: number) { + return `${cellX}:${cellY}`; +} + +function contactSnapTargetsNear( + context: ContactSnapSearchContext, + flowX: number, + flowY: number, + radius: number, +) { + const minCellX = contactSnapCell(flowX - radius, context.cellSize); + const maxCellX = contactSnapCell(flowX + radius, context.cellSize); + const minCellY = contactSnapCell(flowY - radius, context.cellSize); + const maxCellY = contactSnapCell(flowY + radius, context.cellSize); + const targets: ContactSnapIndexedTarget[] = []; + for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { + for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) { + targets.push( + ...(context.buckets.get(contactSnapBucketKey(cellX, cellY)) ?? []), + ); + } + } + return targets; +} + function compareContactSnapPairs( first: ContactSnapPair, second: ContactSnapPair, @@ -2150,12 +2302,11 @@ function compareContactSnapPairs( ); } -function availableContactSnapPairs( - draggedNode: SimulationNode, +function buildContactSnapSearchContext( + draggedNodeId: string, nodes: SimulationNode[], edges: SimulationEdge[], - maximumDistance: number, -) { +): ContactSnapSearchContext { const occupiedPorts = new Set(); edges.forEach((edge) => { if (edge.sourceHandle) { @@ -2165,67 +2316,110 @@ function availableContactSnapPairs( occupiedPorts.add(portConnectionKey(edge.target, edge.targetHandle)); } }); + const cellSize = CONTACT_SNAP_RADIUS_FLOW; + const buckets = new Map(); + const targetByPortKey = new Map(); + nodes.forEach((targetNode, targetNodeOrder) => { + if (targetNode.id === draggedNodeId) { + return; + } + const targetPortOrder = new Map( + targetNode.data.ports.map((port, index) => [port.name, index] as const), + ); + nodePortFlowPlacements(targetNode).forEach((placement) => { + const key = portConnectionKey(targetNode.id, placement.port.name); + if (occupiedPorts.has(key)) { + return; + } + const indexedTarget: ContactSnapIndexedTarget = { + key, + placement, + targetNode, + targetNodeOrder, + targetPortOrder: + targetPortOrder.get(placement.port.name) ?? Number.MAX_SAFE_INTEGER, + }; + const bucketKey = contactSnapBucketKey( + contactSnapCell(placement.flowX, cellSize), + contactSnapCell(placement.flowY, cellSize), + ); + const bucket = buckets.get(bucketKey); + if (bucket) { + bucket.push(indexedTarget); + } else { + buckets.set(bucketKey, [indexedTarget]); + } + targetByPortKey.set(key, indexedTarget); + }); + }); + return { + buckets, + cellSize, + draggedNodeId, + occupiedPorts, + targetByPortKey, + }; +} + +function availableContactSnapPairs( + draggedNode: SimulationNode, + nodes: SimulationNode[], + edges: SimulationEdge[], + maximumDistance: number, + searchContext?: ContactSnapSearchContext, +) { + const resolvedContext = + searchContext?.draggedNodeId === draggedNode.id + ? searchContext + : buildContactSnapSearchContext(draggedNode.id, nodes, edges); + const { occupiedPorts } = resolvedContext; const pairs: ContactSnapPair[] = []; const draggedPortOrder = new Map( draggedNode.data.ports.map((port, index) => [port.name, index] as const), ); - const draggedPlacements = nodePortFlowPlacements(draggedNode); + const draggedPlacements = nodePortFlowPlacements(draggedNode).filter( + (placement) => + !occupiedPorts.has( + portConnectionKey(draggedNode.id, placement.port.name), + ), + ); + const maximumDistanceSquared = maximumDistance * maximumDistance; for (const draggedPlacement of draggedPlacements) { - if ( - occupiedPorts.has( - portConnectionKey(draggedNode.id, draggedPlacement.port.name), - ) - ) { - continue; - } - for (const [targetNodeOrder, targetNode] of nodes.entries()) { - if (targetNode.id === draggedNode.id) { + for (const indexedTarget of contactSnapTargetsNear( + resolvedContext, + draggedPlacement.flowX, + draggedPlacement.flowY, + maximumDistance, + )) { + const { placement: targetPlacement, targetNode } = indexedTarget; + if (!arePortsCompatible(draggedPlacement.port, targetPlacement.port)) { continue; } - const targetPortOrder = new Map( - targetNode.data.ports.map((port, index) => [port.name, index] as const), - ); - for (const targetPlacement of nodePortFlowPlacements(targetNode)) { - if ( - occupiedPorts.has( - portConnectionKey(targetNode.id, targetPlacement.port.name), - ) || - !arePortsCompatible(draggedPlacement.port, targetPlacement.port) - ) { - continue; - } - const distance = Math.hypot( - targetPlacement.flowX - draggedPlacement.flowX, - targetPlacement.flowY - draggedPlacement.flowY, - ); - if (distance > maximumDistance) { - continue; - } - const connection = connectionForContactPorts( - draggedNode, - draggedPlacement.port, - targetNode, - targetPlacement.port, - ); - if (!canConnectPorts(nodes, edges, connection)) { - continue; - } - pairs.push({ - connection, - distance, - draggedPortOrder: - draggedPortOrder.get(draggedPlacement.port.name) ?? - Number.MAX_SAFE_INTEGER, - draggedPortName: draggedPlacement.port.name, - targetNodeId: targetNode.id, - targetNodeOrder, - targetPortOrder: - targetPortOrder.get(targetPlacement.port.name) ?? - Number.MAX_SAFE_INTEGER, - targetPortName: targetPlacement.port.name, - }); + const deltaX = targetPlacement.flowX - draggedPlacement.flowX; + const deltaY = targetPlacement.flowY - draggedPlacement.flowY; + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + if (distanceSquared > maximumDistanceSquared) { + continue; } + const connection = connectionForContactPorts( + draggedNode, + draggedPlacement.port, + targetNode, + targetPlacement.port, + ); + pairs.push({ + connection, + distance: Math.sqrt(distanceSquared), + draggedPortOrder: + draggedPortOrder.get(draggedPlacement.port.name) ?? + Number.MAX_SAFE_INTEGER, + draggedPortName: draggedPlacement.port.name, + targetNodeId: targetNode.id, + targetNodeOrder: indexedTarget.targetNodeOrder, + targetPortOrder: indexedTarget.targetPortOrder, + targetPortName: targetPlacement.port.name, + }); } } @@ -2277,12 +2471,18 @@ function findContactSnapCandidate( nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, + searchContext?: ContactSnapSearchContext, ) { + const resolvedContext = + searchContext?.draggedNodeId === draggedNode.id + ? searchContext + : buildContactSnapSearchContext(draggedNode.id, nodes, edges); const nearbyPairs = availableContactSnapPairs( draggedNode, nodes, edges, maximumDistance, + resolvedContext, ); const anchor = nearbyPairs[0]; if (!anchor) { @@ -2292,10 +2492,10 @@ function findContactSnapCandidate( draggedNode, anchor.draggedPortName, ); - const targetNode = nodes.find((node) => node.id === anchor.targetNodeId); - const targetPlacement = targetNode - ? nodePortFlowPlacement(targetNode, anchor.targetPortName) - : undefined; + const indexedTarget = resolvedContext.targetByPortKey.get( + portConnectionKey(anchor.targetNodeId, anchor.targetPortName), + ); + const targetPlacement = indexedTarget?.placement; if (!draggedPlacement || !targetPlacement) { return null; } @@ -2313,16 +2513,14 @@ function findContactSnapCandidate( ...draggedNode, position: snappedPosition, }; - const snappedNodes = nodes.map((node) => - node.id === draggedNode.id ? snappedDraggedNode : node, - ); // 多个接口必须共享同一个组件落点;只连接该落点下真正重合的接口, // 避免一个组件同时被互相冲突的接口位置“拉向”不同方向。 const exactPairs = availableContactSnapPairs( snappedDraggedNode, - snappedNodes, + nodes, edges, CONTACT_GEOMETRY_TOLERANCE, + resolvedContext, ); const matches = maximumContactSnapMatches(exactPairs).map( ({ @@ -2384,6 +2582,43 @@ function isContactEdgeGeometry(nodes: SimulationNode[], edge: SimulationEdge) { ); } +type NodePortGeometryIndex = Map< + string, + Map +>; + +function buildNodePortGeometryIndex(nodes: SimulationNode[]): NodePortGeometryIndex { + return new Map( + nodes.map((node) => [ + node.id, + new Map( + nodePortFlowPlacements(node).map((placement) => [ + placement.port.name, + { flowX: placement.flowX, flowY: placement.flowY }, + ] as const), + ), + ] as const), + ); +} + +function isContactEdgeGeometryInIndex( + geometryIndex: NodePortGeometryIndex, + edge: SimulationEdge, +) { + const source = edge.sourceHandle + ? geometryIndex.get(edge.source)?.get(edge.sourceHandle) + : undefined; + const target = edge.targetHandle + ? geometryIndex.get(edge.target)?.get(edge.targetHandle) + : undefined; + return Boolean( + source && + target && + Math.hypot(target.flowX - source.flowX, target.flowY - source.flowY) <= + CONTACT_GEOMETRY_TOLERANCE, + ); +} + function separateDeletedContactEdges( nodes: SimulationNode[], deletedEdges: SimulationEdge[], @@ -2453,13 +2688,15 @@ function disconnectInvalidatedContactEdges( transformedNodeIds: Set, ) { const removedEdges: SimulationEdge[] = []; + const previousGeometry = buildNodePortGeometryIndex(previousNodes); + const nextGeometry = buildNodePortGeometryIndex(nextNodes); const remainingEdges = edges.filter((edge) => { const affected = transformedNodeIds.has(edge.source) || transformedNodeIds.has(edge.target); const invalidated = affected && - isContactEdgeGeometry(previousNodes, edge) && - !isContactEdgeGeometry(nextNodes, edge); + isContactEdgeGeometryInIndex(previousGeometry, edge) && + !isContactEdgeGeometryInIndex(nextGeometry, edge); if (invalidated) { removedEdges.push(edge); } @@ -3242,7 +3479,13 @@ function defaultFlowViewportWorldBounds(): FlowViewportWorldBounds { }; } -function FlowViewportScrollbars({ nodes }: { nodes: SimulationNode[] }) { +function FlowViewportScrollbars({ + deferBounds = false, + nodes, +}: { + deferBounds?: boolean; + nodes: SimulationNode[]; +}) { const reactFlow = useReactFlow(); const transform = useStore((flowState) => flowState.transform); const canvasWidth = useStore((flowState) => flowState.width); @@ -3256,13 +3499,20 @@ function FlowViewportScrollbars({ nodes }: { nodes: SimulationNode[] }) { const [worldBounds, setWorldBounds] = useState( defaultFlowViewportWorldBounds, ); - const nodeBounds = useMemo( - () => - nodes.length > 0 - ? reactFlow.getNodesBounds(nodes) - : { x: 0, y: 0, width: 0, height: 0 }, - [nodes, reactFlow], - ); + const nodeBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + const nodeBounds = useMemo(() => { + if (deferBounds) { + return nodeBoundsRef.current; + } + return nodes.length > 0 + ? reactFlow.getNodesBounds(nodes) + : { x: 0, y: 0, width: 0, height: 0 }; + }, [deferBounds, nodes, reactFlow]); + useLayoutEffect(() => { + if (!deferBounds) { + nodeBoundsRef.current = nodeBounds; + } + }, [deferBounds, nodeBounds]); const zoom = Math.max(transform[2], 0.01); const visibleLeft = -transform[0] / zoom; const visibleTop = -transform[1] / zoom; @@ -3539,6 +3789,16 @@ function FlowWorkbench() { () => componentLibraries.flatMap((library) => library.components), [componentLibraries], ); + const defaultParametersByComponentType = useMemo( + () => + new Map( + componentDefinitions.map((definition) => [ + definition.type, + defaultParameters(definition), + ] as const), + ), + [componentDefinitions], + ); const visibleComponentLibraries = useMemo( () => componentLibraries.filter(isVisibleComponentLibrary), [componentLibraries], @@ -3553,6 +3813,7 @@ function FlowWorkbench() { useState(null); const [contactSnapCandidate, setContactSnapCandidate] = useState(null); + const [nodeDragActive, setNodeDragActive] = useState(false); const [projectName, setProjectName] = useState("demo-system"); const [simulationConfig, setSimulationConfig] = useState( defaultSimulationConfig, @@ -3599,6 +3860,8 @@ function FlowWorkbench() { : defaultModelingPaneLayout().componentParametersHeight; const [validationIssues, setValidationIssues] = useState([]); const [validatedSignature, setValidatedSignature] = useState(""); + const projectChangeSignatureRef = useRef(""); + const validationSignatureRef = useRef(""); const [saveState, setSaveState] = useState("正在检查本地草稿..."); const [autosaveReady, setAutosaveReady] = useState(false); const [recoveryProject, setRecoveryProject] = useState(null); @@ -3608,15 +3871,15 @@ function FlowWorkbench() { }); const reactFlow = useReactFlow(); const reactFlowStore = useStoreApi(); - const multipleNodesSelected = useStore((flowState) => { + const multipleNodesSelected = useMemo(() => { let selectedCount = 0; - for (const node of flowState.nodeLookup.values()) { + for (const node of nodes) { if (node.selected && (selectedCount += 1) > 1) { return true; } } return false; - }); + }, [nodes]); const fileInputRef = useRef(null); const workbenchRef = useRef(null); const modelingPaneResizeRef = useRef(null); @@ -3632,6 +3895,23 @@ function FlowWorkbench() { const pendingPasteMiddlePointerRef = useRef( null, ); + const renderedNodeCacheRef = useRef>( + new Map(), + ); + const nodePortGeometryCacheRef = useRef>( + new Map(), + ); + const renderedEdgeCacheRef = useRef>( + new Map(), + ); + const renderedEdgesArrayRef = useRef([]); + const renderedEdgesSourceRef = useRef(null); + const renderedEdgesSelectedIdRef = useRef(null); + const amesimGasMediumOptionsCacheRef = useRef<{ + definitions: ComponentDefinition[]; + nodeDataById: Map; + options: AmesimGasMediumOption[]; + } | null>(null); const consoleEntryIdRef = useRef(0); const activeSimulationIdRef = useRef(null); const previousActiveViewRef = useRef(activeView); @@ -3663,6 +3943,7 @@ function FlowWorkbench() { // React Flow 12 的拖拽取消不会同时清理点击连线起点。 reactFlowStore.setState({ connectionClickStartHandle: null }); setActivePortConnection(null); + setNodeDragActive(false); contactDragSessionRef.current = null; clearContactSnapCandidate(); }, [clearContactSnapCandidate, reactFlowStore]); @@ -4376,20 +4657,58 @@ function FlowWorkbench() { const selectedDefinition = selectedNode ? componentDefinitions.find((definition) => definition.type === selectedNode.data.componentType) : null; - const amesimGasMediumOptions = useMemo( - () => buildAmesimGasMediumOptions(nodes, componentDefinitions), - [componentDefinitions, nodes], - ); + const amesimGasMediumOptionsComputation = useMemo(() => { + const cached = amesimGasMediumOptionsCacheRef.current; + const canReuse = + cached?.definitions === componentDefinitions && + cached.nodeDataById.size === nodes.length && + nodes.every((node) => cached.nodeDataById.get(node.id) === node.data); + if (canReuse) { + return cached; + } + return { + definitions: componentDefinitions, + nodeDataById: new Map( + nodes.map((node) => [node.id, node.data] as const), + ), + options: buildAmesimGasMediumOptions(nodes, componentDefinitions), + }; + }, [componentDefinitions, nodes]); + useLayoutEffect(() => { + amesimGasMediumOptionsCacheRef.current = amesimGasMediumOptionsComputation; + }, [amesimGasMediumOptionsComputation]); + const amesimGasMediumOptions = amesimGasMediumOptionsComputation.options; const buildCurrentProject = useCallback( () => buildProjectPayload(projectName, nodes, edges, simulationConfig), [edges, nodes, projectName, simulationConfig], ); - const validationSignature = useMemo( - () => JSON.stringify(buildCurrentProject()), - [buildCurrentProject], + const projectChangeSignature = useMemo( + () => + nodeDragActive && projectChangeSignatureRef.current + ? projectChangeSignatureRef.current + : projectContentSignature( + projectName, + nodes, + edges, + simulationConfig, + ), + [edges, nodeDragActive, nodes, projectName, simulationConfig], ); + const validationSignature = useMemo( + () => + nodeDragActive && validationSignatureRef.current + ? validationSignatureRef.current + : modelValidationSignature(nodes, edges, simulationConfig), + [edges, nodeDragActive, nodes, simulationConfig], + ); + useLayoutEffect(() => { + if (!nodeDragActive) { + projectChangeSignatureRef.current = projectChangeSignature; + validationSignatureRef.current = validationSignature; + } + }, [nodeDragActive, projectChangeSignature, validationSignature]); const validationCurrent = validatedSignature === validationSignature; const duplicateNames = useMemo(() => findDuplicateNames(nodes), [nodes]); const validationSeverityByNode = useMemo( @@ -4454,12 +4773,57 @@ function FlowWorkbench() { : undefined, [activePortConnection, nodes], ); - const renderedNodes = useMemo( - () => - nodes.map((node) => { + const renderedNodePresentationContext = useMemo( + () => ({ + activeConnectionPort, + activePortConnection, + connectedPortKeys, + contactSnapOriginPortKeys, + contactSnapTargetPortKeys, + multipleNodesSelected, + validationSeverityByNode, + }), + [ + activeConnectionPort, + activePortConnection, + connectedPortKeys, + contactSnapOriginPortKeys, + contactSnapTargetPortKeys, + multipleNodesSelected, + validationSeverityByNode, + ], + ); + const renderedNodeComputation = useMemo( + () => { + const previousCache = renderedNodeCacheRef.current; + const nextCache = new Map(); + const nextRenderedNodes = nodes.map((node) => { + const cached = previousCache.get(node.id); + if ( + cached?.presentationContext === renderedNodePresentationContext && + cached.sourceNode.data === node.data && + cached.sourceNode.className === node.className + ) { + if (cached.sourceNode === node) { + nextCache.set(node.id, cached); + return cached.renderedNode; + } + const renderedNode: SimulationNode = { + ...node, + className: cached.renderedNode.className, + data: cached.renderedNode.data, + }; + nextCache.set(node.id, { + ...cached, + renderedNode, + sourceNode: node, + }); + return renderedNode; + } const hasDedicatedSymbol = hasComponentSymbol( node.data.symbol ?? node.data.componentType, ); + const portStateSignature: string[] = []; const portConnectionStates = Object.fromEntries( node.data.ports.map((port) => { const key = portConnectionKey(node.id, port.name); @@ -4482,6 +4846,7 @@ function FlowWorkbench() { ? "compatible" : "incompatible"; } + portStateSignature.push(`${port.name}:${state}`); return [port.name, state]; }), ); @@ -4494,16 +4859,51 @@ function FlowWorkbench() { ] .filter(Boolean) .join(" "); - return { + const presentationSignature = [ + className, + multipleNodesSelected ? "hide-connected-labels" : "show-all-labels", + ...portStateSignature, + ].join("|"); + const canReusePresentation = + cached?.sourceNode.data === node.data && + cached.presentationSignature === presentationSignature; + if ( + cached?.sourceNode === node && + canReusePresentation + ) { + nextCache.set( + node.id, + cached.presentationContext === renderedNodePresentationContext + ? cached + : { + ...cached, + presentationContext: renderedNodePresentationContext, + }, + ); + return cached.renderedNode; + } + const renderedData = canReusePresentation + ? cached.renderedNode.data + : { + ...node.data, + hideConnectedPortIndexLabels: multipleNodesSelected, + portConnectionStates, + }; + const renderedNode: SimulationNode = { ...node, className: className || undefined, - data: { - ...node.data, - hideConnectedPortIndexLabels: multipleNodesSelected, - portConnectionStates, - }, + data: renderedData, }; - }), + nextCache.set(node.id, { + presentationContext: renderedNodePresentationContext, + presentationSignature, + renderedNode, + sourceNode: node, + }); + return renderedNode; + }); + return { cache: nextCache, nodes: nextRenderedNodes }; + }, [ activeConnectionPort, activePortConnection, @@ -4512,14 +4912,128 @@ function FlowWorkbench() { connectedPortKeys, multipleNodesSelected, nodes, + renderedNodePresentationContext, validationSeverityByNode, ], ); - const renderedEdges = useMemo( - () => - edges.map((edge) => { + useLayoutEffect(() => { + renderedNodeCacheRef.current = renderedNodeComputation.cache; + }, [renderedNodeComputation.cache]); + const renderedNodes = renderedNodeComputation.nodes; + const edgeIndexesByNodeId = useMemo(() => { + const indexes = new Map(); + edges.forEach((edge, edgeIndex) => { + const sourceIndexes = indexes.get(edge.source) ?? []; + sourceIndexes.push(edgeIndex); + indexes.set(edge.source, sourceIndexes); + if (edge.target !== edge.source) { + const targetIndexes = indexes.get(edge.target) ?? []; + targetIndexes.push(edgeIndex); + indexes.set(edge.target, targetIndexes); + } + }); + return indexes; + }, [edges]); + const renderedEdgeComputation = useMemo( + () => { + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); + const previousGeometryCache = nodePortGeometryCacheRef.current; + const geometryUpdates = new Map(); + const geometryDeletes: string[] = []; + const geometryMatchesNode = ( + cached: CachedNodePortGeometry | undefined, + node: SimulationNode, + ) => + Boolean( + cached && + cached.sourceData === node.data && + cached.positionX === node.position.x && + cached.positionY === node.position.y && + cached.rotation === normalizeNodeRotation(node.data.rotation) && + cached.mirrored === Boolean(node.data.mirrored), + ); + const geometryForNode = (node: SimulationNode): CachedNodePortGeometry => { + const updated = geometryUpdates.get(node.id); + if (updated) { + return updated; + } + const rotation = normalizeNodeRotation(node.data.rotation); + const mirrored = Boolean(node.data.mirrored); + const cached = previousGeometryCache.get(node.id); + if (cached && geometryMatchesNode(cached, node)) { + geometryUpdates.set(node.id, cached); + return cached; + } + const geometry: CachedNodePortGeometry = { + mirrored, + placementByPort: new Map( + nodePortFlowPlacements(node).map((placement) => [ + placement.port.name, + { flowX: placement.flowX, flowY: placement.flowY }, + ] as const), + ), + positionX: node.position.x, + positionY: node.position.y, + rotation, + sourceData: node.data, + }; + geometryUpdates.set(node.id, geometry); + return geometry; + }; + const previousEdgeCache = renderedEdgeCacheRef.current; + const previousRenderedEdges = renderedEdgesArrayRef.current; + const requiresFullRecompute = + renderedEdgesSourceRef.current !== edges || + previousRenderedEdges.length !== edges.length || + renderedEdgesSelectedIdRef.current !== selectedEdgeId; + const affectedEdgeIndexes = new Set(); + if (requiresFullRecompute) { + edges.forEach((_edge, edgeIndex) => affectedEdgeIndexes.add(edgeIndex)); + } else { + nodes.forEach((node) => { + if (geometryMatchesNode(previousGeometryCache.get(node.id), node)) { + return; + } + for (const edgeIndex of edgeIndexesByNodeId.get(node.id) ?? []) { + affectedEdgeIndexes.add(edgeIndex); + } + }); + for (const nodeId of previousGeometryCache.keys()) { + if (nodeById.has(nodeId)) { + continue; + } + geometryDeletes.push(nodeId); + for (const edgeIndex of edgeIndexesByNodeId.get(nodeId) ?? []) { + affectedEdgeIndexes.add(edgeIndex); + } + } + } + + const edgeCacheUpdates = new Map(); + let nextRenderedEdges = previousRenderedEdges; + let renderedEdgesChanged = false; + const updateRenderedEdge = (edgeIndex: number) => { + const edge = edges[edgeIndex]; + if (!edge) { + return; + } const selected = edge.selected || edge.id === selectedEdgeId; - const contact = isContactEdgeGeometry(nodes, edge); + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); + const source = + sourceNode && edge.sourceHandle + ? geometryForNode(sourceNode).placementByPort.get(edge.sourceHandle) + : undefined; + const target = + targetNode && edge.targetHandle + ? geometryForNode(targetNode).placementByPort.get(edge.targetHandle) + : undefined; + const contact = Boolean( + source && + target && + Math.hypot(target.flowX - source.flowX, target.flowY - source.flowY) <= + CONTACT_GEOMETRY_TOLERANCE, + ); const className = [ edge.className, contact ? "editor-edge-contact" : "", @@ -4527,7 +5041,28 @@ function FlowWorkbench() { ] .filter(Boolean) .join(" "); - return { + const presentationSignature = `${className}|${contact ? "contact" : "line"}|${selected ? "selected" : "idle"}`; + const cached = previousEdgeCache.get(edge.id); + if ( + cached?.sourceEdge === edge && + cached.presentationSignature === presentationSignature + ) { + if (requiresFullRecompute) { + edgeCacheUpdates.set(edge.id, cached); + } + if ( + requiresFullRecompute || + previousRenderedEdges[edgeIndex] !== cached.renderedEdge + ) { + if (!renderedEdgesChanged) { + nextRenderedEdges = previousRenderedEdges.slice(); + renderedEdgesChanged = true; + } + nextRenderedEdges[edgeIndex] = cached.renderedEdge; + } + return; + } + const renderedEdge: SimulationEdge = { ...edge, className: className || undefined, data: { @@ -4538,9 +5073,57 @@ function FlowWorkbench() { selectable: contact ? false : edge.selectable, type: CONTACT_AWARE_EDGE_TYPE, }; - }), - [edges, nodes, selectedEdgeId], + edgeCacheUpdates.set(edge.id, { + presentationSignature, + renderedEdge, + sourceEdge: edge, + }); + if (!renderedEdgesChanged) { + nextRenderedEdges = previousRenderedEdges.slice(); + renderedEdgesChanged = true; + } + nextRenderedEdges[edgeIndex] = renderedEdge; + }; + + if (requiresFullRecompute) { + nextRenderedEdges = new Array(edges.length); + renderedEdgesChanged = true; + } + [...affectedEdgeIndexes] + .sort((first, second) => first - second) + .forEach(updateRenderedEdge); + return { + edgeCacheUpdates, + edges: nextRenderedEdges, + fullRecompute: requiresFullRecompute, + geometryDeletes, + geometryUpdates, + selectedEdgeId, + sourceEdges: edges, + }; + }, + [edgeIndexesByNodeId, edges, nodes, selectedEdgeId], ); + useLayoutEffect(() => { + if (renderedEdgeComputation.fullRecompute) { + nodePortGeometryCacheRef.current = renderedEdgeComputation.geometryUpdates; + renderedEdgeCacheRef.current = renderedEdgeComputation.edgeCacheUpdates; + } else { + renderedEdgeComputation.geometryDeletes.forEach((nodeId) => { + nodePortGeometryCacheRef.current.delete(nodeId); + }); + renderedEdgeComputation.geometryUpdates.forEach((geometry, nodeId) => { + nodePortGeometryCacheRef.current.set(nodeId, geometry); + }); + renderedEdgeComputation.edgeCacheUpdates.forEach((entry, edgeId) => { + renderedEdgeCacheRef.current.set(edgeId, entry); + }); + } + renderedEdgesArrayRef.current = renderedEdgeComputation.edges; + renderedEdgesSourceRef.current = renderedEdgeComputation.sourceEdges; + renderedEdgesSelectedIdRef.current = renderedEdgeComputation.selectedEdgeId; + }, [renderedEdgeComputation]); + const renderedEdges = renderedEdgeComputation.edges; const flowNodes = useMemo(() => { if (!pendingPaste) { return renderedNodes; @@ -4618,12 +5201,25 @@ function FlowWorkbench() { }, []); const captureSnapshot = useCallback( - (): EditorSnapshot => ({ - nodes: cloneValue(nodesRef.current), - edges: cloneValue(edgesRef.current), - projectName: projectNameRef.current, - simulationConfig: { ...simulationConfigRef.current }, - }), + (): EditorSnapshot => { + const signature = + projectChangeSignatureRef.current || + projectContentSignature( + projectNameRef.current, + nodesRef.current, + edgesRef.current, + simulationConfigRef.current, + ); + return { + // 编辑器节点和边按不可变方式更新;历史记录保留结构共享,恢复时再深拷贝。 + // 这样拖动开始时无需同步克隆整张大图。 + nodes: nodesRef.current.slice(), + edges: edgesRef.current.slice(), + projectName: projectNameRef.current, + signature, + simulationConfig: { ...simulationConfigRef.current }, + }; + }, [], ); @@ -4770,21 +5366,24 @@ function FlowWorkbench() { const handleNodesChange = useCallback( (changes: NodeChange[]) => { const removesNodes = changes.some((change) => change.type === "remove"); - if (removesNodes) { - cancelPortConnection(); - recordHistory(); + if (!removesNodes) { + setNodes((currentNodes) => applyNodeChanges(changes, currentNodes)); + return; } + + cancelPortConnection(); + recordHistory(); const preview = repairAmesimGasReferences( applyNodeChanges(changes, nodesRef.current), componentDefinitions, ); - setNodes((currentNodes) => { - const changedNodes = applyNodeChanges(changes, currentNodes); - return removesNodes - ? repairAmesimGasReferences(changedNodes, componentDefinitions).nodes - : changedNodes; - }); - if (removesNodes && preview.repairedReferenceCount > 0) { + setNodes((currentNodes) => + repairAmesimGasReferences( + applyNodeChanges(changes, currentNodes), + componentDefinitions, + ).nodes, + ); + if (preview.repairedReferenceCount > 0) { appendConsoleEntry( "warning", `介质定义已删除,${preview.repairedReferenceCount} 个介质物性引用已切回 0 — 空气(理想气体,内置默认)`, @@ -4836,6 +5435,7 @@ function FlowWorkbench() { const handleNodeDragStart = useCallback>( (_event, draggedNode, draggedNodes) => { + setNodeDragActive(true); recordHistory(); clearContactSnapCandidate(); const effectiveNodes = nodesRef.current.map((node) => @@ -4847,25 +5447,45 @@ function FlowWorkbench() { const contactEdgeIds = new Set( multipleNodes ? [] - : edgesRef.current - .filter( - (edge) => - (edge.source === draggedNode.id || - edge.target === draggedNode.id) && - isContactEdgeGeometry(effectiveNodes, edge), - ) - .map((edge) => edge.id), + : (edgeIndexesByNodeId.get(draggedNode.id) ?? []).flatMap( + (edgeIndex) => { + const edge = edgesRef.current[edgeIndex]; + if (!edge) { + return []; + } + const cachedEntry = renderedEdgeCacheRef.current.get(edge.id); + const cachedContact = + cachedEntry?.sourceEdge === edge + ? cachedEntry.renderedEdge.data?.isContactEdge + : undefined; + return (typeof cachedContact === "boolean" + ? cachedContact + : isContactEdgeGeometry(effectiveNodes, edge)) + ? [edge.id] + : []; + }, + ), ); contactDragSessionRef.current = { contactEdgeIds, disconnected: false, effectiveEdges: edgesRef.current, + lastCandidate: null, + lastEvaluatedPosition: null, + lastSnapRadius: 0, multipleNodes, nodeId: draggedNode.id, + snapSearchContext: multipleNodes + ? null + : buildContactSnapSearchContext( + draggedNode.id, + effectiveNodes, + edgesRef.current, + ), startPosition: { ...draggedNode.position }, }; }, - [clearContactSnapCandidate, recordHistory], + [clearContactSnapCandidate, edgeIndexesByNodeId, recordHistory], ); const handleNodeDrag = useCallback>( @@ -4891,6 +5511,11 @@ function FlowWorkbench() { ); session.disconnected = true; session.effectiveEdges = remainingEdges; + session.snapSearchContext = buildContactSnapSearchContext( + draggedNode.id, + nodesRef.current, + remainingEdges, + ); edgesRef.current = remainingEdges; setEdges(remainingEdges); setSelectedEdgeId((current) => @@ -4907,15 +5532,17 @@ function FlowWorkbench() { draggedNode), position: { ...draggedNode.position }, }; - const effectiveNodes = nodesRef.current.map((node) => - node.id === draggedNode.id ? effectiveDraggedNode : node, - ); + const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom()); const candidate = findContactSnapCandidate( effectiveDraggedNode, - effectiveNodes, + nodesRef.current, session.effectiveEdges, - contactSnapRadiusForZoom(reactFlow.getZoom()), + snapRadius, + session.snapSearchContext ?? undefined, ); + session.lastCandidate = candidate; + session.lastEvaluatedPosition = { ...effectiveDraggedNode.position }; + session.lastSnapRadius = snapRadius; setContactSnapCandidate((current) => sameContactSnapCandidate(current, candidate) ? current : candidate, ); @@ -4925,6 +5552,7 @@ function FlowWorkbench() { const handleNodeDragStop = useCallback>( (_event, draggedNode) => { + setNodeDragActive(false); const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { clearContactSnapCandidate(); @@ -4938,14 +5566,22 @@ function FlowWorkbench() { const effectiveNodes = nodesRef.current.map((node) => node.id === draggedNode.id ? effectiveDraggedNode : node, ); + const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom()); + const canReuseLastCandidate = + session.lastEvaluatedPosition?.x === effectiveDraggedNode.position.x && + session.lastEvaluatedPosition.y === effectiveDraggedNode.position.y && + session.lastSnapRadius === snapRadius; const candidate = session.multipleNodes ? null - : findContactSnapCandidate( - effectiveDraggedNode, - effectiveNodes, - session.effectiveEdges, - contactSnapRadiusForZoom(reactFlow.getZoom()), - ); + : canReuseLastCandidate + ? session.lastCandidate + : findContactSnapCandidate( + effectiveDraggedNode, + effectiveNodes, + session.effectiveEdges, + snapRadius, + session.snapSearchContext ?? undefined, + ); contactDragSessionRef.current = null; clearContactSnapCandidate(); @@ -5063,8 +5699,8 @@ function FlowWorkbench() { const isValidConnection = useCallback( (connection: Connection | SimulationEdge) => - canConnectPorts(nodes, edges, connection), - [edges, nodes], + canConnectPorts(nodesRef.current, edgesRef.current, connection), + [], ); const onDragStart = (event: DragEvent, type: string) => { @@ -6006,13 +6642,10 @@ function FlowWorkbench() { simulationConfigRef.current, componentDefinitions, ); - const signature = JSON.stringify( - buildProjectPayload( - projectNameRef.current, - nodesRef.current, - edgesRef.current, - simulationConfigRef.current, - ), + const signature = modelValidationSignature( + nodesRef.current, + edgesRef.current, + simulationConfigRef.current, ); setValidationIssues(issues); setValidatedSignature(signature); @@ -6428,20 +7061,26 @@ function FlowWorkbench() { }, []); useEffect(() => { - if (!autosaveReady || recoveryProject) { + if (!autosaveReady || nodeDragActive || recoveryProject) { return; } setSaveState("有未保存更改"); const timer = window.setTimeout(() => { try { - localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(buildCurrentProject())); + const project = buildProjectPayload( + projectNameRef.current, + nodesRef.current, + edgesRef.current, + simulationConfigRef.current, + ); + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project)); setSaveState(`已自动保存 ${formatTime(new Date())}`); } catch (error) { setSaveState(`自动保存失败:${formatError(error)}`); } }, 600); return () => window.clearTimeout(timer); - }, [autosaveReady, buildCurrentProject, recoveryProject]); + }, [autosaveReady, nodeDragActive, projectChangeSignature, recoveryProject]); useEffect(() => { const syncViewFromLocation = () => { @@ -6582,10 +7221,71 @@ function FlowWorkbench() { stopPending={simulationStopPending} /> ); + const handleDragConnectStart = useCallback( + (_event, { nodeId, handleId }) => + beginPortConnection(nodeId, handleId, "drag"), + [beginPortConnection], + ); + const handleClickConnectStart = useCallback( + (_event, { nodeId, handleId }) => + beginPortConnection(nodeId, handleId, "click"), + [beginPortConnection], + ); + const handleModelingEdgeClick = useCallback< + EdgeMouseHandler + >( + (_event, edge) => { + cancelPortConnection(); + setSelectedEdgeId(edge.id); + setSelectedNodeId(null); + }, + [cancelPortConnection], + ); + const handleModelingEdgesDelete = useCallback(() => { + setSelectedEdgeId(null); + }, []); + const handleModelingNodeClick = useCallback< + NodeMouseHandler + >( + (event, node) => { + if ( + !(event.target instanceof Element) || + !event.target.closest(".port-handle") + ) { + cancelPortConnection(); + } + setSelectedNodeId(node.id); + setSelectedEdgeId(null); + }, + [cancelPortConnection], + ); + const handleModelingNodesDelete = useCallback(() => { + setSelectedNodeId(null); + cancelPortConnection(); + }, [cancelPortConnection]); + const handleModelingPaneClick = useCallback( + (_event: ReactMouseEvent) => { + setSelectedNodeId(null); + setSelectedEdgeId(null); + cancelPortConnection(); + }, + [cancelPortConnection], + ); + const handleModelingSelectionChange = useCallback< + OnSelectionChangeFunc + >(({ nodes: selectedNodes, edges: selectedEdges }) => { + setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null); + setSelectedEdgeId( + selectedNodes.length === 0 && selectedEdges.length === 1 + ? selectedEdges[0].id + : null, + ); + }, []); if (activeView === "results") { return resultSnapshot ? ( @@ -7362,7 +8064,9 @@ function FlowWorkbench() { key={definition.type} > @@ -7411,6 +8115,9 @@ function FlowWorkbench() { onDrop={onDrop} onPointerCancelCapture={() => { pendingPasteMiddlePointerRef.current = null; + setNodeDragActive(false); + contactDragSessionRef.current = null; + clearContactSnapCandidate(); handleTightSelectionPointerCancel(); }} onPointerDownCapture={handleFlowPointerDownCapture} @@ -7423,10 +8130,7 @@ function FlowWorkbench() { connectionLineType={ConnectionLineType.Step} connectionRadius={24} connectionDragThreshold={0} - defaultEdgeOptions={{ - interactionWidth: 28, - type: CONTACT_AWARE_EDGE_TYPE, - }} + defaultEdgeOptions={MODELING_DEFAULT_EDGE_OPTIONS} edges={flowEdges} edgeTypes={contactAwareEdgeTypes} elementsSelectable={!pendingPaste} @@ -7434,70 +8138,37 @@ function FlowWorkbench() { nodes={flowNodes} nodesConnectable={!pendingPaste} nodesDraggable={!pendingPaste} - deleteKeyCode={pendingPaste ? null : ["Backspace", "Delete"]} + deleteKeyCode={pendingPaste ? null : MODELING_DELETE_KEYS} onConnect={onConnect} onConnectEnd={endDragPortConnection} - onConnectStart={(_, { nodeId, handleId }) => - beginPortConnection(nodeId, handleId, "drag") - } + onConnectStart={handleDragConnectStart} onClickConnectEnd={endClickPortConnection} - onClickConnectStart={(_, { nodeId, handleId }) => - beginPortConnection(nodeId, handleId, "click") - } + onClickConnectStart={handleClickConnectStart} isValidConnection={isValidConnection} minZoom={0.1} - onEdgeClick={(_, edge) => { - cancelPortConnection(); - setSelectedEdgeId(edge.id); - setSelectedNodeId(null); - }} + onEdgeClick={handleModelingEdgeClick} onEdgesChange={handleEdgesChange} - onEdgesDelete={() => { - setSelectedEdgeId(null); - }} - onNodeClick={(event, node) => { - if ( - !(event.target instanceof Element) || - !event.target.closest(".port-handle") - ) { - cancelPortConnection(); - } - setSelectedNodeId(node.id); - setSelectedEdgeId(null); - }} + onEdgesDelete={handleModelingEdgesDelete} + onNodeClick={handleModelingNodeClick} onNodeDrag={handleNodeDrag} onNodeDragStart={handleNodeDragStart} onNodeDragStop={handleNodeDragStop} onNodesChange={handleNodesChange} - onNodesDelete={() => { - setSelectedNodeId(null); - cancelPortConnection(); - }} + onNodesDelete={handleModelingNodesDelete} onMove={syncPendingPasteToPointer} - onPaneClick={() => { - setSelectedNodeId(null); - setSelectedEdgeId(null); - cancelPortConnection(); - }} - onSelectionChange={({ nodes: selectedNodes, edges: selectedEdges }) => { - setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null); - setSelectedEdgeId( - selectedNodes.length === 0 && selectedEdges.length === 1 - ? selectedEdges[0].id - : null, - ); - }} + onPaneClick={handleModelingPaneClick} + onSelectionChange={handleModelingSelectionChange} onSelectionEnd={finishTightSelection} panActivationKeyCode="Space" - panOnDrag={[1]} + panOnDrag={MODELING_PAN_ON_DRAG} panOnScroll panOnScrollMode={PanOnScrollMode.Vertical} panOnScrollSpeed={1} preventScrolling - proOptions={{ hideAttribution: true }} + proOptions={MODELING_PRO_OPTIONS} selectionMode={SelectionMode.Partial} selectionOnDrag={!pendingPaste} - snapGrid={[GRID_SIZE, GRID_SIZE]} + snapGrid={MODELING_SNAP_GRID} snapToGrid={snapEnabled} zoomActivationKeyCode="Control" zoomOnDoubleClick={false} @@ -7509,24 +8180,7 @@ function FlowWorkbench() { expectedNodeCount={renderedNodes.length} requestKey={modelingFitRequest} /> - {gridVisibility.lines ? ( - - ) : null} - {gridVisibility.dots ? ( - - ) : null} + {pendingPaste ? ( 待放置:左键放置 · 中键旋转 · Esc 取消 @@ -7551,7 +8205,10 @@ function FlowWorkbench() { ) : null} - + {USE_DOCKED_SIMULATION_CONSOLE ? simulationConsole : null} @@ -7700,7 +8357,11 @@ function buildProjectPayload( nodes: SimulationNode[], edges: SimulationEdge[], simulation: SimulationConfig, + calculateContactGeometry = true, ): ReactFlowProjectPayload { + const contactGeometry = calculateContactGeometry + ? buildNodePortGeometryIndex(nodes) + : null; return { projectSchemaVersion: 1, name: name.trim() || "untitled", @@ -7740,7 +8401,9 @@ function buildProjectPayload( sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, data: { - isContactEdge: isContactEdgeGeometry(nodes, edge), + isContactEdge: contactGeometry + ? isContactEdgeGeometryInIndex(contactGeometry, edge) + : Boolean(edge.data?.isContactEdge), }, }; }), @@ -8176,41 +8839,102 @@ function connectionIdentity(edge: SimulationEdge, unordered: boolean) { function loadProjectEdges(edges: ProjectEdgePayload[], nodes: SimulationNode[]) { const loadedEdges: SimulationEdge[] = []; const droppedEdges: Array<{ id: string; reason: string }> = []; + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); + const portByNodeId = new Map( + nodes.map((node) => [ + node.id, + new Map( + displayedPortsForNodeData(node.data).map((port) => [port.name, port] as const), + ), + ] as const), + ); + const occupiedPortKeys = new Set(); edges.forEach((edge) => { const candidate: SimulationEdge = { ...edge, data: { ...edge.data }, type: "step", }; - const reason = loadedEdgeContractIssue(nodes, loadedEdges, candidate); + const reason = loadedEdgeContractIssue( + nodeById, + portByNodeId, + occupiedPortKeys, + candidate, + ); if (reason) { droppedEdges.push({ id: edge.id, reason }); return; } loadedEdges.push(candidate); + occupiedPortKeys.add( + portConnectionKey(candidate.source, candidate.sourceHandle ?? ""), + ); + occupiedPortKeys.add( + portConnectionKey(candidate.target, candidate.targetHandle ?? ""), + ); }); return { edges: loadedEdges, droppedEdges }; } -function loadedEdgeContractIssue( +function projectContentSignature( + name: string, nodes: SimulationNode[], - acceptedEdges: SimulationEdge[], + edges: SimulationEdge[], + simulation: SimulationConfig, +) { + // 选择、拖动和参数编辑需要快速判断“模型检查结果是否仍有效”。 + // 接触边状态可由节点位置和接口信息推导,不应为生成签名再次执行整图几何计算。 + return JSON.stringify( + buildProjectPayload(name, nodes, edges, simulation, false), + ); +} + +function modelValidationSignature( + nodes: SimulationNode[], + edges: SimulationEdge[], + simulation: SimulationConfig, +) { + // 画布坐标、旋转、镜像和科学计数法展示格式不参与模型有效性判断。 + // 将它们排除后,移动/选择元件不会让检查结果失效,也不会序列化无关展示状态。 + return JSON.stringify({ + nodes: nodes.map((node) => ({ + id: node.id, + label: node.data.label, + componentType: node.data.componentType, + modelType: node.data.modelType, + modelVersion: node.data.modelVersion, + modelContractIssues: node.data.modelContractIssues, + ports: node.data.ports, + parameters: node.data.parameters, + parameterUnits: node.data.parameterUnits, + })), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + })), + simulation, + }); +} + +function loadedEdgeContractIssue( + nodeById: ReadonlyMap, + portByNodeId: ReadonlyMap>, + occupiedPortKeys: ReadonlySet, edge: SimulationEdge, ) { if (edge.source === edge.target) { return "不允许连接同一组件"; } - const sourceNode = nodes.find((node) => node.id === edge.source); - const targetNode = nodes.find((node) => node.id === edge.target); + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); if (!sourceNode || !targetNode) { return "引用了不存在的组件"; } - const sourcePort = displayedPortsForNodeData(sourceNode.data).find( - (port) => port.name === edge.sourceHandle, - ); - const targetPort = displayedPortsForNodeData(targetNode.data).find( - (port) => port.name === edge.targetHandle, - ); + const sourcePort = portByNodeId.get(sourceNode.id)?.get(edge.sourceHandle ?? ""); + const targetPort = portByNodeId.get(targetNode.id)?.get(edge.targetHandle ?? ""); if (!sourcePort || !targetPort) { const endpoints = [ !sourcePort ? `${sourceNode.data.label}.${edge.sourceHandle ?? "?"}` : null, @@ -8222,8 +8946,8 @@ function loadedEdgeContractIssue( return "当前目录中的端口类型、物理域或信号方向不兼容"; } if ( - !portAcceptsAnotherConnection(acceptedEdges, sourceNode.id, sourcePort) || - !portAcceptsAnotherConnection(acceptedEdges, targetNode.id, targetPort) + occupiedPortKeys.has(portConnectionKey(sourceNode.id, sourcePort.name)) || + occupiedPortKeys.has(portConnectionKey(targetNode.id, targetPort.name)) ) { return "当前目录合同不允许端口被多条连接占用"; } @@ -9031,13 +9755,14 @@ function cloneValue(value: T): T { } function snapshotSignature(snapshot: EditorSnapshot) { - return JSON.stringify( - buildProjectPayload( + return ( + snapshot.signature || + projectContentSignature( snapshot.projectName, snapshot.nodes, snapshot.edges, snapshot.simulationConfig, - ), + ) ); } diff --git a/frontend/src/CanvasGridBackground.tsx b/frontend/src/CanvasGridBackground.tsx new file mode 100644 index 0000000..56e0def --- /dev/null +++ b/frontend/src/CanvasGridBackground.tsx @@ -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 ? ( + + ) : null} + {visibility.dots ? ( + + ) : null} + + ); +} diff --git a/frontend/src/ComponentSymbol.tsx b/frontend/src/ComponentSymbol.tsx index 4218dd8..857f544 100644 --- a/frontend/src/ComponentSymbol.tsx +++ b/frontend/src/ComponentSymbol.tsx @@ -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({ ); } + +export const ComponentSymbol = memo(ComponentSymbolImpl); +ComponentSymbol.displayName = "ComponentSymbol"; diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index e1fa421..6f055f3 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -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; parameterUnits?: Record; @@ -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 = { export function SimulationResultsView({ snapshot, + gridVisibility, nodeTypes, onViewChange, onSnapshotLoad, }: SimulationResultsViewProps) { + const reactFlow = useReactFlow(); const resultStatus = simulationResultStatus(snapshot.result); const [selectedNodeId, setSelectedNodeId] = useState( 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({
系统图 - {snapshot.project.nodes.length} 个组件 +
+ {snapshot.project.nodes.length} 个组件 + +
- - +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 58bac7d..5d55163 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts index 01aa724..1194170 100644 --- a/frontend/tests/e2e/component-symbols.spec.ts +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -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 }) => { diff --git a/frontend/tests/e2e/fit-view.spec.ts b/frontend/tests/e2e/fit-view.spec.ts index d5465ac..0163173 100644 --- a/frontend/tests/e2e/fit-view.spec.ts +++ b/frontend/tests/e2e/fit-view.spec.ts @@ -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(".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); +}); diff --git a/tests/fixtures/high_stiffness_explicit_rk45.xml b/tests/fixtures/high_stiffness_explicit_rk45.xml new file mode 100644 index 0000000..4682187 --- /dev/null +++ b/tests/fixtures/high_stiffness_explicit_rk45.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_amesim_helium_medium.py b/tests/test_amesim_helium_medium.py index 99d8e05..0778e36 100644 --- a/tests/test_amesim_helium_medium.py +++ b/tests/test_amesim_helium_medium.py @@ -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) diff --git a/tests/test_amesim_pnl0002_pnl0003_component.py b/tests/test_amesim_pnl0002_pnl0003_component.py index 10e4c07..2af1c5f 100644 --- a/tests/test_amesim_pnl0002_pnl0003_component.py +++ b/tests/test_amesim_pnl0002_pnl0003_component.py @@ -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() diff --git a/tests/test_amesim_pnl00r_component.py b/tests/test_amesim_pnl00r_component.py index 65437c4..ac5040f 100644 --- a/tests/test_amesim_pnl00r_component.py +++ b/tests/test_amesim_pnl00r_component.py @@ -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) diff --git a/tests/test_amesim_pnvo001_fixed_component.py b/tests/test_amesim_pnvo001_fixed_component.py index c3a4740..9e78ea8 100644 --- a/tests/test_amesim_pnvo001_fixed_component.py +++ b/tests/test_amesim_pnvo001_fixed_component.py @@ -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) diff --git a/tests/test_contact_solver_causalization.py b/tests/test_contact_solver_causalization.py index fe37ea4..04c2342 100644 --- a/tests/test_contact_solver_causalization.py +++ b/tests/test_contact_solver_causalization.py @@ -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) diff --git a/tests/test_core_solver.py b/tests/test_core_solver.py index bfdfa39..ac7c393 100644 --- a/tests/test_core_solver.py +++ b/tests/test_core_solver.py @@ -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 diff --git a/tests/test_generic_jacobian_sparsity.py b/tests/test_generic_jacobian_sparsity.py new file mode 100644 index 0000000..32fe9c2 --- /dev/null +++ b/tests/test_generic_jacobian_sparsity.py @@ -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() diff --git a/tests/test_generic_system_xml_simulation.py b/tests/test_generic_system_xml_simulation.py index 85a3519..0ecbda7 100644 --- a/tests/test_generic_system_xml_simulation.py +++ b/tests/test_generic_system_xml_simulation.py @@ -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: diff --git a/tests/test_high_stiffness_explicit_rk45.py b/tests/test_high_stiffness_explicit_rk45.py new file mode 100644 index 0000000..fb8011c --- /dev/null +++ b/tests/test_high_stiffness_explicit_rk45.py @@ -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() diff --git a/tests/test_performance_benchmark.py b/tests/test_performance_benchmark.py index 8d2db1c..3796946 100644 --- a/tests/test_performance_benchmark.py +++ b/tests/test_performance_benchmark.py @@ -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() diff --git a/tests/test_pressure_flow_causal_execution.py b/tests/test_pressure_flow_causal_execution.py new file mode 100644 index 0000000..5473dfd --- /dev/null +++ b/tests/test_pressure_flow_causal_execution.py @@ -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() diff --git a/tests/test_pressure_flow_solver_equation_blocks.py b/tests/test_pressure_flow_solver_equation_blocks.py new file mode 100644 index 0000000..2f95104 --- /dev/null +++ b/tests/test_pressure_flow_solver_equation_blocks.py @@ -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() diff --git a/tests/test_pressure_flow_solver_initialization.py b/tests/test_pressure_flow_solver_initialization.py index 5b3fa6a..a948892 100644 --- a/tests/test_pressure_flow_solver_initialization.py +++ b/tests/test_pressure_flow_solver_initialization.py @@ -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() diff --git a/tests/test_pressure_flow_solver_sparsity.py b/tests/test_pressure_flow_solver_sparsity.py new file mode 100644 index 0000000..acbdc2e --- /dev/null +++ b/tests/test_pressure_flow_solver_sparsity.py @@ -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() diff --git a/tests/test_property_performance_instrumentation.py b/tests/test_property_performance_instrumentation.py index bd30ad5..e1f7aa2 100644 --- a/tests/test_property_performance_instrumentation.py +++ b/tests/test_property_performance_instrumentation.py @@ -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() diff --git a/tests/test_simulation_performance_pipeline.py b/tests/test_simulation_performance_pipeline.py index 8802cc9..782e912 100644 --- a/tests/test_simulation_performance_pipeline.py +++ b/tests/test_simulation_performance_pipeline.py @@ -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() diff --git a/tests/test_simulation_property_cache.py b/tests/test_simulation_property_cache.py new file mode 100644 index 0000000..88f9225 --- /dev/null +++ b/tests/test_simulation_property_cache.py @@ -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() diff --git a/tests/test_simulation_warmup.py b/tests/test_simulation_warmup.py new file mode 100644 index 0000000..1acf0cf --- /dev/null +++ b/tests/test_simulation_warmup.py @@ -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() diff --git a/tests/test_stream_pressure_block_solver.py b/tests/test_stream_pressure_block_solver.py new file mode 100644 index 0000000..a0256dc --- /dev/null +++ b/tests/test_stream_pressure_block_solver.py @@ -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() diff --git a/tests/test_stream_resolver_execution_plan.py b/tests/test_stream_resolver_execution_plan.py new file mode 100644 index 0000000..e989c22 --- /dev/null +++ b/tests/test_stream_resolver_execution_plan.py @@ -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() diff --git a/tests/test_thermofluid_closure_plan.py b/tests/test_thermofluid_closure_plan.py new file mode 100644 index 0000000..1bcfc27 --- /dev/null +++ b/tests/test_thermofluid_closure_plan.py @@ -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()