From 8262041dc64bc10e8c82bfad4af5f1a2e06fcd49 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Thu, 30 Jul 2026 07:24:46 +0200 Subject: [PATCH 1/4] Check in draft for EquilibriumSeparator component --- src/tespy/components/__init__.py | 1 + .../components/nodes/equilibrium_separator.py | 213 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/tespy/components/nodes/equilibrium_separator.py diff --git a/src/tespy/components/__init__.py b/src/tespy/components/__init__.py index c450e1aae..d31f63615 100644 --- a/src/tespy/components/__init__.py +++ b/src/tespy/components/__init__.py @@ -23,6 +23,7 @@ from .heat_exchangers.solar_collector import SolarCollector # noqa: F401 from .nodes.droplet_separator import DropletSeparator # noqa: F401 from .nodes.drum import Drum # noqa: F401 +from .nodes.equilibrium_separator import EquilibriumSeparator # noqa: F401 from .nodes.merge import Merge # noqa: F401 from .nodes.node import Node # noqa: F401 from .nodes.separator import Separator # noqa: F401 diff --git a/src/tespy/components/nodes/equilibrium_separator.py b/src/tespy/components/nodes/equilibrium_separator.py new file mode 100644 index 000000000..ef73d3548 --- /dev/null +++ b/src/tespy/components/nodes/equilibrium_separator.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 + +"""Module of class EquilibriumSeparator. + + +This file is part of project TESPy (github.com/oemof/tespy). It's copyrighted +by the contributors recorded in the version control history of the file, +available from its original location +tespy/components/nodes/equilibrium_separator.py + +SPDX-License-Identifier: MIT +""" + +from tespy.components.component import component_registry +from tespy.components.nodes.droplet_separator import DropletSeparator +from tespy.tools.data_containers import ComponentMandatoryConstraints as dc_cmc +from tespy.tools.fluid_properties import h_mix_pQ + + +@component_registry +class EquilibriumSeparator(DropletSeparator): + r""" + Separate the phases of a single fluid in any inlet state. + + In contrast to the + :class:`DropletSeparator ` + this component also handles single phase inlet states: The full mass flow + then leaves through the outlet matching the inlet phase without a change + of state, while the other outlet sees zero mass flow at saturated state. + For two-phase inlets the behavior is identical to the parent component. + + .. image:: /api/_images/components/DropletSeparator.svg + :alt: flowsheet of the equilibriumseparator + :align: center + :class: only-light + + .. image:: /api/_images/components/DropletSeparator_darkmode.svg + :alt: flowsheet of the equilibriumseparator + :align: center + :class: only-dark + + Ports + ----- + + - Fluid inlets: in1 + - Fluid outlets: out1, out2 + + Mandatory Equations + ------------------- + + - mass balance constraint: :py:meth:`mass_flow_func ` + - energy balance constraint: :py:meth:`energy_balance_func ` + - pressure equality constraints: :py:meth:`pressure_structure_matrix ` + - outlet 0 state constraint: :py:meth:`outlet_state_func ` + - outlet 1 state constraint: :py:meth:`outlet_state_func ` + - fluid equality constraints: :py:meth:`fluid_structure_matrix ` + + Parameters + ---------- + + char_warnings : bool + Ignore warnings on default characteristics usage for this component. + + design : list + List containing design parameters (stated as String). + + design_path : str + Path to the components design case. + + label : str + The label of the component. + + local_design : bool + Treat this component in design mode in an offdesign calculation. + + local_offdesign : bool + Treat this component in offdesign mode in a design calculation. + + offdesign : list + List containing offdesign parameters (stated as String). + + printout : bool + Include this component in the network's results printout. + + Example + ------- + With a two-phase inlet the equilibrium separator splits the mass flow + according to the vapor mass fraction, just like the droplet separator. + + >>> from tespy.components import Sink, Source, EquilibriumSeparator + >>> from tespy.connections import Connection + >>> from tespy.networks import Network + >>> nw = Network(iterinfo=False) + >>> nw.units.set_defaults(**{ + ... "pressure": "bar", "pressure_difference": "bar", + ... "temperature": "degC" + ... }) + >>> so = Source('inflow') + >>> sig = Sink('gas outflow') + >>> sil = Sink('liquid outflow') + >>> es = EquilibriumSeparator('separator') + >>> c1 = Connection(so, 'out1', es, 'in1', label='1') + >>> c2 = Connection(es, 'out1', sil, 'in1', label='2') + >>> c3 = Connection(es, 'out2', sig, 'in1', label='3') + >>> nw.add_conns(c1, c2, c3) + >>> c1.set_attr(fluid={'water': 1}, p=1, x=0.6, m=10) + >>> nw.solve('design') + >>> round(c3.m.val_SI, 6) + 6.0 + + With a subcooled liquid inlet all mass leaves through the liquid outlet + at inlet enthalpy, the gas outlet sees zero mass flow at saturated state. + + >>> c1.set_attr(x=None, td_bubble=10) + >>> nw.solve('design') + >>> round(c3.m.val_SI, 6) + 0.0 + >>> round(c2.h.val_SI - c1.h.val_SI, 6) + 0.0 + >>> round(c3.calc_Q(), 6) + 1.0 + """ + + def get_mandatory_constraints(self): + constraints = super().get_mandatory_constraints() + constraints['outlet_constraint_liquid'] = dc_cmc(**{ + 'func': self.outlet_state_func, + 'dependents': self.outlet_state_dependents, + 'num_eq_sets': 1, + 'func_params': {'outconn': 0, 'phase': 'l'}, + 'description': 'outlet 0 state constraint' + }) + constraints['outlet_constraint_gas'] = dc_cmc(**{ + 'func': self.outlet_state_func, + 'dependents': self.outlet_state_dependents, + 'num_eq_sets': 1, + 'func_params': {'outconn': 1, 'phase': 'g'}, + 'description': 'outlet 1 state constraint' + }) + return constraints + + def energy_balance_func(self): + r""" + Calculate energy balance. + + For a two-phase inlet the energy balance is applied. For a single + phase inlet it is replaced by a zero mass flow condition on the + outlet opposite of the inlet phase, the energy balance is then + implied by the outlet state equations. + + Returns + ------- + residual : float + Residual value of energy balance. + + .. math:: + + 0 = \begin{cases} + \dot{m}_{in} \cdot h_{in} - + \dot{m}_{out,1} \cdot h_{out,1} - + \dot{m}_{out,2} \cdot h_{out,2} & \text{two-phase inlet}\\ + \dot{m}_{out,2} & \text{liquid inlet}\\ + \dot{m}_{out,1} & \text{gas inlet} + \end{cases} + """ + phase = self.inl[0].calc_phase() + if phase == "l": + return self.outl[1].m.val_SI + elif phase == "g": + return self.outl[0].m.val_SI + elif phase == "tp": + return super().energy_balance_func() + else: + msg = ( + "The equilibrium separator inlet must be liquid, gas or " + f"two-phase, but the phase is '{phase}'." + ) + raise ValueError(msg) + + def outlet_state_func(self, outconn=None, phase=None): + r""" + Calculate the state of the specified outlet. + + If the inlet phase matches the outlet's phase, the state passes + through unchanged. Otherwise the outlet is at saturated state, i.e. + for a two-phase inlet both outlets are saturated, for a single phase + inlet the opposite outlet is saturated at zero mass flow. + + Returns + ------- + residual : float + Residual value of outlet state equation. + + .. math:: + + 0 = \begin{cases} + h_{in} - h_{out} & \text{inlet phase matches outlet}\\ + h\left(p_{out}, x \right) - h_{out} & \text{otherwise} + \end{cases} + """ + o = self.outl[outconn] + if self.inl[0].calc_phase() == phase: + return self.inl[0].h.val_SI - o.h.val_SI + else: + quality = 0 if phase == "l" else 1 + return h_mix_pQ(o.p.val_SI, quality, o.fluid_data) - o.h.val_SI + + def outlet_state_dependents(self, outconn=None, phase=None): + return [ + self.inl[0].h, + self.outl[outconn].p, + self.outl[outconn].h + ] From ba21bceba88157584edb1688924287de3098b4b0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Thu, 30 Jul 2026 07:29:34 +0200 Subject: [PATCH 2/4] Update docs --- .../components/heat_exchangers/parallel.py | 4 ++-- .../components/nodes/equilibrium_separator.py | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/tespy/components/heat_exchangers/parallel.py b/src/tespy/components/heat_exchangers/parallel.py index 4072d40e6..243c09fd9 100644 --- a/src/tespy/components/heat_exchangers/parallel.py +++ b/src/tespy/components/heat_exchangers/parallel.py @@ -139,12 +139,12 @@ class ParallelFlowHeatExchanger(HeatExchanger): :code:`temperature_difference`. ttd_l : float, dict - Terminal temperature difference at hot side outlet to cold side inlet. + Terminal temperature difference at hot side inlet to cold side inlet. Quantity: :code:`temperature_difference`. Equation: :py:meth:`ttd_l_func `. ttd_u : float, dict - Terminal temperature difference at hot side inlet to cold side outlet. + Terminal temperature difference at hot side outlet to cold side outlet. Quantity: :code:`temperature_difference`. Equation: :py:meth:`ttd_u_func `. diff --git a/src/tespy/components/nodes/equilibrium_separator.py b/src/tespy/components/nodes/equilibrium_separator.py index ef73d3548..c39ebf72c 100644 --- a/src/tespy/components/nodes/equilibrium_separator.py +++ b/src/tespy/components/nodes/equilibrium_separator.py @@ -25,9 +25,9 @@ class EquilibriumSeparator(DropletSeparator): In contrast to the :class:`DropletSeparator ` this component also handles single phase inlet states: The full mass flow - then leaves through the outlet matching the inlet phase without a change - of state, while the other outlet sees zero mass flow at saturated state. - For two-phase inlets the behavior is identical to the parent component. + then leaves through the outlet matching the inlet phase without a change of + state, while the other outlet sees zero mass flow at saturated state. For + two-phase inlets the behavior is identical to the parent component. .. image:: /api/_images/components/DropletSeparator.svg :alt: flowsheet of the equilibriumseparator @@ -51,8 +51,8 @@ class EquilibriumSeparator(DropletSeparator): - mass balance constraint: :py:meth:`mass_flow_func ` - energy balance constraint: :py:meth:`energy_balance_func ` - pressure equality constraints: :py:meth:`pressure_structure_matrix ` - - outlet 0 state constraint: :py:meth:`outlet_state_func ` - - outlet 1 state constraint: :py:meth:`outlet_state_func ` + - outlet 0 liquid state constraint: :py:meth:`outlet_state_func ` + - outlet 1 gas state constraint: :py:meth:`outlet_state_func ` - fluid equality constraints: :py:meth:`fluid_structure_matrix ` Parameters @@ -128,14 +128,14 @@ def get_mandatory_constraints(self): 'dependents': self.outlet_state_dependents, 'num_eq_sets': 1, 'func_params': {'outconn': 0, 'phase': 'l'}, - 'description': 'outlet 0 state constraint' + 'description': 'outlet 0 liquid state constraint' }) constraints['outlet_constraint_gas'] = dc_cmc(**{ 'func': self.outlet_state_func, 'dependents': self.outlet_state_dependents, 'num_eq_sets': 1, 'func_params': {'outconn': 1, 'phase': 'g'}, - 'description': 'outlet 1 state constraint' + 'description': 'outlet 1 gas state constraint' }) return constraints @@ -144,9 +144,9 @@ def energy_balance_func(self): Calculate energy balance. For a two-phase inlet the energy balance is applied. For a single - phase inlet it is replaced by a zero mass flow condition on the - outlet opposite of the inlet phase, the energy balance is then - implied by the outlet state equations. + phase inlet it is replaced by a zero mass flow condition on the outlet + opposite of the inlet phase, the energy balance is then implied by the + outlet state equations. Returns ------- From a49f4f74fe45e37b64d538484103f23c212f15b9 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Thu, 30 Jul 2026 07:31:12 +0200 Subject: [PATCH 3/4] Implement testing --- .../test_equilibrium_separator.py | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/test_components/test_equilibrium_separator.py diff --git a/tests/test_components/test_equilibrium_separator.py b/tests/test_components/test_equilibrium_separator.py new file mode 100644 index 000000000..316d9f956 --- /dev/null +++ b/tests/test_components/test_equilibrium_separator.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 + +"""Module for testing components of type equilibrium separator. +This file is part of project TESPy (github.com/oemof/tespy). It's copyrighted +by the contributors recorded in the version control history of the file, +available from its original location +tests/test_components/test_equilibrium_separator.py +SPDX-License-Identifier: MIT +""" +from pytest import approx +from pytest import fixture + +from tespy.components import EquilibriumSeparator +from tespy.components import Sink +from tespy.components import Source +from tespy.connections import Connection +from tespy.networks import Network + + +@fixture() +def separator_network_setup(): + nw = Network(iterinfo=False) + nw.units.set_defaults(**{ + "pressure": "bar", "pressure_difference": "bar", + "temperature": "degC" + }) + so = Source("inflow") + sil = Sink("liquid outflow") + sig = Sink("gas outflow") + es = EquilibriumSeparator("separator") + c1 = Connection(so, "out1", es, "in1", label="1") + c2 = Connection(es, "out1", sil, "in1", label="2") + c3 = Connection(es, "out2", sig, "in1", label="3") + nw.add_conns(c1, c2, c3) + yield nw + + +def energy_balance_residual(nw): + c1 = nw.get_conn("1") + c2 = nw.get_conn("2") + c3 = nw.get_conn("3") + return ( + c1.m.val_SI * c1.h.val_SI + - c2.m.val_SI * c2.h.val_SI + - c3.m.val_SI * c3.h.val_SI + ) + + +def test_liquid_inlet(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, p=1.5, td_bubble=10) + nw.solve("design") + nw.assert_convergence() + + assert c3.m.val_SI == approx(0, abs=1e-9) + assert c2.m.val_SI == approx(c1.m.val_SI) + assert c2.h.val_SI == approx(c1.h.val_SI) + assert c3.calc_Q() == approx(1) + assert energy_balance_residual(nw) == approx(0, abs=1e-6) + + +def test_gas_inlet(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, p=1.5, td_dew=10) + nw.solve("design") + nw.assert_convergence() + + assert c2.m.val_SI == approx(0, abs=1e-9) + assert c3.m.val_SI == approx(c1.m.val_SI) + assert c3.h.val_SI == approx(c1.h.val_SI) + assert c2.calc_Q() == approx(0, abs=1e-9) + assert energy_balance_residual(nw) == approx(0, abs=1e-6) + + +def test_two_phase_inlet_splits_by_quality(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, p=1.5, x=0.6) + nw.solve("design") + nw.assert_convergence() + + assert c3.m.val_SI == approx(0.6 * c1.m.val_SI) + assert c2.m.val_SI == approx(0.4 * c1.m.val_SI) + assert c2.calc_Q() == approx(0, abs=1e-9) + assert c3.calc_Q() == approx(1) + assert energy_balance_residual(nw) == approx(0, abs=1e-6) + + +def test_saturated_liquid_inlet(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, p=1.5, x=0) + nw.solve("design") + nw.assert_convergence() + + assert c3.m.val_SI == approx(0, abs=1e-9) + assert c2.h.val_SI == approx(c1.h.val_SI) + + +def test_saturated_gas_inlet(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, p=1.5, x=1) + nw.solve("design") + nw.assert_convergence() + + assert c2.m.val_SI == approx(0, abs=1e-9) + assert c3.h.val_SI == approx(c1.h.val_SI) + + +def test_outlet_mass_flow_determines_inlet_state(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, T=111.35) + c3.set_attr(m=9.5) + nw.solve("design") + nw.assert_convergence() + + assert c1.calc_Q() == approx(0.95) + assert c1.calc_T_sat() == approx(c1.T.val_SI) + + +def test_supercritical_inlet_does_not_converge(separator_network_setup): + nw = separator_network_setup + c1 = nw.get_conn("1") + c1.set_attr(fluid={"water": 1}, m=10, p=250, T=400) + nw.solve("design") + assert not nw.converged + + +def test_phase_change_on_resolve(separator_network_setup): + nw = separator_network_setup + c1, c2, c3 = nw.get_conn(["1", "2", "3"]) + c1.set_attr(fluid={"water": 1}, m=10, p=1.5, x=0.6) + nw.solve("design") + nw.assert_convergence() + + c1.set_attr(x=None, td_bubble=10) + nw.solve("design") + nw.assert_convergence() + assert c3.m.val_SI == approx(0, abs=1e-9) + assert c2.h.val_SI == approx(c1.h.val_SI) + + c1.set_attr(td_bubble=None, td_dew=10) + nw.solve("design") + nw.assert_convergence() + assert c2.m.val_SI == approx(0, abs=1e-9) + assert c3.h.val_SI == approx(c1.h.val_SI) + + c1.set_attr(td_dew=None, x=0.3) + nw.solve("design") + nw.assert_convergence() + assert c3.m.val_SI == approx(0.3 * c1.m.val_SI) From 76b6d2187e1aa9cc1cda523154a8c11d43d9b1eb Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Thu, 30 Jul 2026 07:33:57 +0200 Subject: [PATCH 4/4] Update changelog --- docs/whats_new/v0-11-1.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/whats_new/v0-11-1.rst b/docs/whats_new/v0-11-1.rst index 029269966..eab069bd5 100644 --- a/docs/whats_new/v0-11-1.rst +++ b/docs/whats_new/v0-11-1.rst @@ -15,6 +15,9 @@ New Features in case :code:`area_ratio`, :code:`R_cond` and the alpha values of all phases occurring in the solution are set (`PR #1035 `__). +- New :py:class:`~tespy.components.nodes.equilibrium_separator.EquilibriumSeparator` + component: a phase separator that also handles single phase inlet states + (`PR #1036 `__). Other Changes #############