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, )