Skip to content

Commit 7a34ad2

Browse files
authored
fix: handle small negative rates in splitter due to floating precision issues (#1305)
tests: add test of splitter chore: add docstrings Refs. equinor/ecalc-internal#1372
1 parent 1459f9b commit 7a34ad2

File tree

6 files changed

+135
-47
lines changed

6 files changed

+135
-47
lines changed

docs/drafts/next.draft.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ sidebar_position: -58
1212

1313
## Bug Fixes
1414

15+
- Fixed an issue where floating point precision errors could lead to negative mass rates within multiple streams and pressures compressor trains under certain conditions.
16+
1517
## Breaking changes
1618

1719
### CLI

src/libecalc/domain/process/compressor/core/train/compressor_train_common_shaft_multiple_streams_and_pressures.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,15 +480,15 @@ def calculate_compressor_train(
480480
for stage_number, stage in enumerate(self.stages):
481481
stage_inlet_stream = previous_stage_outlet_stream
482482

483-
additional_rates_to_splitter = [
483+
rates_out_of_splitter = [
484484
constraints.stream_rates[stream_number]
485485
for stream_number in self.outlet_stream_connected_to_stage.get(stage_number, [])
486486
]
487-
additional_streams_to_mixer = []
487+
streams_in_to_mixer = []
488488
for stream_number in self.inlet_stream_connected_to_stage.get(stage_number, []):
489489
if stream_number > 0:
490490
if inlet_stream_counter < len(fluid_streams):
491-
additional_streams_to_mixer.append(
491+
streams_in_to_mixer.append(
492492
fluid_streams[inlet_stream_counter].create_stream_with_new_conditions(
493493
conditions=ProcessConditions(
494494
pressure_bara=stage_inlet_stream.pressure_bara,
@@ -501,8 +501,8 @@ def calculate_compressor_train(
501501
stage_results.append(
502502
stage.evaluate(
503503
inlet_stream_stage=stage_inlet_stream,
504-
additional_rates_to_splitter=additional_rates_to_splitter,
505-
additional_streams_to_mixer=additional_streams_to_mixer,
504+
rates_out_of_splitter=rates_out_of_splitter,
505+
streams_in_to_mixer=streams_in_to_mixer,
506506
speed=self.shaft.get_speed(),
507507
asv_rate_fraction=asv_rate_fraction,
508508
asv_additional_mass_rate=asv_additional_mass_rate,

src/libecalc/domain/process/compressor/core/train/stage.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ def evaluate(
141141
self,
142142
inlet_stream_stage: FluidStream,
143143
speed: float,
144-
additional_rates_to_splitter: list[float] | None = None,
145-
additional_streams_to_mixer: list[FluidStream] | None = None,
144+
rates_out_of_splitter: list[float] | None = None,
145+
streams_in_to_mixer: list[FluidStream] | None = None,
146146
asv_rate_fraction: float | None = 0.0,
147147
asv_additional_mass_rate: float | None = 0.0,
148148
) -> CompressorTrainStageResultSingleTimeStep:
@@ -153,8 +153,8 @@ def evaluate(
153153
inlet_stream_stage (FluidStream): The conditions of the inlet fluid stream. If there are several inlet streams,
154154
the first one is the stage inlet stream, the others enter the stage at the Mixer.
155155
speed (float): The speed of the shaft driving the compressor
156-
additional_rates_to_splitter (list[float] | None, optional): Additional rates to the Splitter if defined.
157-
additional_streams_to_mixer (list[FluidStream] | None, optional): Additional streams to the Mixer if defined.
156+
rates_out_of_splitter (list[float] | None, optional): Additional rates to the Splitter if defined.
157+
streams_in_to_mixer (list[FluidStream] | None, optional): Additional streams to the Mixer if defined.
158158
asv_rate_fraction (float | None, optional): Fraction of the available capacity of the compressor to fill
159159
using some kind of pressure control (on the interval [0,1]). Defaults to 0.0.
160160
asv_additional_mass_rate (float | None, optional): Additional recirculated mass rate due to
@@ -165,22 +165,20 @@ def evaluate(
165165
"""
166166
# First the stream passes through the Splitter (if defined)
167167
if self.splitter is not None:
168-
if additional_rates_to_splitter is None:
169-
raise IllegalStateException("additional_rates_to_splitter cannot be None when a splitter is defined")
168+
self.splitter.rates_out_of_splitter = rates_out_of_splitter
170169
inlet_stream_after_splitter = self.split(
171170
inlet_stream_stage=inlet_stream_stage,
172-
additional_rates_to_splitter=additional_rates_to_splitter,
173171
)
174172
else:
175173
inlet_stream_after_splitter = inlet_stream_stage
176174

177175
# Then the stream passes through the Mixer (if defined)
178176
if self.mixer is not None:
179-
if additional_streams_to_mixer is None:
180-
raise IllegalStateException("additional_streams_to_mixer cannot be None when a mixer is defined")
177+
if streams_in_to_mixer is None:
178+
raise IllegalStateException("streams_in_to_mixer cannot be None when a mixer is defined")
181179
inlet_stream_after_mixer = self.mix(
182180
inlet_stream_stage=inlet_stream_after_splitter,
183-
additional_streams_to_mixer=additional_streams_to_mixer,
181+
streams_in_to_mixer=streams_in_to_mixer,
184182
)
185183
else:
186184
inlet_stream_after_mixer = inlet_stream_after_splitter
@@ -257,38 +255,26 @@ def evaluate(
257255
def split(
258256
self,
259257
inlet_stream_stage: FluidStream,
260-
additional_rates_to_splitter: list[float],
261258
) -> FluidStream:
262259
"""Split the inlet stream into many streams. One stream goes to the compressor stage. The other(s) are taken out.
263260
In the future, the additional streams could be used for other purposes, but today they are just dropped completely.
264261
265262
Args:
266263
inlet_stream_stage (FluidStream): The inlet stream for the stage.
267-
additional_rates_to_splitter (list[float]): Additional rates to split from the inlet stream.
268264
269265
Returns:
270266
FluidStream: The stream going to the compressor stage.
271267
"""
272268
assert self.splitter is not None
273-
assert additional_rates_to_splitter is not None
274-
if self.splitter.number_of_outputs != len(additional_rates_to_splitter) + 1:
275-
raise IllegalStateException(
276-
f"Number of additional rates to Splitter ({len(additional_rates_to_splitter)}) "
277-
f"does not match number of Splitter outputs ({self.splitter.number_of_outputs})."
278-
)
279-
all_rates_to_splitter = additional_rates_to_splitter + [
280-
inlet_stream_stage.standard_rate - sum(additional_rates_to_splitter)
281-
]
282269
split_streams = self.splitter.split_stream(
283270
stream=inlet_stream_stage,
284-
split_fractions=all_rates_to_splitter,
285271
)
286272
return split_streams[-1] # The last stream goes to the compressor stage
287273

288274
def mix(
289275
self,
290276
inlet_stream_stage: FluidStream,
291-
additional_streams_to_mixer: list[FluidStream],
277+
streams_in_to_mixer: list[FluidStream],
292278
prefer_first_stream: bool = True,
293279
) -> FluidStream:
294280
"""Mix the inlet stream with additional streams.
@@ -300,22 +286,22 @@ def mix(
300286
301287
Args:
302288
inlet_stream_stage (FluidStream): The inlet stream for the stage.
303-
additional_streams_to_mixer (list[FluidStream]): Additional streams to mix with the inlet stream.
289+
streams_in_to_mixer (list[FluidStream]): Additional streams to mix with the inlet stream.
304290
prefer_first_stream (bool): Whether to prefer the properties of the first stream when mixing streams
305291
with zero mass flow. Defaults to True. (Which fluid to recirculate)
306292
307293
Returns:
308294
FluidStream: The mixed stream.
309295
"""
310296
assert self.mixer is not None
311-
assert additional_streams_to_mixer is not None
312-
if self.mixer.number_of_inputs != len(additional_streams_to_mixer) + 1:
297+
assert streams_in_to_mixer is not None
298+
if self.mixer.number_of_inputs != len(streams_in_to_mixer) + 1:
313299
raise IllegalStateException(
314-
f"Number of additional rates to Splitter ({len(additional_streams_to_mixer)}) "
300+
f"Number of additional rates to Splitter ({len(streams_in_to_mixer)}) "
315301
f"does not match number of Splitter outputs ({self.splitter.number_of_inputs})."
316302
)
317303

318-
all_streams_to_mixer = [inlet_stream_stage] + additional_streams_to_mixer
304+
all_streams_to_mixer = [inlet_stream_stage] + streams_in_to_mixer
319305
if sum(s.mass_rate_kg_per_h for s in all_streams_to_mixer) == 0:
320306
if prefer_first_stream:
321307
return inlet_stream_stage

src/libecalc/domain/process/compressor/core/train/utils/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from libecalc.domain.process.compressor.core.train.utils.numeric_methods import DampState, adaptive_pressure_update
55
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
66

7+
FLOATING_POINT_PRECISION = 1e-6
78
EPSILON = 1e-5
89
PRESSURE_CALCULATION_TOLERANCE = 1e-3
910
POWER_CALCULATION_TOLERANCE = 1e-3
Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,92 @@
1+
from libecalc.common.logger import logger
12
from libecalc.domain.process.value_objects.fluid_stream import FluidStream
23

34

45
class Splitter:
5-
def __init__(self, number_of_outputs: int):
6-
self.number_of_outputs = number_of_outputs
6+
"""
7+
A class representing a splitter that divides a fluid stream into multiple output streams.
8+
9+
Attributes:
10+
number_of_outputs (int): The number of output streams.
11+
_rates_out_of_splitter (list[float]): The flow rates for the first (number_of_outputs - 1) outputs.
12+
"""
713

8-
def split_stream(self, stream: FluidStream, split_fractions: list[float]) -> list[FluidStream]:
14+
def __init__(self, number_of_outputs: int, rates_out_of_splitter: list[float] | None = None):
915
"""
10-
Splits the fluid stream into streams based on the split fractions.
16+
Initializes the Splitter instance.
1117
1218
Args:
13-
stream (FluidStream): The fluid stream to be split.
14-
split_fractions (Sequence[float]): The fractions of the stream to go to the different output streams (0.0 to 1.0).
19+
number_of_outputs (int): The number of output streams. Must be larger than 0.
20+
rates_out_of_splitter (list[float] | None): The flow rates for the first (number_of_outputs - 1) outputs.
21+
If None, initializes with zero flow rates.
22+
23+
Raises:
24+
ValueError: If the length of rates_out_of_splitter does not match (number_of_outputs - 1).
25+
"""
26+
self.number_of_outputs = number_of_outputs
27+
assert isinstance(self.number_of_outputs, int) and self.number_of_outputs > 0
28+
29+
self._rates_out_of_splitter = (
30+
[0.0] * (number_of_outputs - 1) if rates_out_of_splitter is None else rates_out_of_splitter
31+
)
32+
assert len(self._rates_out_of_splitter) == self.number_of_outputs - 1
33+
34+
@property
35+
def rates_out_of_splitter(self) -> list[float]:
36+
"""
37+
Gets the flow rates for the first (number_of_outputs - 1) outputs.
1538
1639
Returns:
17-
list[FluidStream]: A list containing the resulting FluidStreams.
40+
list[float]: The flow rates for the outputs.
41+
"""
42+
return self._rates_out_of_splitter
43+
44+
@rates_out_of_splitter.setter
45+
def rates_out_of_splitter(self, rates: list[float] | None):
46+
"""
47+
Sets the flow rates for the first (number_of_outputs - 1) outputs.
48+
49+
Args:
50+
rates (list[float] | None): The new flow rates. If None, initializes with zero flow rates.
51+
52+
Raises:
53+
ValueError: If the length of rates does not match (number_of_outputs - 1).
1854
"""
19-
# make sure number of split fractions matches number of outputs
20-
if len(split_fractions) != self.number_of_outputs:
21-
raise ValueError("Number of split fractions must match number of outputs.")
55+
self._rates_out_of_splitter = [0.0] * (self.number_of_outputs - 1) if rates is None else rates
56+
assert len(self._rates_out_of_splitter) == self.number_of_outputs - 1
57+
58+
def split_stream(self, stream: FluidStream) -> list[FluidStream]:
59+
"""
60+
Splits the input fluid stream into multiple output streams.
61+
62+
The flow rates for the first (number_of_outputs - 1) outputs are taken from rates_out_of_splitter.
63+
The flow rate for the last output is calculated to ensure mass balance. If the sum of rates_out_of_splitter
64+
exceeds the input stream rate (checked against FLOATING_POINT_PRECISION), an exception is raised.
2265
23-
# normalize split fractions
24-
total = sum(split_fractions)
25-
normalized_fractions = [f / total for f in split_fractions]
66+
Args:
67+
stream (FluidStream): The fluid stream to be split.
68+
69+
Returns:
70+
list[FluidStream]: A list of FluidStream objects representing the output streams.
2671
72+
Raises:
73+
IllegalStateException: If the sum of rates_out_of_splitter exceeds the input stream rate.
74+
"""
75+
last_rate = stream.standard_rate - sum(self.rates_out_of_splitter)
76+
if last_rate < 0:
77+
logger.warning(
78+
f"Sum of output rates from splitter slightly exceeds input stream rate, probably due to floating point precision. "
79+
f"Input stream rate: {stream.standard_rate}, "
80+
f"Sum of output rates: {sum(self.rates_out_of_splitter)}. "
81+
f"Correcting last output rate to zero."
82+
)
83+
last_rate = 0.0
84+
all_rates_out_of_splitter = self.rates_out_of_splitter + [last_rate]
85+
split_fractions = [rate / sum(all_rates_out_of_splitter) for rate in all_rates_out_of_splitter]
2786
return [
2887
FluidStream(
2988
thermo_system=stream.thermo_system,
3089
mass_rate_kg_per_h=stream.mass_rate_kg_per_h * split_fraction,
3190
)
32-
for split_fraction in normalized_fractions
91+
for split_fraction in split_fractions
3392
]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from ecalc_neqsim_wrapper.thermo import STANDARD_PRESSURE_BARA, STANDARD_TEMPERATURE_KELVIN
2+
from libecalc.domain.process.entities.process_units.splitter.splitter import Splitter
3+
from libecalc.domain.process.value_objects.fluid_stream import FluidComposition
4+
from libecalc.domain.process.value_objects.fluid_stream.fluid_model import EoSModel, FluidModel
5+
from libecalc.infrastructure.neqsim_fluid_provider.neqsim_fluid_factory import NeqSimFluidFactory
6+
7+
8+
def test_splitter():
9+
composition = FluidComposition(
10+
nitrogen=3,
11+
CO2=1,
12+
methane=62,
13+
ethane=15,
14+
propane=13,
15+
i_butane=1,
16+
n_butane=2,
17+
i_pentane=1,
18+
n_pentane=1,
19+
n_hexane=1,
20+
water=25,
21+
)
22+
inlet_stream = NeqSimFluidFactory(
23+
FluidModel(
24+
eos_model=EoSModel.SRK,
25+
composition=composition,
26+
)
27+
).create_stream_from_standard_rate(
28+
pressure_bara=STANDARD_PRESSURE_BARA,
29+
temperature_kelvin=STANDARD_TEMPERATURE_KELVIN,
30+
standard_rate_m3_per_day=100000,
31+
)
32+
splitter = Splitter(
33+
number_of_outputs=2,
34+
rates_out_of_splitter=[50000],
35+
)
36+
outlet_streams = splitter.split_stream(inlet_stream)
37+
38+
assert inlet_stream.standard_rate == 100000
39+
assert outlet_streams[0].standard_rate == 50000
40+
assert outlet_streams[1].standard_rate == 50000

0 commit comments

Comments
 (0)