From 62eeffaeb1f095478fffc2cb58c4ffc37402c21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Wed, 6 May 2026 23:32:30 +0200 Subject: [PATCH 1/7] feat(process): OutletFluidNotAchievableError and SpeedSolver handling --- .../process/process_pipeline/process_error.py | 33 +++++++ src/libecalc/process/process_solver/solver.py | 15 ++- .../process_solver/solvers/speed_solver.py | 94 ++++++++++++++++++- .../process/process_units/compressor.py | 31 ++++-- 4 files changed, 160 insertions(+), 13 deletions(-) diff --git a/src/libecalc/process/process_pipeline/process_error.py b/src/libecalc/process/process_pipeline/process_error.py index 9ac05b31ab..f16b53951d 100644 --- a/src/libecalc/process/process_pipeline/process_error.py +++ b/src/libecalc/process/process_pipeline/process_error.py @@ -27,6 +27,39 @@ def __init__( super().__init__(reason) +class OutletFluidNotAchievableError(ProcessError): + """Raised when the compressor cannot produce a thermodynamically valid outlet stream. + + This typically means the EOS / PH flash rejected the requested outlet state + (e.g. NaN/inf properties, enthalpy did not converge, or NeqSim raised an + exception). Unlike RateTooHighError the compressor was operating inside its + chart capacity — the fluid itself is the problem. + + All fields describe the exact operating point at failure, enough to reproduce + the issue in isolation. + """ + + def __init__( + self, + process_unit_id: ProcessUnitId, + inlet_pressure_bara: float | None = None, + inlet_temperature_kelvin: float | None = None, + actual_rate_m3_per_hour: float | None = None, + polytropic_head_joule_per_kg: float | None = None, + polytropic_efficiency: float | None = None, + speed: float | None = None, + reason: str = "Outlet fluid state is not achievable.", + ): + self.process_unit_id = process_unit_id + self.inlet_pressure_bara = inlet_pressure_bara + self.inlet_temperature_kelvin = inlet_temperature_kelvin + self.actual_rate_m3_per_hour = actual_rate_m3_per_hour + self.polytropic_head_joule_per_kg = polytropic_head_joule_per_kg + self.polytropic_efficiency = polytropic_efficiency + self.speed = speed + super().__init__(reason) + + class RateTooHighError(OutsideCapacityError): def __init__( self, diff --git a/src/libecalc/process/process_solver/solver.py b/src/libecalc/process/process_solver/solver.py index 5ac9caa614..9779b18707 100644 --- a/src/libecalc/process/process_solver/solver.py +++ b/src/libecalc/process/process_solver/solver.py @@ -25,6 +25,7 @@ class SolverFailureStatus(StrEnum): BELOW_MINIMUM_FLOW_RATE = "BELOW_MINIMUM_FLOW_RATE" MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET = "MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET" MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET = "MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET" + OUTLET_FLUID_NOT_ACHIEVABLE = "OUTLET_FLUID_NOT_ACHIEVABLE" @dataclass @@ -46,7 +47,19 @@ def with_source_id(self, source_id: ProcessPipelineId) -> Self: return dataclasses.replace(self, source_id=source_id) -SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent +@dataclass +class OutletFluidNotAchievableEvent: + status: SolverFailureStatus + source_id: ProcessUnitId + inlet_pressure_bara: float | None = None + inlet_temperature_kelvin: float | None = None + actual_rate_m3_per_hour: float | None = None + polytropic_head_joule_per_kg: float | None = None + polytropic_efficiency: float | None = None + speed: float | None = None + + +SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent | OutletFluidNotAchievableEvent @dataclass(frozen=True) diff --git a/src/libecalc/process/process_solver/solvers/speed_solver.py b/src/libecalc/process/process_solver/solvers/speed_solver.py index b48e7e12c4..478ff26641 100644 --- a/src/libecalc/process/process_solver/solvers/speed_solver.py +++ b/src/libecalc/process/process_solver/solvers/speed_solver.py @@ -2,11 +2,16 @@ from collections.abc import Callable from libecalc.process.fluid_stream.fluid_stream import FluidStream -from libecalc.process.process_pipeline.process_error import RateTooHighError, RateTooLowError +from libecalc.process.process_pipeline.process_error import ( + OutletFluidNotAchievableError, + RateTooHighError, + RateTooLowError, +) from libecalc.process.process_solver.boundary import Boundary from libecalc.process.process_solver.configuration import SpeedConfiguration from libecalc.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy from libecalc.process.process_solver.solver import ( + OutletFluidNotAchievableEvent, OutsideCapacityEvent, Solution, Solver, @@ -34,6 +39,25 @@ def solve(self, func: Callable[[SpeedConfiguration], FluidStream]) -> Solution[S def get_outlet_stream(speed: float) -> FluidStream: return func(SpeedConfiguration(speed=speed)) + def _fluid_not_achievable_solution( + e: OutletFluidNotAchievableError, configuration: SpeedConfiguration + ) -> Solution[SpeedConfiguration]: + logger.debug(f"Outlet fluid not achievable at speed {configuration.speed}", exc_info=e) + return Solution( + success=False, + configuration=configuration, + failure_event=OutletFluidNotAchievableEvent( + status=SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE, + source_id=e.process_unit_id, + inlet_pressure_bara=e.inlet_pressure_bara, + inlet_temperature_kelvin=e.inlet_temperature_kelvin, + actual_rate_m3_per_hour=e.actual_rate_m3_per_hour, + polytropic_head_joule_per_kg=e.polytropic_head_joule_per_kg, + polytropic_efficiency=e.polytropic_efficiency, + speed=e.speed, + ), + ) + max_speed_configuration = SpeedConfiguration(speed=self._boundary.max) try: maximum_speed_outlet_stream = func(max_speed_configuration) @@ -61,6 +85,34 @@ def get_outlet_stream(speed: float) -> FluidStream: source_id=e.process_unit_id, ), ) + except OutletFluidNotAchievableError: + # EOS fails at max speed — find the highest speed where a valid outlet can still be produced. + logger.debug( + "Outlet fluid not achievable at maximum speed %.1f — searching for highest feasible speed.", + self._boundary.max, + ) + + # Pre-check: if boundary.min also fails, no speed in the range produces a valid outlet. + try: + get_outlet_stream(speed=self._boundary.min) + except OutletFluidNotAchievableError as e: + return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.min)) + + def max_speed_fluid_func(x: float) -> tuple[bool, bool]: + try: + get_outlet_stream(speed=x) + return True, True # EOS succeeded — can try higher + except OutletFluidNotAchievableError: + return False, False # EOS failed — must go lower + except (RateTooHighError, RateTooLowError): + return False, False # rate error at this speed — also unusable + + max_fluid_achievable_speed = self._search_strategy.search( + boundary=self._boundary, + func=max_speed_fluid_func, + ) + max_speed_configuration = SpeedConfiguration(speed=max_fluid_achievable_speed) + maximum_speed_outlet_stream = get_outlet_stream(speed=max_fluid_achievable_speed) if maximum_speed_outlet_stream.pressure_bara < self._target_pressure: return Solution( @@ -95,6 +147,34 @@ def bool_speed_func(x: float): ) minimum_speed_configuration = SpeedConfiguration(speed=minimum_speed_within_capacity) minimum_speed_outlet_stream = func(minimum_speed_configuration) + except OutletFluidNotAchievableError: + # EOS fails at min speed — find the lowest speed where a valid outlet can be produced. + logger.debug( + "Outlet fluid not achievable at minimum speed %.1f — searching for lowest feasible speed.", + self._boundary.min, + ) + + # Pre-check: if boundary.max also fails, no speed in the range produces a valid outlet. + try: + get_outlet_stream(speed=self._boundary.max) + except OutletFluidNotAchievableError as e: + return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.max)) + + def min_speed_fluid_func(x: float) -> tuple[bool, bool]: + try: + get_outlet_stream(speed=x) + return False, True # EOS succeeded — can try lower + except OutletFluidNotAchievableError: + return True, False # EOS failed — must go higher + except (RateTooHighError, RateTooLowError): + return True, False # rate error at this speed — also unusable + + min_fluid_achievable_speed = self._search_strategy.search( + boundary=self._boundary, + func=min_speed_fluid_func, + ) + minimum_speed_configuration = SpeedConfiguration(speed=min_fluid_achievable_speed) + minimum_speed_outlet_stream = get_outlet_stream(speed=min_fluid_achievable_speed) if minimum_speed_outlet_stream.pressure_bara > self._target_pressure: # Solution 2, target pressure is too low @@ -121,8 +201,12 @@ def root_speed_func(x: float) -> float: out = get_outlet_stream(speed=x) return out.pressure_bara - self._target_pressure - speed = self._root_finding_strategy.find_root( - boundary=Boundary(min=minimum_speed_configuration.speed, max=self._boundary.max), - func=root_speed_func, - ) + try: + speed = self._root_finding_strategy.find_root( + boundary=Boundary(min=minimum_speed_configuration.speed, max=max_speed_configuration.speed), + func=root_speed_func, + ) + except OutletFluidNotAchievableError as e: + return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=e.speed or max_speed_configuration.speed)) + return Solution(success=True, configuration=SpeedConfiguration(speed=speed)) diff --git a/src/libecalc/process/process_units/compressor.py b/src/libecalc/process/process_units/compressor.py index 8340a11ab7..ee6684ef1d 100644 --- a/src/libecalc/process/process_units/compressor.py +++ b/src/libecalc/process/process_units/compressor.py @@ -8,7 +8,11 @@ from libecalc.domain.process.value_objects.chart.compressor import CompressorChart from libecalc.process.fluid_stream.fluid_service import FluidService from libecalc.process.fluid_stream.fluid_stream import FluidStream -from libecalc.process.process_pipeline.process_error import RateTooHighError, RateTooLowError +from libecalc.process.process_pipeline.process_error import ( + OutletFluidNotAchievableError, + RateTooHighError, + RateTooLowError, +) from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId from libecalc.process.process_solver.boundary import Boundary @@ -56,12 +60,25 @@ def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: rate=actual_rate, ) - return calculate_outlet_pressure_and_stream( - polytropic_efficiency=polytropic_efficiency, - polytropic_head_joule_per_kg=polytropic_head, - inlet_stream=inlet_stream, - fluid_service=self._fluid_service, - ) + try: + return calculate_outlet_pressure_and_stream( + polytropic_efficiency=polytropic_efficiency, + polytropic_head_joule_per_kg=polytropic_head, + inlet_stream=inlet_stream, + fluid_service=self._fluid_service, + ) + except Exception as exc: + # Any exception from the EOS flash layer (including Java/NeqSim exceptions + # that are not EcalcError subclasses) means the outlet state is not computable. + raise OutletFluidNotAchievableError( + process_unit_id=self._id, + inlet_pressure_bara=inlet_stream.pressure_bara, + inlet_temperature_kelvin=inlet_stream.temperature_kelvin, + actual_rate_m3_per_hour=actual_rate, + polytropic_head_joule_per_kg=polytropic_head, + polytropic_efficiency=polytropic_efficiency, + speed=self.speed, + ) from exc @property def compressor_chart(self) -> CompressorChart: From 3cf7ffd6180ef582585b044dea329b80f672772a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Wed, 6 May 2026 23:32:33 +0200 Subject: [PATCH 2/7] test(process): SpeedSolver OutletFluidNotAchievableError scenarios --- .../solvers/test_speed_solver.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/tests/libecalc/process/process_solver/solvers/test_speed_solver.py b/tests/libecalc/process/process_solver/solvers/test_speed_solver.py index 9c38508cbb..c521c0ffff 100644 --- a/tests/libecalc/process/process_solver/solvers/test_speed_solver.py +++ b/tests/libecalc/process/process_solver/solvers/test_speed_solver.py @@ -4,9 +4,11 @@ from libecalc.process.fluid_stream.fluid_service import FluidService from libecalc.process.fluid_stream.fluid_stream import FluidStream +from libecalc.process.process_pipeline.process_error import OutletFluidNotAchievableError from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId from libecalc.process.process_solver.boundary import Boundary from libecalc.process.process_solver.process_pipeline_runner import propagate_stream_many +from libecalc.process.process_solver.solver import SolverFailureStatus, TargetNotAchievableEvent from libecalc.process.process_solver.solvers.speed_solver import SpeedConfiguration, SpeedSolver from libecalc.process.shaft import Shaft, VariableSpeedShaft @@ -85,3 +87,217 @@ def speed_func(configuration: SpeedConfiguration): assert solution.configuration.speed == expected_speed outlet_stream = speed_func(solution.configuration) assert outlet_stream.pressure_bara == expected_pressure + + +class FluidNotAchievableProcessUnit(ProcessUnit): + """Process unit that raises OutletFluidNotAchievableError based on speed. + + Behaves like SpeedProcessUnit (pressure = inlet_pressure + speed) except when + the speed matches the failure condition. Supports two modes: + - fails_at_or_above: raises when speed >= threshold (for max-speed failure tests) + - fails_at_or_below: raises when speed <= threshold (for min-speed failure tests) + """ + + def __init__( + self, + shaft: Shaft, + fluid_service: FluidService, + *, + fails_at_or_above: float | None = None, + fails_at_or_below: float | None = None, + process_unit_id: ProcessUnitId | None = None, + ): + self._id: Final[ProcessUnitId] = process_unit_id or ProcessUnit._create_id() + self._shaft = shaft + self._fluid_service = fluid_service + self._fails_at_or_above = fails_at_or_above + self._fails_at_or_below = fails_at_or_below + + def get_id(self) -> ProcessUnitId: + return self._id + + def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: + speed = self._shaft.get_speed() + should_fail = (self._fails_at_or_above is not None and speed >= self._fails_at_or_above) or ( + self._fails_at_or_below is not None and speed <= self._fails_at_or_below + ) + if should_fail: + raise OutletFluidNotAchievableError( + process_unit_id=self._id, + speed=speed, + inlet_pressure_bara=inlet_stream.pressure_bara, + ) + return self._fluid_service.create_stream_from_standard_rate( + fluid_model=inlet_stream.fluid_model, + pressure_bara=inlet_stream.pressure_bara + speed, + standard_rate_m3_per_day=inlet_stream.standard_rate_sm3_per_day, + temperature_kelvin=inlet_stream.temperature_kelvin, + ) + + +def test_min_speed_fluid_not_achievable_target_achievable( + search_strategy_factory, + root_finding_strategy, + stream_factory, + shaft, + fluid_service, +): + """OutletFluidNotAchievableError at min speed: binary search finds higher effective min; target still reachable.""" + unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=300) + speed_solver = SpeedSolver( + search_strategy=search_strategy_factory(), + root_finding_strategy=root_finding_strategy, + boundary=Boundary(min=200, max=600), + target_pressure=450, + ) + inlet_stream = stream_factory(standard_rate_m3_per_day=1000, pressure_bara=100) + + def speed_func(configuration: SpeedConfiguration) -> FluidStream: + shaft.set_speed(configuration.speed) + return propagate_stream_many(process_units=[unit], inlet_stream=inlet_stream) + + solution = speed_solver.solve(speed_func) + + assert solution.success is True + assert abs(solution.configuration.speed - 350) < 1.0 + + +def test_min_speed_fluid_not_achievable_target_not_achievable( + search_strategy_factory, + root_finding_strategy, + stream_factory, + shaft, + fluid_service, +): + """OutletFluidNotAchievableError at min speed: effective min too high → target below minimum achievable.""" + unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=400) + speed_solver = SpeedSolver( + search_strategy=search_strategy_factory(), + root_finding_strategy=root_finding_strategy, + boundary=Boundary(min=200, max=600), + target_pressure=350, + ) + inlet_stream = stream_factory(standard_rate_m3_per_day=1000, pressure_bara=100) + + def speed_func(configuration: SpeedConfiguration) -> FluidStream: + shaft.set_speed(configuration.speed) + return propagate_stream_many(process_units=[unit], inlet_stream=inlet_stream) + + solution = speed_solver.solve(speed_func) + + assert solution.success is False + assert isinstance(solution.failure_event, TargetNotAchievableEvent) + assert solution.failure_event.status == SolverFailureStatus.MINIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_ABOVE_TARGET + assert solution.failure_event.achievable_value > 350 + assert solution.failure_event.target_value == 350 + + +def test_max_speed_fluid_not_achievable_target_achievable( + search_strategy_factory, + root_finding_strategy, + stream_factory, + shaft, + fluid_service, +): + """OutletFluidNotAchievableError at max speed: binary search finds lower effective max; target still reachable.""" + unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=500) + speed_solver = SpeedSolver( + search_strategy=search_strategy_factory(), + root_finding_strategy=root_finding_strategy, + boundary=Boundary(min=200, max=600), + target_pressure=350, + ) + inlet_stream = stream_factory(standard_rate_m3_per_day=1000, pressure_bara=100) + + def speed_func(configuration: SpeedConfiguration) -> FluidStream: + shaft.set_speed(configuration.speed) + return propagate_stream_many(process_units=[unit], inlet_stream=inlet_stream) + + solution = speed_solver.solve(speed_func) + + assert solution.success is True + assert abs(solution.configuration.speed - 250) < 1.0 + + +def test_max_speed_fluid_not_achievable_target_not_achievable( + search_strategy_factory, + root_finding_strategy, + stream_factory, + shaft, + fluid_service, +): + """OutletFluidNotAchievableError at max speed: binary search finds lower effective max; target not reachable.""" + unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=300) + speed_solver = SpeedSolver( + search_strategy=search_strategy_factory(), + root_finding_strategy=root_finding_strategy, + boundary=Boundary(min=200, max=600), + target_pressure=500, + ) + inlet_stream = stream_factory(standard_rate_m3_per_day=1000, pressure_bara=100) + + def speed_func(configuration: SpeedConfiguration) -> FluidStream: + shaft.set_speed(configuration.speed) + return propagate_stream_many(process_units=[unit], inlet_stream=inlet_stream) + + solution = speed_solver.solve(speed_func) + + assert solution.success is False + assert isinstance(solution.failure_event, TargetNotAchievableEvent) + assert solution.failure_event.status == SolverFailureStatus.MAXIMUM_ACHIEVABLE_DISCHARGE_PRESSURE_BELOW_TARGET + assert solution.failure_event.achievable_value < 500 + assert solution.failure_event.target_value == 500 + + +def test_all_speeds_fluid_not_achievable_from_max( + search_strategy_factory, + root_finding_strategy, + stream_factory, + shaft, + fluid_service, +): + """All speeds fail EOS when searching from max: pre-check at boundary.min catches it immediately.""" + unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=0) + speed_solver = SpeedSolver( + search_strategy=search_strategy_factory(), + root_finding_strategy=root_finding_strategy, + boundary=Boundary(min=200, max=600), + target_pressure=350, + ) + inlet_stream = stream_factory(standard_rate_m3_per_day=1000, pressure_bara=100) + + def speed_func(configuration: SpeedConfiguration) -> FluidStream: + shaft.set_speed(configuration.speed) + return propagate_stream_many(process_units=[unit], inlet_stream=inlet_stream) + + solution = speed_solver.solve(speed_func) + + assert solution.success is False + assert solution.failure_event.status == SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE + + +def test_all_speeds_fluid_not_achievable_from_min( + search_strategy_factory, + root_finding_strategy, + stream_factory, + shaft, + fluid_service, +): + """All speeds fail EOS when searching from min: pre-check at boundary.max catches it immediately.""" + unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=600) + speed_solver = SpeedSolver( + search_strategy=search_strategy_factory(), + root_finding_strategy=root_finding_strategy, + boundary=Boundary(min=200, max=600), + target_pressure=350, + ) + inlet_stream = stream_factory(standard_rate_m3_per_day=1000, pressure_bara=100) + + def speed_func(configuration: SpeedConfiguration) -> FluidStream: + shaft.set_speed(configuration.speed) + return propagate_stream_many(process_units=[unit], inlet_stream=inlet_stream) + + solution = speed_solver.solve(speed_func) + + assert solution.success is False + assert solution.failure_event.status == SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE From 24ed5840448fa2ddefacef55c3020583bbeb5aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Wed, 6 May 2026 23:36:51 +0200 Subject: [PATCH 3/7] docs: add changelog entry for SpeedSolver EOS failure handling --- docs/drafts/next.draft.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/drafts/next.draft.md b/docs/drafts/next.draft.md index 88ef8ad32b..ba37489cba 100644 --- a/docs/drafts/next.draft.md +++ b/docs/drafts/next.draft.md @@ -12,6 +12,7 @@ sidebar_position: -61 STP: "flare" column has been added to STP Export - for `FIXED` installations only. ## Bug Fixes +Fixed: `SpeedSolver` now correctly handles EOS/PHflash failures near the dense/supercritical boundary instead of propagating unhandled exceptions. - Hardened compressor PH flash handling so invalid thermodynamic states are no longer used in compressor outlet calculations. From 4cfc5959ac3bf3becd9a325f9fece72fa54a6751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Thu, 7 May 2026 11:42:55 +0200 Subject: [PATCH 4/7] refactor(process): introduce CompressorOperatingPoint --- .../process/process_pipeline/process_error.py | 32 ++++++++++++------- src/libecalc/process/process_solver/solver.py | 8 ++--- .../process_solver/solvers/speed_solver.py | 9 ++---- .../process/process_units/compressor.py | 15 +++++---- .../solvers/test_speed_solver.py | 15 +++++++-- 5 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/libecalc/process/process_pipeline/process_error.py b/src/libecalc/process/process_pipeline/process_error.py index f16b53951d..7c294a8157 100644 --- a/src/libecalc/process/process_pipeline/process_error.py +++ b/src/libecalc/process/process_pipeline/process_error.py @@ -1,7 +1,25 @@ +from dataclasses import dataclass + from libecalc.common.errors.exceptions import EcalcError from libecalc.process.process_pipeline.process_unit import ProcessUnitId +@dataclass(frozen=True) +class CompressorOperatingPoint: + """Thermodynamic and mechanical state describing what a compressor was asked to do. + + Together these fields are sufficient to reproduce the operating point in isolation + (build the inlet stream, set the speed, request the head/efficiency). + """ + + inlet_pressure_bara: float + inlet_temperature_kelvin: float + actual_rate_m3_per_hour: float + polytropic_head_joule_per_kg: float + polytropic_efficiency: float + speed: float + + class ProcessError(EcalcError): def __init__(self, reason: str | None = None): self.reason = reason @@ -42,21 +60,11 @@ class OutletFluidNotAchievableError(ProcessError): def __init__( self, process_unit_id: ProcessUnitId, - inlet_pressure_bara: float | None = None, - inlet_temperature_kelvin: float | None = None, - actual_rate_m3_per_hour: float | None = None, - polytropic_head_joule_per_kg: float | None = None, - polytropic_efficiency: float | None = None, - speed: float | None = None, + unachievable_operating_point: CompressorOperatingPoint, reason: str = "Outlet fluid state is not achievable.", ): self.process_unit_id = process_unit_id - self.inlet_pressure_bara = inlet_pressure_bara - self.inlet_temperature_kelvin = inlet_temperature_kelvin - self.actual_rate_m3_per_hour = actual_rate_m3_per_hour - self.polytropic_head_joule_per_kg = polytropic_head_joule_per_kg - self.polytropic_efficiency = polytropic_efficiency - self.speed = speed + self.unachievable_operating_point = unachievable_operating_point super().__init__(reason) diff --git a/src/libecalc/process/process_solver/solver.py b/src/libecalc/process/process_solver/solver.py index 9779b18707..b61c7afef5 100644 --- a/src/libecalc/process/process_solver/solver.py +++ b/src/libecalc/process/process_solver/solver.py @@ -8,6 +8,7 @@ from typing import Self, TypeVar from libecalc.process.fluid_stream.fluid_stream import FluidStream +from libecalc.process.process_pipeline.process_error import CompressorOperatingPoint from libecalc.process.process_pipeline.process_pipeline import ProcessPipelineId from libecalc.process.process_pipeline.process_unit import ProcessUnitId from libecalc.process.process_solver.configuration import ( @@ -51,12 +52,7 @@ def with_source_id(self, source_id: ProcessPipelineId) -> Self: class OutletFluidNotAchievableEvent: status: SolverFailureStatus source_id: ProcessUnitId - inlet_pressure_bara: float | None = None - inlet_temperature_kelvin: float | None = None - actual_rate_m3_per_hour: float | None = None - polytropic_head_joule_per_kg: float | None = None - polytropic_efficiency: float | None = None - speed: float | None = None + unachievable_operating_point: CompressorOperatingPoint SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent | OutletFluidNotAchievableEvent diff --git a/src/libecalc/process/process_solver/solvers/speed_solver.py b/src/libecalc/process/process_solver/solvers/speed_solver.py index 478ff26641..954418e0be 100644 --- a/src/libecalc/process/process_solver/solvers/speed_solver.py +++ b/src/libecalc/process/process_solver/solvers/speed_solver.py @@ -49,12 +49,7 @@ def _fluid_not_achievable_solution( failure_event=OutletFluidNotAchievableEvent( status=SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE, source_id=e.process_unit_id, - inlet_pressure_bara=e.inlet_pressure_bara, - inlet_temperature_kelvin=e.inlet_temperature_kelvin, - actual_rate_m3_per_hour=e.actual_rate_m3_per_hour, - polytropic_head_joule_per_kg=e.polytropic_head_joule_per_kg, - polytropic_efficiency=e.polytropic_efficiency, - speed=e.speed, + unachievable_operating_point=e.unachievable_operating_point, ), ) @@ -207,6 +202,6 @@ def root_speed_func(x: float) -> float: func=root_speed_func, ) except OutletFluidNotAchievableError as e: - return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=e.speed or max_speed_configuration.speed)) + return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=e.unachievable_operating_point.speed)) return Solution(success=True, configuration=SpeedConfiguration(speed=speed)) diff --git a/src/libecalc/process/process_units/compressor.py b/src/libecalc/process/process_units/compressor.py index ee6684ef1d..d7620ca9d2 100644 --- a/src/libecalc/process/process_units/compressor.py +++ b/src/libecalc/process/process_units/compressor.py @@ -9,6 +9,7 @@ from libecalc.process.fluid_stream.fluid_service import FluidService from libecalc.process.fluid_stream.fluid_stream import FluidStream from libecalc.process.process_pipeline.process_error import ( + CompressorOperatingPoint, OutletFluidNotAchievableError, RateTooHighError, RateTooLowError, @@ -72,12 +73,14 @@ def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: # that are not EcalcError subclasses) means the outlet state is not computable. raise OutletFluidNotAchievableError( process_unit_id=self._id, - inlet_pressure_bara=inlet_stream.pressure_bara, - inlet_temperature_kelvin=inlet_stream.temperature_kelvin, - actual_rate_m3_per_hour=actual_rate, - polytropic_head_joule_per_kg=polytropic_head, - polytropic_efficiency=polytropic_efficiency, - speed=self.speed, + unachievable_operating_point=CompressorOperatingPoint( + inlet_pressure_bara=inlet_stream.pressure_bara, + inlet_temperature_kelvin=inlet_stream.temperature_kelvin, + actual_rate_m3_per_hour=actual_rate, + polytropic_head_joule_per_kg=polytropic_head, + polytropic_efficiency=polytropic_efficiency, + speed=self.speed, + ), ) from exc @property diff --git a/tests/libecalc/process/process_solver/solvers/test_speed_solver.py b/tests/libecalc/process/process_solver/solvers/test_speed_solver.py index c521c0ffff..785d95659a 100644 --- a/tests/libecalc/process/process_solver/solvers/test_speed_solver.py +++ b/tests/libecalc/process/process_solver/solvers/test_speed_solver.py @@ -4,7 +4,10 @@ from libecalc.process.fluid_stream.fluid_service import FluidService from libecalc.process.fluid_stream.fluid_stream import FluidStream -from libecalc.process.process_pipeline.process_error import OutletFluidNotAchievableError +from libecalc.process.process_pipeline.process_error import ( + CompressorOperatingPoint, + OutletFluidNotAchievableError, +) from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId from libecalc.process.process_solver.boundary import Boundary from libecalc.process.process_solver.process_pipeline_runner import propagate_stream_many @@ -124,8 +127,14 @@ def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: if should_fail: raise OutletFluidNotAchievableError( process_unit_id=self._id, - speed=speed, - inlet_pressure_bara=inlet_stream.pressure_bara, + unachievable_operating_point=CompressorOperatingPoint( + inlet_pressure_bara=inlet_stream.pressure_bara, + inlet_temperature_kelvin=inlet_stream.temperature_kelvin, + actual_rate_m3_per_hour=0.0, + polytropic_head_joule_per_kg=0.0, + polytropic_efficiency=0.0, + speed=speed, + ), ) return self._fluid_service.create_stream_from_standard_rate( fluid_model=inlet_stream.fluid_model, From cbf60bfeb3038a55565e380e73a1672aeb1deba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Thu, 7 May 2026 11:43:04 +0200 Subject: [PATCH 5/7] refactor(process): split SpeedSolver EOS recovery into helpers; add BinarySearchResult --- .../process_solver/search_strategies.py | 12 ++ .../process_solver/solvers/speed_solver.py | 163 ++++++++++-------- 2 files changed, 105 insertions(+), 70 deletions(-) diff --git a/src/libecalc/process/process_solver/search_strategies.py b/src/libecalc/process/process_solver/search_strategies.py index 0b198623e0..4e33de6a35 100644 --- a/src/libecalc/process/process_solver/search_strategies.py +++ b/src/libecalc/process/process_solver/search_strategies.py @@ -1,5 +1,6 @@ import abc from collections.abc import Callable +from typing import NamedTuple from scipy.optimize import root_scalar @@ -9,6 +10,17 @@ CONVERGENCE_TOLERANCE = 1e-5 +class BinarySearchResult(NamedTuple): + higher: bool + accepted: bool + + +ACCEPT_AND_GO_HIGHER = BinarySearchResult(higher=True, accepted=True) +REJECT_AND_GO_LOWER = BinarySearchResult(higher=False, accepted=False) +ACCEPT_AND_GO_LOWER = BinarySearchResult(higher=False, accepted=True) +REJECT_AND_GO_HIGHER = BinarySearchResult(higher=True, accepted=False) + + class DidNotConvergeError(EcalcError): def __init__( self, diff --git a/src/libecalc/process/process_solver/solvers/speed_solver.py b/src/libecalc/process/process_solver/solvers/speed_solver.py index 954418e0be..f3e23b7246 100644 --- a/src/libecalc/process/process_solver/solvers/speed_solver.py +++ b/src/libecalc/process/process_solver/solvers/speed_solver.py @@ -9,7 +9,15 @@ ) from libecalc.process.process_solver.boundary import Boundary from libecalc.process.process_solver.configuration import SpeedConfiguration -from libecalc.process.process_solver.search_strategies import RootFindingStrategy, SearchStrategy +from libecalc.process.process_solver.search_strategies import ( + ACCEPT_AND_GO_HIGHER, + ACCEPT_AND_GO_LOWER, + REJECT_AND_GO_HIGHER, + REJECT_AND_GO_LOWER, + BinarySearchResult, + RootFindingStrategy, + SearchStrategy, +) from libecalc.process.process_solver.solver import ( OutletFluidNotAchievableEvent, OutsideCapacityEvent, @@ -39,20 +47,6 @@ def solve(self, func: Callable[[SpeedConfiguration], FluidStream]) -> Solution[S def get_outlet_stream(speed: float) -> FluidStream: return func(SpeedConfiguration(speed=speed)) - def _fluid_not_achievable_solution( - e: OutletFluidNotAchievableError, configuration: SpeedConfiguration - ) -> Solution[SpeedConfiguration]: - logger.debug(f"Outlet fluid not achievable at speed {configuration.speed}", exc_info=e) - return Solution( - success=False, - configuration=configuration, - failure_event=OutletFluidNotAchievableEvent( - status=SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE, - source_id=e.process_unit_id, - unachievable_operating_point=e.unachievable_operating_point, - ), - ) - max_speed_configuration = SpeedConfiguration(speed=self._boundary.max) try: maximum_speed_outlet_stream = func(max_speed_configuration) @@ -81,33 +75,11 @@ def _fluid_not_achievable_solution( ), ) except OutletFluidNotAchievableError: - # EOS fails at max speed — find the highest speed where a valid outlet can still be produced. - logger.debug( - "Outlet fluid not achievable at maximum speed %.1f — searching for highest feasible speed.", - self._boundary.max, - ) - - # Pre-check: if boundary.min also fails, no speed in the range produces a valid outlet. - try: - get_outlet_stream(speed=self._boundary.min) - except OutletFluidNotAchievableError as e: - return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.min)) - - def max_speed_fluid_func(x: float) -> tuple[bool, bool]: - try: - get_outlet_stream(speed=x) - return True, True # EOS succeeded — can try higher - except OutletFluidNotAchievableError: - return False, False # EOS failed — must go lower - except (RateTooHighError, RateTooLowError): - return False, False # rate error at this speed — also unusable - - max_fluid_achievable_speed = self._search_strategy.search( - boundary=self._boundary, - func=max_speed_fluid_func, - ) - max_speed_configuration = SpeedConfiguration(speed=max_fluid_achievable_speed) - maximum_speed_outlet_stream = get_outlet_stream(speed=max_fluid_achievable_speed) + result = self._search_highest_eos_feasible_speed(get_outlet_stream) + if isinstance(result, Solution): + return result + max_speed_configuration = result + maximum_speed_outlet_stream = get_outlet_stream(speed=result.speed) if maximum_speed_outlet_stream.pressure_bara < self._target_pressure: return Solution( @@ -143,33 +115,11 @@ def bool_speed_func(x: float): minimum_speed_configuration = SpeedConfiguration(speed=minimum_speed_within_capacity) minimum_speed_outlet_stream = func(minimum_speed_configuration) except OutletFluidNotAchievableError: - # EOS fails at min speed — find the lowest speed where a valid outlet can be produced. - logger.debug( - "Outlet fluid not achievable at minimum speed %.1f — searching for lowest feasible speed.", - self._boundary.min, - ) - - # Pre-check: if boundary.max also fails, no speed in the range produces a valid outlet. - try: - get_outlet_stream(speed=self._boundary.max) - except OutletFluidNotAchievableError as e: - return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.max)) - - def min_speed_fluid_func(x: float) -> tuple[bool, bool]: - try: - get_outlet_stream(speed=x) - return False, True # EOS succeeded — can try lower - except OutletFluidNotAchievableError: - return True, False # EOS failed — must go higher - except (RateTooHighError, RateTooLowError): - return True, False # rate error at this speed — also unusable - - min_fluid_achievable_speed = self._search_strategy.search( - boundary=self._boundary, - func=min_speed_fluid_func, - ) - minimum_speed_configuration = SpeedConfiguration(speed=min_fluid_achievable_speed) - minimum_speed_outlet_stream = get_outlet_stream(speed=min_fluid_achievable_speed) + result = self._search_lowest_eos_feasible_speed(get_outlet_stream) + if isinstance(result, Solution): + return result + minimum_speed_configuration = result + minimum_speed_outlet_stream = get_outlet_stream(speed=result.speed) if minimum_speed_outlet_stream.pressure_bara > self._target_pressure: # Solution 2, target pressure is too low @@ -202,6 +152,79 @@ def root_speed_func(x: float) -> float: func=root_speed_func, ) except OutletFluidNotAchievableError as e: - return _fluid_not_achievable_solution(e, SpeedConfiguration(speed=e.unachievable_operating_point.speed)) + return self._fluid_not_achievable_solution( + e, SpeedConfiguration(speed=e.unachievable_operating_point.speed) + ) return Solution(success=True, configuration=SpeedConfiguration(speed=speed)) + + def _fluid_not_achievable_solution( + self, + e: OutletFluidNotAchievableError, + configuration: SpeedConfiguration, + ) -> Solution[SpeedConfiguration]: + logger.warning(f"Outlet fluid not achievable at speed {configuration.speed}", exc_info=e) + return Solution( + success=False, + configuration=configuration, + failure_event=OutletFluidNotAchievableEvent( + status=SolverFailureStatus.OUTLET_FLUID_NOT_ACHIEVABLE, + source_id=e.process_unit_id, + unachievable_operating_point=e.unachievable_operating_point, + ), + ) + + def _search_highest_eos_feasible_speed( + self, + get_outlet_stream: Callable[[float], FluidStream], + ) -> SpeedConfiguration | Solution[SpeedConfiguration]: + """Binary-search downward for the highest speed where the EOS produces a valid outlet. + + Pre-checks that boundary.min is feasible; if not, returns a failed Solution + (the search would otherwise exhaust iterations without converging). + """ + logger.debug( + "Outlet fluid not achievable at maximum speed %.1f — searching for highest feasible speed.", + self._boundary.max, + ) + try: + get_outlet_stream(self._boundary.min) + except OutletFluidNotAchievableError as e: + return self._fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.min)) + + def feasible(x: float) -> BinarySearchResult: + try: + get_outlet_stream(x) + return ACCEPT_AND_GO_HIGHER + except (OutletFluidNotAchievableError, RateTooHighError, RateTooLowError): + return REJECT_AND_GO_LOWER + + speed = self._search_strategy.search(boundary=self._boundary, func=feasible) + return SpeedConfiguration(speed=speed) + + def _search_lowest_eos_feasible_speed( + self, + get_outlet_stream: Callable[[float], FluidStream], + ) -> SpeedConfiguration | Solution[SpeedConfiguration]: + """Binary-search upward for the lowest speed where the EOS produces a valid outlet. + + Pre-checks that boundary.max is feasible; if not, returns a failed Solution. + """ + logger.debug( + "Outlet fluid not achievable at minimum speed %.1f — searching for lowest feasible speed.", + self._boundary.min, + ) + try: + get_outlet_stream(self._boundary.max) + except OutletFluidNotAchievableError as e: + return self._fluid_not_achievable_solution(e, SpeedConfiguration(speed=self._boundary.max)) + + def feasible(x: float) -> BinarySearchResult: + try: + get_outlet_stream(x) + return ACCEPT_AND_GO_LOWER + except (OutletFluidNotAchievableError, RateTooHighError, RateTooLowError): + return REJECT_AND_GO_HIGHER + + speed = self._search_strategy.search(boundary=self._boundary, func=feasible) + return SpeedConfiguration(speed=speed) From 2542e7b1aa83a2ba0303d1fe067824b7b1131079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Thu, 7 May 2026 11:43:11 +0200 Subject: [PATCH 6/7] test(process): use FakeProcessUnit test double --- .../solvers/test_speed_solver.py | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/tests/libecalc/process/process_solver/solvers/test_speed_solver.py b/tests/libecalc/process/process_solver/solvers/test_speed_solver.py index 785d95659a..9137e168ec 100644 --- a/tests/libecalc/process/process_solver/solvers/test_speed_solver.py +++ b/tests/libecalc/process/process_solver/solvers/test_speed_solver.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from typing import Final import pytest @@ -92,41 +93,43 @@ def speed_func(configuration: SpeedConfiguration): assert outlet_stream.pressure_bara == expected_pressure -class FluidNotAchievableProcessUnit(ProcessUnit): - """Process unit that raises OutletFluidNotAchievableError based on speed. - - Behaves like SpeedProcessUnit (pressure = inlet_pressure + speed) except when - the speed matches the failure condition. Supports two modes: - - fails_at_or_above: raises when speed >= threshold (for max-speed failure tests) - - fails_at_or_below: raises when speed <= threshold (for min-speed failure tests) - """ +class FakeProcessUnit(ProcessUnit): + """Test double whose propagate_stream is supplied as a callable.""" def __init__( self, - shaft: Shaft, - fluid_service: FluidService, - *, - fails_at_or_above: float | None = None, - fails_at_or_below: float | None = None, + propagate_stream: Callable[[FluidStream], FluidStream], process_unit_id: ProcessUnitId | None = None, ): self._id: Final[ProcessUnitId] = process_unit_id or ProcessUnit._create_id() - self._shaft = shaft - self._fluid_service = fluid_service - self._fails_at_or_above = fails_at_or_above - self._fails_at_or_below = fails_at_or_below + self._propagate_stream = propagate_stream def get_id(self) -> ProcessUnitId: return self._id def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: - speed = self._shaft.get_speed() - should_fail = (self._fails_at_or_above is not None and speed >= self._fails_at_or_above) or ( - self._fails_at_or_below is not None and speed <= self._fails_at_or_below + return self._propagate_stream(inlet_stream) + + +def _failing_speed_unit( + shaft: Shaft, + fluid_service: FluidService, + *, + fails_at_or_above: float | None = None, + fails_at_or_below: float | None = None, +) -> FakeProcessUnit: + """Build a FakeProcessUnit that mirrors `pressure = inlet + speed` but raises + OutletFluidNotAchievableError when the shaft speed crosses the given threshold.""" + unit_id = ProcessUnit._create_id() + + def _propagate(inlet_stream: FluidStream) -> FluidStream: + speed = shaft.get_speed() + should_fail = (fails_at_or_above is not None and speed >= fails_at_or_above) or ( + fails_at_or_below is not None and speed <= fails_at_or_below ) if should_fail: raise OutletFluidNotAchievableError( - process_unit_id=self._id, + process_unit_id=unit_id, unachievable_operating_point=CompressorOperatingPoint( inlet_pressure_bara=inlet_stream.pressure_bara, inlet_temperature_kelvin=inlet_stream.temperature_kelvin, @@ -136,13 +139,15 @@ def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: speed=speed, ), ) - return self._fluid_service.create_stream_from_standard_rate( + return fluid_service.create_stream_from_standard_rate( fluid_model=inlet_stream.fluid_model, pressure_bara=inlet_stream.pressure_bara + speed, standard_rate_m3_per_day=inlet_stream.standard_rate_sm3_per_day, temperature_kelvin=inlet_stream.temperature_kelvin, ) + return FakeProcessUnit(propagate_stream=_propagate, process_unit_id=unit_id) + def test_min_speed_fluid_not_achievable_target_achievable( search_strategy_factory, @@ -152,7 +157,7 @@ def test_min_speed_fluid_not_achievable_target_achievable( fluid_service, ): """OutletFluidNotAchievableError at min speed: binary search finds higher effective min; target still reachable.""" - unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=300) + unit = _failing_speed_unit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=300) speed_solver = SpeedSolver( search_strategy=search_strategy_factory(), root_finding_strategy=root_finding_strategy, @@ -179,7 +184,7 @@ def test_min_speed_fluid_not_achievable_target_not_achievable( fluid_service, ): """OutletFluidNotAchievableError at min speed: effective min too high → target below minimum achievable.""" - unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=400) + unit = _failing_speed_unit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=400) speed_solver = SpeedSolver( search_strategy=search_strategy_factory(), root_finding_strategy=root_finding_strategy, @@ -209,7 +214,7 @@ def test_max_speed_fluid_not_achievable_target_achievable( fluid_service, ): """OutletFluidNotAchievableError at max speed: binary search finds lower effective max; target still reachable.""" - unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=500) + unit = _failing_speed_unit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=500) speed_solver = SpeedSolver( search_strategy=search_strategy_factory(), root_finding_strategy=root_finding_strategy, @@ -236,7 +241,7 @@ def test_max_speed_fluid_not_achievable_target_not_achievable( fluid_service, ): """OutletFluidNotAchievableError at max speed: binary search finds lower effective max; target not reachable.""" - unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=300) + unit = _failing_speed_unit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=300) speed_solver = SpeedSolver( search_strategy=search_strategy_factory(), root_finding_strategy=root_finding_strategy, @@ -266,7 +271,7 @@ def test_all_speeds_fluid_not_achievable_from_max( fluid_service, ): """All speeds fail EOS when searching from max: pre-check at boundary.min catches it immediately.""" - unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=0) + unit = _failing_speed_unit(shaft=shaft, fluid_service=fluid_service, fails_at_or_above=0) speed_solver = SpeedSolver( search_strategy=search_strategy_factory(), root_finding_strategy=root_finding_strategy, @@ -293,7 +298,7 @@ def test_all_speeds_fluid_not_achievable_from_min( fluid_service, ): """All speeds fail EOS when searching from min: pre-check at boundary.max catches it immediately.""" - unit = FluidNotAchievableProcessUnit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=600) + unit = _failing_speed_unit(shaft=shaft, fluid_service=fluid_service, fails_at_or_below=600) speed_solver = SpeedSolver( search_strategy=search_strategy_factory(), root_finding_strategy=root_finding_strategy, From 754df3ea9afaa2b5ba0313ca6e2983f4444adcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Petter=20L=C3=B8d=C3=B8en?= Date: Thu, 7 May 2026 14:28:49 +0200 Subject: [PATCH 7/7] refactor(process): narrow Compressor.propagate_stream catch to CompressorThermodynamicCalculationError --- src/libecalc/process/process_units/compressor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libecalc/process/process_units/compressor.py b/src/libecalc/process/process_units/compressor.py index d7620ca9d2..e2a9be4eb5 100644 --- a/src/libecalc/process/process_units/compressor.py +++ b/src/libecalc/process/process_units/compressor.py @@ -1,5 +1,6 @@ from typing import Final +from libecalc.domain.process.compressor.core.exceptions import CompressorThermodynamicCalculationError from libecalc.domain.process.compressor.core.train.utils.common import ( RECIRCULATION_BOUNDARY_TOLERANCE, calculate_outlet_pressure_and_stream, @@ -68,9 +69,9 @@ def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream: inlet_stream=inlet_stream, fluid_service=self._fluid_service, ) - except Exception as exc: - # Any exception from the EOS flash layer (including Java/NeqSim exceptions - # that are not EcalcError subclasses) means the outlet state is not computable. + except CompressorThermodynamicCalculationError as exc: + # The compressor outlet thermodynamics could not produce a usable state + # (invalid Campbell pressure guess, PH flash failure, or invalid PH result). raise OutletFluidNotAchievableError( process_unit_id=self._id, unachievable_operating_point=CompressorOperatingPoint(