Skip to content

Commit f1dc628

Browse files
committed
feat(libecalc): handle unachievable outlet fluid states
Compressor catches CompressorThermodynamicCalculationError and wraps it as OutletFluidNotAchievableError with operating-point context. ShaftSpeedFinder and PipelineSectionSolver handle the new failure type.
1 parent da4cd0f commit f1dc628

6 files changed

Lines changed: 131 additions & 8 deletions

File tree

src/libecalc/process/process_pipeline/process_error.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1+
from dataclasses import dataclass
2+
13
from libecalc.common.errors.exceptions import EcalcError
24
from libecalc.process.process_pipeline.process_unit import ProcessUnitId
35

46

7+
@dataclass(frozen=True)
8+
class CompressorOperatingPoint:
9+
"""Context about the compressor state when a failure occurred."""
10+
11+
inlet_pressure_bara: float
12+
inlet_temperature_kelvin: float
13+
actual_rate_m3_per_hour: float
14+
polytropic_head_joule_per_kg: float
15+
polytropic_efficiency: float
16+
speed: float
17+
18+
519
class ProcessError(EcalcError):
620
def __init__(self, reason: str | None = None):
721
self.reason = reason
@@ -92,3 +106,21 @@ def __init__(
92106
f"Inlet pressure {inlet_pressure_bara:.3f} bara is insufficient for required pressure drop "
93107
f"{required_delta_pressure_bara:.3f} bara."
94108
)
109+
110+
111+
class OutletFluidNotAchievableError(ProcessError):
112+
"""The compressor's EOS calculation could not produce a valid outlet stream.
113+
114+
This occurs when the thermodynamic flash at the computed outlet conditions
115+
fails to converge, indicating a physically unachievable state.
116+
"""
117+
118+
def __init__(
119+
self,
120+
process_unit_id: ProcessUnitId,
121+
unachievable_operating_point: CompressorOperatingPoint,
122+
reason: str = "Outlet fluid state is not achievable.",
123+
):
124+
self.process_unit_id = process_unit_id
125+
self.unachievable_operating_point = unachievable_operating_point
126+
super().__init__(reason)

