同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。 纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
1825 lines
75 KiB
Python
1825 lines
75 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, replace
|
|
from math import floor, isfinite
|
|
import os
|
|
from sys import maxsize
|
|
from typing import Literal
|
|
|
|
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.jacobian import (
|
|
SparseJacobianCompatibilityError,
|
|
SparseSecantJacobian,
|
|
)
|
|
from app.simulation.solvers.mechanical import (
|
|
MechanicalConstraintGroup,
|
|
MechanicalStateReducer,
|
|
)
|
|
from app.simulation.solvers.pneumatic_storage import (
|
|
IdealPneumaticStorageReducer,
|
|
ideal_storage_group_is_reducible,
|
|
)
|
|
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
|
|
from app.simulation.solvers.solver import (
|
|
IntegrationCancelled,
|
|
ODESolution,
|
|
SolveIVPConfig,
|
|
SolverActivityTracker,
|
|
integrate_ode,
|
|
)
|
|
from app.simulation.solvers.signal import SignalResolver
|
|
from app.simulation.solvers.stream import StreamResolver
|
|
from app.simulation.solvers.tangent import (
|
|
ThreePistonTangentCompilation,
|
|
ThreePistonTangentProvider,
|
|
compile_supported_piston_tangent_provider,
|
|
)
|
|
from app.simulation.solvers.thermofluid import (
|
|
ThermofluidClosureDiagnostics,
|
|
ThermofluidClosureError,
|
|
ThermofluidClosureFailure,
|
|
ThermofluidClosureSuccess,
|
|
ThermofluidTransactionPlan,
|
|
)
|
|
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
|
|
|
|
|
SimulationProgressCallback = Callable[[float, str], None]
|
|
SimulationCancellationCheck = Callable[[], bool]
|
|
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
|
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE = "SIMULATION_ODE_JACOBIAN_MODE"
|
|
|
|
|
|
def _requested_ode_jacobian_mode() -> Literal[
|
|
"optimized",
|
|
"hybrid",
|
|
"semi-analytic",
|
|
"scipy",
|
|
]:
|
|
value = os.getenv(
|
|
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE,
|
|
"scipy",
|
|
).strip().lower()
|
|
if value in {"optimized", "colored"}:
|
|
return "optimized"
|
|
if value in {"hybrid", "secant"}:
|
|
return "hybrid"
|
|
if value in {"semi-analytic", "semi_analytic", "analytic"}:
|
|
return "semi-analytic"
|
|
if value in {
|
|
"scipy",
|
|
"native",
|
|
"finite-difference",
|
|
"0",
|
|
"false",
|
|
"no",
|
|
"off",
|
|
}:
|
|
return "scipy"
|
|
raise ValueError(
|
|
f"{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE} must be "
|
|
"'optimized', 'hybrid', 'semi-analytic', or 'scipy'."
|
|
)
|
|
|
|
|
|
@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
|
|
message: str
|
|
|
|
def as_dict(self) -> dict[str, str]:
|
|
return {"code": self.code, "message": self.message}
|
|
|
|
|
|
class SimulationPreparationError(ValueError):
|
|
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
|
|
super().__init__("The compiled model is not ready for simulation.")
|
|
self.issues = issues
|
|
|
|
|
|
class SimulationSampleTimeError(ValueError):
|
|
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GenericSimulationResult:
|
|
success: bool
|
|
status: SimulationRunStatus
|
|
message: str
|
|
simulated_until: float
|
|
requested_stop_time: float
|
|
variables: tuple[ResultVariableMetadata, ...]
|
|
series: dict[str, list[float]]
|
|
final: dict[str, float]
|
|
diagnostics: dict[str, object]
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
return {
|
|
"success": self.success,
|
|
"status": self.status,
|
|
"partial": self.status != "completed",
|
|
"message": self.message,
|
|
"simulatedUntil": self.simulated_until,
|
|
"requestedStopTime": self.requested_stop_time,
|
|
"variables": [variable.as_dict() for variable in self.variables],
|
|
"series": self.series,
|
|
"final": self.final,
|
|
"diagnostics": self.diagnostics,
|
|
}
|
|
|
|
|
|
class _UnionFind:
|
|
def __init__(self, items: set[Endpoint]) -> None:
|
|
self.parent = {item: item for item in items}
|
|
|
|
def find(self, item: Endpoint) -> Endpoint:
|
|
parent = self.parent[item]
|
|
if parent != item:
|
|
self.parent[item] = self.find(parent)
|
|
return self.parent[item]
|
|
|
|
def union(self, first: Endpoint, second: Endpoint) -> None:
|
|
first_root = self.find(first)
|
|
second_root = self.find(second)
|
|
if first_root != second_root:
|
|
self.parent[second_root] = first_root
|
|
|
|
|
|
def _equation_port(component_name: str, variable: str) -> Endpoint | None:
|
|
parts = variable.rsplit(".", 2)
|
|
if len(parts) != 3:
|
|
return None
|
|
prefix, port_name, variable_name = parts
|
|
if prefix != component_name or variable_name != "p":
|
|
return None
|
|
return Endpoint(component_name, port_name)
|
|
|
|
|
|
def simulation_preparation_issues(
|
|
network: SimulationNetwork,
|
|
) -> tuple[SimulationPreparationIssue, ...]:
|
|
issues: list[SimulationPreparationIssue] = []
|
|
physical_endpoints = {
|
|
Endpoint(component.name, port_name)
|
|
for component in network.components.values()
|
|
for port_name in component.required_connection_ports
|
|
}
|
|
connected_endpoints = {
|
|
endpoint
|
|
for connection in network.connections
|
|
if connection.kind == "physical"
|
|
for endpoint in connection.endpoints
|
|
}
|
|
for endpoint in sorted(physical_endpoints - connected_endpoints, key=str):
|
|
issues.append(
|
|
SimulationPreparationIssue(
|
|
"PORT_UNCONNECTED",
|
|
f"Physical port {endpoint} must be connected before simulation.",
|
|
)
|
|
)
|
|
|
|
structure = network.pressure_flow_structure_dict()
|
|
if not structure["isSquare"]:
|
|
issues.append(
|
|
SimulationPreparationIssue(
|
|
"PRESSURE_FLOW_SYSTEM_NOT_SQUARE",
|
|
"Pressure-flow equation count does not match the unknown count: "
|
|
f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.",
|
|
)
|
|
)
|
|
|
|
dynamic_names = {
|
|
component.name
|
|
for component in network.components.values()
|
|
if isinstance(component, DynamicComponent)
|
|
}
|
|
if not dynamic_names:
|
|
issues.append(
|
|
SimulationPreparationIssue(
|
|
"DYNAMIC_STATE_MISSING",
|
|
"Each simulated network requires at least one storage component.",
|
|
)
|
|
)
|
|
|
|
physical_component_names = {
|
|
component.name
|
|
for component in network.components.values()
|
|
if any(
|
|
definition.kind == "physical"
|
|
for definition in component.active_port_definitions
|
|
)
|
|
}
|
|
adjacency = {name: set() for name in physical_component_names}
|
|
for connection in network.connections:
|
|
if connection.kind != "physical":
|
|
continue
|
|
first, second = connection.endpoints
|
|
adjacency[first.component].add(second.component)
|
|
adjacency[second.component].add(first.component)
|
|
remaining = set(adjacency)
|
|
while remaining:
|
|
start = remaining.pop()
|
|
group = {start}
|
|
stack = [start]
|
|
while stack:
|
|
current = stack.pop()
|
|
for neighbour in adjacency[current] - group:
|
|
group.add(neighbour)
|
|
remaining.discard(neighbour)
|
|
stack.append(neighbour)
|
|
if not (group & dynamic_names):
|
|
issues.append(
|
|
SimulationPreparationIssue(
|
|
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
|
|
"A connected physical network has no pressure/enthalpy storage anchor: "
|
|
+ ", ".join(sorted(group))
|
|
+ ".",
|
|
)
|
|
)
|
|
|
|
if physical_endpoints:
|
|
effort_groups = _UnionFind(physical_endpoints)
|
|
for connection in network.connections:
|
|
if connection.kind == "physical":
|
|
effort_groups.union(*connection.endpoints)
|
|
storage_ports: dict[Endpoint, str] = {}
|
|
for component in network.components.values():
|
|
for equation in component.pressure_flow_equation_residuals():
|
|
pressure_ports = [
|
|
endpoint
|
|
for variable in equation.variables
|
|
if (endpoint := _equation_port(component.name, variable)) is not None
|
|
]
|
|
if equation.relation == "equal" and len(pressure_ports) == 2:
|
|
effort_groups.union(pressure_ports[0], pressure_ports[1])
|
|
if equation.relation == "state":
|
|
for endpoint in pressure_ports:
|
|
storage_ports[endpoint] = component.name
|
|
|
|
storages_by_group: dict[Endpoint, dict[Endpoint, str]] = {}
|
|
for endpoint, component_name in storage_ports.items():
|
|
storages_by_group.setdefault(effort_groups.find(endpoint), {})[
|
|
endpoint
|
|
] = component_name
|
|
for storage_endpoints in storages_by_group.values():
|
|
storage_names = set(storage_endpoints.values())
|
|
if len(storage_names) > 1:
|
|
if ideal_storage_group_is_reducible(
|
|
network,
|
|
storage_endpoints,
|
|
):
|
|
continue
|
|
issues.append(
|
|
SimulationPreparationIssue(
|
|
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
|
"Storage components are connected without a resistance: "
|
|
+ ", ".join(sorted(storage_names))
|
|
+ ". Insert an orifice or pipe between them.",
|
|
)
|
|
)
|
|
|
|
return tuple(issues)
|
|
|
|
|
|
def simulation_sample_times(
|
|
config: SolveIVPConfig,
|
|
step: float,
|
|
) -> list[float]:
|
|
t_start = float(config.t_start)
|
|
t_stop = float(config.t_stop)
|
|
if not isfinite(t_start) or not isfinite(t_stop):
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_VALUE_NOT_FINITE",
|
|
"Simulation start and stop times must be finite.",
|
|
)
|
|
if step <= 0.0 or not isfinite(step):
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_STEP_INVALID",
|
|
"Simulation sample step must be finite and greater than zero.",
|
|
)
|
|
duration = t_stop - t_start
|
|
if not isfinite(duration):
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_TIME_SPAN_NOT_FINITE",
|
|
"Simulation time span must be finite.",
|
|
)
|
|
if duration <= 0.0:
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_TIME_RANGE_INVALID",
|
|
"Simulation stop time must be greater than start time.",
|
|
)
|
|
|
|
ratio = duration / step
|
|
if not isfinite(ratio):
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
|
"Simulation sample count cannot be represented by this runtime; "
|
|
"increase sampleStep.",
|
|
)
|
|
interval_count = floor(ratio)
|
|
# There is no product-level point cap. Still reject a collection that the
|
|
# Python runtime cannot index before multiplying by the potentially huge
|
|
# interval count or allocating the output grid.
|
|
if interval_count > maxsize - 2:
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
|
"Simulation sample count cannot be represented by this runtime; "
|
|
"increase sampleStep.",
|
|
)
|
|
last_regular_time = t_start + interval_count * step
|
|
append_stop = last_regular_time < t_stop
|
|
requested_point_count = interval_count + 1 + int(append_stop)
|
|
if requested_point_count > maxsize:
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE",
|
|
"Simulation sample count cannot be represented by this runtime; "
|
|
"increase sampleStep.",
|
|
)
|
|
|
|
times = [t_start]
|
|
for index in range(1, interval_count + 1):
|
|
candidate = t_start + index * step
|
|
if not isfinite(candidate):
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
|
|
"Simulation sampleStep cannot be represented over the requested "
|
|
"absolute time range.",
|
|
)
|
|
if candidate >= t_stop:
|
|
candidate = t_stop
|
|
if candidate <= times[-1]:
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
|
|
"Simulation sampleStep is too small to advance floating-point "
|
|
"time over the requested absolute time range.",
|
|
)
|
|
times.append(candidate)
|
|
if candidate == t_stop:
|
|
break
|
|
if times[-1] < t_stop:
|
|
times.append(t_stop)
|
|
|
|
if len(times) < 2 or any(
|
|
current >= following
|
|
for current, following in zip(times, times[1:])
|
|
):
|
|
raise SimulationSampleTimeError(
|
|
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
|
|
"Simulation sample times must contain at least two strictly "
|
|
"increasing values.",
|
|
)
|
|
return times
|
|
|
|
|
|
class GenericFluidSystem:
|
|
"""Topology-driven, semi-explicit fluid simulation for registered components."""
|
|
|
|
@profile_phase("simulation.system_construction")
|
|
def __init__(self, network: SimulationNetwork) -> None:
|
|
issues = simulation_preparation_issues(network)
|
|
if issues:
|
|
raise SimulationPreparationError(issues)
|
|
self.network = network
|
|
self.dynamic_components = network.dynamic_components()
|
|
self.mechanical_state_reducer = MechanicalStateReducer(
|
|
network,
|
|
self.dynamic_components,
|
|
)
|
|
self.pneumatic_storage_reducer = IdealPneumaticStorageReducer(
|
|
network,
|
|
self.mechanical_state_reducer,
|
|
)
|
|
self.pressure_flow_solver = PressureFlowSolver(network)
|
|
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._thermofluid_transaction_plan = ThermofluidTransactionPlan.compile(
|
|
network,
|
|
diagnostic_owners=(
|
|
self.signal_resolver,
|
|
self.pneumatic_volume_resolver,
|
|
self.stream_resolver,
|
|
self.pressure_flow_solver,
|
|
*self._thermofluid_closure_plan.secondary_pressure_solvers,
|
|
),
|
|
)
|
|
self._thermofluid_closure_diagnostics = ThermofluidClosureDiagnostics()
|
|
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
|
|
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
|
|
self._activity_tracker: SolverActivityTracker | None = 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(),
|
|
validate=True,
|
|
)
|
|
|
|
def apply_state_vector(self, values: list[float]) -> None:
|
|
self.mechanical_state_reducer.apply_state_vector(
|
|
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.
|
|
|
|
Two state entries are coupled when a physical path connects them without
|
|
crossing a third storage state. This over-approximates the local
|
|
pressure-flow/mechanical closure while preserving branch sparsity.
|
|
"""
|
|
from scipy.sparse import lil_matrix
|
|
|
|
entries = self.mechanical_state_reducer.state_entries
|
|
entry_components: list[set[str]] = []
|
|
entry_sizes: list[int] = []
|
|
for entry in entries:
|
|
if isinstance(entry, MechanicalConstraintGroup):
|
|
entry_components.append(
|
|
{component.name for component in entry.components}
|
|
)
|
|
entry_sizes.append(2)
|
|
else:
|
|
entry_components.append({entry.name})
|
|
entry_sizes.append(entry.state_size)
|
|
|
|
owner_by_component = {
|
|
component_name: entry_index
|
|
for entry_index, component_names in enumerate(entry_components)
|
|
for component_name in component_names
|
|
}
|
|
adjacency = {name: set() for name in self.network.components}
|
|
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)
|
|
|
|
dependencies: list[set[int]] = []
|
|
for entry_index, component_names in enumerate(entry_components):
|
|
visited = set(component_names)
|
|
pending = list(component_names)
|
|
found = {entry_index}
|
|
while pending:
|
|
current = pending.pop()
|
|
for neighbour in adjacency[current] - visited:
|
|
visited.add(neighbour)
|
|
neighbour_entry = owner_by_component.get(neighbour)
|
|
if (
|
|
neighbour_entry is not None
|
|
and neighbour_entry != entry_index
|
|
):
|
|
found.add(neighbour_entry)
|
|
else:
|
|
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)
|
|
sparsity = lil_matrix(
|
|
(offsets[-1], offsets[-1]),
|
|
dtype=bool,
|
|
)
|
|
for row_entry, column_entries in enumerate(dependencies):
|
|
for column_entry in column_entries:
|
|
sparsity[
|
|
offsets[row_entry] : offsets[row_entry + 1],
|
|
offsets[column_entry] : offsets[column_entry + 1],
|
|
] = True
|
|
return sparsity.tocsr()
|
|
|
|
def jacobian_sparsity(self):
|
|
if self._jacobian_sparsity is None:
|
|
self._jacobian_sparsity = self._build_jacobian_sparsity()
|
|
return self._jacobian_sparsity
|
|
|
|
def jacobian_sparsity_diagnostics(self) -> dict[str, float | int]:
|
|
from scipy.optimize._numdiff import group_columns
|
|
|
|
sparsity = self.jacobian_sparsity()
|
|
group_count = int(group_columns(sparsity).max(initial=-1)) + 1
|
|
state_count = int(sparsity.shape[0])
|
|
return {
|
|
"nonzeroCount": int(sparsity.nnz),
|
|
"density": (
|
|
float(sparsity.nnz) / float(state_count * state_count)
|
|
if state_count
|
|
else 0.0
|
|
),
|
|
"colorGroupCount": group_count,
|
|
}
|
|
|
|
def _exact_ode_jacobian_rows(self) -> dict[int, dict[int, float]]:
|
|
"""Return mode-independent kinematic rows safe to evaluate exactly."""
|
|
|
|
rows: dict[int, dict[int, float]] = {}
|
|
cursor = 0
|
|
for entry in self.mechanical_state_reducer.state_entries:
|
|
if isinstance(entry, MechanicalConstraintGroup):
|
|
# A discrete endstop can replace x' = v with x' = 0 for the
|
|
# active constrained mode. Keep those rows numerical; free
|
|
# mechanical groups always have d(x')/d(v) = 1.
|
|
if not entry.discrete_endstop_components:
|
|
rows[cursor + 1] = {cursor: 1.0}
|
|
cursor += 2
|
|
else:
|
|
cursor += entry.state_size
|
|
return rows
|
|
|
|
@profile_phase(
|
|
"simulation.closure",
|
|
minimum_mode="audit",
|
|
reset_property_shadow=True,
|
|
)
|
|
def _close_current_state(
|
|
self,
|
|
time: float,
|
|
*,
|
|
record_rhs_outcome: bool = False,
|
|
) -> dict[str, dict[str, float]]:
|
|
if self._activity_tracker is not None:
|
|
self._activity_tracker.record_thermofluid_closure(time)
|
|
transaction = self._thermofluid_transaction_plan.capture()
|
|
last_algebraic_diagnostics = self._last_algebraic_diagnostics
|
|
last_algebraic_scope = self._last_algebraic_scope
|
|
try:
|
|
connected_h, success = self._close_current_state_unchecked(time)
|
|
except ThermofluidClosureError as exc:
|
|
transaction.restore()
|
|
self._last_algebraic_diagnostics = last_algebraic_diagnostics
|
|
self._last_algebraic_scope = last_algebraic_scope
|
|
self._request_causal_residual_audit()
|
|
if record_rhs_outcome:
|
|
failure = self._thermofluid_closure_diagnostics.record_failure(
|
|
exc.diagnostics
|
|
)
|
|
raise ThermofluidClosureError(failure) from None
|
|
raise
|
|
except BaseException:
|
|
transaction.restore()
|
|
self._last_algebraic_diagnostics = last_algebraic_diagnostics
|
|
self._last_algebraic_scope = last_algebraic_scope
|
|
self._request_causal_residual_audit()
|
|
raise
|
|
if record_rhs_outcome:
|
|
self._thermofluid_closure_diagnostics.record_success(success)
|
|
return connected_h
|
|
|
|
def _close_current_state_unchecked(
|
|
self,
|
|
time: float,
|
|
) -> tuple[
|
|
dict[str, dict[str, float]],
|
|
ThermofluidClosureSuccess,
|
|
]:
|
|
signal = self.signal_resolver.solve(time)
|
|
self.signal_propagation_count += signal.propagated
|
|
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
|
|
pneumatic_volume = self.pneumatic_volume_resolver.solve()
|
|
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
|
|
self._refresh_dynamic_components()
|
|
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
|
|
# on the resulting mass flows. A single stream -> pressure-flow refresh
|
|
# 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.
|
|
# 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
|
|
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 = []
|
|
coupling_deltas = []
|
|
max_coupling_iterations = 25
|
|
flow_relative_tolerance = 1.0e-12
|
|
for coupling_iteration in range(1, max_coupling_iterations + 1):
|
|
previous_flows = self._thermofluid_transaction_plan.flow_values()
|
|
stream, connected_h = self.stream_resolver.solve(
|
|
dynamic_ports_are_current=True,
|
|
)
|
|
stream_diagnostics.append(stream)
|
|
for component in self.dynamic_components:
|
|
component.update_stream_outflows(connected_h[component.name])
|
|
self.stream_resolver.refresh_flow_temperature_references()
|
|
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
|
|
)
|
|
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
|
|
coupling_delta = self._thermofluid_transaction_plan.measure_flow_delta(
|
|
previous_flows,
|
|
iteration=coupling_iteration,
|
|
relative_tolerance=flow_relative_tolerance,
|
|
)
|
|
coupling_deltas.append(coupling_delta)
|
|
if (
|
|
not secondary_pressure_solvers
|
|
or coupling_delta.max_delta <= coupling_delta.tolerance
|
|
):
|
|
break
|
|
else:
|
|
raise ThermofluidClosureError(
|
|
ThermofluidClosureFailure.from_iterations(
|
|
time,
|
|
coupling_deltas,
|
|
)
|
|
)
|
|
self.max_thermofluid_iterations = max(
|
|
self.max_thermofluid_iterations,
|
|
coupling_iteration,
|
|
)
|
|
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,
|
|
*(item.max_scaled_residual for item in algebraic_diagnostics),
|
|
)
|
|
self.max_algebraic_evaluations = max(
|
|
self.max_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,
|
|
*(item.iterations for item in stream_diagnostics),
|
|
)
|
|
return connected_h, ThermofluidClosureSuccess.from_iteration(
|
|
time,
|
|
coupling_deltas[-1],
|
|
)
|
|
|
|
@profile_phase("simulation.refresh", minimum_mode="audit")
|
|
def _refresh_dynamic_components(self) -> None:
|
|
for component in self.dynamic_components:
|
|
component.refresh_thermodynamic_ports()
|
|
|
|
@profile_phase("simulation.derivatives", minimum_mode="audit")
|
|
def _state_derivatives(
|
|
self,
|
|
connected_h: dict[str, dict[str, float]],
|
|
) -> list[float]:
|
|
if self._activity_tracker is not None:
|
|
self._activity_tracker.record_phase("state_derivatives")
|
|
return self.pneumatic_storage_reducer.coupled_derivatives(
|
|
self.mechanical_state_reducer.state_derivatives(connected_h)
|
|
)
|
|
|
|
def consistent_initial_state_vector(self, time: float = 0.0) -> list[float]:
|
|
state = self.initial_state_vector()
|
|
self.apply_state_vector(state)
|
|
self._close_current_state(time)
|
|
return state
|
|
|
|
@profile_phase("simulation.rhs", minimum_mode="audit")
|
|
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
|
self.apply_state_vector(state_vector)
|
|
connected_h = self._close_current_state(
|
|
_time,
|
|
record_rhs_outcome=True,
|
|
)
|
|
derivatives = self._state_derivatives(connected_h)
|
|
provider = self._ode_tangent_provider
|
|
if provider is not None:
|
|
provider.record_primal(_time, state_vector, connected_h)
|
|
return derivatives
|
|
|
|
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
|
for component in self.network.components.values():
|
|
for relative_key, value in component.result_values().items():
|
|
series.setdefault(
|
|
f"{component.name}.{relative_key}", []
|
|
).append(value)
|
|
|
|
@with_property_cache
|
|
def simulate(
|
|
self,
|
|
config: SolveIVPConfig,
|
|
*,
|
|
sample_step: float,
|
|
progress_callback: SimulationProgressCallback | None = None,
|
|
cancel_check: SimulationCancellationCheck | None = None,
|
|
activity_tracker: SolverActivityTracker | None = None,
|
|
) -> GenericSimulationResult:
|
|
previous_activity_tracker = self._activity_tracker
|
|
self._activity_tracker = activity_tracker
|
|
try:
|
|
return self._simulate(
|
|
config,
|
|
sample_step=sample_step,
|
|
progress_callback=progress_callback,
|
|
cancel_check=cancel_check,
|
|
activity_tracker=activity_tracker,
|
|
)
|
|
finally:
|
|
self._activity_tracker = previous_activity_tracker
|
|
|
|
def _simulate(
|
|
self,
|
|
config: SolveIVPConfig,
|
|
*,
|
|
sample_step: float,
|
|
progress_callback: SimulationProgressCallback | None = None,
|
|
cancel_check: SimulationCancellationCheck | None = None,
|
|
activity_tracker: SolverActivityTracker | None = None,
|
|
) -> GenericSimulationResult:
|
|
if activity_tracker is not None:
|
|
activity_tracker.record_phase("initializing", config.t_start)
|
|
last_reported_progress = -1.0
|
|
last_reported_phase = ""
|
|
|
|
def report_progress(
|
|
progress: float,
|
|
phase: str,
|
|
*,
|
|
force: bool = False,
|
|
) -> None:
|
|
nonlocal last_reported_phase, last_reported_progress
|
|
if progress_callback is None:
|
|
return
|
|
bounded_progress = min(1.0, max(0.0, progress))
|
|
if (
|
|
force
|
|
or phase != last_reported_phase
|
|
or bounded_progress - last_reported_progress >= 0.0025
|
|
):
|
|
last_reported_phase = phase
|
|
last_reported_progress = max(
|
|
last_reported_progress,
|
|
bounded_progress,
|
|
)
|
|
progress_callback(last_reported_progress, phase)
|
|
|
|
report_progress(0.0, "initializing", force=True)
|
|
with performance_span("simulation.sample_initialization"):
|
|
integration_config = config
|
|
mechanical_tolerance_plan = None
|
|
if isinstance(config.atol, (int, float)):
|
|
mechanical_tolerance_plan = (
|
|
self.mechanical_state_reducer.absolute_tolerance_plan(
|
|
float(config.atol),
|
|
mode=(
|
|
None
|
|
if config.method == "BDF"
|
|
else "legacy"
|
|
),
|
|
)
|
|
)
|
|
integration_config = replace(
|
|
config,
|
|
atol=list(mechanical_tolerance_plan.values),
|
|
)
|
|
t_eval = simulation_sample_times(config, sample_step)
|
|
signal_event_times = self.signal_resolver.event_times(
|
|
config.t_start,
|
|
config.t_stop,
|
|
)
|
|
initial_state = self.consistent_initial_state_vector(config.t_start)
|
|
jac_sparsity = (
|
|
self.jacobian_sparsity()
|
|
if integration_config.method in {"BDF", "Radau"}
|
|
else None
|
|
)
|
|
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
|
|
furthest_solver_time = max(furthest_solver_time, float(time))
|
|
time_fraction = (
|
|
(furthest_solver_time - config.t_start) / duration
|
|
if duration > 0.0
|
|
else 1.0
|
|
)
|
|
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)
|
|
|
|
jacobian = None
|
|
jacobian_fallback_reason: str | None = None
|
|
tangent_compilation: ThreePistonTangentCompilation | None = None
|
|
selected_tangent_provider: ThreePistonTangentProvider | None = None
|
|
self._ode_tangent_provider = None
|
|
requested_jacobian_mode = (
|
|
_requested_ode_jacobian_mode()
|
|
if jac_sparsity is not None
|
|
else "scipy"
|
|
)
|
|
if (
|
|
jac_sparsity is not None
|
|
and requested_jacobian_mode
|
|
in {"optimized", "hybrid", "semi-analytic"}
|
|
):
|
|
state_count = int(jac_sparsity.shape[0])
|
|
if (
|
|
requested_jacobian_mode == "hybrid"
|
|
and not self.pressure_flow_solver.causal_fast_path_enabled
|
|
):
|
|
jacobian_fallback_reason = "causalAlgebraicExecutionUnavailable"
|
|
elif int(jac_sparsity.nnz) >= state_count * state_count:
|
|
jacobian_fallback_reason = "denseStateDependencyPattern"
|
|
else:
|
|
exact_columns = None
|
|
if requested_jacobian_mode == "semi-analytic":
|
|
tangent_compilation = (
|
|
compile_supported_piston_tangent_provider(self)
|
|
)
|
|
if tangent_compilation.eligible:
|
|
provider = tangent_compilation.provider
|
|
if provider is None:
|
|
raise RuntimeError(
|
|
"An eligible tangent compilation has no provider."
|
|
)
|
|
selected_tangent_provider = provider
|
|
exact_columns = (
|
|
tangent_compilation.columns,
|
|
provider,
|
|
)
|
|
else:
|
|
jacobian_fallback_reason = (
|
|
f"semiAnalytic:{tangent_compilation.reason}"
|
|
)
|
|
|
|
if (
|
|
requested_jacobian_mode != "semi-analytic"
|
|
or tangent_compilation is not None
|
|
and tangent_compilation.eligible
|
|
):
|
|
def evaluate_jacobian_rhs(time, state):
|
|
if cancel_check is not None and cancel_check():
|
|
raise IntegrationCancelled
|
|
if activity_tracker is not None:
|
|
activity_tracker.record_rhs(float(time))
|
|
return monitored_rhs(
|
|
time,
|
|
[float(value) for value in state],
|
|
)
|
|
|
|
try:
|
|
jacobian = SparseSecantJacobian(
|
|
evaluate_jacobian_rhs,
|
|
jac_sparsity,
|
|
integration_config.atol,
|
|
exact_rows=self._exact_ode_jacobian_rows(),
|
|
exact_columns=exact_columns,
|
|
max_consecutive_reuses=(
|
|
1
|
|
if requested_jacobian_mode == "hybrid"
|
|
else 0
|
|
),
|
|
)
|
|
self._ode_tangent_provider = (
|
|
selected_tangent_provider
|
|
)
|
|
except SparseJacobianCompatibilityError as exc:
|
|
jacobian_fallback_reason = (
|
|
f"scipyCompatibility:{type(exc).__name__}"
|
|
)
|
|
|
|
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
|
|
|
|
try:
|
|
solution = integrate_ode(
|
|
rhs=monitored_rhs,
|
|
initial_state=initial_state,
|
|
config=integration_config,
|
|
t_eval=t_eval,
|
|
cancel_check=cancel_check,
|
|
accepted_step_callback=(
|
|
report_solver_time if cancel_check is not None else None
|
|
),
|
|
breakpoints=signal_event_times,
|
|
state_transition_handler=(
|
|
handle_state_transition
|
|
if self.mechanical_state_reducer.has_state_events
|
|
else None
|
|
),
|
|
jac_sparsity=jac_sparsity,
|
|
jac=jacobian,
|
|
recoverable_trial_retries=True,
|
|
activity_tracker=activity_tracker,
|
|
)
|
|
finally:
|
|
self._ode_tangent_provider = None
|
|
if isinstance(solution, ODESolution):
|
|
run_status: SimulationRunStatus = solution.status
|
|
integration_error = solution.error
|
|
else:
|
|
run_status = "completed" if bool(solution.success) else "failed"
|
|
integration_error = None
|
|
result_message = str(solution.message)
|
|
postprocess_progress = (
|
|
1.0
|
|
if run_status == "completed"
|
|
else max(0.0, last_reported_progress)
|
|
)
|
|
report_progress(postprocess_progress, "postprocessing", force=True)
|
|
if activity_tracker is not None:
|
|
activity_tracker.record_phase(
|
|
"postprocessing",
|
|
furthest_solver_time,
|
|
)
|
|
times = [float(value) for value in solution.t]
|
|
if isinstance(solution, ODESolution):
|
|
solver_segment_diagnostics = [
|
|
segment.as_dict() for segment in solution.solver_segments
|
|
]
|
|
else:
|
|
solver_segment_diagnostics = [
|
|
{
|
|
"startTime": float(config.t_start),
|
|
"requestedStopTime": float(config.t_stop),
|
|
"simulatedUntil": times[-1] if times else float(config.t_start),
|
|
"nfev": int(getattr(solution, "nfev", 0)),
|
|
"njev": int(getattr(solution, "njev", 0)),
|
|
"nlu": int(getattr(solution, "nlu", 0)),
|
|
"acceptedStepCount": 0,
|
|
"solverStartCount": 1,
|
|
"stateTransitionCount": 0,
|
|
"recoverableRetryCount": 0,
|
|
}
|
|
]
|
|
if jacobian is not None:
|
|
direct_jacobian = jacobian.diagnostics()
|
|
solver_segment_diagnostics[0].update(
|
|
{
|
|
"jacobianEvaluationCount": int(
|
|
direct_jacobian["jacobianEvaluationCount"]
|
|
),
|
|
"jacobianFullBuildCount": int(
|
|
direct_jacobian["fullBuildCount"]
|
|
),
|
|
"jacobianSecantReuseCount": int(
|
|
direct_jacobian["secantReuseCount"]
|
|
),
|
|
"jacobianAuditFailureCount": int(
|
|
direct_jacobian["auditFailureCount"]
|
|
),
|
|
"finiteDifferenceRhsEvaluationCount": int(
|
|
direct_jacobian[
|
|
"finiteDifferenceRhsEvaluationCount"
|
|
]
|
|
),
|
|
"jacobianBaseRhsEvaluationCount": int(
|
|
direct_jacobian["baseRhsEvaluationCount"]
|
|
),
|
|
"jacobianJvAuditRhsEvaluationCount": int(
|
|
direct_jacobian["jvAuditEvaluationCount"]
|
|
),
|
|
"exactColumnBuildCount": int(
|
|
direct_jacobian["exactColumnBuildCount"]
|
|
),
|
|
"exactColumnFallbackCount": int(
|
|
direct_jacobian["exactColumnFallbackCount"]
|
|
),
|
|
"jacobianAssemblySeconds": float(
|
|
direct_jacobian["assemblySeconds"]
|
|
),
|
|
}
|
|
)
|
|
solver_total_keys = (
|
|
"nfev",
|
|
"njev",
|
|
"nlu",
|
|
"acceptedStepCount",
|
|
"solverStartCount",
|
|
"stateTransitionCount",
|
|
"recoverableRetryCount",
|
|
)
|
|
solver_totals = {
|
|
key: sum(int(segment[key]) for segment in solver_segment_diagnostics)
|
|
for key in solver_total_keys
|
|
}
|
|
jacobian_work_keys = (
|
|
"jacobianEvaluationCount",
|
|
"jacobianFullBuildCount",
|
|
"jacobianSecantReuseCount",
|
|
"jacobianAuditFailureCount",
|
|
"finiteDifferenceRhsEvaluationCount",
|
|
"jacobianBaseRhsEvaluationCount",
|
|
"jacobianJvAuditRhsEvaluationCount",
|
|
"exactColumnBuildCount",
|
|
"exactColumnFallbackCount",
|
|
"jacobianAssemblySeconds",
|
|
)
|
|
for key in jacobian_work_keys:
|
|
if any(key in segment for segment in solver_segment_diagnostics):
|
|
solver_totals[key] = sum(
|
|
segment.get(key, 0)
|
|
for segment in solver_segment_diagnostics
|
|
)
|
|
jacobian_diagnostics = (
|
|
self.jacobian_sparsity_diagnostics()
|
|
if integration_config.method in {"BDF", "Radau"}
|
|
else None
|
|
)
|
|
runtime_jacobian_diagnostics: dict[str, object] | None = None
|
|
if jacobian_diagnostics is not None:
|
|
color_group_count = int(jacobian_diagnostics["colorGroupCount"])
|
|
if jacobian is None:
|
|
for segment in solver_segment_diagnostics:
|
|
segment["finiteDifferenceRhsEstimate"] = (
|
|
int(segment["njev"]) * color_group_count
|
|
)
|
|
runtime_jacobian_diagnostics = {
|
|
"mode": "scipySparseFiniteDifference",
|
|
"fallbackReason": jacobian_fallback_reason,
|
|
"jacobianEvaluationCount": int(solver_totals["njev"]),
|
|
"fullBuildCount": int(solver_totals["njev"]),
|
|
"finiteDifferenceRhsEstimateIsExact": False,
|
|
}
|
|
else:
|
|
for segment in solver_segment_diagnostics:
|
|
segment["finiteDifferenceRhsEstimate"] = int(
|
|
segment.get("finiteDifferenceRhsEvaluationCount", 0)
|
|
) + int(
|
|
segment.get("jacobianJvAuditRhsEvaluationCount", 0)
|
|
)
|
|
runtime_jacobian_diagnostics = dict(jacobian.diagnostics())
|
|
runtime_jacobian_diagnostics.update(
|
|
{
|
|
"fallbackReason": jacobian_fallback_reason,
|
|
"finiteDifferenceRhsEstimateIsExact": True,
|
|
}
|
|
)
|
|
if tangent_compilation is not None:
|
|
runtime_jacobian_diagnostics["tangentCompilation"] = (
|
|
tangent_compilation.diagnostics()
|
|
)
|
|
if tangent_compilation.eligible and jacobian is not None:
|
|
runtime_jacobian_diagnostics["mode"] = (
|
|
"semiAnalyticExactColumns"
|
|
)
|
|
exact_builds = int(
|
|
runtime_jacobian_diagnostics[
|
|
"exactColumnBuildCount"
|
|
]
|
|
)
|
|
exact_fallbacks = int(
|
|
runtime_jacobian_diagnostics[
|
|
"exactColumnFallbackCount"
|
|
]
|
|
)
|
|
if exact_builds == 0 and exact_fallbacks == 0:
|
|
effective_mode = "notEvaluated"
|
|
elif exact_builds == 0:
|
|
effective_mode = "numericalFallbackOnly"
|
|
elif exact_fallbacks:
|
|
effective_mode = "mixedExactAndNumericalFallback"
|
|
else:
|
|
effective_mode = "exactColumns"
|
|
runtime_jacobian_diagnostics["effectiveMode"] = (
|
|
effective_mode
|
|
)
|
|
if exact_fallbacks:
|
|
runtime_jacobian_diagnostics[
|
|
"runtimeFallbackReason"
|
|
] = runtime_jacobian_diagnostics[
|
|
"lastExactColumnFallbackReason"
|
|
]
|
|
solver_totals["finiteDifferenceRhsEstimate"] = sum(
|
|
int(segment["finiteDifferenceRhsEstimate"])
|
|
for segment in solver_segment_diagnostics
|
|
)
|
|
if jacobian is None:
|
|
solver_totals["jacobianRhsEvaluationCountEstimate"] = (
|
|
int(solver_totals["finiteDifferenceRhsEstimate"])
|
|
+ int(solver_totals["njev"])
|
|
)
|
|
else:
|
|
solver_totals["jacobianRhsEvaluationCount"] = (
|
|
int(
|
|
solver_totals.get(
|
|
"finiteDifferenceRhsEvaluationCount",
|
|
0,
|
|
)
|
|
)
|
|
+ int(
|
|
solver_totals.get(
|
|
"jacobianBaseRhsEvaluationCount",
|
|
0,
|
|
)
|
|
)
|
|
+ int(
|
|
solver_totals.get(
|
|
"jacobianJvAuditRhsEvaluationCount",
|
|
0,
|
|
)
|
|
)
|
|
)
|
|
|
|
with performance_span("simulation.postprocessing"):
|
|
series: dict[str, list[float]] = {"time": []}
|
|
postprocessing_error: Exception | None = None
|
|
self.mechanical_state_reducer.reset_constraint_modes()
|
|
for time_index in range(len(times)):
|
|
if (
|
|
run_status == "completed"
|
|
and cancel_check is not None
|
|
and cancel_check()
|
|
):
|
|
run_status = "cancelled"
|
|
result_message = (
|
|
"Simulation was stopped while preparing partial results."
|
|
)
|
|
break
|
|
state = [
|
|
float(solution.y[state_index][time_index])
|
|
for state_index in range(len(solution.y))
|
|
]
|
|
try:
|
|
self.apply_state_vector(state)
|
|
self._close_current_state(times[time_index])
|
|
self._append_current_state(series)
|
|
series["time"].append(times[time_index])
|
|
if activity_tracker is not None:
|
|
activity_tracker.record_phase(
|
|
"postprocessing",
|
|
times[time_index],
|
|
)
|
|
except Exception as exc:
|
|
run_status = "failed"
|
|
result_message = str(exc)
|
|
postprocessing_error = exc
|
|
break
|
|
if len(series["time"]) < 2:
|
|
if postprocessing_error is not None:
|
|
raise postprocessing_error
|
|
if integration_error is not None:
|
|
raise integration_error
|
|
|
|
with performance_span("simulation.result_assembly"):
|
|
final = {
|
|
key: values[-1]
|
|
for key, values in series.items()
|
|
if key != "time" and values
|
|
}
|
|
diagnostics = {
|
|
"integration": {
|
|
"method": integration_config.method,
|
|
"mechanicalAbsoluteTolerance": (
|
|
mechanical_tolerance_plan.as_dict()
|
|
if mechanical_tolerance_plan is not None
|
|
else {
|
|
"mode": "callerVector",
|
|
"stateCount": len(initial_state),
|
|
}
|
|
),
|
|
"jacobianSparsity": jacobian_diagnostics,
|
|
"jacobian": runtime_jacobian_diagnostics,
|
|
"segmentCount": len(solver_segment_diagnostics),
|
|
"segments": solver_segment_diagnostics,
|
|
"totals": solver_totals,
|
|
},
|
|
"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._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,
|
|
"maxThermofluidIterations": self.max_thermofluid_iterations,
|
|
"thermofluidClosure": {
|
|
**self._thermofluid_closure_diagnostics.as_dict(),
|
|
"transaction": (
|
|
self._thermofluid_transaction_plan.diagnostics()
|
|
),
|
|
},
|
|
"last": (
|
|
self.stream_resolver.last_diagnostics.as_dict()
|
|
if self.stream_resolver.last_diagnostics is not None
|
|
else None
|
|
),
|
|
},
|
|
"signal": {
|
|
"propagations": self.signal_propagation_count,
|
|
"eventTimes": list(signal_event_times),
|
|
"last": (
|
|
self.signal_resolver.last_diagnostics.as_dict()
|
|
if self.signal_resolver.last_diagnostics is not None
|
|
else None
|
|
),
|
|
},
|
|
"pneumaticVolume": {
|
|
"propagations": self.pneumatic_volume_propagation_count,
|
|
"last": (
|
|
self.pneumatic_volume_resolver.last_diagnostics.as_dict()
|
|
if self.pneumatic_volume_resolver.last_diagnostics is not None
|
|
else None
|
|
),
|
|
},
|
|
"stateCount": len(initial_state),
|
|
"sampleCount": len(series["time"]),
|
|
}
|
|
variables = tuple(
|
|
variable
|
|
for variable in self.network.result_variable_metadata()
|
|
if variable.key in series
|
|
)
|
|
final_simulated_time = (
|
|
float(series["time"][-1])
|
|
if series["time"]
|
|
else float(config.t_start)
|
|
)
|
|
if activity_tracker is not None:
|
|
activity_tracker.record_phase(
|
|
"complete" if run_status == "completed" else run_status,
|
|
final_simulated_time,
|
|
)
|
|
diagnostics["activity"] = (
|
|
activity_tracker.snapshot().as_dict()
|
|
)
|
|
report_progress(
|
|
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
|
"complete" if run_status == "completed" else run_status,
|
|
force=True,
|
|
)
|
|
return GenericSimulationResult(
|
|
success=run_status == "completed" and bool(solution.success),
|
|
status=run_status,
|
|
message=result_message,
|
|
simulated_until=(
|
|
final_simulated_time
|
|
),
|
|
requested_stop_time=float(config.t_stop),
|
|
variables=variables,
|
|
series=series,
|
|
final=final,
|
|
diagnostics=diagnostics,
|
|
)
|