from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from math import floor, isfinite from typing import Literal from app.simulation.core.base import DynamicComponent from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.solvers.algebraic import PressureFlowSolver from app.simulation.solvers.mechanical import MechanicalStateReducer from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode from app.simulation.solvers.signal import SignalResolver from app.simulation.solvers.stream import StreamResolver from app.simulation.systems.network import Endpoint, SimulationNetwork SimulationProgressCallback = Callable[[float, str], None] SimulationCancellationCheck = Callable[[], bool] SimulationRunStatus = Literal["completed", "cancelled", "failed"] @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 @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, definition.name) for component in network.components.values() for definition in component.port_definitions if definition.kind == "physical" } 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.", ) ) adjacency = {name: set() for name in network.components} for connection in network.connections: 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, set[str]] = {} for endpoint, component_name in storage_ports.items(): storages_by_group.setdefault(effort_groups.find(endpoint), set()).add( component_name ) for storage_names in storages_by_group.values(): if len(storage_names) > 1: 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, *, max_points: int = 10001, ) -> list[float]: if step <= 0.0 or not isfinite(step): raise ValueError("Simulation sample step must be finite and greater than zero.") duration = config.t_stop - config.t_start if duration <= 0.0: raise ValueError("Simulation stop time must be greater than start time.") interval_count = int(floor(duration / step + 1e-12)) times = [config.t_start + index * step for index in range(interval_count + 1)] if times[-1] < config.t_stop - 1e-12: times.append(config.t_stop) else: times[-1] = config.t_stop if len(times) > max_points: raise ValueError( f"Simulation requests {len(times)} samples; the limit is {max_points}." ) return times class GenericFluidSystem: """Topology-driven, semi-explicit fluid simulation for registered components.""" 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.pressure_flow_solver = PressureFlowSolver(network) self.pneumatic_volume_resolver = PneumaticVolumeResolver(network) self.signal_resolver = SignalResolver(network) self.stream_resolver = StreamResolver(network) self.algebraic_solve_count = 0 self.max_algebraic_residual = 0.0 self.max_algebraic_evaluations = 0 self.max_stream_iterations = 0 self.signal_propagation_count = 0 self.pneumatic_volume_propagation_count = 0 def initial_state_vector(self) -> list[float]: return self.mechanical_state_reducer.initial_state_vector() def apply_state_vector(self, values: list[float]) -> None: self.mechanical_state_reducer.apply_state_vector(values) def _close_current_state(self, time: float) -> dict[str, dict[str, float]]: signal = self.signal_resolver.solve(time) self.signal_propagation_count += signal.propagated for component in self.dynamic_components: component.refresh_thermodynamic_ports() algebraic = self.pressure_flow_solver.solve() pneumatic_volume = self.pneumatic_volume_resolver.solve() self.pneumatic_volume_propagation_count += pneumatic_volume.propagated if pneumatic_volume.propagated: for component in self.dynamic_components: component.refresh_thermodynamic_ports() algebraic = self.pressure_flow_solver.solve() stream, connected_h = self.stream_resolver.solve() self.mechanical_state_reducer.update_constraint_accelerations() self.algebraic_solve_count += 1 + int(bool(pneumatic_volume.propagated)) self.max_algebraic_residual = max( self.max_algebraic_residual, algebraic.max_scaled_residual, ) self.max_algebraic_evaluations = max( self.max_algebraic_evaluations, algebraic.evaluations, ) self.max_stream_iterations = max( self.max_stream_iterations, stream.iterations, ) return 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 def rhs(self, _time: float, state_vector: list[float]) -> list[float]: self.apply_state_vector(state_vector) connected_h = self._close_current_state(_time) return self.mechanical_state_reducer.state_derivatives(connected_h) 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) def simulate( self, config: SolveIVPConfig, *, sample_step: float, progress_callback: SimulationProgressCallback | None = None, cancel_check: SimulationCancellationCheck | None = None, ) -> GenericSimulationResult: 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) t_eval = simulation_sample_times(config, sample_step) signal_event_times = self.signal_resolver.event_times( config.t_start, config.t_stop, ) initial_state = self.consistent_initial_state_vector(config.t_start) report_progress(0.0, "integrating", force=True) duration = config.t_stop - config.t_start furthest_solver_time = config.t_start 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]: if cancel_check is None: report_solver_time(time) return self.rhs(time, state_vector) solution = integrate_ode( rhs=monitored_rhs, initial_state=initial_state, config=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=( self.mechanical_state_reducer.state_transition if self.mechanical_state_reducer.has_state_events else 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) times = [float(value) for value in solution.t] series: dict[str, list[float]] = {"time": []} postprocessing_error: Exception | None = None self.mechanical_state_reducer.reset_constraint_modes() for time_index in range(len(times)): if ( run_status == "completed" and cancel_check is not None and cancel_check() ): run_status = "cancelled" result_message = "Simulation was stopped while preparing partial results." break state = [ float(solution.y[state_index][time_index]) for state_index in range(len(solution.y)) ] try: self.apply_state_vector(state) self._close_current_state(times[time_index]) self._append_current_state(series) series["time"].append(times[time_index]) except Exception as exc: run_status = "failed" result_message = str(exc) postprocessing_error = exc break if len(series["time"]) < 2: if postprocessing_error is not None: raise postprocessing_error if integration_error is not None: raise integration_error final = { key: values[-1] for key, values in series.items() if key != "time" and values } diagnostics = { "pressureFlow": { "solveCount": self.algebraic_solve_count, "maxScaledResidual": self.max_algebraic_residual, "maxEvaluationsPerSolve": self.max_algebraic_evaluations, "last": ( self.pressure_flow_solver.last_diagnostics.as_dict() if self.pressure_flow_solver.last_diagnostics is not None else None ), }, "stream": { "maxIterationsPerSolve": self.max_stream_iterations, "last": ( self.stream_resolver.last_diagnostics.as_dict() if self.stream_resolver.last_diagnostics is not None else None ), }, "signal": { "propagations": self.signal_propagation_count, "eventTimes": list(signal_event_times), "last": ( self.signal_resolver.last_diagnostics.as_dict() if self.signal_resolver.last_diagnostics is not None else None ), }, "pneumaticVolume": { "propagations": self.pneumatic_volume_propagation_count, "last": ( self.pneumatic_volume_resolver.last_diagnostics.as_dict() if self.pneumatic_volume_resolver.last_diagnostics is not None else None ), }, "stateCount": len(initial_state), "sampleCount": len(series["time"]), } variables = tuple( variable for variable in self.network.result_variable_metadata() if variable.key in series ) report_progress( 1.0 if run_status == "completed" else max(0.0, last_reported_progress), "complete" if run_status == "completed" else run_status, force=True, ) return GenericSimulationResult( success=run_status == "completed" and bool(solution.success), status=run_status, message=result_message, simulated_until=( float(series["time"][-1]) if series["time"] else float(config.t_start) ), requested_stop_time=float(config.t_stop), variables=variables, series=series, final=final, diagnostics=diagnostics, )