Skip to content

Commit 1f418ef

Browse files
committed
refactor(process): split SpeedSolver EOS recovery into helpers; add BinarySearchResult
1 parent 2da4ce8 commit 1f418ef

2 files changed

Lines changed: 105 additions & 70 deletions

File tree

src/libecalc/process/process_solver/search_strategies.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
from collections.abc import Callable
3+
from typing import NamedTuple
34

45
from scipy.optimize import root_scalar
56

@@ -9,6 +10,17 @@
910
CONVERGENCE_TOLERANCE = 1e-5
1011

1112

13+
class BinarySearchResult(NamedTuple):
14+
higher: bool
15+
accepted: bool
16+
17+
18+
ACCEPT_AND_GO_HIGHER = BinarySearchResult(higher=True, accepted=True)
19+
REJECT_AND_GO_LOWER = BinarySearchResult(higher=False, accepted=False)
20+
ACCEPT_AND_GO_LOWER = BinarySearchResult(higher=False, accepted=True)
21+
REJECT_AND_GO_HIGHER = BinarySearchResult(higher=True, accepted=False)
22+
23+
1224
class DidNotConvergeError(EcalcError):
1325
def __init__(
1426
self,

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

Lines changed: 93 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@
99
)
1010
from libecalc.process.process_solver.boundary import Boundary
1111
from libecalc.process.process_solver.configuration import SpeedConfiguration
12-
from libecalc.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy
12+
from libecalc.process.process_solver.search_strategies import (
13+
ACCEPT_AND_GO_HIGHER,
14+
ACCEPT_AND_GO_LOWER,
15+
REJECT_AND_GO_HIGHER,
16+
REJECT_AND_GO_LOWER,
17+
BinarySearchResult,
18+
RootFindingStrategy,
19+
SearchStrategy,
20+
)
1321
from libecalc.process.process_solver.solver import (
1422
OutletFluidNotAchievableEvent,
1523
OutsideCapacityEvent,
@@ -39,20 +47,6 @@ def solve(self, func: Callable[[SpeedConfiguration], FluidStream]) -> Solution[S
3947
def get_outlet_stream(speed: float) -> FluidStream:
4048
return func(SpeedConfiguration(speed=speed))
4149

42-
def _fluid_not_achievable_solution(
43-
e: OutletFluidNotAchievableError, configuration: SpeedConfiguration
44-
) -> Solution[SpeedConfiguration]:
45-
logger.debug(f"Outlet fluid not achievable at speed {configuration.speed}", exc_info=e)
46-
return Solution(
47-
success=False,
48-
configuration=configuration,
49-
failure_event=OutletFluidNotAchievableEvent(
50-
status=SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE,
51-
source_id=e.process_unit_id,
52-
unachievable_operating_point=e.unachievable_operating_point,
53-
),
54-
)
55-
5650
max_speed_configuration = SpeedConfiguration(speed=self._boundary.max)
5751
try:
5852
maximum_speed_outlet_stream = func(max_speed_configuration)
@@ -81,33 +75,11 @@ def _fluid_not_achievable_solution(
8175
),
8276
)
8377
except OutletFluidNotAchievableError:
84-
# EOS fails at max speed — find the highest speed where a valid outlet can still be produced.
85-
logger.debug(
86-
"Outlet fluid not achievable at maximum speed %.1f — searching for highest feasible speed.",
87-
self._boundary.max,
88-
)
89-
90-
# Pre-check: if boundary.min also fails, no speed in the range produces a valid outlet.
91-
try:
92-
get_outlet_stream(speed=self._boundary.min)
93-
except OutletFluidNotAchievableError as e:
94-
return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.min))
95-
96-
def max_speed_fluid_func(x: float) -> tuple[bool, bool]:
97-
try:
98-
get_outlet_stream(speed=x)
99-
return True, True # EOS succeeded — can try higher
100-
except OutletFluidNotAchievableError:
101-
return False, False # EOS failed — must go lower
102-
except (RateTooHighError, RateTooLowError):
103-
return False, False # rate error at this speed — also unusable
104-
105-
max_fluid_achievable_speed = self._search_strategy.search(
106-
boundary=self._boundary,
107-
func=max_speed_fluid_func,
108-
)
109-
max_speed_configuration = SpeedConfiguration(speed=max_fluid_achievable_speed)
110-
maximum_speed_outlet_stream = get_outlet_stream(speed=max_fluid_achievable_speed)
78+
result = self._search_highest_eos_feasible_speed(get_outlet_stream)
79+
if isinstance(result, Solution):
80+
return result
81+
max_speed_configuration = result
82+
maximum_speed_outlet_stream = get_outlet_stream(speed=result.speed)
11183

