Repository: oemof/tespy

Issue:
Bug in post-solve calculation of ttd_min
A typo has crept into the calculation of ttd_min in the calc_parameters function of the base heat exchanger class

Discussion:
@fwitte: Resolved in #587, thank you!

Solution patch:
diff --git a/docs/whats_new.rst b/docs/whats_new.rst
index a93d2e0a..047fddca 100644
--- a/docs/whats_new.rst
+++ b/docs/whats_new.rst
@@ -3,6 +3,7 @@ What's New
 
 Discover notable new features and improvements in each release
 
+.. include::  whats_new/v0-7-8-002.rst
 .. include::  whats_new/v0-7-8-001.rst
 .. include::  whats_new/v0-7-8.rst
 .. include::  whats_new/v0-7-7.rst
diff --git a/docs/whats_new/v0-7-8-002.rst b/docs/whats_new/v0-7-8-002.rst
new file mode 100644
index 00000000..ac89d3e6
--- /dev/null
+++ b/docs/whats_new/v0-7-8-002.rst
@@ -0,0 +1,13 @@
+v0.7.8.post2 - Newton's Nature (December, 29, 2024)
++++++++++++++++++++++++++++++++++++++++++++++++++++
+
+Bug Fixes
+#########
+- Fixed a bug in post processing of the :code:`ttd_min` parameter of
+  :code:`HeatExchangers`
+  (`PR #587 <https://github.com/oemof/tespy/pull/587>`__).
+
+Contributors
+############
+- `@tlmerbecks <https://github.com/tlmerbecks>`__
+- Francesco Witte (`@fwitte <https://github.com/fwitte>`__)
diff --git a/src/tespy/components/heat_exchangers/base.py b/src/tespy/components/heat_exchangers/base.py
index 9e95dffd..446e83d8 100644
--- a/src/tespy/components/heat_exchangers/base.py
+++ b/src/tespy/components/heat_exchangers/base.py
@@ -1121,7 +1121,7 @@ class HeatExchanger(Component):
         )
         self.ttd_u.val = self.inl[0].T.val_SI - self.outl[1].T.val_SI
         self.ttd_l.val = self.outl[0].T.val_SI - self.inl[1].T.val_SI
-        self.ttd_min.val = min(self.ttd_u.val, self.ttd_min.val)
+        self.ttd_min.val = min(self.ttd_u.val, self.ttd_l.val)
 
         # pr and zeta
         for i in range(2):
diff --git a/src/tespy/components/heat_exchangers/condenser.py b/src/tespy/components/heat_exchangers/condenser.py
index 4ebb837b..e417621b 100644
--- a/src/tespy/components/heat_exchangers/condenser.py
+++ b/src/tespy/components/heat_exchangers/condenser.py
@@ -17,6 +17,7 @@ import numpy as np
 
 from tespy.components.component import component_registry
 from tespy.components.heat_exchangers.base import HeatExchanger
+from tespy.tools import logger
 from tespy.tools.data_containers import SimpleDataContainer as dc_simple
 from tespy.tools.document_models import generate_latex_eq
 from tespy.tools.fluid_properties import dh_mix_dpQ
@@ -474,7 +475,7 @@ class Condenser(HeatExchanger):
         )
         self.ttd_u.val = self.inl[0].calc_T_sat() - self.outl[1].T.val_SI
         self.ttd_l.val = self.outl[0].T.val_SI - self.inl[1].T.val_SI
-        self.ttd_min.val = min(self.ttd_u.val, self.ttd_min.val)
+        self.ttd_min.val = min(self.ttd_u.val, self.ttd_l.val)
 
         # pr and zeta
         for i in range(2):


Test patch:
diff --git a/tests/test_components/test_heat_exchangers.py b/tests/test_components/test_heat_exchangers.py
index eee9b871..ae0e602e 100644
--- a/tests/test_components/test_heat_exchangers.py
+++ b/tests/test_components/test_heat_exchangers.py
@@ -12,8 +12,12 @@ SPDX-License-Identifier: MIT
 import math
 
 import numpy as np
+from pytest import approx
+from pytest import fixture
+from pytest import mark
 
 from tespy.components import Condenser
+from tespy.components import Desuperheater
 from tespy.components import HeatExchanger
 from tespy.components import ParabolicTrough
 from tespy.components import SimpleHeatExchanger
