Skip to content

Commit 7167fa3

Browse files
authored
feat(solver): add structured failure events to Solution (#1458)
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.
1 parent 97a4e55 commit 7167fa3

File tree

15 files changed

+362
-35
lines changed

15 files changed

+362
-35
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,17 @@ 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+
process_unit_id=self._id,
37+
)
3438
if actual_rate > self.maximum_flow_rate:
35-
raise RateTooHighError()
39+
raise RateTooHighError(
40+
actual_rate=actual_rate,
41+
boundary_rate=self.maximum_flow_rate,
42+
process_unit_id=self._id,
43+
)
3644

3745
chart_curve_at_given_speed = self.compressor_chart.get_curve_by_speed(speed=self.speed)
3846
if chart_curve_at_given_speed is not None:

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

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

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
DownstreamChokePressureControlStrategy,
1010
)
1111
from libecalc.domain.process.process_solver.pressure_control.upstream_choke import UpstreamChokePressureControlStrategy
12-
from libecalc.domain.process.process_solver.solver import Solution
12+
from libecalc.domain.process.process_solver.solver import (
13+
Solution,
14+
SolverFailureEvent,
15+
SolverFailureStatus,
16+
TargetNotAchievableEvent,
17+
)
1318
from libecalc.domain.process.process_solver.solvers.speed_solver import SpeedConfiguration
1419
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
1520