11284
if maximum_speed_outlet_stream.pressure_bara < self._target_pressure:
11385
return Solution(
@@ -143,33 +115,11 @@ def bool_speed_func(x: float):
143115
minimum_speed_configuration = SpeedConfiguration(speed=minimum_speed_within_capacity)
144116
minimum_speed_outlet_stream = func(minimum_speed_configuration)
145117
except OutletFluidNotAchievableError:
146-
# EOS fails at min speed — find the lowest speed where a valid outlet can be produced.
147-
logger.debug(
148-
"Outlet fluid not achievable at minimum speed %.1f — searching for lowest feasible speed.",
149-
self._boundary.min,
150-
)
151-
152-
# Pre-check: if boundary.max also fails, no speed in the range produces a valid outlet.
153-
try:
154-
get_outlet_stream(speed=self._boundary.max)
155-
except OutletFluidNotAchievableError as e:
156-
return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.max))
157-
158-
def min_speed_fluid_func(x: float) -> tuple[bool, bool]:
159-
try:
160-
get_outlet_stream(speed=x)
161-
return False, True # EOS succeeded — can try lower
162-
except OutletFluidNotAchievableError:
163-
return True, False # EOS failed — must go higher
164-
except (RateTooHighError, RateTooLowError):
165-
return True, False # rate error at this speed — also unusable
166-
167-
min_fluid_achievable_speed = self._search_strategy.search(
168-
boundary=self._boundary,
169-
func=min_speed_fluid_func,
170-
)
171-
minimum_speed_configuration = SpeedConfiguration(speed=min_fluid_achievable_speed)
172-
minimum_speed_outlet_stream = get_outlet_stream(speed=min_fluid_achievable_speed)
118+
result = self._search_lowest_eos_feasible_speed(get_outlet_stream)
119+
if isinstance(result, Solution):
120+
return result
121+
minimum_speed_configuration = result
122+
minimum_speed_outlet_stream = get_outlet_stream(speed=result.speed)
173123

174124
if minimum_speed_outlet_stream.pressure_bara > self._target_pressure:
175125
# Solution 2, target pressure is too low
@@ -202,6 +152,79 @@ def root_speed_func(x: float) -> float:
202152
func=root_speed_func,
203153
)
204154
except OutletFluidNotAchievableError as e:
205-
return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=e.unachievable_operating_point.speed))
155+
return self._fluid_not_achievable_solution(
156+
e, SpeedConfiguration(speed=e.unachievable_operating_point.speed)
157+
)
206158

207159
return Solution(success=True, configuration=SpeedConfiguration(speed=speed))
160+
161+
def _fluid_not_achievable_solution(
162+
self,
163+
e: OutletFluidNotAchievableError,
164+
configuration: SpeedConfiguration,
165+
) -> Solution[SpeedConfiguration]:
166+
logger.warning(f"Outlet fluid not achievable at speed {configuration.speed}", exc_info=e)
167+
return Solution(
168+
success=False,
169+
configuration=configuration,
170+
failure_event=OutletFluidNotAchievableEvent(
171+
status=SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE,
172+
source_id=e.process_unit_id,
173+
unachievable_operating_point=e.unachievable_operating_point,
174+
),
175+
)
176+
177+
def _search_highest_eos_feasible_speed(
178+
self,
179+
get_outlet_stream: Callable[[float], FluidStream],
180+
) -> SpeedConfiguration | Solution[SpeedConfiguration]:
181+
"""Binary-search downward for the highest speed where the EOS produces a valid outlet.
182+
183+
Pre-checks that boundary.min is feasible; if not, returns a failed Solution
184+
(the search would otherwise exhaust iterations without converging).
185+
"""
186+
logger.debug(
187+
"Outlet fluid not achievable at maximum speed %.1f — searching for highest feasible speed.",
188+
self._boundary.max,
189+
)
190+
try:
191+
get_outlet_stream(self._boundary.min)
192+
except OutletFluidNotAchievableError as e:
193+
return self._fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.min))
194+
195+
def feasible(x: float) -> BinarySearchResult:
196+
try:
197+
get_outlet_stream(x)
198+
return ACCEPT_AND_GO_HIGHER
199+
except (OutletFluidNotAchievableError, RateTooHighError, RateTooLowError):
200+
return REJECT_AND_GO_LOWER
201+
202+
speed = self._search_strategy.search(boundary=self._boundary, func=feasible)
203+
return SpeedConfiguration(speed=speed)
204+
205+
def _search_lowest_eos_feasible_speed(
206+
self,
207+
get_outlet_stream: Callable[[float], FluidStream],
208+
) -> SpeedConfiguration | Solution[SpeedConfiguration]:
209+
"""Binary-search upward for the lowest speed where the EOS produces a valid outlet.
210+
211+
Pre-checks that boundary.max is feasible; if not, returns a failed Solution.
212+
"""
213+
logger.debug(
214+
"Outlet fluid not achievable at minimum speed %.1f — searching for lowest feasible speed.",
215+
self._boundary.min,
216+
)
217+
try:
218+
get_outlet_stream(self._boundary.max)
219+
except OutletFluidNotAchievableError as e:
220+
return self._fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.max))
221+
222+
def feasible(x: float) -> BinarySearchResult:
223+
try:
224+
get_outlet_stream(x)
225+
return ACCEPT_AND_GO_LOWER
226+
except (OutletFluidNotAchievableError, RateTooHighError, RateTooLowError):
227+
return REJECT_AND_GO_HIGHER
228+
229+
speed = self._search_strategy.search(boundary=self._boundary, func=feasible)
230+
return SpeedConfiguration(speed=speed)

0 commit comments

Comments
 (0)