Skip to content

Commit 4fc87d2

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 45df459 commit 4fc87d2

6 files changed

Lines changed: 147 additions & 14 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: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33

44
from libecalc.domain.process.compressor.core.exceptions import CompressorThermodynamicCalculationError
55
from libecalc.process.fluid_stream.fluid_stream import FluidStream
6-
from libecalc.process.process_pipeline.process_error import CompressorStonewallError, CompressorSurgeError
6+
from libecalc.process.process_pipeline.process_error import (
7+
CompressorStonewallError,
8+
CompressorSurgeError,
9+
OutletFluidNotAchievableError,
10+
)
711
from libecalc.process.process_solver.boundary import Boundary
812
from libecalc.process.process_solver.configuration import SpeedConfiguration
913
from libecalc.process.process_solver.finder import Finder, Finding
1014
from libecalc.process.process_solver.search_strategies import Bisect, BisectResult, RootFindingStrategy
1115
from libecalc.process.process_solver.solver import (
1216
CompressorStonewallFailure,
1317
CompressorSurgeFailure,
18+
OutletFluidNotAchievableFailure,
1419
TargetDirection,
1520
TargetPressureUnreachableFailure,
1621
ThermodynamicCalculationFailure,
@@ -60,6 +65,23 @@ def find(self, func: Callable[[SpeedConfiguration], FluidStream]) -> Finding[Spe
6065
boundary=Boundary(min=self._boundary.min, max=valid_max),
6166
target_pressure=self._target_pressure,
6267
).find(func)
68+
except OutletFluidNotAchievableError as e:
69+
logger.debug(
70+
"Outlet fluid not achievable at max speed %.1f rpm; searching for highest valid speed.",
71+
self._boundary.max,
72+
)
73+
valid_max = self._search_strategy.highest_true(self._boundary, lambda speed: self._eos_ok(func, speed))
74+
if valid_max is None:
75+
return Finding(
76+
configuration=max_speed_configuration,
77+
failure=OutletFluidNotAchievableFailure.from_error(e),
78+
)
79+
return ShaftSpeedFinder(
80+
search_strategy=self._search_strategy,
81+
root_finding_strategy=self._root_finding_strategy,
82+
boundary=Boundary(min=self._boundary.min, max=valid_max),
83+
target_pressure=self._target_pressure,
84+
).find(func)
6385

6486
if maximum_speed_outlet_stream.pressure_bara < self._target_pressure:
6587
return Finding(
@@ -116,30 +138,39 @@ def _eos_ok(func: Callable[[SpeedConfiguration], FluidStream], speed: float) ->
116138
try:
117139
func(SpeedConfiguration(speed=speed))
118140
return True
119-
except (CompressorThermodynamicCalculationError, CompressorStonewallError, CompressorSurgeError):
141+
except (
142+
CompressorThermodynamicCalculationError,
143+
OutletFluidNotAchievableError,
144+
CompressorStonewallError,
145+
CompressorSurgeError,
146+
):
120147
return False
121148

122149
def _find_min_within_capacity_speed(
123150
self, func: Callable[[SpeedConfiguration], FluidStream]
124151
) -> tuple[SpeedConfiguration, FluidStream]:
125152
"""Return the lowest speed configuration within flow capacity, and its outlet stream.
126153
127-
``CompressorStonewallError`` and ``CompressorThermodynamicCalculationError`` at the boundary
128-
minimum are recoverable: higher speed raises the stonewall limit or enters the valid
129-
EOS range; search upward.
154+
``CompressorStonewallError``, ``OutletFluidNotAchievableError``, and
155+
``CompressorThermodynamicCalculationError`` at the boundary minimum are recoverable:
156+
higher speed raises the stonewall limit or enters the valid EOS range; search upward.
130157
"""
131158
minimum_speed_configuration = SpeedConfiguration(speed=self._boundary.min)
132159
try:
133160
minimum_result = func(minimum_speed_configuration)
134161
return minimum_speed_configuration, minimum_result
135-
except (CompressorStonewallError, CompressorThermodynamicCalculationError) as e:
162+
except (CompressorStonewallError, CompressorThermodynamicCalculationError, OutletFluidNotAchievableError) as e:
136163
logger.debug(f"No solution found for minimum speed: {self._boundary.min}", exc_info=e)
137164

138165
def bool_speed_func(x: float) -> BisectResult:
139166
try:
140167
func(SpeedConfiguration(speed=x))
141168
return BisectResult(higher=False, accepted=True)
142-
except (CompressorStonewallError, CompressorThermodynamicCalculationError):
169+
except (
170+
CompressorStonewallError,
171+
CompressorThermodynamicCalculationError,
172+
OutletFluidNotAchievableError,
173+
):
143174
return BisectResult(higher=True, accepted=False)
144175
except CompressorSurgeError:
145176
return BisectResult(higher=False, accepted=False)

src/libecalc/process/process_solver/pipeline_section_solver.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CompressorStonewallFailure,
1919
CompressorSurgeFailure,
2020
ConvergenceFailure,
21+
OutletFluidNotAchievableFailure,
2122
Solution,
2223
TargetDirection,
2324
TargetPressureUnreachableFailure,
@@ -112,7 +113,13 @@ def _find_solution(
112113
)
113114

114115
if isinstance(
115-
speed_finding.failure, (CompressorStonewallFailure, CompressorSurgeFailure, ThermodynamicCalculationFailure)
116+
speed_finding.failure,
117+
(
118+
CompressorStonewallFailure,
119+
CompressorSurgeFailure,
120+
ThermodynamicCalculationFailure,
121+
OutletFluidNotAchievableFailure,
122+
),
116123
):
117124
return Solution(configuration=[shaft_config], failure=speed_finding.failure)
118125

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 = ""
@@ -136,6 +153,8 @@ def process_error_to_failure(e: ProcessError) -> SolverFailure:
136153
inlet_pressure_bara=e.inlet_pressure_bara,
137154
required_delta_pressure_bara=e.required_delta_pressure_bara,
138155
)
156+
if isinstance(e, OutletFluidNotAchievableError):
157+
return OutletFluidNotAchievableFailure.from_error(e)
139158
if isinstance(e, CompressorThermodynamicCalculationError):
140159
return ThermodynamicCalculationFailure(reason=str(e))
141160
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)