@@ -89,6 +94,7 @@ def find_solution(
8994

9095
current_inlet = inlet_stream
9196
overall_success = True
97+
failure_event: SolverFailureEvent | None = None
9298

9399
for segment, target in zip(self._segments, pressure_targets):
94100
segment.runner.apply_configuration(shaft_config)
@@ -112,12 +118,22 @@ def find_solution(
112118
outlet = segment.runner.run(inlet_stream=current_inlet)
113119
if not pressure_control_solution.success:
114120
overall_success = False
121+
if failure_event is None:
122+
failure_event = pressure_control_solution.failure_event
115123
elif outlet.pressure_bara < target:
116124
overall_success = False
125+
if failure_event is None:
126+
failure_event = TargetNotAchievableEvent(
127+
status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET,
128+
achievable_value=outlet.pressure_bara,
129+
target_value=target.value,
130+
source_id=segment.process_system_id,
131+
)
117132

118133
current_inlet = outlet
119134

120135
return Solution(
121136
success=overall_success,
122137
configuration=list(all_configurations.values()),
138+
failure_event=failure_event,
123139
)

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

Lines changed: 27 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 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
)
@@ -38,13 +42,15 @@ class OutletPressureSolver:
3842
def __init__(
3943
self,
4044
shaft_id: ShaftId,
45+
process_system_id: ProcessSystemId,
4146
runner: ProcessRunner,
4247
anti_surge_strategy: AntiSurgeStrategy,
4348
pressure_control_strategy: PressureControlStrategy,
4449
root_finding_strategy: RootFindingStrategy,
4550
speed_boundary: Boundary,
4651
) -> None:
4752
self._shaft_id: Final = shaft_id
53+
self._process_system_id: Final = process_system_id
4854
self._root_finding_strategy: Final = root_finding_strategy
4955
self._anti_surge_strategy: Final = anti_surge_strategy
5056
self._simulator: Final = runner
@@ -69,6 +75,10 @@ def pressure_control_strategy(self) -> PressureControlStrategy:
6975
def shaft_id(self) -> ShaftId:
7076
return self._shaft_id
7177

78+
@property
79+
def process_system_id(self) -> ProcessSystemId:
80+
return self._process_system_id
81+
7282
def _get_initial_speed_boundary(self) -> Boundary:
7383
return self._speed_boundary
7484

@@ -135,7 +145,11 @@ def find_solution(
135145
)
136146

137147
if not self._anti_surge_solution.success:
138-
return Solution(success=False, configuration=list(configurations.values()))
148+
return Solution(
149+
success=False,
150+
configuration=list(configurations.values()),
151+
failure_event=self._anti_surge_solution.failure_event,
152+
)
139153

140154
outlet_at_chosen_speed = self._get_outlet_stream(
141155
inlet_stream=inlet_stream,
@@ -146,6 +160,12 @@ def find_solution(
146160
return Solution(
147161
success=False,
148162
configuration=list(configurations.values()),
163+
failure_event=TargetNotAchievableEvent(
164+
status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET,
165+
achievable_value=outlet_at_chosen_speed.pressure_bara,
166+
target_value=pressure_constraint.value,
167+
source_id=self._process_system_id,
168+
),
149169
)
150170

151171
pressure_control_solution = self._pressure_control_strategy.apply(
@@ -156,7 +176,12 @@ def find_solution(
156176
for pressure_control_configuration in pressure_control_solution.configuration:
157177
configurations[pressure_control_configuration.simulation_unit_id] = pressure_control_configuration
158178

179+
failure_event = pressure_control_solution.failure_event
180+
if isinstance(failure_event, TargetNotAchievableEvent) and failure_event.source_id is None:
181+
failure_event = failure_event.with_source_id(self._process_system_id)
182+
159183
return Solution(
160184
success=pressure_control_solution.success,
161185
configuration=list(configurations.values()),
186+
failure_event=failure_event,
162187
)

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
@@ -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 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,
@@ -61,6 +65,11 @@ def recirculation_func(config: RecirculationConfiguration) -> FluidStream:
6165
value=min_configuration,
6266
)
6367
],
68+
failure_event=TargetNotAchievableEvent(
69+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
70+
achievable_value=min_pressure_stream.pressure_bara,
71+
target_value=target_pressure.value,
72+
),
6473
)
6574

6675
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
@@ -7,7 +7,11 @@
77
from libecalc.domain.process.process_solver.pressure_control.pressure_control_strategy import PressureControlStrategy
88
from libecalc.domain.process.process_solver.process_runner import ProcessRunner
99
from libecalc.domain.process.process_solver.search_strategies import BinarySearchStrategy, RootFindingStrategy
10-
from libecalc.domain.process.process_solver.solver import Solution
10+
from libecalc.domain.process.process_solver.solver import (
11+
Solution,
12+
SolverFailureStatus,
13+
TargetNotAchievableEvent,
14+
)
1115
from libecalc.domain.process.process_solver.solvers.downstream_choke_solver import ChokeConfiguration
1216
from libecalc.domain.process.process_solver.solvers.recirculation_solver import (
1317
RecirculationConfiguration,
@@ -57,6 +61,11 @@ def apply(
5761
return Solution(
5862
success=False,
5963
configuration=minimum_achievable_pressure_configurations,
64+
failure_event=TargetNotAchievableEvent(
65+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
66+
achievable_value=minimum_achievable_pressure_stream.pressure_bara,
67+
target_value=target_pressure.value,
68+
),
6069
)
6170

6271
n_stages = len(self._recirculation_loop_ids)
@@ -100,6 +109,7 @@ def recirculation_func(config: RecirculationConfiguration):
100109
return Solution(
101110
success=False,
102111
configuration=configurations,
112+
failure_event=solution.failure_event,
103113
)
104114

105115
self._simulator.apply_configurations(configurations)
@@ -174,6 +184,11 @@ def apply(
174184
return Solution(
175185
success=False,
176186
configuration=minimum_achievable_pressure_configurations,
187+
failure_event=TargetNotAchievableEvent(
188+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
189+
achievable_value=minimum_achievable_pressure_stream.pressure_bara,
190+
target_value=target_pressure.value,
191+
),
177192
)
178193

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

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,52 @@
11
import abc
2+
import dataclasses
23
from collections.abc import Callable, Sequence
3-
from dataclasses import dataclass
4-
from typing import Generic, TypeVar
4+
from dataclasses import dataclass, field
5+
from enum import Enum
6+
from typing import Generic, Self, TypeVar
57

68
from libecalc.domain.process.process_solver.configuration import Configuration, OperatingConfiguration, SimulationUnitId
9+
from libecalc.domain.process.process_system.process_system import ProcessSystemId
10+
from libecalc.domain.process.process_system.process_unit import ProcessUnitId
711
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
812

913
TConfiguration = TypeVar("TConfiguration")
1014

1115

16+
class SolverFailureStatus(str, Enum):
17+
ABOVE_MAXIMUM_FLOW_RATE = "ABOVE_MAXIMUM_FLOW_RATE"
18+
BELOW_MINIMUM_FLOW_RATE = "BELOW_MINIMUM_FLOW_RATE"
19+
MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET = "MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET"
20+
MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET = "MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET"
21+
22+
23+
@dataclass
24+
class OutsideCapacityEvent:
25+
status: SolverFailureStatus
26+
source_id: ProcessUnitId
27+
actual_value: float | None = None
28+
boundary_value: float | None = None
29+
30+
31+
@dataclass
32+
class TargetNotAchievableEvent:
33+
status: SolverFailureStatus
34+
achievable_value: float
35+
target_value: float
36+
source_id: ProcessSystemId | None = None
37+
38+
def with_source_id(self, source_id: ProcessSystemId) -> Self:
39+
return dataclasses.replace(self, source_id=source_id)
40+
41+
42+
SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent
43+
44+
1245
@dataclass
1346
class Solution(Generic[TConfiguration]):
1447
success: bool
1548
configuration: TConfiguration
49+
failure_event: SolverFailureEvent | None = field(default=None)
1650

1751
def get_configuration(
1852
self: "Solution[Sequence[Configuration[OperatingConfiguration]]]",

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
from libecalc.domain.process.process_solver.configuration import RecirculationConfiguration
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

@@ -50,9 +56,18 @@ def bool_func(x: float, mode: Literal["minimize", "maximize"]) -> tuple[bool, bo
5056
boundary=self._recirculation_rate_boundary,
5157
func=lambda x: bool_func(x, mode="minimize"),
5258
)
53-
except RateTooHighError:
59+
except RateTooHighError as e:
5460
# Flow is above stonewall at zero recirculation; adding recirculation cannot help.
55-
return Solution(success=False, configuration=RecirculationConfiguration(recirculation_rate=minimum_rate))
61+
return Solution(
62+
success=False,
63+
configuration=RecirculationConfiguration(recirculation_rate=minimum_rate),
64+
failure_event=OutsideCapacityEvent(
65+
status=SolverFailureStatus.ABOVE_MAXIMUM_FLOW_RATE,
66+
actual_value=e.actual_rate,
67+
boundary_value=e.boundary_rate,
68+
source_id=e.process_unit_id,
69+
),
70+
)
5671

5772
target_pressure = self._target_pressure
5873
if target_pressure is None:
@@ -73,16 +88,32 @@ def bool_func(x: float, mode: Literal["minimize", "maximize"]) -> tuple[bool, bo
7388
minimum_outlet_stream = func(RecirculationConfiguration(recirculation_rate=minimum_rate))
7489
if minimum_outlet_stream.pressure_bara <= target_pressure:
7590
# Highest possible pressure is too low
91+
is_success = minimum_outlet_stream.pressure_bara == target_pressure
7692
return Solution(
77-
success=minimum_outlet_stream.pressure_bara == target_pressure,
93+
success=is_success,
7894
configuration=RecirculationConfiguration(recirculation_rate=minimum_rate),
95+
failure_event=None
96+
if is_success
97+
else TargetNotAchievableEvent(
98+
status=SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET,
99+
achievable_value=minimum_outlet_stream.pressure_bara,
100+
target_value=target_pressure.value,
101+
),
79102
)
80103
maximum_outlet_stream = func(RecirculationConfiguration(recirculation_rate=maximum_rate))
81104
if maximum_outlet_stream.pressure_bara >= target_pressure:
82105
# Lowest possible pressure is too high
106+
is_success = maximum_outlet_stream.pressure_bara == self._target_pressure
83107
return Solution(
84-
success=maximum_outlet_stream.pressure_bara == self._target_pressure,
108+
success=is_success,
85109
configuration=RecirculationConfiguration(recirculation_rate=maximum_rate),
110+
failure_event=None
111+
if is_success
112+
else TargetNotAchievableEvent(
113+
status=SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET,
114+
achievable_value=maximum_outlet_stream.pressure_bara,
115+
target_value=target_pressure.value,
116+
),
86117
)
87118

88119
recirculation_rate = self._root_finding_strategy.find_root(

0 commit comments

Comments
 (0)