Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from libecalc.domain.process.entities.process_units.compressor import Compressor
from libecalc.domain.process.process_solver.float_constraint import FloatConstraint
from libecalc.domain.process.process_solver.outlet_pressure_solver import OutletPressureSolver
from libecalc.domain.process.process_solver.process_runner import ProcessRunner
from libecalc.domain.process.value_objects.fluid_stream import FluidStream


class FeasibilitySolver:
"""
Calculates how much of a given inlet rate exceeds what a compressor train
can handle for a target pressure.

Orchestrates OutletPressureSolver and queries compressor charts to find
the feasible rate — the excess is redirected via stream distribution.
"""

def __init__(
self,
outlet_pressure_solver: OutletPressureSolver,
compressors: list[Compressor],
runner: ProcessRunner,
):
self._solver = outlet_pressure_solver
self._compressors = compressors
self._runner = runner

def get_excess_rate(
self,
inlet_stream: FluidStream,
target_pressure: FloatConstraint,
) -> float:
"""
Rate [sm³/day] that exceeds what this train can handle.

This is the amount that must be redirected (e.g. via overflow)
to another train in the stream distribution.
"""
feasible = self._find_feasible_rate(inlet_stream, target_pressure)
excess_rate = max(0.0, inlet_stream.standard_rate_sm3_per_day - feasible)
return excess_rate

def _find_feasible_rate(
self,
inlet_stream: FluidStream,
target_pressure: FloatConstraint,
) -> float:
"""Highest standard rate [sm³/day] for which the train can meet target_pressure.

Returns the full inlet rate if the solver succeeds, otherwise finds the
bottleneck compressor's stone wall limit at the current operating point.
"""
solution = self._solver.find_solution(target_pressure, inlet_stream)

if solution.success:
# The train can handle the full rate — no need to search for a bottleneck.
return inlet_stream.standard_rate_sm3_per_day

# Apply the configuration before querying compressor charts.
self._runner.apply_configurations(solution.configuration)

# Search for compressor with the lowest max rate
min_max_rate = float("inf")
for compressor in self._compressors:
compressor_inlet = self._runner.run(
inlet_stream=inlet_stream,
to_id=compressor.get_id(),
)
max_rate = compressor.get_maximum_standard_rate(compressor_inlet)
min_max_rate = min(min_max_rate, max_rate)

feasible_rate = max(0.0, min_max_rate)

return feasible_rate
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from libecalc.domain.process.value_objects.fluid_stream import FluidService, FluidStream


class HasCapacity(abc.ABC):
class HasExcessRate(abc.ABC):
@abc.abstractmethod
def get_unhandled_rate(self, rate: float, pressure: float) -> float: ...
def get_excess_rate(self, inlet_stream: FluidStream) -> float: ...


T = TypeVar("T", bound=Hashable)
Expand All @@ -28,7 +28,7 @@ class CommonStreamDistribution(StreamDistribution, Generic[T]):
def __init__(
self,
inlet_stream: FluidStream,
items: dict[T, HasCapacity],
items: dict[T, HasExcessRate],

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.

I was thinking CommonStreamDistribution would use FeasibilitySolver directly, so passing target_pressure in addition. Not sure if that matters much, but it would remove the HasExcessRate interface which might be a bit confusing. It's unclear that excess rate depends on target pressure in that interface.

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.

I see your point about clarity. However, if we remove the StreamDistributionItem (HasValidity, HasExcessCapacity), CommonStreamDistribution needs to keep track of two dictionaries (feasibility solvers, target pressures) - and I guess tests will be "heavier" (need to build OutletPressureSolver, ProcessRunner, compressors etc.). Another thing is that PrioritiesStreamDistribution is using HasValidity. Without StreamDistributionItem I assume PrioritiesStreamDistribution also needs to take feasibility solvers and target pressures.

Can it be an alternative to have a clearer docstring in the HasExcessRate interface, to clarify the target pressure dependency?

rate_fractions: list[float],
overflows: list[Overflow[T]],
fluid_service: FluidService,
Expand Down Expand Up @@ -69,9 +69,15 @@ def _adjust_for_overflow(self) -> dict[T, float]:
current_rate = rate + overflow_rate
if overflow is not None:
item = self._items[item_id]
unhandled_rate = item.get_unhandled_rate(current_rate, self._inlet_stream.pressure_bara)
handled_rate = current_rate - unhandled_rate
overflow_map[overflow.to_id].append(unhandled_rate)
stream = self._fluid_service.create_stream_from_standard_rate(
fluid_model=self._inlet_stream.fluid_model,
standard_rate_m3_per_day=current_rate,
temperature_kelvin=self._inlet_stream.temperature_kelvin,
pressure_bara=self._inlet_stream.pressure_bara,
)
excess_rate = item.get_excess_rate(stream)
handled_rate = current_rate - excess_rate
overflow_map[overflow.to_id].append(excess_rate)
adjusted_rates[item_id] = handled_rate
else:
adjusted_rates[item_id] = current_rate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from libecalc.domain.process.process_solver.feasibility_solver import FeasibilitySolver
from libecalc.domain.process.process_solver.float_constraint import FloatConstraint
from libecalc.domain.process.stream_distribution.common_stream_distribution import HasExcessRate
from libecalc.domain.process.stream_distribution.priorities_stream_distribution import HasValidity
from libecalc.domain.process.value_objects.fluid_stream import FluidStream


class StreamDistributionItem(HasExcessRate, HasValidity):
"""Connects a compressor train's solver to the stream distribution system."""

def __init__(
self,
feasibility_solver: FeasibilitySolver,
target_pressure: FloatConstraint,
):
self._feasibility_solver = feasibility_solver
self._target_pressure = target_pressure

def is_valid(self, inlet_stream: FluidStream) -> bool:
"""Can the train operate at these inlet conditions?"""
return self.get_excess_rate(inlet_stream) == 0.0

def get_excess_rate(self, inlet_stream: FluidStream) -> float:
"""How much rate (sm³/day) exceeds this train's capacity?"""
return self._feasibility_solver.get_excess_rate(
inlet_stream=inlet_stream, target_pressure=self._target_pressure
)
9 changes: 5 additions & 4 deletions tests/libecalc/application/test_stream_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@
from inline_snapshot import snapshot
from libecalc.domain.process.stream_distribution.common_stream_distribution import (
CommonStreamDistribution,
HasCapacity,
HasExcessRate,
Overflow,
)
from libecalc.domain.component_validation_error import DomainValidationException
from libecalc.domain.process.value_objects.fluid_stream import FluidStream


class Item(HasCapacity):
class Item(HasExcessRate):
def __init__(self, capacity: float):
self.id = uuid4()
self._capacity = capacity

def get_unhandled_rate(self, rate: float, pressure: float) -> float:
return max(0.0, rate - self._capacity)
def get_excess_rate(self, inlet_stream: FluidStream) -> float:
return max(0.0, inlet_stream.standard_rate_sm3_per_day - self._capacity)


class TestCommonStreamDistribution:
Expand Down
Loading