Skip to content

Commit 854a522

Browse files
committed
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 actual and boundary values (m3/h) when available - TargetNotAchievableEvent: pressure target out of achievable range, with achievable and target values (bara)
1 parent 6bf7397 commit 854a522

10 files changed

Lines changed: 207 additions & 29 deletions

File tree

src/libecalc/domain/process/entities/process_units/compressor.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@ def get_id(self) -> ProcessUnitId:
3030
def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream:
3131
actual_rate = inlet_stream.volumetric_rate_m3_per_hour
3232
if actual_rate < self.minimum_flow_rate:
33-
raise RateTooLowError()
33+
raise RateTooLowError(
34+
actual_rate=actual_rate,
35+
boundary_rate=self.minimum_flow_rate,
36+
)
3437
if actual_rate > self.maximum_flow_rate:
35-
raise RateTooHighError()
38+
raise RateTooHighError(
39+
actual_rate=actual_rate,
40+
boundary_rate=self.maximum_flow_rate,
41+
)
3642

3743
chart_curve_at_given_speed = self.compressor_chart.get_curve_by_speed(speed=self.speed)
3844
if chart_curve_at_given_speed is not None:

src/libecalc/domain/process/process_solver/anti_surge/individual_asv.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from libecalc.domain.process.entities.process_units.compressor import Compressor
55
from libecalc.domain.process.process_solver.anti_surge.anti_surge_strategy import AntiSurgeStrategy
66
from libecalc.domain.process.process_solver.process_runner import Configuration, ProcessRunner
7-
from libecalc.domain.process.process_solver.solver import Solution
7+
from libecalc.domain.process.process_solver.solver import (
8+
OutsideCapacityEvent,
9+
Solution,
10+
SolverFailureStatus,
11+
)
812
from libecalc.domain.process.process_solver.solvers.recirculation_solver import RecirculationConfiguration
913
from libecalc.domain.process.process_system.process_error import RateTooHighError
1014
from libecalc.domain.process.process_system.process_system import ProcessSystemId
@@ -61,12 +65,27 @@ def apply(self, inlet_stream: FluidStream) -> Solution[Sequence[Configuration[Re
6165
for loop_id, compressor in zip(self._recirculation_loop_ids, self._compressors, strict=True):
6266
try:
6367
inlet_stream_compressor = self._simulator.run(inlet_stream=inlet_stream, to_id=compressor.get_id())
64-
except RateTooHighError:
65-
return Solution(success=False, configuration=configurations)
66-
if inlet_stream_compressor.standard_rate_sm3_per_day > compressor.get_maximum_standard_rate(
67-
inlet_stream_compressor
68-
):
69-
return Solution(success=False, configuration=configurations)
68+
except RateTooHighError as e:
69+
return Solution(
70+
success=False,
71+
configuration=configurations,
72+
failure_event=OutsideCapacityEvent(
73+
status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE,
74+
actual_value=e.actual_rate,
75+
boundary_value=e.boundary_rate,
76+
),
77+
)
78+
max_actual_rate = compressor.maximum_flow_rate
79+
if inlet_stream_compressor.volumetric_rate_m3_per_hour > max_actual_rate:
80+
return Solution(
81+
success=False,
82+
configuration=configurations,
83+
failure_event=OutsideCapacityEvent(
84+
status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE,
85+
actual_value=inlet_stream_compressor.volumetric_rate_m3_per_hour,
86+
boundary_value=max_actual_rate,
87+
),
88+
)
7089
boundary = compressor.get_recirculation_range(inlet_stream=inlet_stream_compressor)
7190
configuration: Configuration[RecirculationConfiguration] = Configuration(
7291
simulation_unit_id=loop_id,

src/libecalc/domain/process/process_solver/outlet_pressure_solver.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy
1010
from libecalc.domain.process.process_solver.process_runner import Configuration, ProcessRunner
1111
from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy
12-
from libecalc.domain.process.process_solver.solver import Solution
12+
from libecalc.domain.process.process_solver.solver import (
13+
Solution,
14+
SolverFailureStatus,
15+
TargetNotAchievableEvent,
16+
)
1317
from libecalc.domain.process.process_solver.solvers.recirculation_solver import (
1418
RecirculationConfiguration,
1519
)
@@ -121,7 +125,11 @@ def find_solution(
121125
)
122126

123127
if not self._anti_surge_solution.success:
124-
return Solution(success=False, configuration=list(configurations.values()))
128+
return Solution(
129+
success=False,
130+
configuration=list(configurations.values()),
131+
failure_event=self._anti_surge_solution.failure_event,
132+
)
125133

126134
outlet_at_chosen_speed = self._get_outlet_stream(
127135
inlet_stream=inlet_stream,
@@ -132,6 +140,11 @@ def find_solution(
132140
return Solution(
133141
success=False,
134142
configuration=list(configurations.values()),
143+
failure_event=TargetNotAchievableEvent(
144+
status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET,
145+
achievable_value=outlet_at_chosen_speed.pressure_bara,
146+
target_value=pressure_constraint.value,
147+
),
135148
)
136149

137150
pressure_control_solution = self._pressure_control_strategy.apply(

src/libecalc/domain/process/process_solver/pressure_control/common_asv.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy
66
from libecalc.domain.process.process_solver.process_runner import Configuration, ProcessRunner
77
from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy
8-
from libecalc.domain.process.process_solver.solver import Solution
8+
from libecalc.domain.process.process_solver.solver import (
9+
Solution,
10+
SolverFailureStatus,
11+
TargetNotAchievableEvent,
12+
)
913
from libecalc.domain.process.process_solver.solvers.downstream_choke_solver import ChokeConfiguration
1014
from libecalc.domain.process.process_solver.solvers.recirculation_solver import (
1115
RecirculationConfiguration,
@@ -60,6 +64,11 @@ def recirculation_func(config: RecirculationConfiguration) -> FluidStream:
6064
value=min_configuration,
6165
)
6266
],
67+
failure_event=TargetNotAchievableEvent(
68+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
69+
achievable_value=min_pressure_stream.pressure_bara,
70+
target_value=target_pressure.value,
71+
),
6372
)
6473

6574
solver = RecirculationSolver(

src/libecalc/domain/process/process_solver/pressure_control/individual_asv.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy
77
from libecalc.domain.process.process_solver.process_runner import Configuration, ProcessRunner
88
from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy
9-
from libecalc.domain.process.process_solver.solver import Solution
9+
from libecalc.domain.process.process_solver.solver import (
10+
Solution,
11+
SolverFailureStatus,
12+
TargetNotAchievableEvent,
13+
)
1014
from libecalc.domain.process.process_solver.solvers.downstream_choke_solver import ChokeConfiguration
1115
from libecalc.domain.process.process_solver.solvers.recirculation_solver import (
1216
RecirculationConfiguration,
@@ -56,6 +60,11 @@ def apply(
5660
return Solution(
5761
success=False,
5862
configuration=minimum_achievable_pressure_configurations,
63+
failure_event=TargetNotAchievableEvent(
64+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
65+
achievable_value=minimum_achievable_pressure_stream.pressure_bara,
66+
target_value=target_pressure.value,
67+
),
5968
)
6069

6170
n_stages = len(self._recirculation_loop_ids)
@@ -99,6 +108,7 @@ def recirculation_func(config: RecirculationConfiguration):
99108
return Solution(
100109
success=False,
101110
configuration=configurations,
111+
failure_event=solution.failure_event,
102112
)
103113

104114
self._simulator.apply_configurations(configurations)
@@ -173,6 +183,11 @@ def apply(
173183
return Solution(
174184
success=False,
175185
configuration=minimum_achievable_pressure_configurations,
186+
failure_event=TargetNotAchievableEvent(
187+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
188+
achievable_value=minimum_achievable_pressure_stream.pressure_bara,
189+
target_value=target_pressure.value,
190+
),
176191
)
177192

178193
def get_outlet_stream(rate_fraction: float) -> FluidStream:

src/libecalc/domain/process/process_solver/solver.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
11
import abc
22
from collections.abc import Callable
3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
4+
from enum import Enum
45
from typing import Generic, TypeVar
56

67
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
78

89
TConfiguration = TypeVar("TConfiguration")
910

1011

12+
class SolverFailureStatus(str, Enum):
13+
ABOVE_MAXIMUM_FLOW_RATE = "ABOVE_MAXIMUM_FLOW_RATE"
14+
BELOW_MINIMUM_FLOW_RATE = "BELOW_MINIMUM_FLOW_RATE"
15+
MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET = "MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET"
16+
MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET = "MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET"
17+
18+
19+
@dataclass
20+
class OutsideCapacityEvent:
21+
status: SolverFailureStatus
22+
actual_value: float | None = None
23+
boundary_value: float | None = None
24+
25+
26+
@dataclass
27+
class TargetNotAchievableEvent:
28+
status: SolverFailureStatus
29+
achievable_value: float
30+
target_value: float
31+
32+
33+
SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent
34+
35+
1136
@dataclass
1237
class Solution(Generic[TConfiguration]):
1338
success: bool
1439
configuration: TConfiguration
40+
failure_event: SolverFailureEvent | None = field(default=None)
1541

1642

1743
class Solver(abc.ABC, Generic[TConfiguration]):

src/libecalc/domain/process/process_solver/solvers/recirculation_solver.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
from libecalc.domain.process.process_solver.boundary import Boundary
66
from libecalc.domain.process.process_solver.float_constraint import FloatConstraint
77
from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy
8-
from libecalc.domain.process.process_solver.solver import Solution, Solver
8+
from libecalc.domain.process.process_solver.solver import (
9+
OutsideCapacityEvent,
10+
Solution,
11+
Solver,
12+
SolverFailureStatus,
13+
TargetNotAchievableEvent,
14+
)
915
from libecalc.domain.process.process_system.process_error import RateTooHighError, RateTooLowError
1016
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
1117

@@ -58,9 +64,17 @@ def bool_func(x: float, mode: Literal["minimize", "maximize"]) -> tuple[bool, bo
5864
boundary=self._recirculation_rate_boundary,
5965
func=lambda x: bool_func(x, mode="minimize"),
6066
)
61-
except RateTooHighError:
67+
except RateTooHighError as e:
6268
# Flow is above stonewall at zero recirculation; adding recirculation cannot help.
63-
return Solution(success=False, configuration=RecirculationConfiguration(recirculation_rate=minimum_rate))
69+
return Solution(
70+
success=False,
71+
configuration=RecirculationConfiguration(recirculation_rate=minimum_rate),
72+
failure_event=OutsideCapacityEvent(
73+
status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE,
74+
actual_value=e.actual_rate,
75+
boundary_value=e.boundary_rate,
76+
),
77+
)
6478

6579
target_pressure = self._target_pressure
6680
if target_pressure is None:
@@ -81,16 +95,32 @@ def bool_func(x: float, mode: Literal["minimize", "maximize"]) -> tuple[bool, bo
8195
minimum_outlet_stream = func(RecirculationConfiguration(recirculation_rate=minimum_rate))
8296
if minimum_outlet_stream.pressure_bara <= target_pressure:
8397
# Highest possible pressure is too low
98+
is_success = minimum_outlet_stream.pressure_bara == target_pressure
8499
return Solution(
85-
success=minimum_outlet_stream.pressure_bara == target_pressure,
100+
success=is_success,
86101
configuration=RecirculationConfiguration(recirculation_rate=minimum_rate),
102+
failure_event=None
103+
if is_success
104+
else TargetNotAchievableEvent(
105+
status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET,
106+
achievable_value=minimum_outlet_stream.pressure_bara,
107+
target_value=target_pressure.value,
108+
),
87109
)
88110
maximum_outlet_stream = func(RecirculationConfiguration(recirculation_rate=maximum_rate))
89111
if maximum_outlet_stream.pressure_bara >= target_pressure:
90112
# Lowest possible pressure is too high
113+
is_success = maximum_outlet_stream.pressure_bara == self._target_pressure
91114
return Solution(
92-
success=maximum_outlet_stream.pressure_bara == self._target_pressure,
115+
success=is_success,
93116
configuration=RecirculationConfiguration(recirculation_rate=maximum_rate),
117+
failure_event=None
118+
if is_success
119+
else TargetNotAchievableEvent(
120+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
121+
achievable_value=maximum_outlet_stream.pressure_bara,
122+
target_value=target_pressure.value,
123+
),
94124
)
95125

96126
recirculation_rate = self._root_finding_strategy.find_root(

src/libecalc/domain/process/process_solver/solvers/speed_solver.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44

55
from libecalc.domain.process.process_solver.boundary import Boundary
66
from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy
7-
from libecalc.domain.process.process_solver.solver import Solution, Solver
8-
from libecalc.domain.process.process_system.process_error import ProcessError, RateTooHighError, RateTooLowError
7+
from libecalc.domain.process.process_solver.solver import (
8+
OutsideCapacityEvent,
9+
Solution,
10+
Solver,
11+
SolverFailureStatus,
12+
TargetNotAchievableEvent,
13+
)
14+
from libecalc.domain.process.process_system.process_error import RateTooHighError, RateTooLowError
915
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
1016

1117
logger = logging.getLogger(__name__)
@@ -36,15 +42,39 @@ def get_outlet_stream(speed: float) -> FluidStream:
3642
max_speed_configuration = SpeedConfiguration(speed=self._boundary.max)
3743
try:
3844
maximum_speed_outlet_stream = func(max_speed_configuration)
39-
except ProcessError as e:
45+
except RateTooHighError as e:
46+
logger.debug(f"No solution found for maximum speed: {max_speed_configuration}", exc_info=e)
47+
return Solution(
48+
success=False,
49+
configuration=max_speed_configuration,
50+
failure_event=OutsideCapacityEvent(
51+
status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE,
52+
actual_value=e.actual_rate,
53+
boundary_value=e.boundary_rate,
54+
),
55+
)
56+
except RateTooLowError as e:
4057
logger.debug(f"No solution found for maximum speed: {max_speed_configuration}", exc_info=e)
4158
return Solution(
4259
success=False,
4360
configuration=max_speed_configuration,
61+
failure_event=OutsideCapacityEvent(
62+
status=SolverFailureStatus.BELOW_MINIMUM_FLOW_RATE,
63+
actual_value=e.actual_rate,
64+
boundary_value=e.boundary_rate,
65+
),
4466
)
4567

4668
if maximum_speed_outlet_stream.pressure_bara < self._target_pressure:
47-
return Solution(success=False, configuration=SpeedConfiguration(self._boundary.max))
69+
return Solution(
70+
success=False,
71+
configuration=SpeedConfiguration(self._boundary.max),
72+
failure_event=TargetNotAchievableEvent(
73+
status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET,
74+
achievable_value=maximum_speed_outlet_stream.pressure_bara,
75+
target_value=self._target_pressure,
76+
),
77+
)
4878

4979
try:
5080
minimum_speed_configuration = SpeedConfiguration(speed=self._boundary.min)
@@ -71,7 +101,15 @@ def bool_speed_func(x: float):
71101

72102
if minimum_speed_outlet_stream.pressure_bara > self._target_pressure:
73103
# Solution 2, target pressure is too low
74-
return Solution(success=False, configuration=minimum_speed_configuration)
104+
return Solution(
105+
success=False,
106+
configuration=minimum_speed_configuration,
107+
failure_event=TargetNotAchievableEvent(
108+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
109+
achievable_value=minimum_speed_outlet_stream.pressure_bara,
110+
target_value=self._target_pressure,
111+
),
112+
)
75113

76114
assert (
77115
minimum_speed_outlet_stream.pressure_bara

src/libecalc/domain/process/process_solver/solvers/upstream_choke_solver.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from libecalc.domain.process.process_solver.boundary import Boundary
44
from libecalc.domain.process.process_solver.search_strategies import RootFindingStrategy
5-
from libecalc.domain.process.process_solver.solver import Solution, Solver
5+
from libecalc.domain.process.process_solver.solver import (
6+
Solution,
7+
Solver,
8+
SolverFailureStatus,
9+
TargetNotAchievableEvent,
10+
)
611
from libecalc.domain.process.process_system.process_error import RateTooHighError
712
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
813

@@ -45,9 +50,18 @@ def outlet_pressure(config: ChokeConfiguration) -> float:
4550

4651
# Evaluate outlet pressure at maximum allowed upstream ΔP (within boundary).
4752
max_cfg = ChokeConfiguration(delta_pressure=self._delta_pressure_boundary.max)
48-
if outlet_pressure(max_cfg) > self._target_pressure:
53+
max_cfg_pressure = outlet_pressure(max_cfg)
54+
if max_cfg_pressure > self._target_pressure:
4955
# If we are still above target even at max choking, then no solution exists within the boundary.
50-
return Solution(success=False, configuration=max_cfg)
56+
return Solution(
57+
success=False,
58+
configuration=max_cfg,
59+
failure_event=TargetNotAchievableEvent(
60+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
61+
achievable_value=max_cfg_pressure,
62+
target_value=self._target_pressure,
63+
),
64+
)
5165

5266
pressure_change = self._root_finding_strategy.find_root(
5367
boundary=self._delta_pressure_boundary,

0 commit comments

Comments
 (0)