Skip to content

Commit 667945a

Browse files
committed
fix: prevent mass loss for supercritical fluids in liquid remover
1 parent 65db5e0 commit 667945a

7 files changed

Lines changed: 250 additions & 42 deletions

File tree

src/ecalc_neqsim_wrapper/fluid_service.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,39 @@ def mass_rate_to_standard_rate(
511511
standard_density = self._get_standard_density(fluid_model)
512512
return mass_rate_kg_per_h * 24.0 / standard_density
513513

514+
_critical_point_cache: ClassVar[dict[tuple, tuple[float, float]]] = {}
515+
516+
def get_critical_point(
517+
self,
518+
fluid_model: FluidModel,
519+
) -> tuple[float, float]:
520+
"""Get the EoS-computed critical point for a fluid composition.
521+
522+
Uses NeqSim's criticalPointFlash() which solves for the true mixture
523+
critical point using the equation of state. Results are cached by
524+
(composition, eos_model) since the critical point is independent of
525+
the stream's actual T and P.
526+
527+
Returns:
528+
Tuple of (critical_temperature_kelvin, critical_pressure_bara)
529+
"""
530+
composition = fluid_model.composition.normalized()
531+
key = (_make_composition_key(composition), fluid_model.eos_model)
532+
533+
cached = self._critical_point_cache.get(key)
534+
if cached is not None:
535+
return cached
536+
537+
# Create a disposable fluid — critical_point() clones internally
538+
disposable = NeqsimFluid.create_thermo_system(
539+
composition=composition,
540+
eos_model=fluid_model.eos_model,
541+
)
542+
result = disposable.critical_point()
543+
self._critical_point_cache[key] = result
544+
_logger.debug("Critical point for %s: Tc=%.2f K, Pc=%.2f bar", fluid_model.eos_model.name, *result)
545+
return result
546+
514547

515548
def get_fluid_service_stats() -> dict[str, dict]:
516549
"""Get cache statistics for the fluid service caches."""

src/ecalc_neqsim_wrapper/thermo.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,20 @@ def clone_gas_phase(self) -> NeqsimFluid:
368368
def copy(self) -> NeqsimFluid:
369369
return NeqsimFluid(thermodynamic_system=self._thermodynamic_system.clone(), use_gerg=self._use_gerg)
370370

371+
def critical_point(self) -> tuple[float, float]:
372+
"""Compute the EoS critical point for this fluid's composition.
373+
374+
Uses a disposable clone — the original thermo system is not mutated.
375+
376+
Returns:
377+
Tuple of (critical_temperature_kelvin, critical_pressure_bara)
378+
"""
379+
ts = self._thermodynamic_system.clone()
380+
neqsim_module = NeqsimService.instance().get_neqsim_module()
381+
ops = neqsim_module.thermodynamicoperations.ThermodynamicOperations(ts) # pyright: ignore[reportAttributeAccessIssue]
382+
ops.criticalPointFlash()
383+
return float(ts.getTemperature()), float(ts.getPressure())
384+
371385
@Capturer.capture_return_values( # type: ignore[misc]
372386
do_save_captured_content=False, output_directory=Path(os.getcwd()) / "captured_data" / "neqsim-ph"
373387
)

src/libecalc/process/fluid_stream/fluid_service.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,24 @@ def mass_rate_to_standard_rate(
197197
Volumetric flow rate at standard conditions [Sm3/day]
198198
"""
199199
...
200+
201+
# === Critical Point ===
202+
203+
@abc.abstractmethod
204+
def get_critical_point(
205+
self,
206+
fluid_model: FluidModel,
207+
) -> tuple[float, float]:
208+
"""Get the critical temperature and pressure for a fluid composition.
209+
210+
Uses the equation of state to compute the true mixture critical point.
211+
Results should be cached by composition + EoS since the critical point
212+
is independent of the stream's actual T and P.
213+
214+
Args:
215+
fluid_model: The fluid model (composition + EoS)
216+
217+
Returns:
218+
Tuple of (critical_temperature_kelvin, critical_pressure_bara)
219+
"""
220+
...

src/libecalc/process/process_pipeline/process_error.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ def __init__(
6363
super().__init__(f"Inlet stream contains liquid (vapor fraction: {vapor_fraction:.3f})")
6464

6565

66+
class NoGasPhaseError(ProcessError):
67+
"""The stream has no gas phase — liquid removal produces nothing to compress."""
68+
69+
def __init__(
70+
self,
71+
process_unit_id: ProcessUnitId,
72+
vapor_fraction: float,
73+
):
74+
self.process_unit_id = process_unit_id
75+
self.vapor_fraction = vapor_fraction
76+
super().__init__(f"No gas phase present (vapor fraction: {vapor_fraction:.6f})")
77+
78+
6679
class OfftakeExceedsInletError(ProcessError):
6780
def __init__(
6881
self,

src/libecalc/process/process_solver/solver.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
CompressorSurgeError,
1515
InsufficientInletPressureError,
1616
LiquidAtInletError,
17+
NoGasPhaseError,
1718
OfftakeExceedsInletError,
1819
ProcessError,
1920
)
@@ -101,6 +102,12 @@ class LiquidAtInletFailure(SolverFailure):
101102
vapor_fraction: float | None = None
102103

103104

105+
@dataclass
106+
class NoGasPhaseFailure(SolverFailure):
107+
process_unit_id: ProcessUnitId | None = None
108+
vapor_fraction: float | None = None
109+
110+
104111
@dataclass
105112
class InsufficientInletPressureFailure(SolverFailure):
106113
process_unit_id: ProcessUnitId | None = None
@@ -134,6 +141,8 @@ def process_error_to_failure(e: ProcessError) -> SolverFailure:
134141
"""Map a ProcessError to the appropriate typed SolverFailure."""
135142
if isinstance(e, LiquidAtInletError):
136143
return LiquidAtInletFailure(process_unit_id=e.process_unit_id, vapor_fraction=e.vapor_fraction)
144+
if isinstance(e, NoGasPhaseError):
145+
return NoGasPhaseFailure(process_unit_id=e.process_unit_id, vapor_fraction=e.vapor_fraction)
137146
if isinstance(e, OfftakeExceedsInletError):
138147
return OfftakeExceedsInletFailure(
139148
process_unit_id=e.process_unit_id, available_rate=e.available_rate, offtake_rate=e.offtake_rate
Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import logging
12
from typing import Final
23

34
from libecalc.process.fluid_stream.constants import ThermodynamicConstants
45
from libecalc.process.fluid_stream.fluid_service import FluidService
56
from libecalc.process.fluid_stream.fluid_stream import FluidStream
7+
from libecalc.process.process_pipeline.process_error import NoGasPhaseError
68
from libecalc.process.process_pipeline.process_unit import ProcessUnit, ProcessUnitId
79

10+
logger = logging.getLogger(__name__)
11+
12+
_PURE_VAPOR_THRESHOLD = ThermodynamicConstants.PURE_VAPOR_THRESHOLD # 0.9999
13+
_PURE_LIQUID_THRESHOLD = 1.0 - _PURE_VAPOR_THRESHOLD # 0.0001
14+
815

916
class LiquidRemover(ProcessUnit):
1017
def __init__(self, fluid_service: FluidService, process_unit_id: ProcessUnitId | None = None):
@@ -14,32 +21,53 @@ def __init__(self, fluid_service: FluidService, process_unit_id: ProcessUnitId |
1421
def get_id(self) -> ProcessUnitId:
1522
return self._id
1623

17-
def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream:
24+
def _is_supercritical(self, inlet_stream: FluidStream) -> bool:
25+
"""Check if the fluid is above its EoS-computed critical point.
26+
27+
Uses the fluid service's cached critical point calculation, which is
28+
exact for both pure components and mixtures.
1829
"""
19-
Removes liquid from the fluid stream. The new stream's mass rate is scaled
20-
down by the gas mass fraction so the dropped-out liquid isn't re-injected
21-
into the gas phase.
30+
tc, pc = self._fluid_service.get_critical_point(inlet_stream.fluid_model)
31+
return inlet_stream.temperature_kelvin > tc and inlet_stream.pressure_bara > pc
2232

23-
The removed liquid (mass = inlet.mass_rate * (1 - gas_mass_fraction),
24-
composition = inlet - new_fluid) is currently discarded. It could later
25-
be exposed as a separate outlet stream — e.g. routed to an oil pump,
26-
accounted for in emissions, or reported back to the user.
33+
def propagate_stream(self, inlet_stream: FluidStream) -> FluidStream:
34+
"""Remove liquid from a two-phase fluid stream.
2735
28-
Args:
29-
inlet_stream: The fluid stream to be scrubbed.
36+
Liquid removal only makes sense when both phases coexist:
3037
31-
Returns:
32-
FluidStream: A new FluidStream with liquid removed.
38+
- vapor_fraction >= 0.9999: all gas, nothing to remove.
39+
- vapor_fraction <= 0.0001 AND supercritical: mislabelled by flash,
40+
pass through unchanged.
41+
- vapor_fraction <= 0.0001 AND NOT supercritical: genuinely liquid,
42+
raise NoGasPhaseError — liquid removal cannot produce gas.
43+
- Otherwise: genuine two-phase, remove liquid and keep gas.
3344
"""
34-
if inlet_stream.vapor_fraction_molar < ThermodynamicConstants.PURE_VAPOR_THRESHOLD:
35-
new_fluid = self._fluid_service.remove_liquid(inlet_stream.fluid)
36-
inlet_molar_mass = inlet_stream.fluid.molar_mass
37-
assert inlet_molar_mass > 0.0, (
38-
f"Degenerate stream with non-positive molar mass ({inlet_molar_mass}) reached LiquidRemover — "
39-
"this should have been caught at stream construction."
40-
)
41-
gas_mass_fraction = inlet_stream.vapor_fraction_molar * new_fluid.molar_mass / inlet_molar_mass
42-
new_mass_rate = inlet_stream.mass_rate_kg_per_h * gas_mass_fraction
43-
return inlet_stream.with_new_fluid(new_fluid).with_mass_rate(new_mass_rate)
44-
else:
45+
vf = inlet_stream.vapor_fraction_molar
46+
47+
if vf >= _PURE_VAPOR_THRESHOLD:
4548
return inlet_stream
49+
50+
if vf <= _PURE_LIQUID_THRESHOLD:
51+
if self._is_supercritical(inlet_stream):
52+
logger.debug(
53+
"LiquidRemover: skipping — supercritical fluid (T=%.1f K, P=%.1f bara, vf=%.6f)",
54+
inlet_stream.temperature_kelvin,
55+
inlet_stream.pressure_bara,
56+
vf,
57+
)
58+
return inlet_stream
59+
60+
raise NoGasPhaseError(
61+
process_unit_id=self._id,
62+
vapor_fraction=vf,
63+
)
64+
65+
new_fluid = self._fluid_service.remove_liquid(inlet_stream.fluid)
66+
inlet_molar_mass = inlet_stream.fluid.molar_mass
67+
assert inlet_molar_mass > 0.0, (
68+
f"Degenerate stream with non-positive molar mass ({inlet_molar_mass}) reached LiquidRemover — "
69+
"this should have been caught at stream construction."
70+
)
71+
gas_mass_fraction = vf * new_fluid.molar_mass / inlet_molar_mass
72+
new_mass_rate = inlet_stream.mass_rate_kg_per_h * gas_mass_fraction
73+
return inlet_stream.with_new_fluid(new_fluid).with_mass_rate(new_mass_rate)

tests/libecalc/process/process_units/test_liquid_remover.py

Lines changed: 109 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
from unittest.mock import MagicMock
2-
31
import pytest
42

53
from ecalc_neqsim_wrapper.thermo import STANDARD_PRESSURE_BARA, STANDARD_TEMPERATURE_KELVIN
64
from libecalc.process.fluid_stream.fluid_model import EoSModel, FluidComposition, FluidModel
75
from libecalc.process.fluid_stream.fluid_stream import FluidStream
8-
from libecalc.process.process_units.liquid_remover import LiquidRemover
6+
from libecalc.process.process_pipeline.process_error import NoGasPhaseError
97

108

119
def test_liquid_remover_removes_liquid(fluid_service, liquid_remover_factory):
@@ -38,23 +36,11 @@ def test_liquid_remover_removes_liquid(fluid_service, liquid_remover_factory):
3836

3937
assert inlet_stream.vapor_fraction_molar < 1.0
4038
assert outlet_stream.vapor_fraction_molar == 1.0
41-
42-
expected_gas_mass_fraction = (
43-
inlet_stream.vapor_fraction_molar * outlet_stream.fluid.molar_mass / inlet_stream.fluid.molar_mass
44-
)
45-
expected_mass_rate = inlet_stream.mass_rate_kg_per_h * expected_gas_mass_fraction
4639
assert outlet_stream.mass_rate_kg_per_h < inlet_stream.mass_rate_kg_per_h
47-
assert outlet_stream.mass_rate_kg_per_h == expected_mass_rate
4840

4941

5042
def test_liquid_remover_passthrough_when_no_liquid(fluid_service, liquid_remover_factory):
51-
composition = FluidComposition(
52-
nitrogen=3,
53-
CO2=1,
54-
methane=80,
55-
ethane=10,
56-
propane=6,
57-
)
43+
composition = FluidComposition(nitrogen=3, CO2=1, methane=80, ethane=10, propane=6)
5844
fluid_model = FluidModel(eos_model=EoSModel.SRK, composition=composition)
5945
fluid = fluid_service.create_fluid(
6046
fluid_model=fluid_model,
@@ -73,13 +59,117 @@ def test_liquid_remover_passthrough_when_no_liquid(fluid_service, liquid_remover
7359
assert outlet_stream.mass_rate_kg_per_h == inlet_stream.mass_rate_kg_per_h
7460

7561

76-
def test_liquid_remover_raises_on_non_positive_inlet_molar_mass():
62+
def test_liquid_remover_passthrough_supercritical_co2(fluid_service, liquid_remover_factory):
63+
"""Pure CO2 above critical point: NeqSim reports vapor_fraction=0,
64+
but the EoS critical point check detects supercritical and prevents mass loss."""
65+
composition = FluidComposition(CO2=1.0)
66+
fluid_model = FluidModel(eos_model=EoSModel.SRK, composition=composition)
67+
68+
fluid = fluid_service.create_fluid(
69+
fluid_model=fluid_model,
70+
pressure_bara=350.0,
71+
temperature_kelvin=308.15,
72+
)
73+
inlet_stream = FluidStream.from_standard_rate(
74+
standard_rate_m3_per_day=100000,
75+
fluid_model=fluid.fluid_model,
76+
fluid_properties=fluid.properties,
77+
)
78+
79+
# NeqSim mislabels supercritical CO2 as liquid
80+
assert inlet_stream.vapor_fraction_molar <= 0.0001
81+
82+
remover = liquid_remover_factory()
83+
outlet_stream = remover.propagate_stream(inlet_stream)
84+
85+
# Mass fully conserved
86+
assert outlet_stream.mass_rate_kg_per_h == inlet_stream.mass_rate_kg_per_h
87+
88+
89+
def test_liquid_remover_passthrough_subcritical_co2_vapor(fluid_service, liquid_remover_factory):
90+
"""CO2 below critical pressure: should be all vapor, passes through."""
91+
composition = FluidComposition(CO2=1.0)
92+
fluid_model = FluidModel(eos_model=EoSModel.SRK, composition=composition)
93+
94+
fluid = fluid_service.create_fluid(
95+
fluid_model=fluid_model,
96+
pressure_bara=50.0,
97+
temperature_kelvin=293.15,
98+
)
99+
inlet_stream = FluidStream.from_standard_rate(
100+
standard_rate_m3_per_day=100000,
101+
fluid_model=fluid.fluid_model,
102+
fluid_properties=fluid.properties,
103+
)
104+
105+
remover = liquid_remover_factory()
106+
outlet_stream = remover.propagate_stream(inlet_stream)
107+
108+
# Pure CO2 at these conditions is single-phase, mass conserved
109+
assert outlet_stream.mass_rate_kg_per_h == inlet_stream.mass_rate_kg_per_h
110+
111+
112+
def test_liquid_remover_raises_for_genuine_liquid(fluid_service, liquid_remover_factory):
113+
"""Water at ambient conditions is genuinely liquid (not supercritical).
114+
The LiquidRemover raises NoGasPhaseError — no gas to extract."""
115+
composition = FluidComposition(water=1.0)
116+
fluid_model = FluidModel(eos_model=EoSModel.SRK, composition=composition)
117+
118+
fluid = fluid_service.create_fluid(
119+
fluid_model=fluid_model,
120+
pressure_bara=10.0,
121+
temperature_kelvin=293.15,
122+
)
123+
inlet_stream = FluidStream.from_standard_rate(
124+
standard_rate_m3_per_day=100000,
125+
fluid_model=fluid.fluid_model,
126+
fluid_properties=fluid.properties,
127+
)
128+
129+
assert inlet_stream.vapor_fraction_molar <= 0.0001
130+
131+
remover = liquid_remover_factory()
132+
with pytest.raises(NoGasPhaseError):
133+
remover.propagate_stream(inlet_stream)
134+
135+
136+
def test_liquid_remover_passthrough_supercritical_co2_mixture(fluid_service, liquid_remover_factory):
137+
"""CO2-dominant mixture above its EoS-computed critical point.
138+
Exercises the critical point calculation across multiple components."""
139+
composition = FluidComposition(CO2=95.0, methane=5.0)
140+
fluid_model = FluidModel(eos_model=EoSModel.SRK, composition=composition)
141+
142+
# EoS critical point ≈ 297 K, 72 bar
143+
# At 310 K, 100 bar → above both → supercritical
144+
fluid = fluid_service.create_fluid(
145+
fluid_model=fluid_model,
146+
pressure_bara=100.0,
147+
temperature_kelvin=310.0,
148+
)
149+
inlet_stream = FluidStream.from_standard_rate(
150+
standard_rate_m3_per_day=100000,
151+
fluid_model=fluid.fluid_model,
152+
fluid_properties=fluid.properties,
153+
)
154+
155+
remover = liquid_remover_factory()
156+
outlet_stream = remover.propagate_stream(inlet_stream)
157+
158+
assert outlet_stream.mass_rate_kg_per_h == inlet_stream.mass_rate_kg_per_h
159+
160+
161+
def test_liquid_remover_raises_on_non_positive_inlet_molar_mass(fluid_service, liquid_remover_factory):
162+
"""The assertion guarding against degenerate streams with zero molar mass is still active."""
163+
from unittest.mock import MagicMock
164+
165+
from libecalc.process.process_units.liquid_remover import LiquidRemover
166+
77167
inlet_stream = MagicMock()
78168
inlet_stream.vapor_fraction_molar = 0.5
79169
inlet_stream.fluid.molar_mass = 0.0
80170

81-
fluid_service = MagicMock()
82-
remover = LiquidRemover(fluid_service=fluid_service)
171+
mock_fluid_service = MagicMock()
172+
remover = LiquidRemover(fluid_service=mock_fluid_service)
83173

84174
with pytest.raises(AssertionError, match="non-positive molar mass"):
85175
remover.propagate_stream(inlet_stream)

0 commit comments

Comments
 (0)