From e6ee51ff5c197075305a61ea867a7ebc194d06b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Wed, 25 Mar 2026 13:05:07 +0100 Subject: [PATCH] feat(solver): add structured failure events to Solution Replace bare success=False returns with typed failure events: - OutsideCapacityEvent: rate above stonewall or below surge, with required source_id (ProcessUnitId) and optional actual/boundary values (m3/h) - TargetNotAchievableEvent: pressure target out of achievable range, with achievable/target values (bara) and optional source_id (ProcessSystemId) Compressor passes its process_unit_id (required) when raising RateTooHighError/ RateTooLowError. OutletPressureSolver takes a required process_system_id such that TargetNotAchievableEvent in multi pressure system can trace which segment failed. --- .../entities/process_units/compressor.py | 12 ++- .../anti_surge/individual_asv.py | 35 +++++-- .../process_solver/multi_pressure_solver.py | 18 +++- .../process_solver/outlet_pressure_solver.py | 29 +++++- .../pressure_control/common_asv.py | 11 ++- .../pressure_control/individual_asv.py | 17 +++- .../domain/process/process_solver/solver.py | 38 +++++++- .../solvers/recirculation_solver.py | 41 ++++++++- .../process_solver/solvers/speed_solver.py | 50 +++++++++- .../solvers/upstream_choke_solver.py | 20 +++- .../process/process_system/process_error.py | 23 ++++- .../domain/process/process_solver/conftest.py | 4 +- .../solvers/test_recirculation_solver.py | 4 +- .../solvers/test_upstream_choke_solver.py | 3 +- .../test_multi_pressure_solver.py | 92 +++++++++++++++++++ 15 files changed, 362 insertions(+), 35 deletions(-) diff --git a/src/libecalc/domain/process/entities/process_units/compressor.py b/src/libecalc/domain/process/entities/process_units/compressor.py index 30fcd15bde..382d5054a8 100644 --- a/src/libecalc/domain/process/entities/process_units/compressor.py +++ b/src/libecalc/domain/process/entities/process_units/compressor.py @@ -30,9 +30,17 @@ def get_id(self) -> ProcessUnitId: def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: actual_rate = inlet_stream.volumetric_rate_m3_per_hour if actual_rate < self.minimum_flow_rate: - raise RateTooLowError() + raise RateTooLowError( + actual_rate=actual_rate, + boundary_rate=self.minimum_flow_rate, + process_unit_id=self._id, + ) if actual_rate > self.maximum_flow_rate: - raise RateTooHighError() + raise RateTooHighError( + actual_rate=actual_rate, + boundary_rate=self.maximum_flow_rate, + process_unit_id=self._id, + ) chart_curve_at_given_speed = self.compressor_chart.get_curve_by_speed(speed=self.speed) if chart_curve_at_given_speed is not None: diff --git a/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py b/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py index bb00a22766..a69f66766c 100644 --- a/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py +++ b/src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py @@ -5,7 +5,11 @@ from libecalc.domain.process.process_solver.anti_surge.anti_surge_strategy import AntiSurgeStrategy from libecalc.domain.process.process_solver.configuration import Configuration from libecalc.domain.process.process_solver.process_runner import ProcessRunner -from libecalc.domain.process.process_solver.solver import Solution +from libecalc.domain.process.process_solver.solver import ( + OutsideCapacityEvent, + Solution, + SolverFailureStatus, +) from libecalc.domain.process.process_solver.solvers.recirculation_solver import RecirculationConfiguration from libecalc.domain.process.process_system.process_error import RateTooHighError from libecalc.domain.process.process_system.process_system import ProcessSystemId @@ -62,12 +66,29 @@ def apply(self, inlet_stream: FluidStream) -> Solution[Sequence[Configuration[Re for loop_id, compressor in zip(self._recirculation_loop_ids, self._compressors, strict=True): try: inlet_stream_compressor = self._simulator.run(inlet_stream=inlet_stream, to_id=compressor.get_id()) - except RateTooHighError: - return Solution(success=False, configuration=configurations) - if inlet_stream_compressor.standard_rate_sm3_per_day > compressor.get_maximum_standard_rate( - inlet_stream_compressor - ): - return Solution(success=False, configuration=configurations) + except RateTooHighError as e: + return Solution( + success=False, + configuration=configurations, + failure_event=OutsideCapacityEvent( + status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE, + actual_value=e.actual_rate, + boundary_value=e.boundary_rate, + source_id=e.process_unit_id, + ), + ) + max_actual_rate = compressor.maximum_flow_rate + if inlet_stream_compressor.volumetric_rate_m3_per_hour > max_actual_rate: + return Solution( + success=False, + configuration=configurations, + failure_event=OutsideCapacityEvent( + status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE, + actual_value=inlet_stream_compressor.volumetric_rate_m3_per_hour, + boundary_value=max_actual_rate, + source_id=compressor.get_id(), + ), + ) boundary = compressor.get_recirculation_range(inlet_stream=inlet_stream_compressor) configuration: Configuration[RecirculationConfiguration] = Configuration( simulation_unit_id=loop_id, diff --git a/src/libecalc/domain/process/process_solver/multi_pressure_solver.py b/src/libecalc/domain/process/process_solver/multi_pressure_solver.py index 9844944d06..6bd3ed8df3 100644 --- a/src/libecalc/domain/process/process_solver/multi_pressure_solver.py +++ b/src/libecalc/domain/process/process_solver/multi_pressure_solver.py @@ -9,7 +9,12 @@ DownstreamChokePressureControlStrategy, ) from libecalc.domain.process.process_solver.pressure_control.upstream_choke import UpstreamChokePressureControlStrategy -from libecalc.domain.process.process_solver.solver import Solution +from libecalc.domain.process.process_solver.solver import ( + Solution, + SolverFailureEvent, + SolverFailureStatus, + TargetNotAchievableEvent, +) from libecalc.domain.process.process_solver.solvers.speed_solver import SpeedConfiguration from libecalc.domain.process.value_objects.fluid_stream import FluidStream @@ -89,6 +94,7 @@ def find_solution( current_inlet = inlet_stream overall_success = True + failure_event: SolverFailureEvent | None = None for segment, target in zip(self._segments, pressure_targets): segment.runner.apply_configuration(shaft_config) @@ -112,12 +118,22 @@ def find_solution( outlet = segment.runner.run(inlet_stream=current_inlet) if not pressure_control_solution.success: overall_success = False + if failure_event is None: + failure_event = pressure_control_solution.failure_event elif outlet.pressure_bara < target: overall_success = False + if failure_event is None: + failure_event = TargetNotAchievableEvent( + status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET, + achievable_value=outlet.pressure_bara, + target_value=target.value, + source_id=segment.process_system_id, + ) current_inlet = outlet return Solution( success=overall_success, configuration=list(all_configurations.values()), + failure_event=failure_event, ) diff --git a/src/libecalc/domain/process/process_solver/outlet_pressure_solver.py b/src/libecalc/domain/process/process_solver/outlet_pressure_solver.py index cb3336ef13..06d0f39adf 100644 --- a/src/libecalc/domain/process/process_solver/outlet_pressure_solver.py +++ b/src/libecalc/domain/process/process_solver/outlet_pressure_solver.py @@ -9,7 +9,11 @@ from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy from libecalc.domain.process.process_solver.process_runner import ProcessRunner from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy -from libecalc.domain.process.process_solver.solver import Solution +from libecalc.domain.process.process_solver.solver import ( + Solution, + SolverFailureStatus, + TargetNotAchievableEvent, +) from libecalc.domain.process.process_solver.solvers.recirculation_solver import ( RecirculationConfiguration, ) @@ -38,6 +42,7 @@ class OutletPressureSolver: def __init__( self, shaft_id: ShaftId, + process_system_id: ProcessSystemId, runner: ProcessRunner, anti_surge_strategy: AntiSurgeStrategy, pressure_control_strategy: PressureControlStrategy, @@ -45,6 +50,7 @@ def __init__( speed_boundary: Boundary, ) -> None: self._shaft_id: Final = shaft_id + self._process_system_id: Final = process_system_id self._root_finding_strategy: Final = root_finding_strategy self._anti_surge_strategy: Final = anti_surge_strategy self._simulator: Final = runner @@ -69,6 +75,10 @@ def pressure_control_strategy(self) -> PressureControlStrategy: def shaft_id(self) -> ShaftId: return self._shaft_id + @property + def process_system_id(self) -> ProcessSystemId: + return self._process_system_id + def _get_initial_speed_boundary(self) -> Boundary: return self._speed_boundary @@ -135,7 +145,11 @@ def find_solution( ) if not self._anti_surge_solution.success: - return Solution(success=False, configuration=list(configurations.values())) + return Solution( + success=False, + configuration=list(configurations.values()), + failure_event=self._anti_surge_solution.failure_event, + ) outlet_at_chosen_speed = self._get_outlet_stream( inlet_stream=inlet_stream, @@ -146,6 +160,12 @@ def find_solution( return Solution( success=False, configuration=list(configurations.values()), + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET, + achievable_value=outlet_at_chosen_speed.pressure_bara, + target_value=pressure_constraint.value, + source_id=self._process_system_id, + ), ) pressure_control_solution = self._pressure_control_strategy.apply( @@ -156,7 +176,12 @@ def find_solution( for pressure_control_configuration in pressure_control_solution.configuration: configurations[pressure_control_configuration.simulation_unit_id] = pressure_control_configuration + failure_event = pressure_control_solution.failure_event + if isinstance(failure_event, TargetNotAchievableEvent) and failure_event.source_id is None: + failure_event = failure_event.with_source_id(self._process_system_id) + return Solution( success=pressure_control_solution.success, configuration=list(configurations.values()), + failure_event=failure_event, ) diff --git a/src/libecalc/domain/process/process_solver/pressure_control/common_asv.py b/src/libecalc/domain/process/process_solver/pressure_control/common_asv.py index 653cf07193..eb1922be6f 100644 --- a/src/libecalc/domain/process/process_solver/pressure_control/common_asv.py +++ b/src/libecalc/domain/process/process_solver/pressure_control/common_asv.py @@ -6,7 +6,11 @@ from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy from libecalc.domain.process.process_solver.process_runner import ProcessRunner from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy -from libecalc.domain.process.process_solver.solver import Solution +from libecalc.domain.process.process_solver.solver import ( + Solution, + SolverFailureStatus, + TargetNotAchievableEvent, +) from libecalc.domain.process.process_solver.solvers.downstream_choke_solver import ChokeConfiguration from libecalc.domain.process.process_solver.solvers.recirculation_solver import ( RecirculationConfiguration, @@ -61,6 +65,11 @@ def recirculation_func(config: RecirculationConfiguration) -> FluidStream: value=min_configuration, ) ], + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET, + achievable_value=min_pressure_stream.pressure_bara, + target_value=target_pressure.value, + ), ) solver = RecirculationSolver( diff --git a/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py b/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py index cd8c0b5673..4f77003c9d 100644 --- a/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py +++ b/src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py @@ -7,7 +7,11 @@ from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy from libecalc.domain.process.process_solver.process_runner import ProcessRunner from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy -from libecalc.domain.process.process_solver.solver import Solution +from libecalc.domain.process.process_solver.solver import ( + Solution, + SolverFailureStatus, + TargetNotAchievableEvent, +) from libecalc.domain.process.process_solver.solvers.downstream_choke_solver import ChokeConfiguration from libecalc.domain.process.process_solver.solvers.recirculation_solver import ( RecirculationConfiguration, @@ -57,6 +61,11 @@ def apply( return Solution( success=False, configuration=minimum_achievable_pressure_configurations, + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET, + achievable_value=minimum_achievable_pressure_stream.pressure_bara, + target_value=target_pressure.value, + ), ) n_stages = len(self._recirculation_loop_ids) @@ -100,6 +109,7 @@ def recirculation_func(config: RecirculationConfiguration): return Solution( success=False, configuration=configurations, + failure_event=solution.failure_event, ) self._simulator.apply_configurations(configurations) @@ -174,6 +184,11 @@ def apply( return Solution( success=False, configuration=minimum_achievable_pressure_configurations, + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET, + achievable_value=minimum_achievable_pressure_stream.pressure_bara, + target_value=target_pressure.value, + ), ) def get_outlet_stream(rate_fraction: float) -> FluidStream: diff --git a/src/libecalc/domain/process/process_solver/solver.py b/src/libecalc/domain/process/process_solver/solver.py index d5831f57ed..dcbdf76689 100644 --- a/src/libecalc/domain/process/process_solver/solver.py +++ b/src/libecalc/domain/process/process_solver/solver.py @@ -1,18 +1,52 @@ import abc +import dataclasses from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import Generic, TypeVar +from dataclasses import dataclass, field +from enum import Enum +from typing import Generic, Self, TypeVar from libecalc.domain.process.process_solver.configuration import Configuration, OperatingConfiguration, SimulationUnitId +from libecalc.domain.process.process_system.process_system import ProcessSystemId +from libecalc.domain.process.process_system.process_unit import ProcessUnitId from libecalc.domain.process.value_objects.fluid_stream import FluidStream TConfiguration = TypeVar("TConfiguration") +class SolverFailureStatus(str, Enum): + ABOVE_MAXIMUM_FLOW_RATE = "ABOVE_MAXIMUM_FLOW_RATE" + BELOW_MINIMUM_FLOW_RATE = "BELOW_MINIMUM_FLOW_RATE" + MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET = "MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET" + MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET = "MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET" + + +@dataclass +class OutsideCapacityEvent: + status: SolverFailureStatus + source_id: ProcessUnitId + actual_value: float | None = None + boundary_value: float | None = None + + +@dataclass +class TargetNotAchievableEvent: + status: SolverFailureStatus + achievable_value: float + target_value: float + source_id: ProcessSystemId | None = None + + def with_source_id(self, source_id: ProcessSystemId) -> Self: + return dataclasses.replace(self, source_id=source_id) + + +SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent + + @dataclass class Solution(Generic[TConfiguration]): success: bool configuration: TConfiguration + failure_event: SolverFailureEvent | None = field(default=None) def get_configuration( self: "Solution[Sequence[Configuration[OperatingConfiguration]]]", diff --git a/src/libecalc/domain/process/process_solver/solvers/recirculation_solver.py b/src/libecalc/domain/process/process_solver/solvers/recirculation_solver.py index 848b334fbd..5f06b5f415 100644 --- a/src/libecalc/domain/process/process_solver/solvers/recirculation_solver.py +++ b/src/libecalc/domain/process/process_solver/solvers/recirculation_solver.py @@ -5,7 +5,13 @@ from libecalc.domain.process.process_solver.configuration import RecirculationConfiguration from libecalc.domain.process.process_solver.float_constraint import FloatConstraint from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy -from libecalc.domain.process.process_solver.solver import Solution, Solver +from libecalc.domain.process.process_solver.solver import ( + OutsideCapacityEvent, + Solution, + Solver, + SolverFailureStatus, + TargetNotAchievableEvent, +) from libecalc.domain.process.process_system.process_error import RateTooHighError, RateTooLowError from libecalc.domain.process.value_objects.fluid_stream import FluidStream @@ -50,9 +56,18 @@ def bool_func(x: float, mode: Literal["minimize", "maximize"]) -> tuple[bool, bo boundary=self._recirculation_rate_boundary, func=lambda x: bool_func(x, mode="minimize"), ) - except RateTooHighError: + except RateTooHighError as e: # Flow is above stonewall at zero recirculation; adding recirculation cannot help. - return Solution(success=False, configuration=RecirculationConfiguration(recirculation_rate=minimum_rate)) + return Solution( + success=False, + configuration=RecirculationConfiguration(recirculation_rate=minimum_rate), + failure_event=OutsideCapacityEvent( + status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE, + actual_value=e.actual_rate, + boundary_value=e.boundary_rate, + source_id=e.process_unit_id, + ), + ) target_pressure = self._target_pressure if target_pressure is None: @@ -73,16 +88,32 @@ def bool_func(x: float, mode: Literal["minimize", "maximize"]) -> tuple[bool, bo minimum_outlet_stream = func(RecirculationConfiguration(recirculation_rate=minimum_rate)) if minimum_outlet_stream.pressure_bara <= target_pressure: # Highest possible pressure is too low + is_success = minimum_outlet_stream.pressure_bara == target_pressure return Solution( - success=minimum_outlet_stream.pressure_bara == target_pressure, + success=is_success, configuration=RecirculationConfiguration(recirculation_rate=minimum_rate), + failure_event=None + if is_success + else TargetNotAchievableEvent( + status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET, + achievable_value=minimum_outlet_stream.pressure_bara, + target_value=target_pressure.value, + ), ) maximum_outlet_stream = func(RecirculationConfiguration(recirculation_rate=maximum_rate)) if maximum_outlet_stream.pressure_bara >= target_pressure: # Lowest possible pressure is too high + is_success = maximum_outlet_stream.pressure_bara == self._target_pressure return Solution( - success=maximum_outlet_stream.pressure_bara == self._target_pressure, + success=is_success, configuration=RecirculationConfiguration(recirculation_rate=maximum_rate), + failure_event=None + if is_success + else TargetNotAchievableEvent( + status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET, + achievable_value=maximum_outlet_stream.pressure_bara, + target_value=target_pressure.value, + ), ) recirculation_rate = self._root_finding_strategy.find_root( diff --git a/src/libecalc/domain/process/process_solver/solvers/speed_solver.py b/src/libecalc/domain/process/process_solver/solvers/speed_solver.py index 747abfb984..dcc21375d9 100644 --- a/src/libecalc/domain/process/process_solver/solvers/speed_solver.py +++ b/src/libecalc/domain/process/process_solver/solvers/speed_solver.py @@ -4,8 +4,14 @@ from libecalc.domain.process.process_solver.boundary import Boundary from libecalc.domain.process.process_solver.configuration import SpeedConfiguration from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy -from libecalc.domain.process.process_solver.solver import Solution, Solver -from libecalc.domain.process.process_system.process_error import ProcessError, RateTooHighError, RateTooLowError +from libecalc.domain.process.process_solver.solver import ( + OutsideCapacityEvent, + Solution, + Solver, + SolverFailureStatus, + TargetNotAchievableEvent, +) +from libecalc.domain.process.process_system.process_error import RateTooHighError, RateTooLowError from libecalc.domain.process.value_objects.fluid_stream import FluidStream logger = logging.getLogger(__name__) @@ -31,15 +37,41 @@ def get_outlet_stream(speed: float) -> FluidStream: max_speed_configuration = SpeedConfiguration(speed=self._boundary.max) try: maximum_speed_outlet_stream = func(max_speed_configuration) - except ProcessError as e: + except RateTooHighError as e: + logger.debug(f"No solution found for maximum speed: {max_speed_configuration}", exc_info=e) + return Solution( + success=False, + configuration=max_speed_configuration, + failure_event=OutsideCapacityEvent( + status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE, + actual_value=e.actual_rate, + boundary_value=e.boundary_rate, + source_id=e.process_unit_id, + ), + ) + except RateTooLowError as e: logger.debug(f"No solution found for maximum speed: {max_speed_configuration}", exc_info=e) return Solution( success=False, configuration=max_speed_configuration, + failure_event=OutsideCapacityEvent( + status=SolverFailureStatus.BELOW_MINIMUM_FLOW_RATE, + actual_value=e.actual_rate, + boundary_value=e.boundary_rate, + source_id=e.process_unit_id, + ), ) if maximum_speed_outlet_stream.pressure_bara < self._target_pressure: - return Solution(success=False, configuration=SpeedConfiguration(self._boundary.max)) + return Solution( + success=False, + configuration=SpeedConfiguration(self._boundary.max), + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET, + achievable_value=maximum_speed_outlet_stream.pressure_bara, + target_value=self._target_pressure, + ), + ) try: minimum_speed_configuration = SpeedConfiguration(speed=self._boundary.min) @@ -66,7 +98,15 @@ def bool_speed_func(x: float): if minimum_speed_outlet_stream.pressure_bara > self._target_pressure: # Solution 2, target pressure is too low - return Solution(success=False, configuration=minimum_speed_configuration) + return Solution( + success=False, + configuration=minimum_speed_configuration, + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET, + achievable_value=minimum_speed_outlet_stream.pressure_bara, + target_value=self._target_pressure, + ), + ) assert ( minimum_speed_outlet_stream.pressure_bara diff --git a/src/libecalc/domain/process/process_solver/solvers/upstream_choke_solver.py b/src/libecalc/domain/process/process_solver/solvers/upstream_choke_solver.py index 796e6f38dd..c394d0990f 100644 --- a/src/libecalc/domain/process/process_solver/solvers/upstream_choke_solver.py +++ b/src/libecalc/domain/process/process_solver/solvers/upstream_choke_solver.py @@ -2,7 +2,12 @@ from libecalc.domain.process.process_solver.boundary import Boundary from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy -from libecalc.domain.process.process_solver.solver import Solution, Solver +from libecalc.domain.process.process_solver.solver import ( + Solution, + Solver, + SolverFailureStatus, + TargetNotAchievableEvent, +) from libecalc.domain.process.process_system.process_error import RateTooHighError from libecalc.domain.process.value_objects.fluid_stream import FluidStream @@ -45,9 +50,18 @@ def outlet_pressure(config: ChokeConfiguration) -> float: # Evaluate outlet pressure at maximum allowed upstream ΔP (within boundary). max_cfg = ChokeConfiguration(delta_pressure=self._delta_pressure_boundary.max) - if outlet_pressure(max_cfg) > self._target_pressure: + max_cfg_pressure = outlet_pressure(max_cfg) + if max_cfg_pressure > self._target_pressure: # If we are still above target even at max choking, then no solution exists within the boundary. - return Solution(success=False, configuration=max_cfg) + return Solution( + success=False, + configuration=max_cfg, + failure_event=TargetNotAchievableEvent( + status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET, + achievable_value=max_cfg_pressure, + target_value=self._target_pressure, + ), + ) pressure_change = self._root_finding_strategy.find_root( boundary=self._delta_pressure_boundary, diff --git a/src/libecalc/domain/process/process_system/process_error.py b/src/libecalc/domain/process/process_system/process_error.py index dc3f83ed6a..3cca77fe75 100644 --- a/src/libecalc/domain/process/process_system/process_error.py +++ b/src/libecalc/domain/process/process_system/process_error.py @@ -1,4 +1,5 @@ from libecalc.common.errors.exceptions import EcalcError +from libecalc.domain.process.process_system.process_unit import ProcessUnitId class ProcessError(EcalcError): @@ -13,10 +14,28 @@ def __init__(self, reason: str = "Operational point is outside capacity."): class RateTooLowError(OutsideCapacityError): - def __init__(self, reason: str = "Rate is too low."): + def __init__( + self, + process_unit_id: ProcessUnitId, + actual_rate: float | None = None, + boundary_rate: float | None = None, + reason: str = "Rate is too low.", + ): + self.actual_rate = actual_rate + self.boundary_rate = boundary_rate + self.process_unit_id = process_unit_id super().__init__(reason) class RateTooHighError(OutsideCapacityError): - def __init__(self, reason: str = "Rate is too high."): + def __init__( + self, + process_unit_id: ProcessUnitId, + actual_rate: float | None = None, + boundary_rate: float | None = None, + reason: str = "Rate is too high.", + ): + self.actual_rate = actual_rate + self.boundary_rate = boundary_rate + self.process_unit_id = process_unit_id super().__init__(reason) diff --git a/tests/libecalc/domain/process/process_solver/conftest.py b/tests/libecalc/domain/process/process_solver/conftest.py index 61bb7264f9..b8c08ab8f9 100644 --- a/tests/libecalc/domain/process/process_solver/conftest.py +++ b/tests/libecalc/domain/process/process_solver/conftest.py @@ -19,7 +19,7 @@ from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy from libecalc.domain.process.process_solver.pressure_control.upstream_choke import UpstreamChokePressureControlStrategy from libecalc.domain.process.process_solver.process_runner import ProcessRunner -from libecalc.domain.process.process_system.process_system import ProcessSystemId +from libecalc.domain.process.process_system.process_system import ProcessSystemId, create_process_system_id from libecalc.domain.process.process_system.process_unit import ProcessUnit, ProcessUnitId from libecalc.domain.process.value_objects.chart import ChartCurve @@ -70,9 +70,11 @@ def create_outlet_pressure_solver( anti_surge_strategy: AntiSurgeStrategy, pressure_control_strategy: PressureControlStrategy, speed_boundary: Boundary, + process_system_id: ProcessSystemId | None = None, ): return OutletPressureSolver( shaft_id=shaft.get_id(), + process_system_id=process_system_id or create_process_system_id(), runner=runner, anti_surge_strategy=anti_surge_strategy, pressure_control_strategy=pressure_control_strategy, diff --git a/tests/libecalc/domain/process/process_solver/solvers/test_recirculation_solver.py b/tests/libecalc/domain/process/process_solver/solvers/test_recirculation_solver.py index e7928fa9e0..c5aaec0a51 100644 --- a/tests/libecalc/domain/process/process_solver/solvers/test_recirculation_solver.py +++ b/tests/libecalc/domain/process/process_solver/solvers/test_recirculation_solver.py @@ -22,9 +22,9 @@ def get_id(self) -> ProcessUnitId: def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: if inlet_stream.volumetric_rate_m3_per_hour < self._minimum_rate: - raise RateTooLowError() + raise RateTooLowError(process_unit_id=self._id) if inlet_stream.volumetric_rate_m3_per_hour > self._maximum_rate: - raise RateTooHighError() + raise RateTooHighError(process_unit_id=self._id) return self._fluid_service.create_stream_from_standard_rate( fluid_model=inlet_stream.fluid_model, pressure_bara=inlet_stream.pressure_bara + inlet_stream.standard_rate_sm3_per_day, diff --git a/tests/libecalc/domain/process/process_solver/solvers/test_upstream_choke_solver.py b/tests/libecalc/domain/process/process_solver/solvers/test_upstream_choke_solver.py index 39c89961ab..1498492369 100644 --- a/tests/libecalc/domain/process/process_solver/solvers/test_upstream_choke_solver.py +++ b/tests/libecalc/domain/process/process_solver/solvers/test_upstream_choke_solver.py @@ -5,6 +5,7 @@ from libecalc.domain.process.process_solver.solvers.downstream_choke_solver import ChokeConfiguration from libecalc.domain.process.process_solver.solvers.upstream_choke_solver import UpstreamChokeSolver from libecalc.domain.process.process_system.process_error import RateTooHighError +from libecalc.domain.process.process_system.process_unit import create_process_unit_id from libecalc.domain.process.value_objects.fluid_stream import FluidStream @@ -76,7 +77,7 @@ def test_upstream_choke_solver_handles_rate_too_high_at_max_choke( def choke_func(configuration: ChokeConfiguration) -> FluidStream: suction_pressure = inlet_pressure - configuration.delta_pressure if suction_pressure < feasible_suction_pressure: - raise RateTooHighError() + raise RateTooHighError(process_unit_id=create_process_unit_id()) return stream_factory( standard_rate_m3_per_day=1000, pressure_bara=suction_pressure + pressure_added, diff --git a/tests/libecalc/domain/process/process_solver/test_multi_pressure_solver.py b/tests/libecalc/domain/process/process_solver/test_multi_pressure_solver.py index 8598342a3e..b958049744 100644 --- a/tests/libecalc/domain/process/process_solver/test_multi_pressure_solver.py +++ b/tests/libecalc/domain/process/process_solver/test_multi_pressure_solver.py @@ -10,7 +10,10 @@ from libecalc.domain.process.entities.shaft import VariableSpeedShaft from libecalc.domain.process.process_solver.boundary import Boundary from libecalc.domain.process.process_solver.float_constraint import FloatConstraint +from libecalc.domain.process.process_solver.multi_pressure_solver import MultiPressureSolver from libecalc.domain.process.process_solver.outlet_pressure_solver import OutletPressureSolver +from libecalc.domain.process.process_solver.solver import TargetNotAchievableEvent +from libecalc.domain.process.process_system.process_system import create_process_system_id from .conftest import make_variable_speed_chart_data @@ -124,6 +127,7 @@ def test_two_stage_train_with_interstage_pressure_vs_legacy( ) low_pressure_segment = OutletPressureSolver( shaft_id=shaft_new.get_id(), + process_system_id=create_process_system_id(), runner=low_pressure_runner, anti_surge_strategy=individual_asv_anti_surge_strategy_factory( runner=low_pressure_runner, @@ -140,6 +144,7 @@ def test_two_stage_train_with_interstage_pressure_vs_legacy( ) high_pressure_segment = OutletPressureSolver( shaft_id=shaft_new.get_id(), + process_system_id=create_process_system_id(), runner=high_pressure_runner, anti_surge_strategy=individual_asv_anti_surge_strategy_factory( runner=high_pressure_runner, @@ -288,6 +293,7 @@ def test_three_stage_train_with_mixers_and_splitters_at_interstage( def make_segment(runner, loop_ids, compressors): return OutletPressureSolver( shaft_id=shaft.get_id(), + process_system_id=create_process_system_id(), runner=runner, anti_surge_strategy=individual_asv_anti_surge_strategy_factory( runner=runner, @@ -327,3 +333,89 @@ def make_segment(runner, loop_ids, compressors): assert low_pressure_outlet.pressure_bara == pytest.approx(interstage_1, rel=0.001) assert medium_pressure_outlet.pressure_bara == pytest.approx(interstage_2, rel=0.001) assert high_pressure_outlet.pressure_bara == pytest.approx(target_pressure, rel=0.001) + + +def test_target_not_achievable_event_identifies_failing_segment( + stream_factory, + chart_data_factory, + fluid_service, + stage_units_factory, + with_individual_asv, + process_runner_factory, + individual_asv_anti_surge_strategy_factory, + individual_asv_rate_control_strategy_factory, + root_finding_strategy, +): + """TargetNotAchievableEvent.source_id should identify the second segment when it fails.""" + + temperature = 300.0 + q0 = stream_factory(standard_rate_m3_per_day=10_000, pressure_bara=30.0, temperature_kelvin=temperature) + q0_vol = float(q0.volumetric_rate_m3_per_hour) + + chart_data = make_variable_speed_chart_data( + chart_data_factory, + min_rate=0.0, + max_rate=q0_vol * 5.0, + head_hi=150_000.0, + head_lo=50_000.0, + eff=0.75, + ) + + shaft = VariableSpeedShaft() + + lp_units_raw = stage_units_factory(chart_data=chart_data, shaft=shaft, temperature_kelvin=temperature) + lp_units, lp_loop_ids, lp_compressors = with_individual_asv(lp_units_raw) + lp_runner = process_runner_factory(units=lp_units, shaft=shaft) + + hp_units_raw = stage_units_factory(chart_data=chart_data, shaft=shaft, temperature_kelvin=temperature) + hp_units, hp_loop_ids, hp_compressors = with_individual_asv(hp_units_raw) + hp_runner = process_runner_factory(units=hp_units, shaft=shaft) + + speed_boundary = Boundary( + min=max(c.get_speed_boundary().min for c in lp_compressors + hp_compressors), + max=min(c.get_speed_boundary().max for c in lp_compressors + hp_compressors), + ) + + lp_process_system_id = create_process_system_id() + hp_process_system_id = create_process_system_id() + + lp_segment = OutletPressureSolver( + shaft_id=shaft.get_id(), + process_system_id=lp_process_system_id, + runner=lp_runner, + anti_surge_strategy=individual_asv_anti_surge_strategy_factory( + runner=lp_runner, recirculation_loop_ids=lp_loop_ids, compressors=lp_compressors + ), + pressure_control_strategy=individual_asv_rate_control_strategy_factory( + runner=lp_runner, recirculation_loop_ids=lp_loop_ids, compressors=lp_compressors + ), + root_finding_strategy=root_finding_strategy, + speed_boundary=speed_boundary, + ) + hp_segment = OutletPressureSolver( + shaft_id=shaft.get_id(), + process_system_id=hp_process_system_id, + runner=hp_runner, + anti_surge_strategy=individual_asv_anti_surge_strategy_factory( + runner=hp_runner, recirculation_loop_ids=hp_loop_ids, compressors=hp_compressors + ), + pressure_control_strategy=individual_asv_rate_control_strategy_factory( + runner=hp_runner, recirculation_loop_ids=hp_loop_ids, compressors=hp_compressors + ), + root_finding_strategy=root_finding_strategy, + speed_boundary=speed_boundary, + ) + + solver = MultiPressureSolver(segments=[lp_segment, hp_segment]) + + inlet_stream = stream_factory(standard_rate_m3_per_day=10_000, pressure_bara=30.0, temperature_kelvin=temperature) + + # First segment target is achievable; second is not + solution = solver.find_solution( + pressure_targets=[FloatConstraint(60.0), FloatConstraint(9999.0)], + inlet_stream=inlet_stream, + ) + + assert not solution.success + assert isinstance(solution.failure_event, TargetNotAchievableEvent) + assert solution.failure_event.source_id == hp_process_system_id