Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/drafts/next.draft.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
41 changes: 41 additions & 0 deletions src/libecalc/process/process_pipeline/process_error.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,6 +45,29 @@ 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,
unachievable_operating_point: CompressorOperatingPoint,
reason: str = "Outlet fluid state is not achievable.",
):
self.process_unit_id = process_unit_id
self.unachievable_operating_point = unachievable_operating_point
super().__init__(reason)


class RateTooHighError(OutsideCapacityError):
def __init__(
self,
Expand Down
12 changes: 12 additions & 0 deletions src/libecalc/process/process_solver/search_strategies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
from collections.abc import Callable
from typing import NamedTuple

from scipy.optimize import root_scalar

Expand All @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/libecalc/process/process_solver/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -25,6 +26,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
Expand All @@ -46,7 +48,14 @@ 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
unachievable_operating_point: CompressorOperatingPoint


SolverFailureEvent = OutsideCapacityEvent | TargetNotAchievableEvent | OutletFluidNotAchievableEvent


@dataclass(frozen=True)
Expand Down
114 changes: 108 additions & 6 deletions src/libecalc/process/process_solver/solvers/speed_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@
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.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,
Solution,
Solver,
Expand Down Expand Up @@ -61,6 +74,12 @@ def get_outlet_stream(speed: float) -> FluidStream:
source_id=e.process_unit_id,
),
)
except OutletFluidNotAchievableError:
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When max speed fails EOS, this block adjusts max_speed_configuration to the highest EOS-feasible speed and computes maximum_speed_outlet_stream at that adjusted speed. If the target is still above that adjusted maximum, the failure branch below currently returns the original boundary max, which makes the returned configuration inconsistent with achievable_value and can cause callers to re-run the EOS-invalid speed. Consider returning max_speed_configuration from that target-not-achievable branch instead.


if maximum_speed_outlet_stream.pressure_bara < self._target_pressure:
return Solution(
Expand Down Expand Up @@ -95,6 +114,12 @@ 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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method is getting long, would it make sense to split it into (reusable) steps/phases - to easier understand/read each step?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

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
Expand All @@ -121,8 +146,85 @@ 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 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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new failure event still needs a caller-side short-circuit. OutletPressureSolver.find_solution() currently treats every failed speed solution as a pressure-control case: it applies the returned speed, runs anti-surge, and then re-runs the pipeline. For an OUTLET_FLUID_NOT_ACHIEVABLE result, that speed is specifically not runnable, so the typed solver failure can become an escaping OutletFluidNotAchievableError again. Consider returning this failure directly from OutletPressureSolver before anti-surge/pressure-control handling, preserving speed_solution.failure_event and the speed configuration.

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):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This search can still leak a capacity error when the lower end of the speed range is invalid for capacity while the upper end is invalid for EOS. For example, boundary.min may raise RateTooHighError, an intermediate speed may be valid, and high speeds may raise OutletFluidNotAchievableError; the pre-check only catches EOS failures, and treating RateTooHighError as "go lower" moves in the wrong direction for speed-dependent max-flow capacity. Consider first narrowing to the lowest capacity-feasible speed, then searching for the highest EOS-feasible speed within that narrowed interval.

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)
35 changes: 28 additions & 7 deletions src/libecalc/process/process_units/compressor.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,7 +9,12 @@
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 (
CompressorOperatingPoint,
OutletFluidNotAchievableError,
RateTooHighError,
RateTooLowError,
)
from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId
from libecalc.process.process_solver.boundary import Boundary

Expand Down Expand Up @@ -56,12 +62,27 @@ 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 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(
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:
Expand Down
Loading
Loading