@@ -23,8 +27,228 @@ from tespy.components import Source
 from tespy.connections import Bus
 from tespy.connections import Connection
 from tespy.networks import Network
+from tespy.tools.fluid_properties import h_mix_pT
 
 
+def _calc_Q(c1, c2):
+    return c1.m.val_SI * (c2.h.val_SI - c1.h.val_SI)
+
+
+def _calc_zeta(c1, c2):
+    return (
+        (c1.p.val_SI - c2.p.val_SI) * math.pi ** 2
+        / (4 * c1.m.val_SI ** 2 * (c1.vol.val_SI + c2.vol.val_SI))
+    )
+
+
+def _calc_pr(c1, c2):
+    return c2.p.val_SI / c1.p.val_SI
+
+
+def _calc_dp(c1, c2):
+    return c1.p.val_SI - c2.p.val_SI
+
+
+def _calc_ttd_l(c2, c3):
+    return c2.T.val_SI - c3.T.val_SI
+
+
+def _calc_ttd_u(c1, c4):
+    return c1.T.val_SI - c4.T.val_SI
+
+
+def _calc_ttd_u_condenser(c1, c4):
+    return c1.calc_T_sat() - c4.T.val_SI
+
+
+def _calc_td_log(ttd_u, ttd_l):
+    return (ttd_l - ttd_u) / math.log(ttd_l / ttd_u)
+
+
+def _calc_kA(Q, td_log):
+    return - Q / td_log
+
+
+def _calc_eff_hot(c1, c2, c3):
+    h_at_T_in_cold = h_mix_pT(
+        c2.p.val_SI, c3.T.val_SI, c2.fluid_data, c2.mixing_rule
+    )
+    return (c2.h.val_SI - c1.h.val_SI) / (h_at_T_in_cold - c1.h.val_SI)
+
+
+def _calc_eff_cold(c3, c4, c1):
+    h_at_T_in_hot = h_mix_pT(
+        c4.p.val_SI, c1.T.val_SI, c4.fluid_data, c4.mixing_rule
+    )
+    return (c4.h.val_SI - c3.h.val_SI) / (h_at_T_in_hot - c3.h.val_SI)
+
+
+@fixture
+def heatexchanger_network(request):
+
+    nw = Network(T_unit='C', p_unit='bar', v_unit='m3 / s')
+
+    inl1 = Source('inlet 1')
+    outl1 = Sink('outlet 1')
+    inl2 = Source('inlet 2')
+    outl2 = Sink('outlet 2')
+    instance = request.param('heat exchanger')
+
+    c1 = Connection(inl1, 'out1', instance, 'in1', label='1')
+    c2 = Connection(instance, 'out1', outl1, 'in1', label='2')
+    c3 = Connection(inl2, 'out1', instance, 'in2', label='3')
+    c4 = Connection(instance, 'out2', outl2, 'in1', label='4')
+
+    nw.add_conns(c1, c2, c3, c4)
+
+    return nw
+
+
+@fixture
+def heatexchanger_converged_network(heatexchanger_network):
+    # rename for shorter variable name
+    nw = heatexchanger_network
+
+    c1, c2, c3, c4 = nw.get_conn(['1', '2', '3', '4'])
+    instance = nw.get_comp('heat exchanger')
+
+    c1.set_attr(T=120, p=1, fluid={'H2O': 1}, m=1)
+    c2.set_attr(p=0.95)
+    c3.set_attr(T=40, p=5, fluid={'Ar': 1})
+    c4.set_attr(T=90, p=4.9)
+
+    if type(instance) == HeatExchanger:
+        c2.set_attr(T=105)
+
+    nw.solve('design')
+    nw._convergence_check()
+
+    return nw
+
+
+class TestHeatExchanger:
+
+    @mark.parametrize(
+        'heatexchanger_network',
+        [HeatExchanger, Condenser, Desuperheater],
+        indirect=True
+    )
+    def test_postprocessing(self, heatexchanger_converged_network):
+        # rename for shorter variable name
+        nw = heatexchanger_converged_network
+
+        c1, c2, c3, c4 = nw.get_conn(['1', '2', '3', '4'])
+        instance = nw.get_comp('heat exchanger')
+
+        assert approx(instance.Q.val) == _calc_Q(c1, c2)
+
+        assert approx(instance.pr1.val) == _calc_pr(c1, c2)
+        assert approx(instance.pr2.val) == _calc_pr(c3, c4)
+
+        # this already utilizes SI values, it should be changed!
+        assert approx(instance.dp1.val_SI) == _calc_dp(c1, c2)
+        assert approx(instance.dp2.val_SI) == _calc_dp(c3, c4)
+
+        assert approx(instance.zeta1.val) == _calc_zeta(c1, c2)
+        assert approx(instance.zeta2.val) == _calc_zeta(c3, c4)
+
+        assert approx(instance.ttd_l.val) == _calc_ttd_l(c2, c3)
+        # this is not really beautiful
+        if type(instance) == Condenser:
+            assert approx(instance.ttd_u.val) == _calc_ttd_u_condenser(c1, c4)
+        else:
+            assert approx(instance.ttd_u.val) == _calc_ttd_u(c1, c4)
+
+        assert instance.ttd_min.val == min(instance.ttd_l.val, instance.ttd_u.val)
+
+        assert approx(instance.td_log.val) == _calc_td_log(instance.ttd_u.val, instance.ttd_l.val)
+        assert approx(instance.kA.val) == _calc_kA(instance.Q.val, instance.td_log.val)
+
+        assert approx(instance.eff_hot.val) == _calc_eff_hot(c1, c2, c3)
+        assert approx(instance.eff_cold.val) == _calc_eff_cold(c3, c4, c1)
+
+        assert instance.eff_max.val == max(instance.eff_cold.val, instance.eff_hot.val)
+
+    @mark.parametrize(
+        'heatexchanger_network',
+        [HeatExchanger, Condenser, Desuperheater],
+        indirect=True
+    )
+    def test_pr1(self, heatexchanger_converged_network):
+        nw = heatexchanger_converged_network
+
+        c1, c2 = nw.get_conn(['1', '2'])
+        instance = nw.get_comp('heat exchanger')
+
+        pr = 0.95
+        c2.set_attr(p=None)
+        instance.set_attr(pr1=pr)
+
+        nw.solve('design')
+        nw._convergence_check()
+
+        assert approx(pr) == _calc_pr(c1, c2)
+
+    @mark.parametrize(
+        'heatexchanger_network',
+        [HeatExchanger, Condenser, Desuperheater],
+        indirect=True
+    )
+    def test_zeta1(self, heatexchanger_converged_network):
+        nw = heatexchanger_converged_network
+
+        c1, c2 = nw.get_conn(['1', '2'])
+        instance = nw.get_comp('heat exchanger')
+
+        zeta = 100
+        c2.set_attr(p=None)
+        instance.set_attr(zeta1=zeta)
+
+        nw.solve('design')
+        nw._convergence_check()
+
+        assert approx(zeta) == _calc_zeta(c1, c2)
+
+    @mark.parametrize(
+        'heatexchanger_network',
+        [HeatExchanger, Condenser, Desuperheater],
+        indirect=True
+    )
+    def test_pr2(self, heatexchanger_converged_network):
+        nw = heatexchanger_converged_network
+
+        c3, c4 = nw.get_conn(['3', '4'])
+        instance = nw.get_comp('heat exchanger')
+
+        pr = 0.95
+        c4.set_attr(p=None)
+        instance.set_attr(pr2=pr)
+
+        nw.solve('design')
+        nw._convergence_check()
+
+        assert approx(pr) == _calc_pr(c3, c4)
+
+    @mark.parametrize(
+        'heatexchanger_network',
+        [HeatExchanger, Condenser, Desuperheater],
+        indirect=True
+    )
+    def test_zeta2(self, heatexchanger_converged_network):
+        nw = heatexchanger_converged_network
+
+        c3, c4 = nw.get_conn(['3', '4'])
+        instance = nw.get_comp('heat exchanger')
+
+        zeta = 100
+        c4.set_attr(p=None)
+        instance.set_attr(zeta2=zeta)
+
+        nw.solve('design')
+        nw._convergence_check()
+
+        assert approx(zeta) == _calc_zeta(c3, c4)
+
 class TestHeatExchangers:
 
     def setup_method(self):
@@ -133,8 +357,8 @@ class TestHeatExchangers:
         self.nw.solve('design')
         self.nw._convergence_check()
         kA_network = self.nw.results['SimpleHeatExchanger'].loc[
-            instance.label, 'kA']
-        print(kA_network)
+            instance.label, 'kA'
+        ]
         msg = 'kA value must not be included in network results.'
         expr = not instance.kA.is_result and np.isnan(kA_network)
         assert expr, msg