src/libecalc/process/process_solver/finders/shaft_speed_finder.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
CompressorStonewallError,
88
CompressorSurgeError,
99
InsufficientInletPressureError,
10+
OutletFluidNotAchievableError,
1011
)
1112
from libecalc.process.process_solver.boundary import Boundary
1213
from libecalc.process.process_solver.configuration import SpeedConfiguration
@@ -16,6 +17,7 @@
1617
CompressorStonewallFailure,
1718
CompressorSurgeFailure,
1819
InsufficientInletPressureFailure,
20+
OutletFluidNotAchievableFailure,
1921
TargetDirection,
2022
TargetPressureUnreachableFailure,
2123
ThermodynamicCalculationFailure,
@@ -65,6 +67,23 @@ def find(self, func: Callable[[SpeedConfiguration], FluidStream]) -> Finding[Spe
6567
boundary=Boundary(min=self._boundary.min, max=valid_max),
6668
target_pressure=self._target_pressure,
6769
).find(func)
70+
except OutletFluidNotAchievableError as e:
71+
logger.debug(
72+
"Outlet fluid not achievable at max speed %.1f rpm; searching for highest valid speed.",
73+
self._boundary.max,
74+
)
75+
valid_max = self._search_strategy.highest_true(self._boundary, lambda speed: self._eos_ok(func, speed))
76+
if valid_max is None:
77+
return Finding(
78+
configuration=max_speed_configuration,
79+
failure=OutletFluidNotAchievableFailure.from_error(e),
80+
)
81+
return ShaftSpeedFinder(
82+
search_strategy=self._search_strategy,
83+
root_finding_strategy=self._root_finding_strategy,
84+
boundary=Boundary(min=self._boundary.min, max=valid_max),
85+
target_pressure=self._target_pressure,
86+
).find(func)
6887
except InsufficientInletPressureError as e:
6988
logger.debug(f"Insufficient inlet pressure at maximum speed: {max_speed_configuration}")
7089
return Finding(
@@ -136,6 +155,7 @@ def _eos_ok(func: Callable[[SpeedConfiguration], FluidStream], speed: float) ->
136155
return True
137156
except (
138157
CompressorThermodynamicCalculationError,
158+
OutletFluidNotAchievableError,
139159
CompressorStonewallError,
140160
CompressorSurgeError,
141161
InsufficientInletPressureError,
@@ -147,7 +167,7 @@ def _find_min_within_capacity_speed(
147167
) -> tuple[SpeedConfiguration, FluidStream]:
148168
"""Return the lowest speed configuration within flow capacity, and its outlet stream.
149169
150-
``CompressorStonewallError``, ``CompressorThermodynamicCalculationError``, and
170+
``CompressorStonewallError``, ``CompressorThermodynamicCalculationError``, ``OutletFluidNotAchievableError``, and
151171
``InsufficientInletPressureError`` at the boundary minimum are recoverable:
152172
higher speed raises the stonewall limit or enters the valid EOS/pressure range;
153173
search upward.
@@ -156,7 +176,12 @@ def _find_min_within_capacity_speed(
156176
try:
157177
minimum_result = func(minimum_speed_configuration)
158178
return minimum_speed_configuration, minimum_result
159-
except (CompressorStonewallError, CompressorThermodynamicCalculationError, InsufficientInletPressureError) as e:
179+
except (
180+
CompressorStonewallError,
181+
OutletFluidNotAchievableError,
182+
CompressorThermodynamicCalculationError,
183+
InsufficientInletPressureError,
184+
) as e:
160185
logger.debug(f"No solution found for minimum speed: {self._boundary.min}", exc_info=e)
161186

162187
def bool_speed_func(x: float) -> BisectResult:
@@ -166,6 +191,7 @@ def bool_speed_func(x: float) -> BisectResult:
166191
except (
167192
CompressorStonewallError,
168193
CompressorThermodynamicCalculationError,
194+
OutletFluidNotAchievableError,
169195
InsufficientInletPressureError,
170196
):
171197
return BisectResult(higher=True, accepted=False)

src/libecalc/process/process_solver/pipeline_section_solver.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
CompressorSurgeFailure,
2020
ConvergenceFailure,
2121
InsufficientInletPressureFailure,
22+
OutletFluidNotAchievableFailure,
2223
Solution,
2324
TargetDirection,
2425
TargetPressureUnreachableFailure,
@@ -119,6 +120,7 @@ def _find_solution(
119120
CompressorSurgeFailure,
120121
ThermodynamicCalculationFailure,
121122
InsufficientInletPressureFailure,
123+
OutletFluidNotAchievableFailure,
122124
),
123125
):
124126
return Solution(configuration=[shaft_config], failure=speed_finding.failure)

src/libecalc/process/process_solver/solver.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
from libecalc.domain.process.compressor.core.exceptions import CompressorThermodynamicCalculationError
1111
from libecalc.process.fluid_stream.fluid_stream import FluidStream
1212
from libecalc.process.process_pipeline.process_error import (
13+
CompressorOperatingPoint,
1314
CompressorStonewallError,
1415
CompressorSurgeError,
1516
InsufficientInletPressureError,
1617
LiquidAtInletError,
1718
OfftakeExceedsInletError,
19+
OutletFluidNotAchievableError,
1820
ProcessError,
1921
)
2022
from libecalc.process.process_pipeline.process_pipeline import ProcessPipelineId, ProcessPipelineSectionId
@@ -74,6 +76,21 @@ class ThermodynamicCalculationFailure(SolverFailure):
7476
reason: str = ""
7577

7678

79+
@dataclass
80+
class OutletFluidNotAchievableFailure(SolverFailure):
81+
"""The compressor EOS calculation could not produce a valid outlet stream."""
82+
83+
process_unit_id: ProcessUnitId | None = None
84+
operating_point: CompressorOperatingPoint | None = None
85+
86+
@classmethod
87+
def from_error(cls, e: OutletFluidNotAchievableError) -> Self:
88+
return cls(
89+
process_unit_id=e.process_unit_id,
90+
operating_point=e.unachievable_operating_point,
91+
)
92+
93+
7794
@dataclass
7895
class ConvergenceFailure(SolverFailure):
7996
reason: str = ""
@@ -140,6 +157,8 @@ def process_error_to_failure(e: ProcessError) -> SolverFailure:
140157
)
141158
if isinstance(e, InsufficientInletPressureError):
142159
return InsufficientInletPressureFailure.from_error(e)
160+
if isinstance(e, OutletFluidNotAchievableError):
161+
return OutletFluidNotAchievableFailure.from_error(e)
143162
if isinstance(e, CompressorThermodynamicCalculationError):
144163
return ThermodynamicCalculationFailure(reason=str(e))
145164
if isinstance(e, CompressorStonewallError):

src/libecalc/process/process_units/compressor.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Final
22

33
from libecalc.common.ddd import value_object
4+
from libecalc.domain.process.compressor.core.exceptions import CompressorThermodynamicCalculationError
45
from libecalc.domain.process.compressor.core.train.utils.common import (
56
RECIRCULATION_BOUNDARY_TOLERANCE,
67
calculate_outlet_pressure_and_stream,
@@ -11,9 +12,11 @@
1112
from libecalc.process.fluid_stream.fluid_service import FluidService
1213
from libecalc.process.fluid_stream.fluid_stream import FluidStream
1314
from libecalc.process.process_pipeline.process_error import (
15+
CompressorOperatingPoint,
1416
CompressorStonewallError,
1517
CompressorSurgeError,
1618
LiquidAtInletError,
19+
OutletFluidNotAchievableError,
1720
)
1821
from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId
1922
from libecalc.process.process_solver.boundary import Boundary
@@ -63,12 +66,25 @@ def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream:
6366

6467
operational_point: OperationalPoint = self.get_operational_point(inlet_stream=inlet_stream)
6568

66-
return calculate_outlet_pressure_and_stream(
67-
polytropic_efficiency=operational_point.polytropic_efficiency,
68-
polytropic_head_joule_per_kg=operational_point.polytropic_head_joule_per_kg,
69-
inlet_stream=inlet_stream,
70-
fluid_service=self._fluid_service,
71-
)
69+
try:
70+
return calculate_outlet_pressure_and_stream(
71+
polytropic_efficiency=operational_point.polytropic_efficiency,
72+
polytropic_head_joule_per_kg=operational_point.polytropic_head_joule_per_kg,
73+
inlet_stream=inlet_stream,
74+
fluid_service=self._fluid_service,
75+
)
76+
except CompressorThermodynamicCalculationError as exc:
77+
raise OutletFluidNotAchievableError(
78+
process_unit_id=self._id,
79+
unachievable_operating_point=CompressorOperatingPoint(
80+
inlet_pressure_bara=inlet_stream.pressure_bara,
81+
inlet_temperature_kelvin=inlet_stream.temperature_kelvin,
82+
actual_rate_m3_per_hour=operational_point.actual_rate_am3_h,
83+
polytropic_head_joule_per_kg=operational_point.polytropic_head_joule_per_kg,
84+
polytropic_efficiency=operational_point.polytropic_efficiency,
85+
speed=self.speed,
86+
),
87+
) from exc
7288

7389
def get_operational_point(self, inlet_stream: FluidStream) -> OperationalPoint:
7490
"""

tests/libecalc/process/process_units/test_compressor.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,31 @@ def test_recirculation_range_min_positive_when_below_surge(self, stream_factory,
105105
)
106106
boundary = compressor.get_recirculation_range(inlet_stream=inlet)
107107
assert boundary.min > 0.0
108+
109+
110+
class TestCompressorEOSFailure:
111+
def test_raises_outlet_fluid_not_achievable_on_eos_failure(self, stream_factory, compressor, shaft):
112+
"""When the EOS cannot compute an outlet stream, OutletFluidNotAchievableError is raised
113+
with the operating-point context attached."""
114+
from unittest.mock import patch
115+
116+
from libecalc.domain.process.compressor.core.exceptions import CompressorThermodynamicCalculationError
117+
from libecalc.process.process_pipeline.process_error import OutletFluidNotAchievableError
118+
119+
speed = (shaft.get_speed_boundary().min + shaft.get_speed_boundary().max) / 2
120+
shaft.set_speed(speed)
121+
inlet = _inlet_at_midpoint(stream_factory, compressor)
122+
123+
with patch(
124+
"libecalc.process.process_units.compressor.calculate_outlet_pressure_and_stream",
125+
side_effect=CompressorThermodynamicCalculationError(operation="flash", reason="flash failed"),
126+
):
127+
with pytest.raises(OutletFluidNotAchievableError) as exc_info:
128+
compressor.propagate_stream(inlet_stream=inlet)
129+
130+
error = exc_info.value
131+
assert error.process_unit_id == compressor.get_id()
132+
op = error.unachievable_operating_point
133+
assert op.inlet_pressure_bara == inlet.pressure_bara
134+
assert op.speed == speed
135+
assert op.actual_rate_m3_per_hour > 0

0 commit comments

Comments
 (0)