From 408ef53512da69523d45c15e874c41b171e0459f Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sun, 4 Jan 2026 10:04:52 +0100 Subject: [PATCH 01/69] Prepare the possibility to specify wrapper kwrags in the form of a dict --- docs/whats_new/v0-9-12.rst | 1 - src/tespy/connections/connection.py | 6 +++++- src/tespy/tools/data_containers.py | 1 + src/tespy/tools/fluid_properties/wrappers.py | 9 +++++---- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/whats_new/v0-9-12.rst b/docs/whats_new/v0-9-12.rst index 44a9d5969..fe1d626e2 100644 --- a/docs/whats_new/v0-9-12.rst +++ b/docs/whats_new/v0-9-12.rst @@ -63,7 +63,6 @@ Bug Fixes pressure relaxation factor to overwrite the factors for all other variables (`PR #875 `__). - Contributors ############ - Francesco Witte (`@fwitte `__) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index e48962192..224462c99 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -842,7 +842,11 @@ def _create_fluid_wrapper(self): else: self.fluid.back_end[fluid] = None - self.fluid.wrapper[fluid] = self.fluid.engine[fluid](fluid, back_end) + wrapper_kwargs = {} + if fluid in self.fluid.wrapper_kwargs: + wrapper_kwargs = self.fluid.wrapper_kwargs + + self.fluid.wrapper[fluid] = self.fluid.engine[fluid](fluid, back_end, **wrapper_kwargs) def _precalc_guess_values(self): """ diff --git a/src/tespy/tools/data_containers.py b/src/tespy/tools/data_containers.py index a63d5615d..778be3ed9 100644 --- a/src/tespy/tools/data_containers.py +++ b/src/tespy/tools/data_containers.py @@ -802,6 +802,7 @@ def attr(): "wrapper": dict(), "back_end": dict(), "engine": dict(), + "wrapper_kwargs": dict(), "description": None, "quantity": None, "_is_var": set(), diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index 083f9cd1a..325df17b2 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -37,7 +37,7 @@ def __reduce__(self): @wrapper_registry class FluidPropertyWrapper: - def __init__(self, fluid, back_end=None) -> None: + def __init__(self, fluid, back_end=None, **kwargs) -> None: """Base class for fluid property wrappers Parameters @@ -50,6 +50,7 @@ def __init__(self, fluid, back_end=None) -> None: self.back_end = back_end self.fluid = fluid self.mixture_type = None + self.__dict__.update(kwargs) def _not_implemented(self) -> None: raise NotImplementedError( @@ -132,7 +133,7 @@ def s_pT(self, p, T): @wrapper_registry class CoolPropWrapper(FluidPropertyWrapper): - def __init__(self, fluid, back_end=None) -> None: + def __init__(self, fluid, back_end=None, **kwargs) -> None: """Wrapper for CoolProp.CoolProp.AbstractState instance calls Parameters @@ -369,7 +370,7 @@ def s_pT(self, p, T): class IAPWSWrapper(FluidPropertyWrapper): - def __init__(self, fluid, back_end=None) -> None: + def __init__(self, fluid, back_end=None, **kwargs) -> None: """Wrapper for iapws library calls Parameters @@ -487,7 +488,7 @@ def s_pT(self, p, T): @wrapper_registry class PyromatWrapper(FluidPropertyWrapper): - def __init__(self, fluid, back_end=None) -> None: + def __init__(self, fluid, back_end=None, **kwargs) -> None: """Wrapper for the Pyromat fluid property library Parameters From 1a3dc9ab7eb36e7a4e928c7f9540efa8a1c711d2 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sun, 4 Jan 2026 10:19:02 +0100 Subject: [PATCH 02/69] Implement a simple test method to check if kwargs can be injected via Connection API --- src/tespy/connections/connection.py | 10 ++++++++-- tests/test_connections.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 224462c99..afb858b10 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -772,6 +772,9 @@ def _fluid_specification(self, key, value): elif key == "fluid_balance": self.fluid_balance.is_set = value + elif key == "fluid_wrapper_kwargs": + self.fluid.wrapper_kwargs = value + else: msg = f"Connections do not have an attribute named {key}" logger.error(msg) @@ -833,6 +836,7 @@ def _create_fluid_wrapper(self): for fluid in self.fluid.val: if fluid in self.fluid.wrapper: continue + if fluid not in self.fluid.engine: self.fluid.engine[fluid] = CoolPropWrapper @@ -844,9 +848,11 @@ def _create_fluid_wrapper(self): wrapper_kwargs = {} if fluid in self.fluid.wrapper_kwargs: - wrapper_kwargs = self.fluid.wrapper_kwargs + wrapper_kwargs = self.fluid.wrapper_kwargs[fluid] - self.fluid.wrapper[fluid] = self.fluid.engine[fluid](fluid, back_end, **wrapper_kwargs) + self.fluid.wrapper[fluid] = self.fluid.engine[fluid]( + fluid, back_end, **wrapper_kwargs + ) def _precalc_guess_values(self): """ diff --git a/tests/test_connections.py b/tests/test_connections.py index 9fe6c5458..79c07d6c2 100644 --- a/tests/test_connections.py +++ b/tests/test_connections.py @@ -33,6 +33,7 @@ from tespy.tools.data_containers import FluidProperties as dc_prop from tespy.tools.fluid_properties.functions import T_bubble_p from tespy.tools.fluid_properties.functions import T_dew_p +from tespy.tools.fluid_properties.wrappers import FluidPropertyWrapper from tespy.tools.units import SI_UNITS @@ -527,6 +528,7 @@ def make_connection(cls): QUANTITY_EXEMPTIONS = {} + def properties_of(instance): return [ prop @@ -534,6 +536,7 @@ def properties_of(instance): if isinstance(container, dc_prop) ] + def pytest_generate_tests(metafunc): if "cls_name" in metafunc.fixturenames and "prop" in metafunc.fixturenames: params = [] @@ -543,6 +546,7 @@ def pytest_generate_tests(metafunc): params.append(pytest.param(name, prop, id=f"{cls.__name__}::{prop}")) metafunc.parametrize("cls_name,prop", params) + def test_property_value_not_none(cls_name, prop): instance = make_connection(connection_registry.items[cls_name]) @@ -554,3 +558,16 @@ def test_property_value_not_none(cls_name, prop): ) assert condition, f"Quantity for {prop} of {cls_name} must not be None" + + +def test_wrapper_kwargs_injection(): + so = Source("source") + si = Sink("sink") + c = Connection(so, "out1", si, "in1", label="c") + c.set_attr( + fluid={"H2O": 1}, + fluid_wrapper_kwargs={"H2O": {"testkeyword": "data"}}, + fluid_engines={"H2O": FluidPropertyWrapper} + ) + c._create_fluid_wrapper() + assert "testkeyword" in c.fluid.wrapper["H2O"].__dict__ From edfbb4f6cd9534712ef5a580221a80a67dc52ee0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 15:04:57 +0100 Subject: [PATCH 03/69] Also propagate the fluid wrapper kwargs --- src/tespy/networks/network.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index ed4ae19d5..e1b09f6b9 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -917,16 +917,21 @@ def _propagate_fluid_wrappers(self): any_fluids_set = [] engines = {} back_ends = {} + wrapper_kwargs = {} any_fluids = [] any_fluids0 = [] mixing_rules = [] for c in all_connections: for f in c.fluid.is_set: any_fluids_set += [f] + if f in c.fluid.engine: engines[f] = c.fluid.engine[f] if f in c.fluid.back_end: back_ends[f] = c.fluid.back_end[f] + if f in c.fluid.wrapper_kwargs: + wrapper_kwargs[f] = c.fluid.wrapper_kwargs[f] + any_fluids += list(c.fluid.val.keys()) any_fluids0 += list(c.fluid.val0.keys()) if c.mixing_rule is not None: @@ -957,7 +962,7 @@ def _propagate_fluid_wrappers(self): num_potential_fluids = len(potential_fluids) if num_potential_fluids == 0: msg = ( - "The follwing connections of your network are missing any " + "The following connections of your network are missing any " "kind of fluid composition information:" f"{', '.join([c.label for c in all_connections])}." ) @@ -981,6 +986,8 @@ def _propagate_fluid_wrappers(self): c.fluid.engine[f] = engine for f, back_end in back_ends.items(): c.fluid.back_end[f] = back_end + for f, w_kwargs in wrapper_kwargs.items(): + c.fluid.wrapper_kwargs[f] = w_kwargs c._create_fluid_wrapper() From ab5c920ab56909d17b10e0ba019c97643255daf5 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 15:05:53 +0100 Subject: [PATCH 04/69] Implement a simple way for Arrhenius viscosity model and linear density and heat capacity --- src/tespy/tools/fluid_properties/helpers.py | 28 +++++ src/tespy/tools/fluid_properties/wrappers.py | 110 ++++++++++++++++++- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/tespy/tools/fluid_properties/helpers.py b/src/tespy/tools/fluid_properties/helpers.py index 033ed1801..5ef6d3e14 100644 --- a/src/tespy/tools/fluid_properties/helpers.py +++ b/src/tespy/tools/fluid_properties/helpers.py @@ -360,3 +360,31 @@ def colebrook(reynolds, ks, diameter, darcy_friction_factor, **kwargs): / (3.71 * diameter) ) + 1 / darcy_friction_factor ** 0.5 ) + + +def _check_fitting_data_structure(x: np.ndarray, y: np.ndarray) -> None: + if len(x) != len(y): + msg = "" + raise ValueError(msg) + elif len(x) < 3: + msg = "" + raise ValueError(msg) + + +def fit_incompressible_viscosity(temperature: np.ndarray, viscosity: np.ndarray) -> tuple: + _check_fitting_data_structure(temperature, viscosity) + + x = 1.0 / temperature + y = np.log(viscosity) + + intercept, slope = np.polyfit(x, y, 1) + + return np.exp(slope), intercept + + +def fit_incompressible_linear(temperature: np.ndarray, y: np.ndarray) -> tuple: + _check_fitting_data_structure(temperature, y) + + intercept, slope = np.polyfit(temperature, y, 1) + + return slope, intercept diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index 325df17b2..9818cabad 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -10,9 +10,11 @@ SPDX-License-Identifier: MIT """ - import CoolProp as CP +import numpy as np +from tespy.tools.fluid_properties.helpers import fit_incompressible_linear +from tespy.tools.fluid_properties.helpers import fit_incompressible_viscosity from tespy.tools.global_vars import ERR @@ -50,7 +52,6 @@ def __init__(self, fluid, back_end=None, **kwargs) -> None: self.back_end = back_end self.fluid = fluid self.mixture_type = None - self.__dict__.update(kwargs) def _not_implemented(self) -> None: raise NotImplementedError( @@ -366,6 +367,107 @@ def s_pT(self, p, T): return self.AS.smass() +@wrapper_registry +class IncompressibleFluidWrapper(FluidPropertyWrapper): + + def __init__(self, fluid, back_end=None, **kwargs): + super().__init__(fluid, back_end, **kwargs) + + self.temperature_data = kwargs.get("temperature_data", None) + self.heat_capacity_data = kwargs.get("heat_capacity_data", None) + self.density_data = kwargs.get("density_data", None) + self.viscosity_data = kwargs.get("viscosity_data", None) + + if self.temperature_data is None: + pass + elif self.heat_capacity_data is None: + pass + elif self.density_data is None: + pass + elif self.viscosity_data is None: + pass + + self._fit_data() + self._set_constants() + + def _fit_data(self): + A, B = fit_incompressible_linear( + self.temperature_data, self.heat_capacity_data + ) + self._heat_capacity = { + "A": A, + "B": B + } + A, B = fit_incompressible_linear( + self.temperature_data, self.density_data + ) + self._density = { + "A": A, + "B": B + } + A, B = fit_incompressible_viscosity( + self.temperature_data, self.viscosity_data + ) + self._viscosity = { + "A": A, + "B": B + } + + def _set_constants(self): + self._T_ref = min(self.temperature_data) + # evaluate at T=T_ref + self._h_ref = self._h_pT(None, self._T_ref) + + self._T_min = self._T_ref + self._T_max = max(self.temperature_data) + + self._molar_mass = 1 + self._p_min = 100 + self._p_max = 10000000 + self._p_crit = self._p_max + + self._T_crit = None + + def T_ph(self, p, h): + # Inverse function of h_pT, using quadratic formula with adding the + # root + return ( + ( + -self._heat_capacity["A"] + + ( + self._heat_capacity["A"] ** 2 + - 4 * 0.5 * self._heat_capacity["B"] * - (h + self._h_ref) + ) ** 0.5 + ) + / (2 * 0.5 * self._heat_capacity["B"]) + ) + + def h_pT(self, p, T): + return self._h_pT(p, T) - self._h_ref + + def _h_pT(self, p, T): + # h = integral cp(T) dT + return ( + 0.5 * self._heat_capacity["B"] * T ** 2 + + self._heat_capacity["A"] * T + ) + + def d_ph(self, p, h): + return self.d_pT(p, self.T_ph(p, h)) + + def d_pT(self, p, T): + return ( + self._density["B"] * T + + self._density["A"] + ) + + def viscosity_ph(self, p, h): + return self.viscosity_pT(p, self.T_ph(p, h)) + + def viscosity_pT(self, p, T): + return self._viscosity["A"] * np.exp(self._viscosity["B"] / T) + + @wrapper_registry class IAPWSWrapper(FluidPropertyWrapper): @@ -460,8 +562,8 @@ def phase_ph(self, p, h): return "g" elif phase in ["Two phases", "Saturated vapor", "Saturated liquid"]: return "tp" - else: # to ensure consistent behaviour to CoolPropWrapper - return "phase not recognised" + else: # to ensure consistent behavior to CoolPropWrapper + return "phase not recognized" def d_ph(self, p, h): return self.AS(h=h / 1e3, P=p / 1e6).rho From 7afb0d1aabc89eb46ca841a7ddbde51ff1ee6a46 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 15:21:22 +0100 Subject: [PATCH 05/69] Update changelog --- docs/whats_new/v0-9-12.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/whats_new/v0-9-12.rst b/docs/whats_new/v0-9-12.rst index fe1d626e2..abc005ad5 100644 --- a/docs/whats_new/v0-9-12.rst +++ b/docs/whats_new/v0-9-12.rst @@ -1,6 +1,23 @@ v0.9.12 - Under development +++++++++++++++++++++++++++ +New Features +############ +- Custom fluid property wrappers can now receive arbitrary :code:`kwargs` + making injection of information for the underlying property models much + easier (`PR #877 `__). +- There is a new :code:`FluidPropertyWrapper` for incompressible fluids such as + thermo-oils, which you can pass measurement or manufacturer data to. The + :code:`IncompressibleFluidWrapper` will automatically fit functions to the + data points you provide: + + - heat capacity and density: linear interpolation + :math:`f\left(T\right) = A + B \cdot T`. + - viscosity: Arrhenius equation + :math:`v\left(T\right) = A \cdot e ^ \frac{B}{T}`. + + (`PR #878 `__) + Other changes ############# - The optimization API has changed to integrate :code:`pymoo` instead of From 77363eb4a3cfce9eb580d2bdfd6327ee89dfb672 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 16:45:14 +0100 Subject: [PATCH 06/69] Add testing for the newly implemented wrapper --- .../test_incompressible.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test_tools/test_fluid_properties/test_incompressible.py diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py new file mode 100644 index 000000000..9ffada32e --- /dev/null +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 + +"""Module for testing fluid properties of gas mixtures. + +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_tools/test_fluid_properties/test_iapws.py + +SPDX-License-Identifier: MIT +""" +from pytest import fixture, approx + +import numpy as np + +from tespy.tools.fluid_properties.wrappers import IncompressibleFluidWrapper + + +@fixture +def property_data(): + # sample data from a publicly available datasheet + # https://petrocanadalubricants.com/api/sitecore/lubesapi/downloadresource?docID=IM-7852E&type=TechData&lang=english&name=CALFLO%20AF + return { + "temperature_data": np.array([292.647, 310.808, 366.241, 421.673, 477.108, 532.542, 588.826, 618.580]), + "heat_capacity_data": np.array([1901.775, 1961.529, 2143.908, 2326.287, 2508.674, 2691.060, 2876.242, 2974.135]), + "density_data": np.array([863.811, 852.596, 818.368, 784.139, 749.909, 715.678, 680.924, 662.551]), + "viscosity_data": np.array([0.050335, 0.028525, 0.007075, 0.002500, 0.00111, 0.000579, 0.000334, 0.000259]) + } + + +@fixture +def wrapper_instance(property_data): + return IncompressibleFluidWrapper( + "fluid name", + None, + **property_data + ) + + +def test_setup_wrapper(wrapper_instance): + assert wrapper_instance is not None + + +def test_enthalpy_forwards_backwards(wrapper_instance): + temperature = 500 + h = wrapper_instance.h_pT(None, temperature) + assert approx(temperature) == wrapper_instance.T_ph(None, h) + + +def test_density(property_data, wrapper_instance): + + density_data = property_data["density_data"] + temperature_data = property_data["temperature_data"] + + density = wrapper_instance.d_pT(None, temperature_data) + np.testing.assert_allclose(density, density_data, rtol=1e-3) + + +def test_heat_capacity(property_data, wrapper_instance): + + capacity_data = property_data["heat_capacity_data"] + temperature_data = property_data["temperature_data"] + # capacity is not implemented as method in wrapper, using enthalpy over + # temperature differences + d = 1e-3 + capacity = [ + (h_u - h_l) / (2 * d) + for h_l, h_u in + zip( + wrapper_instance.h_pT(None, temperature_data - d), + wrapper_instance.h_pT(None, temperature_data + d) + ) + ] + np.testing.assert_allclose(capacity, capacity_data, rtol=1e-3) + + +def test_viscosity(property_data, wrapper_instance): + + viscosity_data = property_data["viscosity_data"] + temperature_data = property_data["temperature_data"] + + viscosity = wrapper_instance.viscosity_pT(None, temperature_data) + # allow higher tolerance for viscosity + np.testing.assert_allclose(viscosity, viscosity_data, rtol=5e-2) From f2daf984300077cd750e8705b09bd9445c45a7ef Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 16:47:27 +0100 Subject: [PATCH 07/69] Run isort --- .../test_tools/test_fluid_properties/test_incompressible.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index 9ffada32e..155df6ce9 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -5,13 +5,13 @@ 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_tools/test_fluid_properties/test_iapws.py +tests/test_tools/test_fluid_properties/test_incompressible.py SPDX-License-Identifier: MIT """ -from pytest import fixture, approx - import numpy as np +from pytest import approx +from pytest import fixture from tespy.tools.fluid_properties.wrappers import IncompressibleFluidWrapper From 58c3ad87c225f39aff70584251d5dcfb91b23f1b Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 16:58:33 +0100 Subject: [PATCH 08/69] Change minimum number of datapoints --- src/tespy/tools/fluid_properties/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tespy/tools/fluid_properties/helpers.py b/src/tespy/tools/fluid_properties/helpers.py index 5ef6d3e14..55e996092 100644 --- a/src/tespy/tools/fluid_properties/helpers.py +++ b/src/tespy/tools/fluid_properties/helpers.py @@ -366,7 +366,7 @@ def _check_fitting_data_structure(x: np.ndarray, y: np.ndarray) -> None: if len(x) != len(y): msg = "" raise ValueError(msg) - elif len(x) < 3: + elif len(x) < 2: msg = "" raise ValueError(msg) From 76d7242713081c67c7be41e7af78741729277787 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 16:59:07 +0100 Subject: [PATCH 09/69] Fix heat capacity data --- tests/test_connections.py | 3 +- tests/test_networks/test_network.py | 36 +++++++++++++++++++ .../test_incompressible.py | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/test_connections.py b/tests/test_connections.py index 79c07d6c2..86401678a 100644 --- a/tests/test_connections.py +++ b/tests/test_connections.py @@ -569,5 +569,6 @@ def test_wrapper_kwargs_injection(): fluid_wrapper_kwargs={"H2O": {"testkeyword": "data"}}, fluid_engines={"H2O": FluidPropertyWrapper} ) + # if kwargs could not be passed to the Wrapper instantiation this would + # raise an error c._create_fluid_wrapper() - assert "testkeyword" in c.fluid.wrapper["H2O"].__dict__ diff --git a/tests/test_networks/test_network.py b/tests/test_networks/test_network.py index 7c1ccfe93..7fc17729f 100644 --- a/tests/test_networks/test_network.py +++ b/tests/test_networks/test_network.py @@ -35,6 +35,7 @@ from tespy.connections import Ref from tespy.networks import Network from tespy.tools.data_containers import ComponentMandatoryConstraints as dc_cmc +from tespy.tools.fluid_properties.wrappers import IncompressibleFluidWrapper from tespy.tools.helpers import TESPyNetworkError from tespy.tools.helpers import _numeric_deriv @@ -1177,3 +1178,38 @@ class FakeSource(Source): nw.add_conns(c1) nw.solve("design", init_only=True) + + +def test_fluid_kwargs_propagation(): + nw = Network() + nw.units.set_defaults(temperature="°C", pressure="bar") + + pipe = SimpleHeatExchanger("pipe") + + so = Source("source") + si = Sink("sink") + + c1 = Connection(so, "out1", pipe, "in1", label="c1") + c2 = Connection(pipe, "out1", si, "in1", label="c2") + + nw.add_conns(c1, c2) + + fluid_kwargs = { + "temperature_data": np.array([273.15, 373.15]), + "density_data": np.array([1000, 1100]), + "heat_capacity_data": np.array([4000, 4100]), + "viscosity_data": np.array([0.05, 0.00025]) + } + + c1.set_attr( + fluid={"f": 1}, + fluid_engines={"f": IncompressibleFluidWrapper}, + fluid_wrapper_kwargs={"f": fluid_kwargs}, + p=1, T=30 + ) + c2.set_attr(p=0.9, T=50) + + # this specification requires viscosity + pipe.set_attr(Q=1500) + + nw.solve("design") diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index 155df6ce9..d6b35505a 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -22,7 +22,7 @@ def property_data(): # https://petrocanadalubricants.com/api/sitecore/lubesapi/downloadresource?docID=IM-7852E&type=TechData&lang=english&name=CALFLO%20AF return { "temperature_data": np.array([292.647, 310.808, 366.241, 421.673, 477.108, 532.542, 588.826, 618.580]), - "heat_capacity_data": np.array([1901.775, 1961.529, 2143.908, 2326.287, 2508.674, 2691.060, 2876.242, 2974.135]), + "heat_capacity_data": np.array([1901.775, 1961.529, 2143.908, 2326.287, 2508.674, 2691.060, 2876.242, 2974.135]) * 1000, "density_data": np.array([863.811, 852.596, 818.368, 784.139, 749.909, 715.678, 680.924, 662.551]), "viscosity_data": np.array([0.050335, 0.028525, 0.007075, 0.002500, 0.00111, 0.000579, 0.000334, 0.000259]) } From 9d1caffac8ef0ceb8c1989589520ca3f9bdb533a Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 5 Jan 2026 18:32:36 +0100 Subject: [PATCH 10/69] Write some docs --- docs/advanced_features/fluid_properties.rst | 126 ++++++++++++++++-- docs/whats_new/v0-9-12.rst | 5 +- src/tespy/tools/fluid_properties/__init__.py | 1 + tests/test_networks/test_network.py | 2 - .../test_incompressible.py | 2 +- 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/docs/advanced_features/fluid_properties.rst b/docs/advanced_features/fluid_properties.rst index 228412431..61df2dc4a 100644 --- a/docs/advanced_features/fluid_properties.rst +++ b/docs/advanced_features/fluid_properties.rst @@ -4,8 +4,13 @@ Fluid properties ================ The default fluid property engine `CoolProp `_. All available fluids can be found on their homepage. Also see :cite:`Bell2014`. -Since version 0.7 of TESPy it is possible to use other engines. TESPy comes with -two additional predefined engines, i.e. +Since version 0.7 of TESPy it is possible to use other engines. TESPy supports +one additional predefined engine, which can be used to fit functions to simple +custom fluid property data, the :code:`IncompressibleFluidWrapper`. See +:ref:`this section ` for more information. + +On top, there are two additional predefined engines, which are untested but may +serve as an inspiration for you to create your onw one, i.e. - the `iapws `_ library and - the `pyromat `_ library. @@ -62,19 +67,122 @@ If you are looking for heat transfer fluids, the list of incompressible might be interesting for you. In contrast to the pure fluids, the properties cover liquid state only. +If you have measurement data or data from manufacturer data sheets and want to +use these in your TESPy model, TESPy can create fitting functions for these +with the :code:`IncompressibleFluidWrapper`. See +:ref:`this section ` for more information. + Fluid mixtures ++++++++++++++ TESPy provides support for three types of mixtures: - ideal: Mixtures for gases only. - ideal-cond: Mixture for gases with condensation calculation for water share. -- incompressible: Mixtures for incompressible fluids. +- incompressible: Mixtures for CoolProp-based incompressible fluids. + +These mixtures are handled externally by TESPy by using the pure fluid +properties of CoolProp and then applying the respective mixing rules, read more +about it :ref:`here `. + +More accurate formulations are available directly through CoolProp, which +provides a back end for predefined mixtures. This back end is rather instable +when using HEOS. In general, to use the mixture feature of CoolProp we +recommend using the REFPROP back end instead of HEOS. Also note, the CoolProp +mixture back end is not tested thoroughly. Please reach out if you would like +to support us in adopting the TESPy implementation. + +.. _incompressible_wrapper_label: +IncompressibleFluidWrapper +-------------------------- +You can use the :code:`IncompressibleFluidWrapper` engine of TESPy to model a +fluid based on your own data. To do this, you need tabular data as function of +temperature: + +- Mass density +- Mass specific heat capacity +- Dynamic viscosity + +The :code:`IncompressibleFluidWrapper` will automatically fit functions to your +data: + +- density and heat capacity through linear interpolation: + :math:`f\left(T\right) = A + B \cdot T` +- viscosity through Arrhenius equation + :math:`\eta\left(T\right) = A \cdot e ^ \frac{B}{T}` + +We can make use of the engine as shown in the example below. First, we set up +a very simple system, just a flow of fluid through a heat exchanger. + +.. code-block:: python + + >>> from tespy.components import Sink + >>> from tespy.components import Source + >>> from tespy.components import SimpleHeatExchanger + >>> from tespy.connections import Connection + >>> from tespy.networks import Network + >>> from tespy.tools.fluid_properties import IncompressibleFluidWrapper + >>> import numpy as np + >>> nw = Network(iterinfo=False) + >>> nw.units.set_defaults( + ... temperature="°C", + ... pressure="bar", + ... heat="kW" + ... ) + + >>> heatexchanger = SimpleHeatExchanger("heat exchanger") + + >>> so = Source("source") + >>> si = Sink("sink") + + >>> c1 = Connection(so, "out1", heatexchanger, "in1", label="c1") + >>> c2 = Connection(heatexchanger, "out1", si, "in1", label="c2") -Furthermore, CoolProp provides a back end for predefined mixtures, which is -rather instable using HEOS. Using the CoolProp mixture back-end is not tested, -reach out if you would like to support us in adopting the TESPy implementation. -In general, to use the mixture feature of CoolProp we recommend using the -REFPROP back end instead of HEOS. + >>> nw.add_conns(c1, c2) + +Next, we have to prepare our data to be utilized by TESPy. The information +required needs to be passed in SI units and must be gridded with the same +spacing of temperature for all measurements. + +.. code-block:: python + + >>> fluid_kwargs = { + ... "temperature_data": np.array([273.15, 373.15]), # K + ... "density_data": np.array([1000, 1100]), # kg/m3 + ... "heat_capacity_data": np.array([4000, 4100]) * 1e3, # J/kg + ... "viscosity_data": np.array([0.05, 0.00025]) # Pa*s + ... } + +.. attention:: + + The keys of the dictionary must be named :code:`temperature_data`, + :code:`density_data`, :code:`heat_capacity_data` and + :code:`viscosity_data`! + +Then, we can specify the fluid on one of our connections by providing two +additional keywords: + +- :code:`fluid_engines` indicating which fluid uses which wrapper class. +- :code:`fluid_wrapper_kwargs` providing for each fluid (if applicable) the + required data. + +.. code-block:: python + + >>> c1.set_attr( + ... fluid={"f": 1}, + ... fluid_engines={"f": IncompressibleFluidWrapper}, + ... fluid_wrapper_kwargs={"f": fluid_kwargs}, + ... ) + +We can specify missing boundary conditions and solve the problem. + +.. code-block:: python + + >>> c1.set_attr(v=2, p=1, T=30) + >>> c2.set_attr(p=0.9) + >>> heatexchanger.set_attr(Q=10) + + >>> nw.solve("design") + >>> nw.assert_convergence() Using other engines ------------------- @@ -271,7 +379,6 @@ the previous section: .. code-block:: python - >>> from tespy.components import Sink >>> from tespy.components import Source >>> from tespy.components import Turbine @@ -304,6 +411,7 @@ the previous section: >>> round(c2.T.val, 1) 306.3 +.. _mixture_routines_label: Mixture routines in TESPy ------------------------- Different types of mixture routines are implemented in TESPy. You can select, diff --git a/docs/whats_new/v0-9-12.rst b/docs/whats_new/v0-9-12.rst index abc005ad5..268eebe60 100644 --- a/docs/whats_new/v0-9-12.rst +++ b/docs/whats_new/v0-9-12.rst @@ -14,7 +14,10 @@ New Features - heat capacity and density: linear interpolation :math:`f\left(T\right) = A + B \cdot T`. - viscosity: Arrhenius equation - :math:`v\left(T\right) = A \cdot e ^ \frac{B}{T}`. + :math:`v\left(T\right) = A \cdot e ^ \frac{B}{T}` + + For an example in a TESPy model see + :ref:`this section ` (`PR #878 `__) diff --git a/src/tespy/tools/fluid_properties/__init__.py b/src/tespy/tools/fluid_properties/__init__.py index 6351f42e7..80cfd0526 100644 --- a/src/tespy/tools/fluid_properties/__init__.py +++ b/src/tespy/tools/fluid_properties/__init__.py @@ -22,3 +22,4 @@ from .functions import viscosity_mix_pT # noqa: F401 from .helpers import single_fluid # noqa: F401 from .wrappers import CoolPropWrapper # noqa: F401 +from .wrappers import IncompressibleFluidWrapper # noqa: F401 diff --git a/tests/test_networks/test_network.py b/tests/test_networks/test_network.py index 7fc17729f..d81c275f0 100644 --- a/tests/test_networks/test_network.py +++ b/tests/test_networks/test_network.py @@ -1208,8 +1208,6 @@ def test_fluid_kwargs_propagation(): p=1, T=30 ) c2.set_attr(p=0.9, T=50) - - # this specification requires viscosity pipe.set_attr(Q=1500) nw.solve("design") diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index d6b35505a..a09d684a5 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -"""Module for testing fluid properties of gas mixtures. +"""Module for testing fluid properties of incompressibles. 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, From 3e6910b7bdba05c9ab207a9b0798f6f6acdf1c7b Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 6 Jan 2026 07:11:42 +0100 Subject: [PATCH 11/69] Insert missing newlines --- docs/advanced_features/fluid_properties.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/advanced_features/fluid_properties.rst b/docs/advanced_features/fluid_properties.rst index 61df2dc4a..1a67b1049 100644 --- a/docs/advanced_features/fluid_properties.rst +++ b/docs/advanced_features/fluid_properties.rst @@ -92,6 +92,7 @@ mixture back end is not tested thoroughly. Please reach out if you would like to support us in adopting the TESPy implementation. .. _incompressible_wrapper_label: + IncompressibleFluidWrapper -------------------------- You can use the :code:`IncompressibleFluidWrapper` engine of TESPy to model a @@ -412,6 +413,7 @@ the previous section: 306.3 .. _mixture_routines_label: + Mixture routines in TESPy ------------------------- Different types of mixture routines are implemented in TESPy. You can select, From bac59219ea8ab67ff96518c5d39f2924485108de Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 6 Jan 2026 07:49:08 +0100 Subject: [PATCH 12/69] Clean up a bit --- src/tespy/tools/fluid_properties/wrappers.py | 33 +++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index 9818cabad..e778bfb37 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -12,6 +12,7 @@ """ import CoolProp as CP import numpy as np +from scipy.optimize import brentq from tespy.tools.fluid_properties.helpers import fit_incompressible_linear from tespy.tools.fluid_properties.helpers import fit_incompressible_viscosity @@ -373,19 +374,24 @@ class IncompressibleFluidWrapper(FluidPropertyWrapper): def __init__(self, fluid, back_end=None, **kwargs): super().__init__(fluid, back_end, **kwargs) - self.temperature_data = kwargs.get("temperature_data", None) - self.heat_capacity_data = kwargs.get("heat_capacity_data", None) - self.density_data = kwargs.get("density_data", None) - self.viscosity_data = kwargs.get("viscosity_data", None) + self.temperature_data = None + self.heat_capacity_data = None + self.density_data = None + self.viscosity_data = None - if self.temperature_data is None: - pass - elif self.heat_capacity_data is None: - pass - elif self.density_data is None: - pass - elif self.viscosity_data is None: - pass + for key in ["temperature", "heat_capacity", "density", "viscosity"]: + value = kwargs.get(f"{key}_data") + if value is None: + msg = ( + f"The {self.__class__.__name__} requires specification of " + f"the '{key}_data' keyword in the form of a numpy array." + ) + raise KeyError(msg) + else: + setattr(self, f"{key}_data", value) + + self._T_ref = kwargs.get("T_ref", min(self.temperature_data)) + self._p_ref = kwargs.get("p_ref", 1e5) self._fit_data() self._set_constants() @@ -414,8 +420,7 @@ def _fit_data(self): } def _set_constants(self): - self._T_ref = min(self.temperature_data) - # evaluate at T=T_ref + # evaluate h at T=T_ref self._h_ref = self._h_pT(None, self._T_ref) self._T_min = self._T_ref From 2c41acbd5a17cc0319674fd20e15e188be82f4f3 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 6 Jan 2026 07:49:18 +0100 Subject: [PATCH 13/69] Add entropy --- src/tespy/tools/fluid_properties/wrappers.py | 30 +++++++++++++++- .../test_incompressible.py | 36 ++++++++++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index e778bfb37..360f0a8ea 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -74,7 +74,7 @@ def T_ps(self, p, s): def h_pT(self, p, T): self._not_implemented() - def h_ps(self, p, T): + def h_ps(self, p, s): self._not_implemented() def h_QT(self, Q, T): @@ -457,6 +457,34 @@ def _h_pT(self, p, T): + self._heat_capacity["A"] * T ) + def h_ps(self, p, s): + return self.h_pT(p, self.T_ps(p, s)) + + def s_ph(self, p, h): + return self.s_pT(p, self.T_ph(p, h)) + + def s_pT(self, p, T): + # s0 = 0 + return ( + self._heat_capacity["A"] * np.log(T / self._T_ref) + + self._heat_capacity["B"] * (T - self._T_ref) + - self.d_pT(p, T) * (p - self._p_ref) + ) + + def isentropic(self, p_1, h_1, p_2): + return self.h_ps(p_2, self.s_ph(p_1, h_1)) + + def _inverse_s_pT(self, T, s, p): + return s - self.s_pT(p, T) + + def T_ps(self, p, s): + return brentq( + self._inverse_s_pT, + self._T_min, + self._T_max, + args=(s, p) + ) + def d_ph(self, p, h): return self.d_pT(p, self.T_ph(p, h)) diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index a09d684a5..9328f2abf 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -41,10 +41,38 @@ def test_setup_wrapper(wrapper_instance): assert wrapper_instance is not None -def test_enthalpy_forwards_backwards(wrapper_instance): - temperature = 500 - h = wrapper_instance.h_pT(None, temperature) - assert approx(temperature) == wrapper_instance.T_ph(None, h) +def test_enthalpy_forwards_backwards(property_data, wrapper_instance): + temperature_data = property_data["temperature_data"] + temperature_check = [] + + for temperature in temperature_data: + h = wrapper_instance.h_pT(None, temperature) + temperature_check.append(wrapper_instance.T_ph(None, h)) + + np.testing.assert_allclose(temperature_check, temperature_data) + + +def test_entropy_forwards_backwards(property_data, wrapper_instance): + temperature_data = property_data["temperature_data"] + temperature_check = [] + + for temperature in temperature_data: + s = wrapper_instance.s_pT(1e5, temperature) + temperature_check.append(wrapper_instance.T_ps(1e5, s)) + + np.testing.assert_allclose(temperature_check, temperature_data) + + +def test_entropy_enthalpy_roundtrip(property_data, wrapper_instance): + temperature_data = property_data["temperature_data"] + temperature_check = [] + + for temperature in temperature_data: + s = wrapper_instance.s_pT(1e5, temperature) + h = wrapper_instance.h_ps(1e5, s) + temperature_check.append(wrapper_instance.T_ph(1e5, h)) + + np.testing.assert_allclose(temperature_check, temperature_data) def test_density(property_data, wrapper_instance): From 793e3495cf1cc7ba5957d1b9ff8cb75dd5b53579 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 6 Jan 2026 08:14:48 +0100 Subject: [PATCH 14/69] Fix isentropic calculation by only considering pressure change and assuming constant temperature --- src/tespy/tools/fluid_properties/wrappers.py | 8 +++++--- .../test_fluid_properties/test_incompressible.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index 360f0a8ea..2d770cfef 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -472,9 +472,11 @@ def s_pT(self, p, T): ) def isentropic(self, p_1, h_1, p_2): - return self.h_ps(p_2, self.s_ph(p_1, h_1)) + # assumption that temperature barely changes + T = self.T_ph(p_1, h_1) + return h_1 + (p_2 - p_1) / self.d_pT(p_1, T) - def _inverse_s_pT(self, T, s, p): + def _inverse_s_pT(self, T, p, s): return s - self.s_pT(p, T) def T_ps(self, p, s): @@ -482,7 +484,7 @@ def T_ps(self, p, s): self._inverse_s_pT, self._T_min, self._T_max, - args=(s, p) + args=(p, s) ) def d_ph(self, p, h): diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index 9328f2abf..8e5e480ee 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -75,6 +75,19 @@ def test_entropy_enthalpy_roundtrip(property_data, wrapper_instance): np.testing.assert_allclose(temperature_check, temperature_data) +def test_isentropic(property_data, wrapper_instance): + temperature_data = property_data["temperature_data"] + + enthalpy_inflow = wrapper_instance.h_pT(None, temperature_data) + enthalpy_outflow = wrapper_instance.isentropic(1e5, enthalpy_inflow, 2e5) + rho = wrapper_instance.d_pT(None, temperature_data) + + np.testing.assert_allclose( + enthalpy_outflow - enthalpy_inflow, 1e5 / rho + ) + + + def test_density(property_data, wrapper_instance): density_data = property_data["density_data"] From 8041ad3296cccb5a2708f354bc4bdd7490dc5d1d Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 7 Jan 2026 18:11:56 +0100 Subject: [PATCH 15/69] Add a fitting report and change viscosity to higher order polynomial --- src/tespy/tools/fluid_properties/helpers.py | 14 ++- src/tespy/tools/fluid_properties/wrappers.py | 105 +++++++++++++++--- .../test_incompressible.py | 1 - 3 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/tespy/tools/fluid_properties/helpers.py b/src/tespy/tools/fluid_properties/helpers.py index 55e996092..77dc3700e 100644 --- a/src/tespy/tools/fluid_properties/helpers.py +++ b/src/tespy/tools/fluid_properties/helpers.py @@ -377,14 +377,20 @@ def fit_incompressible_viscosity(temperature: np.ndarray, viscosity: np.ndarray) x = 1.0 / temperature y = np.log(viscosity) + return np.polyfit(x, y, 3) + + +def _fit_arrhenius(temperature: np.ndarray, viscosity: np.ndarray) -> tuple: + _check_fitting_data_structure(temperature, viscosity) + x = 1.0 / temperature + y = np.log(viscosity) + intercept, slope = np.polyfit(x, y, 1) - return np.exp(slope), intercept + return intercept, np.exp(slope) def fit_incompressible_linear(temperature: np.ndarray, y: np.ndarray) -> tuple: _check_fitting_data_structure(temperature, y) - intercept, slope = np.polyfit(temperature, y, 1) - - return slope, intercept + return np.polyfit(temperature, y, 1) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index 2d770cfef..d91d5adb7 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -390,6 +390,8 @@ def __init__(self, fluid, back_end=None, **kwargs): else: setattr(self, f"{key}_data", value) + self.conductivity_data = kwargs.get("conductivity_data") + self._T_ref = kwargs.get("T_ref", min(self.temperature_data)) self._p_ref = kwargs.get("p_ref", 1e5) @@ -404,6 +406,7 @@ def _fit_data(self): "A": A, "B": B } + A, B = fit_incompressible_linear( self.temperature_data, self.density_data ) @@ -411,12 +414,27 @@ def _fit_data(self): "A": A, "B": B } - A, B = fit_incompressible_viscosity( + + if self.conductivity_data is not None: + A, B = fit_incompressible_linear( + self.temperature_data, self.conductivity_data + ) + else: + A, B = np.nan, np.nan + + self._conductivity = { + "A": A, + "B": B + } + + A, B, C, D = fit_incompressible_viscosity( self.temperature_data, self.viscosity_data ) self._viscosity = { "A": A, - "B": B + "B": B, + "C": C, + "D": D } def _set_constants(self): @@ -433,18 +451,70 @@ def _set_constants(self): self._T_crit = None + def get_fitting_report(self): + import matplotlib.pyplot as plt + + def plot_property(ax, temperature, measurements, evaluation): + + ax.scatter(temperature, measurements, s=1) + ax.plot(temperature, evaluation, "-") + + ax_err = ax.twinx() + + ax_err.scatter(temperature, (evaluation - measurements) / measurements * 100) + + ax_err.set_ylabel("Deviation between fit and data in %") + + + fig, ax = plt.subplots(2, 2, figsize=(10, 10), sharex=True) + + + ax[0, 0].set_title("Heat capacity") + ax[0, 1].set_title("Density") + ax[1, 0].set_title("Viscosity") + ax[1, 1].set_title("Thermal conductivity") + + temperature_data = self.temperature_data + heat_capacity_data = self.heat_capacity_data + density_data = self.density_data + viscosity_data = self.viscosity_data + conductivity_data = self.conductivity_data + + d = 0.001 + heat_capacity_eval = ( + self.h_pT(None, temperature_data + d) + - self.h_pT(None, temperature_data - d) + ) / (2 * d) + + density_eval = self.d_pT(None, temperature_data) + viscosity_eval = self.viscosity_pT(None, temperature_data) + conductivity_eval = self.conductivity_pT(None, temperature_data) + + plot_property(ax[0, 0], temperature_data, heat_capacity_data, heat_capacity_eval) + + plot_property(ax[0, 1], temperature_data, density_data, density_eval) + + plot_property(ax[1, 0], temperature_data, viscosity_data, viscosity_eval) + ax[1, 0].set_yscale("log") + + plot_property(ax[1, 1], temperature_data, conductivity_data, conductivity_eval) + + plt.tight_layout() + + return fig, ax + def T_ph(self, p, h): # Inverse function of h_pT, using quadratic formula with adding the # root return ( ( - -self._heat_capacity["A"] + -self._heat_capacity["B"] + ( - self._heat_capacity["A"] ** 2 - - 4 * 0.5 * self._heat_capacity["B"] * - (h + self._h_ref) + self._heat_capacity["B"] ** 2 + - 4 * 0.5 * self._heat_capacity["A"] * - (h + self._h_ref) ) ** 0.5 ) - / (2 * 0.5 * self._heat_capacity["B"]) + / (2 * 0.5 * self._heat_capacity["A"]) ) def h_pT(self, p, T): @@ -453,8 +523,8 @@ def h_pT(self, p, T): def _h_pT(self, p, T): # h = integral cp(T) dT return ( - 0.5 * self._heat_capacity["B"] * T ** 2 - + self._heat_capacity["A"] * T + 0.5 * self._heat_capacity["A"] * T ** 2 + + self._heat_capacity["B"] * T ) def h_ps(self, p, s): @@ -466,8 +536,8 @@ def s_ph(self, p, h): def s_pT(self, p, T): # s0 = 0 return ( - self._heat_capacity["A"] * np.log(T / self._T_ref) - + self._heat_capacity["B"] * (T - self._T_ref) + self._heat_capacity["B"] * np.log(T / self._T_ref) + + self._heat_capacity["A"] * (T - self._T_ref) - self.d_pT(p, T) * (p - self._p_ref) ) @@ -487,20 +557,25 @@ def T_ps(self, p, s): args=(p, s) ) + def conductivity_pT(self, p, T): + return self._conductivity["A"] * T + self._conductivity["B"] + def d_ph(self, p, h): return self.d_pT(p, self.T_ph(p, h)) def d_pT(self, p, T): - return ( - self._density["B"] * T - + self._density["A"] - ) + return self._density["A"] * T + self._density["B"] def viscosity_ph(self, p, h): return self.viscosity_pT(p, self.T_ph(p, h)) def viscosity_pT(self, p, T): - return self._viscosity["A"] * np.exp(self._viscosity["B"] / T) + return np.exp( + self._viscosity["A"] / T ** 3 + + self._viscosity["B"] / T ** 2 + + self._viscosity["C"] / T + + self._viscosity["D"] + ) @wrapper_registry diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index 8e5e480ee..74c0b2bf7 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -87,7 +87,6 @@ def test_isentropic(property_data, wrapper_instance): ) - def test_density(property_data, wrapper_instance): density_data = property_data["density_data"] From a3bcc3b528c828037fc5b6b41e0cb6c7903c16f0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 7 Jan 2026 18:12:49 +0100 Subject: [PATCH 16/69] Make error tolerance more strict --- tests/test_tools/test_fluid_properties/test_incompressible.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index 74c0b2bf7..eda1811a6 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -121,4 +121,4 @@ def test_viscosity(property_data, wrapper_instance): viscosity = wrapper_instance.viscosity_pT(None, temperature_data) # allow higher tolerance for viscosity - np.testing.assert_allclose(viscosity, viscosity_data, rtol=5e-2) + np.testing.assert_allclose(viscosity, viscosity_data, rtol=1e-2) From fee7e3c439ba65d3ea9426b2d44ca544fdc45a65 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 9 Jan 2026 15:26:09 +0100 Subject: [PATCH 17/69] Add a legend and proper axis labels for the plots --- src/tespy/tools/fluid_properties/wrappers.py | 26 +++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index d91d5adb7..0260efaee 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -456,15 +456,19 @@ def get_fitting_report(self): def plot_property(ax, temperature, measurements, evaluation): - ax.scatter(temperature, measurements, s=1) - ax.plot(temperature, evaluation, "-") + _fit, = ax.plot(temperature, evaluation, "-", color="red") + _data = ax.scatter(temperature, measurements, marker="x", c="blue") ax_err = ax.twinx() - ax_err.scatter(temperature, (evaluation - measurements) / measurements * 100) + _err = ax_err.scatter( + temperature, (evaluation - measurements) / measurements * 100, + c="#0000ff66" + ) ax_err.set_ylabel("Deviation between fit and data in %") + return [_data, _fit, _err] fig, ax = plt.subplots(2, 2, figsize=(10, 10), sharex=True) @@ -490,7 +494,8 @@ def plot_property(ax, temperature, measurements, evaluation): viscosity_eval = self.viscosity_pT(None, temperature_data) conductivity_eval = self.conductivity_pT(None, temperature_data) - plot_property(ax[0, 0], temperature_data, heat_capacity_data, heat_capacity_eval) + lines = plot_property(ax[0, 0], temperature_data, heat_capacity_data, heat_capacity_eval) + labels = ["datapoints", "fitted function", "deviation"] plot_property(ax[0, 1], temperature_data, density_data, density_eval) @@ -499,6 +504,19 @@ def plot_property(ax, temperature, measurements, evaluation): plot_property(ax[1, 1], temperature_data, conductivity_data, conductivity_eval) + + ax[0, 0].set_ylabel("Heat capacity in J/kgK") + ax[0, 1].set_ylabel("Density in kg/m3") + ax[1, 0].set_ylabel("Viscosity in Pas") + ax[1, 1].set_ylabel("Thermal conductivity in W/mK") + + ax[1, 0].set_xlabel("Temperature in K") + ax[1, 1].set_xlabel("Temperature in K") + + fig.legend( + lines, labels, loc="upper center", ncol=3, bbox_to_anchor=(0.5, 1.05) + ) + plt.tight_layout() return fig, ax From 25d8ce78cbb6acbde580c32dc3caa2cbf5207157 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sat, 10 Jan 2026 12:41:43 +0100 Subject: [PATCH 18/69] Move some methods from the network to the respective connection classes --- src/tespy/connections/connection.py | 70 ++++++++++++++++++++++++ src/tespy/connections/powerconnection.py | 4 ++ src/tespy/networks/network.py | 70 +----------------------- 3 files changed, 75 insertions(+), 69 deletions(-) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index e48962192..84abfb114 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -212,6 +212,9 @@ def _serializable(): def get_variables(self): return {} + def _guess_starting_values(self, units): + pass + def _preprocess(self, row_idx): self.num_eq = 0 @@ -844,6 +847,73 @@ def _create_fluid_wrapper(self): self.fluid.wrapper[fluid] = self.fluid.engine[fluid](fluid, back_end) + def _guess_starting_values(self, units): + # the below part does not work for PowerConnection right now + if sum(self.fluid.val.values()) == 0: + msg = ( + 'The starting value for the fluid composition of the ' + f'connection {self.label} is empty. This might lead to issues ' + 'in the initialisation and solving process as fluid ' + 'property functions can not be called. Make sure you ' + 'specified a fluid composition in all parts of the network.' + ) + logger.warning(msg) + + for key, variable in self.get_variables().items(): + # for connections variables can be presolved and not be var anymore + if variable.is_var: + if not self.good_starting_values: + self._guess_starting_value_from_connected_components(key, units) + + variable.set_SI_from_val0(units) + # variable.set_SI_from_val0() + variable.set_reference_val_SI(variable._val_SI) + + self._precalc_guess_values() + + def _guess_starting_value_from_connected_components(self, key, units): + r""" + Set starting values for fluid properties. + + The component classes provide generic starting values for their inlets + and outlets. + + Parameters + ---------- + c : tespy.connections.connection.Connection + Connection to initialise. + """ + if np.isnan(self.get_attr(key).val0): + # starting value for mass flow is random between 1 and 2 kg/s + # (should be generated based on some hash maybe?) + if key == 'm': + seed = abs(hash(self.label)) % (2**32) + rng = np.random.default_rng(seed=seed) + value = float(rng.random() + 1) + + # generic starting values for pressure and enthalpy + elif key in ['p', 'h']: + # retrieve starting values from component information + val_s = self.source.initialise_source(self, key) + val_t = self.target.initialise_target(self, key) + + if val_s == 0 and val_t == 0: + if key == 'p': + value = 1e5 + elif key == 'h': + value = 1e6 + + elif val_s == 0: + value = val_t + elif val_t == 0: + value = val_s + else: + value = (val_s + val_t) / 2 + + # these values are SI, so they are set to the respective variable + self.get_attr(key).set_reference_val_SI(value) + self.get_attr(key).set_val0_from_SI(units) + def _precalc_guess_values(self): """ Precalculate enthalpy values for connections. diff --git a/src/tespy/connections/powerconnection.py b/src/tespy/connections/powerconnection.py index 3503e346a..7589c0a41 100644 --- a/src/tespy/connections/powerconnection.py +++ b/src/tespy/connections/powerconnection.py @@ -130,6 +130,10 @@ def set_attr(self, **kwargs): logger.error(msg) raise KeyError(msg) + def _guess_starting_values(self, units): + if self.E.is_var and not self.good_starting_values: + self.E.set_reference_val_SI(0.0) + def _precalc_guess_values(self): pass diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index ed4ae19d5..0ff84634a 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -2023,27 +2023,7 @@ def _set_starting_values(self): df = dfs[c.__class__.__name__] self._write_starting_values_to_connection(c, df) - if type(c) == Connection: - # the below part does not work for PowerConnection right now - if sum(c.fluid.val.values()) == 0: - msg = ( - 'The starting value for the fluid composition of the ' - f'connection {c.label} is empty. This might lead to issues ' - 'in the initialisation and solving process as fluid ' - 'property functions can not be called. Make sure you ' - 'specified a fluid composition in all parts of the network.' - ) - logger.warning(msg) - - for key, variable in c.get_variables().items(): - # for connections variables can be presolved and not be var anymore - if variable.is_var: - if not c.good_starting_values: - self._guess_starting_value_from_connected_components(c, key) - - variable.set_SI_from_val0(self.units) - # variable.set_SI_from_val0() - variable.set_reference_val_SI(variable._val_SI) + c._guess_starting_values(self.units) for cp in self.comps["object"]: for key, variable in cp.get_variables().items(): @@ -2054,57 +2034,9 @@ def _set_starting_values(self): variable.set_SI_from_val(self.units) variable.set_reference_val_SI(variable._val_SI) - for c in self.conns['object']: - c._precalc_guess_values() - msg = 'Generic fluid property specification complete.' logger.debug(msg) - def _guess_starting_value_from_connected_components(self, c, key): - r""" - Set starting values for fluid properties. - - The component classes provide generic starting values for their inlets - and outlets. - - Parameters - ---------- - c : tespy.connections.connection.Connection - Connection to initialise. - """ - if np.isnan(c.get_attr(key).val0): - # starting value for mass flow is random between 1 and 2 kg/s - # (should be generated based on some hash maybe?) - if key == 'm': - seed = abs(hash(c.label)) % (2**32) - rng = np.random.default_rng(seed=seed) - value = float(rng.random() + 1) - - # generic starting values for pressure and enthalpy - elif key in ['p', 'h']: - # retrieve starting values from component information - val_s = c.source.initialise_source(c, key) - val_t = c.target.initialise_target(c, key) - - if val_s == 0 and val_t == 0: - if key == 'p': - value = 1e5 - elif key == 'h': - value = 1e6 - - elif val_s == 0: - value = val_t - elif val_t == 0: - value = val_s - else: - value = (val_s + val_t) / 2 - - elif key == 'E': - value = 0.0 - - # these values are SI, so they are set to the respective variable - c.get_attr(key).set_reference_val_SI(value) - c.get_attr(key).set_val0_from_SI(self.units) @staticmethod def _load_network_state(json_path): From bb51297b033434ae8d8155a939c020a8a6a4b2b4 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sat, 10 Jan 2026 12:45:11 +0100 Subject: [PATCH 19/69] Check in a new HumidAirConnection class with some basic functionality --- src/tespy/connections/__init__.py | 1 + src/tespy/connections/humidairconnection.py | 137 ++++++++++++++++++++ src/tespy/networks/network.py | 2 +- tests/test_connections.py | 3 +- 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/tespy/connections/humidairconnection.py diff --git a/src/tespy/connections/__init__.py b/src/tespy/connections/__init__.py index 3741bb556..db5e15280 100644 --- a/src/tespy/connections/__init__.py +++ b/src/tespy/connections/__init__.py @@ -3,4 +3,5 @@ from .bus import Bus # noqa: F401 from .connection import Connection # noqa: F401 from .connection import Ref # noqa: F401 +from .humidairconnection import HumidAirConnection # noqa: F401 from .powerconnection import PowerConnection # noqa: F401 diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py new file mode 100644 index 000000000..8b468a83a --- /dev/null +++ b/src/tespy/connections/humidairconnection.py @@ -0,0 +1,137 @@ +from CoolProp.CoolProp import HAPropsSI + +from tespy.tools import fluid_properties as fp +from tespy.tools.data_containers import FluidComposition as dc_flu +from tespy.tools.data_containers import FluidProperties as dc_prop + +from .connection import Connection +from .connection import connection_registry + + +@connection_registry +class HumidAirConnection(Connection): + + def get_variables(self): + return { + "m": self.m, "p": self.p, "h": self.h, "w": self.w + } + + def get_parameters(self): + return { + "fluid": dc_flu( + d=1e-5, + description="mass fractions of the fluid composition" + ), + "m": dc_prop( + quantity="mass_flow", + description="mass flow of the fluid (system variable)" + ), + "p": dc_prop( + quantity="pressure", + description="absolute pressure of the fluid (system variable)" + ), + "h": dc_prop( + quantity="enthalpy", + description="mass specific enthalpy of the fluid (system variable)" + ), + "w": dc_prop( + quantity="ratio", + description="mass of water per mass of dry air" + ), + "T": dc_prop( + func=self.T_func, + dependents=self.T_dependents, + num_eq=1, + quantity="temperature", + description="temperature of the fluid" + ), + "v": dc_prop( + func=self.v_func, + dependents=self.v_dependents, + num_eq=1, + quantity="volumetric_flow", + description="volumetric flow of the fluid" + ), + "vol": dc_prop( + quantity="specific_volume", + description="specific volume of the fluid (output only)" + ), + "s": dc_prop( + quantity="entropy", + description="specific entropy of the fluid (output only)" + ), + # "r": dc_prop( + # func=self.r_func, + # dependents=self.r_dependents, + # quantity="ratio", + # description="relative humidity" + # ) + } + + def calc_T(self, T0=None): + return HAPropsSI( + "T", "P", self.p.val_SI, "H", self.h.val_SI, "W", self.w.val_SI / 1e3 + ) + + def T_dependents(self): + return [self.p, self.h, self.w] + + def calc_vol(self, T0=None): + return HAPropsSI( + "V", "P", self.p.val_SI, "H", self.h.val_SI, "W", self.w.val_SI / 1e3 + ) + + def v_dependents(self): + return [self.m, self.p, self.h, self.w] + + def _guess_starting_values(self, units): + self.h.set_reference_val_SI(4e5) + self.w.set_reference_val_SI(5) + self._precalc_guess_values() + + def _precalc_guess_values(self): + if not self.h.is_var: + return + + if not self.good_starting_values: + if self.T.is_set: + try: + self.h.set_reference_val_SI( + HAPropsSI("H", "P", self.p.val_SI, "T", self.T.val_SI, "W", self.w.val_SI / 1e3) + ) + except ValueError: + pass + + def _presolve(self): + return [] + + def _adjust_to_property_limits(self, nw): + fl = fp.single_fluid(self.fluid_data) + # if self.h.is_var: + # self._adjust_enthalpy(fl) + + @classmethod + def _result_attributes(cls): + return ["m", "p", "h", "T", "w", "s", "vol", "v"] + + @classmethod + def _print_attributes(cls): + return ["m", "p", "h", "T", "w"] + + def calc_results(self, units): + self.T.val_SI = self.calc_T() + self.vol.val_SI = self.calc_vol() + self.v.val_SI = self.vol.val_SI * self.m.val_SI + + for prop in self._result_attributes(): + param = self.get_attr(prop) + if not param.is_set: + param.set_val_from_SI(units) + + self.m.set_val0_from_SI(units) + self.p.set_val0_from_SI(units) + self.h.set_val0_from_SI(units) + self.w.set_val0_from_SI(units) + self.fluid.val0 = self.fluid.val.copy() + + return True diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index 0ff84634a..97e242cf4 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -2788,7 +2788,7 @@ def _update_variables(self): if data["variable"] in ["m", "h", "E"]: container = data["obj"] container._val_SI += increment[container.J_col] * relax - elif data["variable"] == "p": + elif data["variable"] in ["p", "w"]: container = data["obj"] p_relax = max( 1, -2 * increment[container.J_col] / container.val_SI diff --git a/tests/test_connections.py b/tests/test_connections.py index 9fe6c5458..098213ec4 100644 --- a/tests/test_connections.py +++ b/tests/test_connections.py @@ -29,6 +29,7 @@ from tespy.connections import Ref from tespy.connections.connection import ConnectionBase from tespy.connections.connection import connection_registry +from tespy.connections.humidairconnection import HumidAirConnection from tespy.networks import Network from tespy.tools.data_containers import FluidProperties as dc_prop from tespy.tools.fluid_properties.functions import T_bubble_p @@ -515,7 +516,7 @@ def test_all_classes_in_registry(obj): def make_connection(cls): - if cls == Connection: + if cls == Connection or cls == HumidAirConnection: return cls(Source(""), "out1", Sink(""), "in1") elif cls == PowerConnection: return cls(PowerSource(""), "power", PowerSink(""), "power") From 327b7539e2b3a28eeba9f7bffdeac787323bb283 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sat, 10 Jan 2026 12:48:59 +0100 Subject: [PATCH 20/69] Add relative humidity equation --- src/tespy/connections/humidairconnection.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 8b468a83a..67972c3a7 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -60,12 +60,13 @@ def get_parameters(self): quantity="entropy", description="specific entropy of the fluid (output only)" ), - # "r": dc_prop( - # func=self.r_func, - # dependents=self.r_dependents, - # quantity="ratio", - # description="relative humidity" - # ) + "r": dc_prop( + func=self.r_func, + dependents=self.r_dependents, + num_eq=1, + quantity="ratio", + description="relative humidity" + ) } def calc_T(self, T0=None): @@ -84,6 +85,12 @@ def calc_vol(self, T0=None): def v_dependents(self): return [self.m, self.p, self.h, self.w] + def r_func(self): + return self.w.val_SI / 1e3 - HAPropsSI("W", "P", self.p.val_SI, "H", self.h.val_SI, "R", self.r.val_SI) + + def r_dependents(self): + return [self.p, self.h, self.w] + def _guess_starting_values(self, units): self.h.set_reference_val_SI(4e5) self.w.set_reference_val_SI(5) From a7fbdeab81a60f994ea2fec1cb94c835c2d9b387 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sat, 10 Jan 2026 13:13:58 +0100 Subject: [PATCH 21/69] Try to improve the convergence stabilization --- src/tespy/connections/humidairconnection.py | 28 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 67972c3a7..1d15c378d 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -113,9 +113,31 @@ def _presolve(self): return [] def _adjust_to_property_limits(self, nw): - fl = fp.single_fluid(self.fluid_data) - # if self.h.is_var: - # self._adjust_enthalpy(fl) + + if self.w.is_var: + if self.w.val_SI < 0.01: + self.w.set_reference_val_SI(0.011) + + if self.p.is_var: + if self.p.val_SI < 100: + self.p.val_SI = 101 + elif self.p.val_SI > 100e5: + self.p.val_SI = 99e5 + + if self.h.is_var: + # TODO: check minimum temperature how it matches minimum humidity ratio + d = self.h._reference_container._d + hmin = HAPropsSI("H", "T", -30 + 273.15, "P", self.p.val_SI, "R", 1) + if self.h.val_SI < hmin: + delta = max(abs(self.h.val_SI * d), d) * 5 + self.set_reference_val_SI(hmin + delta) + + else: + # TODO: where to get reasonable hmax from?! + hmax = HAPropsSI("H", "T", 300 + 273.15, "P", self.p.val_SI, "R", 0) + if self.h.val_SI > hmax: + delta = max(abs(self.h.val_SI * d), d) * 5 + self.set_reference_val_SI(hmax - delta) @classmethod def _result_attributes(cls): From 8dc7e978868d0d2def9bc1978726234753ecfa54 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sat, 10 Jan 2026 20:00:06 +0100 Subject: [PATCH 22/69] Make some quick and dirty adjustments to reuse _mix_ph methods with humid air --- src/tespy/connections/humidairconnection.py | 16 +++++++++++++ src/tespy/tools/fluid_properties/functions.py | 24 +++++++++++++++++++ src/tespy/tools/fluid_properties/helpers.py | 3 +++ 3 files changed, 43 insertions(+) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 1d15c378d..cd2ec53fd 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -91,6 +91,22 @@ def r_func(self): def r_dependents(self): return [self.p, self.h, self.w] + def get_fluid_data(self): + return ( + { + "_HUMID_AIR": True, + "w": self.w.val_SI / 1000 + } | + { + fluid: { + "wrapper": self.fluid.wrapper[fluid], + "mass_fraction": self.fluid.val[fluid] + } for fluid in self.fluid.val + } + ) + + fluid_data = property(get_fluid_data) + def _guess_starting_values(self, units): self.h.set_reference_val_SI(4e5) self.w.set_reference_val_SI(5) diff --git a/src/tespy/tools/fluid_properties/functions.py b/src/tespy/tools/fluid_properties/functions.py index 835bd9d20..acc20f78f 100644 --- a/src/tespy/tools/fluid_properties/functions.py +++ b/src/tespy/tools/fluid_properties/functions.py @@ -25,9 +25,14 @@ from .mixtures import T_MIX_PS_REVERSE from .mixtures import V_MIX_PT_DIRECT from .mixtures import VISCOSITY_MIX_PT_DIRECT +from CoolProp.CoolProp import HAPropsSI def isentropic(p_1, h_1, p_2, fluid_data, mixing_rule=None, T0=None): + if "_HUMID_AIR" in fluid_data: + w = fluid_data["w"] + return None + if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].isentropic(p_1, h_1, p_2) @@ -133,6 +138,10 @@ def calc_chemical_exergy(pamb, Tamb, fluid_data, Chem_Ex, mixing_rule=None, T0=N def T_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): + if "_HUMID_AIR" in fluid_data: + w = fluid_data["w"] + return HAPropsSI("T", "P", p, "H", h, "W", w) + if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].T_ph(p, h) @@ -170,6 +179,10 @@ def dT_mix_ph_dfluid(p, h, fluid, fluid_data, mixing_rule=None, T0=None): def h_mix_pT(p, T, fluid_data, mixing_rule=None): + if "_HUMID_AIR" in fluid_data: + w = fluid_data["w"] + return HAPropsSI("H", "P", p, "T", T, "W", w) + if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].h_pT(p, T) @@ -203,6 +216,9 @@ def Q_mix_ph(p, h, fluid_data, mixing_rule=None): raise ValueError(msg) def phase_mix_ph(p, h, fluid_data, mixing_rule=None): + if "_HUMID_AIR" in fluid_data: + raise NotImplementedError("phase not defined for humid air") + if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].phase_ph(p, h) @@ -273,6 +289,10 @@ def dT_sat_dp(p, fluid_data, mixing_rule=None): def s_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): + if "_HUMID_AIR" in fluid_data: + w = fluid_data["w"] + return HAPropsSI("S", "P", p, "H", h, "W", w) + if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].s_ph(p, h) @@ -283,6 +303,10 @@ def s_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): def s_mix_pT(p, T, fluid_data, mixing_rule=None): + if "_HUMID_AIR" in fluid_data: + w = fluid_data["w"] + return HAPropsSI("S", "P", p, "T", T, "W", w) + if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].s_pT(p, T) diff --git a/src/tespy/tools/fluid_properties/helpers.py b/src/tespy/tools/fluid_properties/helpers.py index 033ed1801..4bdbcd89d 100644 --- a/src/tespy/tools/fluid_properties/helpers.py +++ b/src/tespy/tools/fluid_properties/helpers.py @@ -49,6 +49,9 @@ def get_pure_fluid(fluid_data): def single_fluid(fluid_data): r"""Return the name of the pure fluid in a fluid vector.""" + if "_HUMID_AIR" in fluid_data: + return None + if get_number_of_fluids(fluid_data) > 1: return None else: From 0f6b40c188f8a4a7cce8c3603f632810c410404a Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sun, 11 Jan 2026 17:38:42 +0100 Subject: [PATCH 23/69] Make a much simpler implementation --- src/tespy/tools/fluid_properties/functions.py | 72 +++++++++---------- src/tespy/tools/fluid_properties/mixtures.py | 65 +++++++++++++---- 2 files changed, 86 insertions(+), 51 deletions(-) diff --git a/src/tespy/tools/fluid_properties/functions.py b/src/tespy/tools/fluid_properties/functions.py index acc20f78f..f72ca12b7 100644 --- a/src/tespy/tools/fluid_properties/functions.py +++ b/src/tespy/tools/fluid_properties/functions.py @@ -10,6 +10,7 @@ SPDX-License-Identifier: MIT """ +from CoolProp.CoolProp import HAPropsSI from tespy.tools.global_vars import FLUID_ALIASES from tespy.tools.logger import logger @@ -25,14 +26,10 @@ from .mixtures import T_MIX_PS_REVERSE from .mixtures import V_MIX_PT_DIRECT from .mixtures import VISCOSITY_MIX_PT_DIRECT -from CoolProp.CoolProp import HAPropsSI +from .mixtures import _get_humid_air_humidity_ratio def isentropic(p_1, h_1, p_2, fluid_data, mixing_rule=None, T0=None): - if "_HUMID_AIR" in fluid_data: - w = fluid_data["w"] - return None - if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].isentropic(p_1, h_1, p_2) @@ -138,20 +135,20 @@ def calc_chemical_exergy(pamb, Tamb, fluid_data, Chem_Ex, mixing_rule=None, T0=N def T_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): - if "_HUMID_AIR" in fluid_data: - w = fluid_data["w"] - return HAPropsSI("T", "P", p, "H", h, "W", w) - if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].T_ph(p, h) else: _check_mixing_rule(mixing_rule, T_MIX_PH_REVERSE, "temperature (from enthalpy)") - kwargs = { - "p": p, "target_value": h, "fluid_data": fluid_data, "T0": T0, - "f": T_MIX_PH_REVERSE[mixing_rule] - } - return inverse_temperature_mixture(**kwargs) + if mixing_rule == "humidair": + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("T", "P", p, "H", h, "W", w) + else: + kwargs = { + "p": p, "target_value": h, "fluid_data": fluid_data, "T0": T0, + "f": T_MIX_PH_REVERSE[mixing_rule] + } + return inverse_temperature_mixture(**kwargs) def dT_mix_pdh(p, h, fluid_data, mixing_rule=None, T0=None): @@ -179,10 +176,6 @@ def dT_mix_ph_dfluid(p, h, fluid, fluid_data, mixing_rule=None, T0=None): def h_mix_pT(p, T, fluid_data, mixing_rule=None): - if "_HUMID_AIR" in fluid_data: - w = fluid_data["w"] - return HAPropsSI("H", "P", p, "T", T, "W", w) - if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].h_pT(p, T) @@ -216,9 +209,6 @@ def Q_mix_ph(p, h, fluid_data, mixing_rule=None): raise ValueError(msg) def phase_mix_ph(p, h, fluid_data, mixing_rule=None): - if "_HUMID_AIR" in fluid_data: - raise NotImplementedError("phase not defined for humid air") - if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].phase_ph(p, h) @@ -289,10 +279,6 @@ def dT_sat_dp(p, fluid_data, mixing_rule=None): def s_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): - if "_HUMID_AIR" in fluid_data: - w = fluid_data["w"] - return HAPropsSI("S", "P", p, "H", h, "W", w) - if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].s_ph(p, h) @@ -303,10 +289,6 @@ def s_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): def s_mix_pT(p, T, fluid_data, mixing_rule=None): - if "_HUMID_AIR" in fluid_data: - w = fluid_data["w"] - return HAPropsSI("S", "P", p, "T", T, "W", w) - if get_number_of_fluids(fluid_data) == 1: pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].s_pT(p, T) @@ -320,12 +302,16 @@ def T_mix_ps(p, s, fluid_data, mixing_rule=None, T0=None): pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].T_ps(p, s) else: - _check_mixing_rule(mixing_rule, T_MIX_PS_REVERSE, "temperature (from entropy)") - kwargs = { - "p": p, "target_value": s, "fluid_data": fluid_data, "T0": T0, - "f": T_MIX_PS_REVERSE[mixing_rule] - } - return inverse_temperature_mixture(**kwargs) + if mixing_rule == "humidair": + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("T", "P", p, "S", s, "W", w) + else: + _check_mixing_rule(mixing_rule, T_MIX_PS_REVERSE, "temperature (from entropy)") + kwargs = { + "p": p, "target_value": s, "fluid_data": fluid_data, "T0": T0, + "f": T_MIX_PS_REVERSE[mixing_rule] + } + return inverse_temperature_mixture(**kwargs) def v_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): @@ -333,8 +319,12 @@ def v_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): pure_fluid = get_pure_fluid(fluid_data) return 1 / pure_fluid["wrapper"].d_ph(p, h) else: - T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) - return v_mix_pT(p, T, fluid_data, mixing_rule) + if mixing_rule == "humidair": + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("V", "P", p, "H", h, "W", w) + else: + T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) + return v_mix_pT(p, T, fluid_data, mixing_rule) def dv_mix_dph(p, h, fluid_data, mixing_rule=None, T0=None): @@ -365,8 +355,12 @@ def viscosity_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): pure_fluid = get_pure_fluid(fluid_data) return pure_fluid["wrapper"].viscosity_ph(p, h) else: - T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) - return viscosity_mix_pT(p, T, fluid_data, mixing_rule) + if mixing_rule == "humidair": + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("Visc", "P", p, "H", h, "W", w) + else: + T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) + return viscosity_mix_pT(p, T, fluid_data, mixing_rule) def viscosity_mix_pT(p, T, fluid_data, mixing_rule=None): diff --git a/src/tespy/tools/fluid_properties/mixtures.py b/src/tespy/tools/fluid_properties/mixtures.py index d26cdcb10..9abb914e5 100644 --- a/src/tespy/tools/fluid_properties/mixtures.py +++ b/src/tespy/tools/fluid_properties/mixtures.py @@ -13,6 +13,8 @@ import math +from CoolProp.CoolProp import HAPropsSI + from tespy.tools.global_vars import FLUID_ALIASES from tespy.tools.global_vars import gas_constants @@ -36,7 +38,7 @@ def h_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): def h_mix_pT_ideal_cond(p=None, T=None, fluid_data=None, **kwargs): - water_alias = _water_in_mixture(fluid_data) + water_alias = _fluid_in_mixture("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) mass_fractions_gas, molar_fraction_gas, mass_liquid, _, p_sat, pp_water = cond_check(p, T, fluid_data, water_alias) @@ -88,6 +90,24 @@ def h_mix_pT_incompressible(p, T, fluid_data, **kwargs): return h +def _get_humid_air_humidity_ratio(fluid_data): + water_alias = _fluid_in_mixture("H2O", fluid_data) + water_alias = next(iter(water_alias)) + + air_alias = _fluid_in_mixture("air", fluid_data) + air_alias = next(iter(air_alias)) + + return ( + fluid_data[water_alias]["mass_fraction"] + / fluid_data[air_alias]["mass_fraction"] + ) + + +def h_mix_pT_humidair(p, T, fluid_data, **kwargs): + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("H", "P", p, "T", T, "W", w) + + def s_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): molar_fractions = get_molar_fractions(fluid_data) @@ -103,7 +123,7 @@ def s_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): def s_mix_pT_ideal_cond(p=None, T=None, fluid_data=None, **kwargs): - water_alias = _water_in_mixture(fluid_data) + water_alias = _fluid_in_mixture("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) mass_fractions_gas, molar_fraction_gas, mass_liquid, _, p_sat, pp_water = cond_check(p, T, fluid_data, water_alias) @@ -136,6 +156,11 @@ def s_mix_pT_incompressible(p=None, T=None, fluid_data=None, **kwargs): return s +def s_mix_pT_humidair(p, T, fluid_data, **kwargs): + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("S", "P", p, "T", T, "W", w) + + def v_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): molar_fractions = get_molar_fractions(fluid_data) @@ -151,7 +176,7 @@ def v_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): def v_mix_pT_ideal_cond(p=None, T=None, fluid_data=None, **kwargs): - water_alias = _water_in_mixture(fluid_data) + water_alias = _fluid_in_mixture("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) _, molar_fraction_gas, mass_liquid, _, p_sat, pp_water = cond_check(p, T, fluid_data, water_alias) @@ -183,6 +208,11 @@ def v_mix_pT_incompressible(p=None, T=None, fluid_data=None, **kwargs): return v +def v_mix_pT_humidair(p, T, fluid_data, **kwargs): + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("V", "P", p, "T", T, "W", w) + + def viscosity_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): r""" Calculate dynamic viscosity from pressure and temperature. @@ -239,10 +269,15 @@ def viscosity_mix_pT_incompressible(p=None, T=None, fluid_data=None, **kwargs): return viscosity +def viscosity_mix_pT_humidair(p, T, fluid_data, **kwargs): + w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("Visc", "P", p, "T", T, "W", w) + + def exergy_chemical_ideal_cond(pamb, Tamb, fluid_data, Chem_Ex): molar_fractions = get_molar_fractions(fluid_data) - water_alias = _water_in_mixture(fluid_data) + water_alias = _fluid_in_mixture("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) _, molar_fractions_gas, _, molar_liquid, _, _ = cond_check( @@ -277,9 +312,9 @@ def exergy_chemical_ideal_cond(pamb, Tamb, fluid_data, Chem_Ex): return ex_chemical * 1e3 # Data from Chem_Ex are in kJ / mol -def _water_in_mixture(fluid_data): +def _fluid_in_mixture(fluid, fluid_data): return ( - FLUID_ALIASES.get_fluid("H2O") + FLUID_ALIASES.get_fluid(fluid) & set([ f for f in fluid_data if _is_larger_than_precision(fluid_data[f]["mass_fraction"]) @@ -351,14 +386,16 @@ def cond_check(p, T, fluid_data, water_alias): T_MIX_PH_REVERSE = { "ideal": h_mix_pT_ideal, "ideal-cond": h_mix_pT_ideal_cond, - "incompressible": h_mix_pT_incompressible + "incompressible": h_mix_pT_incompressible, + "humidair": h_mix_pT_humidair } T_MIX_PS_REVERSE = { "ideal": s_mix_pT_ideal, "ideal-cond": s_mix_pT_ideal_cond, - "incompressible": s_mix_pT_incompressible + "incompressible": s_mix_pT_incompressible, + "humidair": s_mix_pT_humidair } @@ -366,28 +403,32 @@ def cond_check(p, T, fluid_data, water_alias): "ideal": h_mix_pT_ideal, "ideal-cond": h_mix_pT_ideal_cond, "incompressible": h_mix_pT_incompressible, - "forced-gas": h_mix_pT_forced_gas + "forced-gas": h_mix_pT_forced_gas, + "humidair": h_mix_pT_humidair } S_MIX_PT_DIRECT = { "ideal": s_mix_pT_ideal, "ideal-cond": s_mix_pT_ideal_cond, - "incompressible": s_mix_pT_incompressible + "incompressible": s_mix_pT_incompressible, + "humidair": s_mix_pT_humidair } V_MIX_PT_DIRECT = { "ideal": v_mix_pT_ideal, "ideal-cond": v_mix_pT_ideal_cond, - "incompressible": v_mix_pT_incompressible + "incompressible": v_mix_pT_incompressible, + "humidair": v_mix_pT_humidair } VISCOSITY_MIX_PT_DIRECT = { "ideal": viscosity_mix_pT_ideal, "ideal-cond": viscosity_mix_pT_ideal, - "incompressible": viscosity_mix_pT_incompressible + "incompressible": viscosity_mix_pT_incompressible, + "humidair": viscosity_mix_pT_humidair } EXERGY_CHEMICAL = { From cd17f164ac9f3020d99f5311c95a3638f8a98498 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Sun, 11 Jan 2026 17:40:18 +0100 Subject: [PATCH 24/69] Add one sample notebook --- .../test_fluid_properties/humidair.ipynb | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/test_tools/test_fluid_properties/humidair.ipynb diff --git a/tests/test_tools/test_fluid_properties/humidair.ipynb b/tests/test_tools/test_fluid_properties/humidair.ipynb new file mode 100644 index 000000000..8349181a5 --- /dev/null +++ b/tests/test_tools/test_fluid_properties/humidair.ipynb @@ -0,0 +1,202 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 24, + "id": "5796d5ee", + "metadata": {}, + "outputs": [], + "source": [ + "from tespy.connections import HumidAirConnection, Connection\n", + "from tespy.components import Source, Sink, MovingBoundaryHeatExchanger\n", + "from tespy.networks import Network\n", + "\n", + "from CoolProp.CoolProp import HAPropsSI" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "d60a9cea", + "metadata": {}, + "outputs": [], + "source": [ + "nw = Network()\n", + "nw.units.set_defaults(\n", + " temperature=\"°C\",\n", + " pressure=\"bar\",\n", + " heat=\"kW\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "947c79c2", + "metadata": {}, + "outputs": [], + "source": [ + "def get_water_air_mixture_fractions_pTR(p, T, R):\n", + " w = HAPropsSI(\"W\", \"P\", p, \"T\", T, \"R\", R)\n", + " return get_water_air_mixture_fractions_w(w)\n", + "\n", + "\n", + "def get_water_air_mixture_fractions_w(w):\n", + " air = 1 / (1 + w)\n", + " return {\"air\": air, \"water\": 1 - air}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10a7b936", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " iter | residual | progress | massflow | pressure | enthalpy | fluid | component \n", + "-------+------------+------------+------------+------------+------------+------------+------------\n", + " 1 | 1.88e+06 | 0 % | 3.72e+02 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", + " 2 | 1.06e-04 | 100 % | 2.09e-08 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", + " 3 | 0.00e+00 | 100 % | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", + " 4 | 0.00e+00 | 100 % | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", + "Total iterations: 4, Calculation time: 0.00 s, Iterations per second: 2700.78\n" + ] + } + ], + "source": [ + "so = Source(\"source\")\n", + "hex = MovingBoundaryHeatExchanger(\"heat exchanger\")\n", + "si = Sink(\"sink\")\n", + "\n", + "so2 = Source(\"refrigerant source\")\n", + "si2 = Sink(\"refrigerant sink\")\n", + "\n", + "a1 = Connection(so2, \"out1\", hex, \"in2\", label=\"a1\")\n", + "a2 = Connection(hex, \"out2\", si2, \"in1\", label=\"a2\")\n", + "\n", + "c1 = Connection(so, \"out1\", hex, \"in1\", label=\"c1\")\n", + "c2 = Connection(hex, \"out1\", si, \"in1\", label=\"c2\")\n", + "\n", + "nw.add_conns(a1, a2, c1, c2)\n", + "\n", + "a1.set_attr(fluid={\"NH3\": 1}, m=2, x=0.3)\n", + "a2.set_attr(td_dew=5, T_dew=-20)\n", + "\n", + "c1.set_attr(\n", + " fluid=get_water_air_mixture_fractions_pTR(p=1e5, T=268.16, R=1),\n", + " p=1,\n", + " T=-5,\n", + " mixing_rule=\"humidair\"\n", + ")\n", + "c2.set_attr(T=-10)\n", + "\n", + "hex.set_attr(dp1=0, dp2=0)\n", + "\n", + "nw.solve(\"design\")" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "3fc42bdf", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "##### RESULTS (MovingBoundaryHeatExchanger) #####\n", + "+----------------+-----------+----------+----------+----------+----------+-----------+----------+----------+----------+----------+----------+----------+------------+-----------+-----------+----------+------------+\n", + "| | Q | kA | td_log | ttd_u | ttd_l | ttd_min | pr1 | pr2 | dp1 | dp2 | zeta1 | zeta2 | eff_cold | eff_hot | eff_max | UA | td_pinch |\n", + "|----------------+-----------+----------+----------+----------+----------+-----------+----------+----------+----------+----------+----------+----------+------------+-----------+-----------+----------+------------|\n", + "| heat exchanger | -1.88e+03 | 1.88e+05 | 1.00e+01 | 1.00e+01 | 1.00e+01 | 1.00e+01 | 1.00e+00 | 1.00e+00 | \u001b[94m0.00e+00\u001b[0m | \u001b[94m0.00e+00\u001b[0m | 0.00e+00 | 0.00e+00 | 9.76e-01 | 3.33e-01 | 9.76e-01 | 1.53e+05 | 1.00e+01 |\n", + "+----------------+-----------+----------+----------+----------+----------+-----------+----------+----------+----------+----------+----------+----------+------------+-----------+-----------+----------+------------+\n", + "##### RESULTS (Connection) #####\n", + "+----+-----------+-----------+------------+------------+-------------+---------+\n", + "| | m | p | h | T | x | phase |\n", + "|----+-----------+-----------+------------+------------+-------------+---------|\n", + "| a1 | \u001b[94m2.000e+00\u001b[0m | 1.900e+00 | 6.529e+05 | -2.000e+01 | \u001b[94m3.000e-01\u001b[0m | tp |\n", + "| a2 | 2.000e+00 | 1.900e+00 | 1.595e+06 | -1.500e+01 | 1.000e+00 | g |\n", + "| c1 | 3.730e+02 | \u001b[94m1.000e+00\u001b[0m | 1.256e+03 | \u001b[94m-5.000e+00\u001b[0m | nan | nan |\n", + "| c2 | 3.730e+02 | 1.000e+00 | -3.796e+03 | \u001b[94m-1.000e+01\u001b[0m | nan | nan |\n", + "+----+-----------+-----------+------------+------------+-------------+---------+\n" + ] + } + ], + "source": [ + "nw.print_results()" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "fd871dc0", + "metadata": {}, + "outputs": [], + "source": [ + "heat, T_hot, T_cold, _, _ = hex.calc_sections()" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "2f9ed087", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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48lt2b9/v9f+3nR933zzTa+2FhQUyN/fXzfeeKNXOSte35qaGqWkpOi55547o/IlJSW69tprdeWVV2rHjh1asGCBfvazn3l9aXfm78v54mt7N23apBkzZuif//yntm/friuvvFLXXXedPv30U69yI0eO9Lq2mzdvPhfV95mv7W1TWFjo1Z7IyEhzX2+6vv/1X//l1c6DBw/K6XS2+9216vXtMkYvNmnSJCMzM9N839zcbMTExBg5OTkdlv/hD39oXHvttV7b0tLSjPnz5xuGYRgej8eIiooyHn/8cXN/RUWFYbfbjZdffvkctMA3vrb3VE1NTUZISIixYsUKc9ttt91mXH/99V1d1S7ha3uXLVtmhIaGfufxevv1feqpp4yQkBCjurra3Gbl69tGkrF69ervLfPrX//aGDlypNe2H/3oR8asWbPM92f753e+nEl7OzJixAhj0aJF5vuFCxcaKSkpXVexc+RM2vvhhx8akozjx49/Z5nefH1Xr15t2Gw2o7S01NzWU67v2ei1PSgNDQ3avn27pk+fbm7z8/PT9OnTlZeX1+Fn8vLyvMpL0qxZs8zyJSUlKisr8yoTGhqqtLS07zzm+dKZ9p6qtrZWjY2NcjqdXts3btyoyMhIDR06VD//+c9VXl7epXXvjM62t7q6WvHx8YqLi9P111+vzz//3NzX26/vCy+8oJtuuknBwcFe2614fX11ut/drvjzszKPx6Oqqqp2v7t79+5VTEyMkpKSdPPNN+vAgQPdVMOukZqaqujoaM2YMUMfffSRub23X98XXnhB06dPV3x8vNf23nZ9T9VrA8qxY8fU3NyswYMHe20fPHhwu/uWbcrKyr63fNt/fTnm+dKZ9p7qN7/5jWJiYrx+yWfPnq2VK1fqgw8+0KOPPqrc3FxdffXVam5u7tL6+6oz7R06dKhefPFFrVmzRn/729/k8Xg0efJkffXVV5J69/XdunWrCgoK9LOf/cxru1Wvr6++63fX7Xarrq6uS34/rGzJkiWqrq7WD3/4Q3NbWlqali9frnXr1mnp0qUqKSnR5Zdfrqqqqm6saedER0fr+eef1xtvvKE33nhDcXFxysjI0CeffCKpa/79s6rDhw/rnXfeafe725uu73fpkU8zRtd75JFH9Morr2jjxo1eA0dvuukm8+fRo0drzJgxSk5O1saNGzVt2rTuqGqnXXrppbr00kvN95MnT9bw4cP1pz/9SYsXL+7Gmp17L7zwgkaPHq1JkyZ5be9N17evWrVqlRYtWqQ1a9Z4jcm4+uqrzZ/HjBmjtLQ0xcfH6+9//7vuuOOO7qhqpw0dOlRDhw4130+ePFlFRUV66qmn9Ne//rUba3burVixQmFhYZozZ47X9t50fb9Lr+1BGTRokPz9/fXNN994bf/mm28UFRXV4WeioqK+t3zbf3055vnSmfa2WbJkiR555BG99957GjNmzPeWTUpK0qBBg7Rv376zrvPZOJv2tunXr5/Gjh1rtqW3Xt+amhq98sorZ/SPllWur6++63fX4XAoKCioS/6+WNErr7yin/3sZ/r73//e7hbXqcLCwnTJJZf0uGv7XSZNmmS2pbdeX8Mw9OKLL+rWW29VYGDg95btbddX6sUBJTAwUOPHj9cHH3xgbvN4PPrggw+8/i/6ZJdeeqlXeUlav369WT4xMVFRUVFeZdxut/Lz87/zmOdLZ9ortcxaWbx4sdatW6cJEyac9jxfffWVysvLFR0d3SX17qzOtvdkzc3N+uyzz8y29MbrK7VMna+vr9ctt9xy2vNY5fr66nS/u13x98VqXn75Zd1+++16+eWXvaaOf5fq6moVFRX1uGv7XXbs2GG2pTdeX0nKzc3Vvn37zuh/Lnrb9ZXUu2fxvPLKK4bdbjeWL19ufPHFF8add95phIWFGWVlZYZhGMatt95q/Pa3vzXLf/TRR0ZAQICxZMkS48svvzQWLlxo9OvXz/jss8/MMo888ogRFhZmrFmzxti1a5dx/fXXG4mJiUZdXd15b9+pfG3vI488YgQGBhqvv/668fXXX5uvqqoqwzAMo6qqyvjVr35l5OXlGSUlJcb7779vjBs3zrj44ouNEydOdEsbT+ZrexctWmS8++67RlFRkbF9+3bjpptuMvr37298/vnnZpnedH3bXHbZZcaPfvSjdtutfH2rqqqMTz/91Pj0008NScaTTz5pfPrpp8b+/fsNwzCM3/72t8att95qli8uLjYGDBhg3HfffcaXX35pPPfcc4a/v7+xbt06s8zp/vy6k6/tfemll4yAgADjueee8/rdraioMMv88pe/NDZu3GiUlJQYH330kTF9+nRj0KBBxpEjR857+07la3ufeuop46233jL27t1rfPbZZ8YvfvELw8/Pz3j//ffNMr3p+ra55ZZbjLS0tA6PaeXr21V6dUAxDMN49tlnjSFDhhiBgYHGpEmTjC1btpj7rrjiCuO2227zKv/3v//duOSSS4zAwEBj5MiRxj/+8Q+v/R6Px/jP//xPY/DgwYbdbjemTZtmFBYWno+mnBFf2hsfH29IavdauHChYRiGUVtba8ycOdO44IILjH79+hnx8fHGvHnzLPEL38aX9i5YsMAsO3jwYOOaa64xPvnkE6/j9abraxiGsXv3bkOS8d5777U7lpWvb9u00lNfbe277bbbjCuuuKLdZ1JTU43AwEAjKSnJWLZsWbvjft+fX3fytb1XXHHF95Y3jJZp1tHR0UZgYKBx4YUXGj/60Y+Mffv2nd+GfQdf2/voo48aycnJRv/+/Q2n02lkZGQYGzZsaHfc3nJ9DaNliYOgoCDjz3/+c4fHtPL17So2wzCMc9xJAwAA4JNeOwYFAAD0XAQUAABgOQQUAABgOQQUAABgOQQUAABgOQQUAABgOQQUAABgOQQUAABg2rRpk6677jrFxMTIZrPprbfe8vkYhmFoyZIluuSSS2S323XhhRfq4Ycf9ukYPM0YAACYampqlJKSop/+9Kf6t3/7t04d4xe/+IXee+89LVmyRKNHj5bL5ZLL5fLpGKwkCwAAOmSz2bR69WrNmTPH3FZfX68HHnhAL7/8sioqKjRq1Cg9+uijysjIkCR9+eWXGjNmjAoKCjR06NBOn5tbPAAA4IxlZWUpLy9Pr7zyinbt2qUbb7xRs2fP1t69eyVJb7/9tpKSkrR27VolJiYqISFBP/vZz3zuQSGgAACAM3LgwAEtW7ZMr732mi6//HIlJyfrV7/6lS677DItW7ZMklRcXKz9+/frtdde08qVK7V8+XJt375d//7v/+7TuRiDAgAAzshnn32m5uZmXXLJJV7b6+vrFRERIUnyeDyqr6/XypUrzXIvvPCCxo8fr8LCwjO+7UNAAQAAZ6S6ulr+/v7avn27/P39vfYNHDhQkhQdHa2AgACvEDN8+HBJLT0wBBQAANClxo4dq+bmZh05ckSXX355h2WmTJmipqYmFRUVKTk5WZK0Z88eSVJ8fPwZn4tZPAAAwFRdXa19+/ZJagkkTz75pK688ko5nU4NGTJEt9xyiz766CM98cQTGjt2rI4ePaoPPvhAY8aM0bXXXiuPx6OJEydq4MCBevrpp+XxeJSZmSmHw6H33nvvjOtBQAEAAKaNGzfqyiuvbLf9tttu0/Lly9XY2Kg//OEPWrlypQ4dOqRBgwYpPT1dixYt0ujRoyVJhw8f1t1336333ntPwcHBuvrqq/XEE0/I6XSecT0IKAAAwHKYZgwAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACzn/wcOYqIOloqfKwAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib import pyplot as plt\n", + "\n", + "\n", + "plt.plot(heat, T_hot)\n", + "plt.plot(heat, T_cold)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "tespy", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 59fa5e33d8d97ee0c749d7be1beb735e27513148 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 12 Jan 2026 07:55:46 +0100 Subject: [PATCH 25/69] Use MovingBoundary instead of Condenser --- docs/advanced_tutorials/heat_pump_steps.rst | 14 +++++++------- tutorial/advanced/stepwise.py | 13 +++++++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/advanced_tutorials/heat_pump_steps.rst b/docs/advanced_tutorials/heat_pump_steps.rst index b3b09ebac..b926dcf7a 100644 --- a/docs/advanced_tutorials/heat_pump_steps.rst +++ b/docs/advanced_tutorials/heat_pump_steps.rst @@ -142,7 +142,7 @@ decides the overall mass flow in the systems. In this tutorial we will first build the system with parameters that ensure stable starting values for a simulation, which in the end will be switched to reasonable values for the individual parts of the system. For - example, instead of the evaporation pressure we will use the terminal + example, instead of the evaporation pressure we will use the pinch temperature difference at the condenser instead. .. literalinclude:: /../tutorial/advanced/stepwise.py @@ -152,7 +152,7 @@ decides the overall mass flow in the systems. In order to calculate this network further parametrization is necessary, as e.g. the fluids are not determined yet: At the hot inlet of the condenser we -define the temperature, pressure and the fluid informaton. A good guess for +define the temperature, pressure and the fluid information. A good guess for pressure can be obtained from CoolProp's PropsSI function. We know that the condensation temperature must be higher than the consumer's feed flow temperature. Therefore, we can set the pressure to a slightly higher value of @@ -398,11 +398,11 @@ Solve and Set Final System Parameters Now we solve again. After that, we can exchange our good guesses with actual useful parameters: -The condensation and evaporation pressure levels will be replaced by terminal -temperature values of the condenser and the evaporator respectively. The lower -terminal temperature value of the evaporator :code:`ttd_l` defines the pinch -point. The upper terminal temperature value :code:`ttd_u` of the condenser -defines the condensation pressure. +The condensation and evaporation pressure levels will be replaced by pinch +temperature difference of the condenser and terminal temperature difference of +the evaporator respectively. The lower terminal temperature value of the +evaporator :code:`ttd_l` is identical to the pinch point temperature +difference. The degree of superheating in the superheater will be determined by the upper terminal temperature instead of the enthalpy value at connection 6. The outlet diff --git a/tutorial/advanced/stepwise.py b/tutorial/advanced/stepwise.py index d720ffb5e..e6d601684 100644 --- a/tutorial/advanced/stepwise.py +++ b/tutorial/advanced/stepwise.py @@ -7,7 +7,7 @@ temperature="degC", pressure="bar", enthalpy="kJ/kg", power="kW", heat="kW" ) # %%[sec_2] -from tespy.components import Condenser +from tespy.components import MovingBoundaryHeatExchanger from tespy.components import CycleCloser from tespy.components import SimpleHeatExchanger from tespy.components import Pump @@ -20,7 +20,7 @@ va = Sink("valve") # consumer system -cd = Condenser("condenser") +cd = MovingBoundaryHeatExchanger("condenser") rp = Pump("recirculation pump") cons = SimpleHeatExchanger("consumer") # %%[sec_3] @@ -43,6 +43,7 @@ from CoolProp.CoolProp import PropsSI as PSI p_cond = PSI("P", "Q", 1, "T", 273.15 + 95, working_fluid) / 1e5 c0.set_attr(T=170, p=p_cond, fluid={working_fluid: 1}) +c1.set_attr(x=0) c20.set_attr(T=60, p=2, fluid={"water": 1}) c22.set_attr(T=90) @@ -88,6 +89,8 @@ ev.set_attr(pr1=0.99) su.set_attr(pr1=0.99, pr2=0.99) # %%[sec_10] +# condenser outlet state +c1.set_attr(x=0) # evaporator system cold side c4.set_attr(x=0.9, T=5) @@ -153,7 +156,7 @@ nw.solve("design") c0.set_attr(p=None) -cd.set_attr(ttd_u=5) +cd.set_attr(td_pinch=10) c4.set_attr(T=None) ev.set_attr(ttd_l=5) @@ -179,7 +182,9 @@ cons.set_attr(design=["pr"], offdesign=["zeta"]) cd.set_attr( - design=["pr2", "ttd_u"], offdesign=["zeta2", "kA_char"] + design=["pr2", "td_pinch"], offdesign=["zeta2", "UA_cecchinato"], + alpha_ratio=1, area_ratio=1, re_exp_sf=0.8, re_exp_r=0.55, + refrigerant_index=0 ) from tespy.tools.characteristics import CharLine From 1486716e27a8e60060d38e72a4b8ba3da86791a5 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 12 Jan 2026 07:55:56 +0100 Subject: [PATCH 26/69] Go back to normal logo --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a22cd87b5..497107600 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -435,8 +435,8 @@ def setup(app): } html_theme_options = { - "light_logo": "./images/logo_tespy_mid_christmas.svg", - "dark_logo": "./images/logo_tespy_mid_darkmode_christmas.svg", + "light_logo": "./images/logo_tespy_mid.svg", + "dark_logo": "./images/logo_tespy_mid_darkmode.svg", "announcement": """
""", From cf7faf5a7b51b48296e7438bd55d8d7a59624ad1 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 12 Jan 2026 08:23:20 +0100 Subject: [PATCH 27/69] Update tutorial based on latest API capabilities --- docs/advanced_tutorials/heat_pump_steps.rst | 75 +++++++++++---------- tutorial/advanced/stepwise.py | 19 +++--- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/docs/advanced_tutorials/heat_pump_steps.rst b/docs/advanced_tutorials/heat_pump_steps.rst index b926dcf7a..15877507b 100644 --- a/docs/advanced_tutorials/heat_pump_steps.rst +++ b/docs/advanced_tutorials/heat_pump_steps.rst @@ -152,11 +152,11 @@ decides the overall mass flow in the systems. In order to calculate this network further parametrization is necessary, as e.g. the fluids are not determined yet: At the hot inlet of the condenser we -define the temperature, pressure and the fluid information. A good guess for -pressure can be obtained from CoolProp's PropsSI function. We know that the -condensation temperature must be higher than the consumer's feed flow -temperature. Therefore, we can set the pressure to a slightly higher value of -that temperature's corresponding condensation pressure. +define the temperature and the fluid information. On top we can define the +pressure level by specifying the dew line temperature. We know, that the +condensation temperature will be slightly below the heat demand feed flow +temperature. Therefore, we can set the dew line temperature to a slightly lower +value of that temperature. The same needs to be done for the consumer cycle. We suggest setting the parameters at the pump's inlet. On top, we assume that the consumer requires a @@ -240,7 +240,9 @@ Connections Since the old connection :code:`1` lead to a sink, we have to replace this connection in the network. We can do that by using the method :code:`del_conns` passing :code:`c1`. After that, we can create the new -connections and add them to the network as we did before. +connections and add them to the network as we did before. Do not forget to add +the :code:`x=0` specification to make liquid saturated state at the outlet of +the condenser. The valve connects to the drum at the inlet :code:`'in1'`. The drum's outlet :code:`'out1'` is saturated liquid and connects to the evaporator's cold side @@ -264,16 +266,16 @@ connections to the model: Parametrization +++++++++++++++ Previous parametrization stays untouched. Regarding the evaporator, we specify -pressure ratios on hot side as well as the evaporation pressure, for which we -can obtain a good initial guess based on the ambient temperature level using -CoolProp. From this specification the pinch point layout will be a result, -similar as in waste heat steam generators. The pressure ratio of the cold side -*MUST NOT* be specified in this setup as the drum assumes pressure equality -for all inlets and outlets. +pressure ratios on hot side. On top, the evaporation pressure is indirectly +defined through the evaporation temperature and the fact, that we are in +two-phase region. From this specification the pinch point layout will be a +result, similar as in waste heat steam generators. The pressure ratio of the +cold side *MUST NOT* be specified in this setup as the drum assumes pressure +equality for all inlets and outlets. The superheater will also use the pressure ratios on hot and cold side. -Further we set a value for the enthalpy at the working fluid side outlet. This -determines the degree of overheating and is again based on a good guess. +Further we set a value for the degree of superheating :code:`td_dew` at the +working fluid side outlet. .. literalinclude:: /../tutorial/advanced/stepwise.py :language: python @@ -308,8 +310,8 @@ Compressor system To complete the heat pump, we will add the compressor system to our existing network. This requires to change the connections 0, 6 and 17. The connection 6 has to be changed to include the compressor. After the last compressor stage, -connection 0 has to redefined, since we need to include the CycleCloser of the -working fluid's cycle. The connection 17 has to be connected to the heat +connection 0 has to be redefined, since we need to include the CycleCloser of +the working fluid's cycle. The connection 17 has to be connected to the heat exchanger for intermittent cooling as well as the bypass. .. figure:: /_static/images/tutorials/heat_pump_stepwise/flowsheet.svg @@ -375,18 +377,21 @@ pressure losses on both sides. :start-after: [sec_14] :end-before: [sec_15] -Regarding the connections we set enthalpy values for all working fluid side -connections. After the superheater and intermittent cooling the value will be -near saturation (enthalpy value of connection c5), after the compressors it -will be higher. +Regarding the connections we set degree of superheating at the outlet of the +superheater and intermittent cooling, since these values will be near +saturation. The temperature at both compressor exits will be higher than those, +we can implicitly define this through the enthalpy fixed to the saturated gas +enthalpy multiplied by a bit. This specification is for numerical stability, it +will be changed in the next step. For the ambient side, we set temperature, pressure and fluid at connection 11. On top of that, we can specify the temperature of the ambient water after leaving the intermittent cooler. -With re-adding of connection 0 we have to set the fluid and the pressure again, -but not the temperature value, because this value will be a result of the -condensation pressure and the given enthalpy at the compressor's outlet. +With re-adding of connection 0 we have to set the fluid and the dew line +temperature again, but not the actual temperature value, because this value +will be a result of the condensation pressure and the given enthalpy at the +compressor's outlet. .. literalinclude:: /../tutorial/advanced/stepwise.py :language: python @@ -398,18 +403,16 @@ Solve and Set Final System Parameters Now we solve again. After that, we can exchange our good guesses with actual useful parameters: -The condensation and evaporation pressure levels will be replaced by pinch -temperature difference of the condenser and terminal temperature difference of -the evaporator respectively. The lower terminal temperature value of the -evaporator :code:`ttd_l` is identical to the pinch point temperature -difference. +The condensation dew line temperature and evaporation temperature will be +replaced by pinch temperature difference of the condenser and terminal +temperature difference of the evaporator respectively. The lower terminal +temperature value of the evaporator :code:`ttd_l` is identical to its pinch +point temperature difference. The degree of superheating in the superheater will be determined by the upper -terminal temperature instead of the enthalpy value at connection 6. The outlet +terminal temperature instead of the superheating specification. The outlet enthalpies after both compressors are replaced by the isentropic efficiency -values. Finally, the enthalpy after the intermittent cooling is replaced by -the temperature difference to the boiling point. With this we can ensure, the -working fluid does not start to condensate at the intermittent cooler. +values. .. literalinclude:: /../tutorial/advanced/stepwise.py :language: python @@ -439,8 +442,9 @@ The changes we want to apply can be summarized as follows: - All heat exchangers should be calculated based on their heat transfer coefficient with a characteristic for correction of that value depending - on the change of mass flow (:code:`kA_char`). Therefore, terminal temperature - value specifications need to be added to the design parameters. Also, the + on the change of mass flow (:code:`kA_char` and :code:`UA_cecchinato` for the + condenser). Therefore, pinch temperature difference and terminal temperature + difference value specifications need to be design parameters. Also, the temperature at connection 14 cannot be specified anymore, since it will be a result of the intermittent cooler's characteristics. - Pumps and compressors will have a characteristic function for their @@ -454,7 +458,8 @@ On top of that, for the evaporator the characteristic function of the heat transfer coefficient should follow different data than the default characteristic. The name of that line is 'EVAPORATING FLUID' for the cold side. The default line 'DEFAULT' will be kept for the hot side. These lines -are available in the :ref:`tespy.data ` module. +are available in the :ref:`tespy.data ` module. For the condenser +the specifications are inspired from this paper :cite:`cecchinato2010`. .. attention:: diff --git a/tutorial/advanced/stepwise.py b/tutorial/advanced/stepwise.py index e6d601684..96096de4c 100644 --- a/tutorial/advanced/stepwise.py +++ b/tutorial/advanced/stepwise.py @@ -40,9 +40,7 @@ rp.set_attr(eta_s=0.75) cons.set_attr(pr=0.99) # %%[sec_5] -from CoolProp.CoolProp import PropsSI as PSI -p_cond = PSI("P", "Q", 1, "T", 273.15 + 95, working_fluid) / 1e5 -c0.set_attr(T=170, p=p_cond, fluid={working_fluid: 1}) +c0.set_attr(T=170, T_dew=85, fluid={working_fluid: 1}) c1.set_attr(x=0) c20.set_attr(T=60, p=2, fluid={"water": 1}) c22.set_attr(T=90) @@ -94,8 +92,7 @@ # evaporator system cold side c4.set_attr(x=0.9, T=5) -h_sat = PSI("H", "Q", 1, "T", 273.15 + 15, working_fluid) / 1e3 -c6.set_attr(h=h_sat) +c6.set_attr(td_dew=5) # evaporator system hot side c17.set_attr(T=15, fluid={"water": 1}) @@ -142,10 +139,10 @@ ic.set_attr(pr1=0.99, pr2=0.98) hsp.set_attr(eta_s=0.75) # %%[sec_15] -c0.set_attr(p=p_cond, fluid={working_fluid: 1}) +c0.set_attr(T_dew=85, fluid={working_fluid: 1}) -c6.set_attr(h=c5.h.val + 10) -c8.set_attr(h=c5.h.val + 10) +c6.set_attr(td_dew=5) +c8.set_attr(td_dew=5) c7.set_attr(h=c5.h.val * 1.2) c9.set_attr(h=c5.h.val * 1.2) @@ -155,13 +152,13 @@ # %% [sec_16] nw.solve("design") -c0.set_attr(p=None) +c0.set_attr(T_dew=None) cd.set_attr(td_pinch=10) c4.set_attr(T=None) ev.set_attr(ttd_l=5) -c6.set_attr(h=None) +c6.set_attr(td_dew=None) su.set_attr(ttd_u=5) c7.set_attr(h=None) @@ -183,7 +180,7 @@ cd.set_attr( design=["pr2", "td_pinch"], offdesign=["zeta2", "UA_cecchinato"], - alpha_ratio=1, area_ratio=1, re_exp_sf=0.8, re_exp_r=0.55, + alpha_ratio=1, area_ratio=1, re_exp_r=0.8, re_exp_sf=0.50, refrigerant_index=0 ) From 3f95cf94f630f81c48dbb84a953b808353ed84ec Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Mon, 12 Jan 2026 08:28:59 +0100 Subject: [PATCH 28/69] Update meeting information --- README.rst | 17 ++++++++--------- docs/community/community.rst | 23 ++++++++++------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index 14a9bc070..efc5883f5 100644 --- a/README.rst +++ b/README.rst @@ -123,17 +123,16 @@ use. Get in touch ============ -Online "Stammtisch" -------------------- +Community meetings +------------------ -We have decided to start a reoccurring "Stammtisch" meeting for all interested -TESPy users and (potential) developers. You are invited to join us on every 3rd -Monday of a month at 17:00 CE(S)T for a casual get together. The first meeting -will be held at June, 20, 2022. The intent of this meeting is to establish a -more active and well-connected network of TESPy users and developers. +There are online QA meetings every month, please check the +`oemof calendar `__ for date, time and meeting +link. You are invited to join us with your questions, issues and suggestions! -If you are interested, you can simply join the meeting at -https://meet.jit.si/tespy_user_meeting. We are looking forward to seeing you! +Furthermore, there are in-person community meetings. These are held once or +twice a year. To learn about upcoming meetings, follow the blog over at +``__ for the announcements. User forum ---------- diff --git a/docs/community/community.rst b/docs/community/community.rst index 83bc07f5d..9220c3a0f 100644 --- a/docs/community/community.rst +++ b/docs/community/community.rst @@ -15,17 +15,14 @@ about using the software, you are invited to start a discussion there. Monthly online meeting ====================== -There is a monthly online meeting for all TESPy users and developers. You are -invited to join us on **every 3rd Monday of a month at 17:00 CE(S)T** for a -casual get together. The intent of this meeting is to establish a more active -and well connected network of TESPy users and developers. You can bring your -challenges, questions and ideas and we will discuss them together or plan -potential follow-up meetings. +There are online QA meetings every month, please check the +`oemof calendar `__ for date, time and meeting +link. You are invited to join us with your questions, issues and suggestions! +Please also check the announcement banner at the top of the documentation +pages, you will get updates on rescheduled date/time. -If you are interested, you can simply join the meeting at -https://meet.jit.si/tespy_user_meeting. We are looking forward to seeing you! - -.. attention:: - - Please also check the announcement banner at the top of the documentation - pages, you will get updates on rescheduled date/time. +In-person meetings +================== +Furthermore, there are in-person community meetings. These are held once or +twice a year. To learn about upcoming meetings, follow the blog over at +``__ for the announcements. From e3f38d029a9794bfdd2e13b6a08711c98531ca6a Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 08:37:06 +0100 Subject: [PATCH 29/69] Add a conductivity fitting test --- .../test_fluid_properties/test_incompressible.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_tools/test_fluid_properties/test_incompressible.py b/tests/test_tools/test_fluid_properties/test_incompressible.py index eda1811a6..06b97e7f4 100644 --- a/tests/test_tools/test_fluid_properties/test_incompressible.py +++ b/tests/test_tools/test_fluid_properties/test_incompressible.py @@ -24,7 +24,8 @@ def property_data(): "temperature_data": np.array([292.647, 310.808, 366.241, 421.673, 477.108, 532.542, 588.826, 618.580]), "heat_capacity_data": np.array([1901.775, 1961.529, 2143.908, 2326.287, 2508.674, 2691.060, 2876.242, 2974.135]) * 1000, "density_data": np.array([863.811, 852.596, 818.368, 784.139, 749.909, 715.678, 680.924, 662.551]), - "viscosity_data": np.array([0.050335, 0.028525, 0.007075, 0.002500, 0.00111, 0.000579, 0.000334, 0.000259]) + "viscosity_data": np.array([0.050335, 0.028525, 0.007075, 0.002500, 0.00111, 0.000579, 0.000334, 0.000259]), + "conductivity_data": np.array([0.1419, 0.1410, 0.1382, 0.1354 , 0.1327, 0.1299, 0.1271, 0.1256]) } @@ -122,3 +123,13 @@ def test_viscosity(property_data, wrapper_instance): viscosity = wrapper_instance.viscosity_pT(None, temperature_data) # allow higher tolerance for viscosity np.testing.assert_allclose(viscosity, viscosity_data, rtol=1e-2) + + +def test_conductivity(property_data, wrapper_instance): + + conductivity_data = property_data["conductivity_data"] + temperature_data = property_data["temperature_data"] + + conductivity = wrapper_instance.conductivity_pT(None, temperature_data) + # allow higher tolerance for viscosity + np.testing.assert_allclose(conductivity, conductivity_data, rtol=1e-3) From ee1b999d3788a72e34dc6edef4c7d0dfaaa7d8a4 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 08:43:48 +0100 Subject: [PATCH 30/69] Also test conductivity high level access --- src/tespy/tools/fluid_properties/__init__.py | 1 + src/tespy/tools/fluid_properties/functions.py | 12 ++++++++++++ src/tespy/tools/fluid_properties/wrappers.py | 17 +++++++++++++++++ tests/test_networks/test_network.py | 12 +++++++++++- 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/tespy/tools/fluid_properties/__init__.py b/src/tespy/tools/fluid_properties/__init__.py index 80cfd0526..dc95484b7 100644 --- a/src/tespy/tools/fluid_properties/__init__.py +++ b/src/tespy/tools/fluid_properties/__init__.py @@ -4,6 +4,7 @@ from .functions import T_mix_ph # noqa: F401 from .functions import T_mix_ps # noqa: F401 from .functions import T_sat_p # noqa: F401 +from .functions import conductivity_mix_ph # noqa: F401 from .functions import dh_mix_dpQ # noqa: F401 from .functions import dT_mix_dph # noqa: F401 from .functions import dT_mix_pdh # noqa: F401 diff --git a/src/tespy/tools/fluid_properties/functions.py b/src/tespy/tools/fluid_properties/functions.py index 835bd9d20..00a0bf3c4 100644 --- a/src/tespy/tools/fluid_properties/functions.py +++ b/src/tespy/tools/fluid_properties/functions.py @@ -352,3 +352,15 @@ def viscosity_mix_pT(p, T, fluid_data, mixing_rule=None): else: _check_mixing_rule(mixing_rule, V_MIX_PT_DIRECT, "viscosity") return VISCOSITY_MIX_PT_DIRECT[mixing_rule](p, T, fluid_data) + + +def conductivity_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): + if get_number_of_fluids(fluid_data) == 1: + pure_fluid = get_pure_fluid(fluid_data) + return pure_fluid["wrapper"].conductivity_ph(p, h) + else: + msg = ( + "Calculation of thermal conductivity is not implemented for " + "TESPy based mixtures. You are happily invited to contribute it!" + ) + raise NotImplementedError(msg) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index 0260efaee..f2d7583d1 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -125,6 +125,12 @@ def viscosity_ph(self, p, h): def viscosity_pT(self, p, T): self._not_implemented() + def conductivity_ph(self, p, h): + self._not_implemented() + + def conductivity_pT(self, p, T): + self._not_implemented() + def s_ph(self, p, h): self._not_implemented() @@ -359,6 +365,14 @@ def viscosity_pT(self, p, T): self.AS.update(CP.PT_INPUTS, p, T) return self.AS.viscosity() + def conductivity_ph(self, p, h): + self.AS.update(CP.HmassP_INPUTS, h, p) + return self.AS.conductivity() + + def conductivity_pT(self, p, T): + self.AS.update(CP.PT_INPUTS, p, T) + return self.AS.conductivity() + def s_ph(self, p, h): self.AS.update(CP.HmassP_INPUTS, h, p) return self.AS.smass() @@ -575,6 +589,9 @@ def T_ps(self, p, s): args=(p, s) ) + def conductivity_ph(self, p, h): + return self.conductivity_pT(p, self.T_ph(p, h)) + def conductivity_pT(self, p, T): return self._conductivity["A"] * T + self._conductivity["B"] diff --git a/tests/test_networks/test_network.py b/tests/test_networks/test_network.py index d81c275f0..1dd620423 100644 --- a/tests/test_networks/test_network.py +++ b/tests/test_networks/test_network.py @@ -35,6 +35,7 @@ from tespy.connections import Ref from tespy.networks import Network from tespy.tools.data_containers import ComponentMandatoryConstraints as dc_cmc +from tespy.tools.fluid_properties import conductivity_mix_ph from tespy.tools.fluid_properties.wrappers import IncompressibleFluidWrapper from tespy.tools.helpers import TESPyNetworkError from tespy.tools.helpers import _numeric_deriv @@ -1198,7 +1199,8 @@ def test_fluid_kwargs_propagation(): "temperature_data": np.array([273.15, 373.15]), "density_data": np.array([1000, 1100]), "heat_capacity_data": np.array([4000, 4100]), - "viscosity_data": np.array([0.05, 0.00025]) + "viscosity_data": np.array([0.05, 0.00025]), + "conductivity_data": np.array([0.1425, 0.135]) } c1.set_attr( @@ -1211,3 +1213,11 @@ def test_fluid_kwargs_propagation(): pipe.set_attr(Q=1500) nw.solve("design") + + # 50 °C is exactly half of range + # heat capacity is implicitly tested, as it is required to find the + # temperature from the enthalpy passed into the function + assert approx(1 / c2.calc_vol()) == 1050 + assert approx( + conductivity_mix_ph(c2.p.val_SI, c2.h.val_SI, c2.fluid_data) + ) == 0.13875 From c53d9f080fef7ee28d13e1d00154a922e58e3617 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 08:49:52 +0100 Subject: [PATCH 31/69] UPdate docs --- docs/advanced_features/fluid_properties.rst | 7 ++++++- docs/whats_new/v0-9-12.rst | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/advanced_features/fluid_properties.rst b/docs/advanced_features/fluid_properties.rst index 1a67b1049..3a2bd96f4 100644 --- a/docs/advanced_features/fluid_properties.rst +++ b/docs/advanced_features/fluid_properties.rst @@ -259,6 +259,11 @@ enthalpy to the temperature. Lastly, to make the calculation of isentropic efficiencies possible, we can add the equation for change in enthalpy on isentropic change of pressure for an ideal gas. +.. tip:: + + You can also inject :code:`kwargs` from the connection specification to + your concrete wrapper instance, see the section on + :ref:`incompressible fluids `. .. code-block:: python @@ -269,7 +274,7 @@ isentropic change of pressure for an ideal gas. >>> class KKHWrapper(FluidPropertyWrapper): ... - ... def __init__(self, fluid, back_end=None, reference_temperature=298.15) -> None: + ... def __init__(self, fluid, back_end=None, reference_temperature=298.15, **kwargs) -> None: ... super().__init__(fluid, back_end) ... ... if self.fluid not in COEF: diff --git a/docs/whats_new/v0-9-12.rst b/docs/whats_new/v0-9-12.rst index 268eebe60..364885e6c 100644 --- a/docs/whats_new/v0-9-12.rst +++ b/docs/whats_new/v0-9-12.rst @@ -5,7 +5,9 @@ New Features ############ - Custom fluid property wrappers can now receive arbitrary :code:`kwargs` making injection of information for the underlying property models much - easier (`PR #877 `__). + easier. Check the section on the + :ref:`incompressible fluid properties ` + (`PR #877 `__). - There is a new :code:`FluidPropertyWrapper` for incompressible fluids such as thermo-oils, which you can pass measurement or manufacturer data to. The :code:`IncompressibleFluidWrapper` will automatically fit functions to the From def2b2746f06ac38a6ba897ebca4c678bde6d892 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 08:58:43 +0100 Subject: [PATCH 32/69] Update fitting description for viscosity and add a cautionary note --- docs/advanced_features/fluid_properties.rst | 12 ++++++++++-- docs/whats_new/v0-9-12.rst | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/advanced_features/fluid_properties.rst b/docs/advanced_features/fluid_properties.rst index 3a2bd96f4..a947f03d1 100644 --- a/docs/advanced_features/fluid_properties.rst +++ b/docs/advanced_features/fluid_properties.rst @@ -108,8 +108,8 @@ data: - density and heat capacity through linear interpolation: :math:`f\left(T\right) = A + B \cdot T` -- viscosity through Arrhenius equation - :math:`\eta\left(T\right) = A \cdot e ^ \frac{B}{T}` +- viscosity through an exponential polynomial equation + :math:`\eta\left(T\right) = e ^ {\frac{A}{T ^ 3} + \frac{B}{T ^ 2} + \frac{C}{T} + D}` We can make use of the engine as shown in the example below. First, we set up a very simple system, just a flow of fluid through a heat exchanger. @@ -144,6 +144,14 @@ Next, we have to prepare our data to be utilized by TESPy. The information required needs to be passed in SI units and must be gridded with the same spacing of temperature for all measurements. +.. attention + + Please note, that this example is purely for showing how to utilize this + implementation. In the example only 2 datapoints are available. This works + well for density and heat capacity as linear fitting functions are applied. + For viscosity this is somewhat sketchy, since you would need at least 4 + datapoints to fit a function of polynomial 3! + .. code-block:: python >>> fluid_kwargs = { diff --git a/docs/whats_new/v0-9-12.rst b/docs/whats_new/v0-9-12.rst index 364885e6c..35a7badbe 100644 --- a/docs/whats_new/v0-9-12.rst +++ b/docs/whats_new/v0-9-12.rst @@ -15,8 +15,8 @@ New Features - heat capacity and density: linear interpolation :math:`f\left(T\right) = A + B \cdot T`. - - viscosity: Arrhenius equation - :math:`v\left(T\right) = A \cdot e ^ \frac{B}{T}` + - viscosity: exponential polynomial equation + :math:`\eta\left(T\right) = e ^ {\frac{A}{T ^ 3} + \frac{B}{T ^ 2} + \frac{C}{T} + D}` For an example in a TESPy model see :ref:`this section ` From 15c6714f8798b89278b47483800e042f9395df10 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 09:54:42 +0100 Subject: [PATCH 33/69] Issue a future warning for specification of Ref and numerical value at the same time --- src/tespy/connections/connection.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index e48962192..9e2b9b2ae 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -147,6 +147,23 @@ def _parameter_specification(self, key, value): elif is_numeric or is_quantity: # value specification if key in self.property_data: + if f"{key}_ref" in self.property_data: + if self.get_attr(f"{key}_ref" ).is_set: + msg = ( + f"You have specified a numerical value for the " + f"parameter '{key}' while having a Ref specified " + f"at the same time at connection {self.label}. In " + "the moment, this does not overwrite setting the " + "Ref. To unset the Ref before setting the " + f"numerical value, run .set_attr({key}=None) " + f"before .set_attr({key}={value}). With the next " + "major release of TESPy setting a numerical value " + "will automatically unset a specified Ref " + "making it impossible to set a numerical value " + "and a Ref for one parameter on one connection " + "simultaneously." + ) + warnings.warn(msg, FutureWarning) self.get_attr(key).set_attr(is_set=True, val=value) else: self.get_attr(key.replace('0', '')).set_attr(val0=value) @@ -158,6 +175,22 @@ def _parameter_specification(self, key, value): logger.error(msg) raise NotImplementedError(msg) else: + if self.get_attr(key).is_set: + msg = ( + f"You have specified a Ref for the parameter '{key}' " + "while having a numerical value specified at the same " + f"time at connection {self.label}. In the moment, " + "this does not overwrite setting the numerical value. " + "To unset the specified value before setting the Ref, " + f"run .set_attr({key}=None) before " + f".set_attr({key}=Ref(...))." + "With the next major release of TESPy setting a Ref " + "will automatically unset a specified numerical " + "value making it impossible to set a numerical value " + "and a Ref for one parameter on one connection " + "simultaneously." + ) + warnings.warn(msg, FutureWarning) self.get_attr(f"{key}_ref").set_attr(ref=value) self.get_attr(f"{key}_ref").set_attr(is_set=True) From 0d63967ca7cc8bf35e899ee9851083652e4c84fe Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 12:32:44 +0100 Subject: [PATCH 34/69] Add some class docstring --- src/tespy/tools/fluid_properties/wrappers.py | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/tespy/tools/fluid_properties/wrappers.py b/src/tespy/tools/fluid_properties/wrappers.py index f2d7583d1..1c2a7f80c 100644 --- a/src/tespy/tools/fluid_properties/wrappers.py +++ b/src/tespy/tools/fluid_properties/wrappers.py @@ -384,8 +384,49 @@ def s_pT(self, p, T): @wrapper_registry class IncompressibleFluidWrapper(FluidPropertyWrapper): + """Class to represent a fluid in TESPy using tabular data + + Parameters + ---------- + fluid : str + Name of fluid + back_end : str, optional + Name of the back end in context of CoolProp, by default None + temperature_data : np.ndarray + Array of temperature measurements in SI units (Kelvin) + density_data : np.ndarray + Array of corresponding density values in SI units (kg/m3) + heat_capacity_data : np.ndarray + Array of corresponding heat capacity values in SI units (J/kg) + viscosity_data : np.ndarray + Array of corresponding **dynamic** viscosity values in SI units (Pas) + conductivity_data : np.ndarray + Array of corresponding thermal conductivity values in SI units + (W/mK) + """ def __init__(self, fluid, back_end=None, **kwargs): + """Class to represent a fluid in TESPy using tabular data + + Parameters + ---------- + fluid : str + Name of fluid + back_end : str, optional + Name of the back end in context of CoolProp, by default None + temperature_data : np.ndarray + Array of temperature measurements in SI units (Kelvin) + density_data : np.ndarray + Array of corresponding density values in SI units (kg/m3) + heat_capacity_data : np.ndarray + Array of corresponding heat capacity values in SI units (J/kg) + viscosity_data : np.ndarray + Array of corresponding **dynamic** viscosity values in SI units + (Pas) + conductivity_data : np.ndarray + Array of corresponding thermal conductivity values in SI units + (W/mK) + """ super().__init__(fluid, back_end, **kwargs) self.temperature_data = None From 953570fc853c1a8e168708f079f765c7774eb5d5 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 12:39:11 +0100 Subject: [PATCH 35/69] Add to FAQ --- docs/knowledge_center/faq.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/knowledge_center/faq.rst b/docs/knowledge_center/faq.rst index 47746ba94..37e34fe07 100644 --- a/docs/knowledge_center/faq.rst +++ b/docs/knowledge_center/faq.rst @@ -233,6 +233,19 @@ Customizing model behavior ... for var in [c.p, c.h] ... ] + .. dropdown:: I have custom fluid property data, how can I integrate them into my model? + + In the documentation section + :ref:`on fluid property engines ` you will find + a lot of helpful information. Specifically for liquids/incompressibles + there is already an interface, that takes your datatables and then fits + equations to them and integrates them into your tespy model. Check out + the information on that topic in + :ref:`this section `. You can also + implement your own class, that handles your fluid property equations. + Follow the examples given in the sections mentioned to learn, how that + can be accomplished. + .. _faq_postprocessing_label: Visualization, post-processing and cycle analysis From 7f86bfd3ee598f5f8441132a99c8ccc2c682cb67 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 13 Jan 2026 18:58:42 +0100 Subject: [PATCH 36/69] Update warning text --- src/tespy/connections/connection.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index db57bd209..73d9a9ecc 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -158,7 +158,7 @@ def _parameter_specification(self, key, value): f"numerical value, run .set_attr({key}=None) " f"before .set_attr({key}={value}). With the next " "major release of TESPy setting a numerical value " - "will automatically unset a specified Ref " + "will always replace a previously specified Ref " "making it impossible to set a numerical value " "and a Ref for one parameter on one connection " "simultaneously." @@ -183,11 +183,11 @@ def _parameter_specification(self, key, value): "this does not overwrite setting the numerical value. " "To unset the specified value before setting the Ref, " f"run .set_attr({key}=None) before " - f".set_attr({key}=Ref(...))." - "With the next major release of TESPy setting a Ref " - "will automatically unset a specified numerical " - "value making it impossible to set a numerical value " - "and a Ref for one parameter on one connection " + f".set_attr({key}=Ref(...)). With the next major " + "release of TESPy setting a Ref will automatically " + "replace a previously specified numerical value " + "making it impossible to set a numerical value and a " + "Ref for one parameter on one connection " "simultaneously." ) warnings.warn(msg, FutureWarning) From 15a1bd2df00dd01d788052a71d318cf3d6d72532 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 14 Jan 2026 08:53:40 +0100 Subject: [PATCH 37/69] Make it possible to specify relative humidity as equation to the model --- src/tespy/connections/connection.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 84abfb114..3e6e83437 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -1267,6 +1267,13 @@ def get_parameters(self): dependents=self.Td_bp_dependents, num_eq=1, quantity="temperature_difference", description="temperature difference to boiling point (deprecated)" + ), + "r": dc_prop( + func=self.r_func, + dependents=self.r_dependents, + num_eq=1, + quantity="ratio", + description="relative humidity" ) } @@ -1460,6 +1467,25 @@ def v_ref_dependents(self): ref = self.v_ref.ref return self.v_dependents() + ref.obj.v_dependents() + def calc_r(self): + from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio + from CoolProp.CoolProp import HAPropsSI + w = _get_humid_air_humidity_ratio(self.fluid_data) + try: + return HAPropsSI("R", "P", self.p.val_SI, "T", self.T.val_SI, "W", w) + except ValueError as e: + value = str(e).split("value (")[1].split(")")[0] + return float(value) + + def r_func(self): + return self.r.val_SI - self.calc_r() + + def r_dependents(self): + return { + "scalars": [self.p, self.h], + "vectors": [{self.fluid: {"water"}}] + } + def calc_x(self): try: return Q_mix_ph(self.p.val_SI, self.h.val_SI, self.fluid_data) From 57c5f7066506b811adbfc9869db537e0fd3b5bb0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 14 Jan 2026 08:54:21 +0100 Subject: [PATCH 38/69] Make quick and dirty convergence helpers for humidair mixing_rule --- src/tespy/connections/connection.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 3e6e83437..77e697e63 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -1818,6 +1818,30 @@ def _adjust_to_property_limits(self, nw): if (self.Td_bp.is_set or self.state.is_set or self.x.is_set or self.td_bubble.is_set or self.td_dew.is_set) and self.it < 10: self._adjust_to_two_phase(fl) + elif self.mixing_rule == "humidair": + # def _adjust_to_property_limits(self, nw): + from CoolProp.CoolProp import HAPropsSI + + if self.p.is_var: + if self.p.val_SI < 100: + self.p.val_SI = 101 + elif self.p.val_SI > 100e5: + self.p.val_SI = 99e5 + + if self.h.is_var: + # TODO: check minimum temperature how it matches minimum humidity ratio + d = self.h._reference_container._d + hmin = HAPropsSI("H", "T", -30 + 273.15, "P", self.p.val_SI, "R", 1) + if self.h.val_SI < hmin: + delta = max(abs(self.h.val_SI * d), d) * 5 + self.set_reference_val_SI(hmin + delta) + + else: + # TODO: where to get reasonable hmax from?! + hmax = HAPropsSI("H", "T", 300 + 273.15, "P", self.p.val_SI, "R", 0) + if self.h.val_SI > hmax: + delta = max(abs(self.h.val_SI * d), d) * 5 + self.set_reference_val_SI(hmax - delta) # mixture elif self.it < 5 and not self.good_starting_values: # pressure From bb8200564b048279e7e3754e2ba110f6eb8c82dc Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 14 Jan 2026 08:54:47 +0100 Subject: [PATCH 39/69] Remove outputs --- .../test_fluid_properties/humidair.ipynb | 120 +++++++----------- 1 file changed, 48 insertions(+), 72 deletions(-) diff --git a/tests/test_tools/test_fluid_properties/humidair.ipynb b/tests/test_tools/test_fluid_properties/humidair.ipynb index 8349181a5..abbce7a0a 100644 --- a/tests/test_tools/test_fluid_properties/humidair.ipynb +++ b/tests/test_tools/test_fluid_properties/humidair.ipynb @@ -2,12 +2,12 @@ "cells": [ { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "id": "5796d5ee", "metadata": {}, "outputs": [], "source": [ - "from tespy.connections import HumidAirConnection, Connection\n", + "from tespy.connections import HumidAirConnection, Connection, Ref\n", "from tespy.components import Source, Sink, MovingBoundaryHeatExchanger\n", "from tespy.networks import Network\n", "\n", @@ -16,7 +16,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "id": "d60a9cea", "metadata": {}, "outputs": [], @@ -31,7 +31,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "id": "947c79c2", "metadata": {}, "outputs": [], @@ -51,22 +51,7 @@ "execution_count": null, "id": "10a7b936", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - " iter | residual | progress | massflow | pressure | enthalpy | fluid | component \n", - "-------+------------+------------+------------+------------+------------+------------+------------\n", - " 1 | 1.88e+06 | 0 % | 3.72e+02 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", - " 2 | 1.06e-04 | 100 % | 2.09e-08 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", - " 3 | 0.00e+00 | 100 % | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", - " 4 | 0.00e+00 | 100 % | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 | 0.00e+00 \n", - "Total iterations: 4, Calculation time: 0.00 s, Iterations per second: 2700.78\n" - ] - } - ], + "outputs": [], "source": [ "so = Source(\"source\")\n", "hex = MovingBoundaryHeatExchanger(\"heat exchanger\")\n", @@ -87,12 +72,15 @@ "a2.set_attr(td_dew=5, T_dew=-20)\n", "\n", "c1.set_attr(\n", - " fluid=get_water_air_mixture_fractions_pTR(p=1e5, T=268.16, R=1),\n", + " fluid0={\"water\": 0.0005, \"air\": 0.9995},#get_water_air_mixture_fractions_pTR(p=1e5, T=268.16, R=1),\n", " p=1,\n", " T=-5,\n", - " mixing_rule=\"humidair\"\n", + " mixing_rule=\"humidair\",\n", + " r=1,\n", + " fluid_balance=True,\n", + " h0=0\n", ")\n", - "c2.set_attr(T=-10)\n", + "c2.set_attr(T=Ref(c1, 1, -5), h0=0)\n", "\n", "hex.set_attr(dp1=0, dp2=0)\n", "\n", @@ -101,40 +89,49 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, + "id": "1c3bb42f", + "metadata": {}, + "outputs": [], + "source": [ + "c2.set_attr(T=None)\n", + "c2.set_attr(T=-10)\n", + "nw.solve(\"design\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6867709", + "metadata": {}, + "outputs": [], + "source": [ + "c1.fluid.val" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dcb0ed15", + "metadata": {}, + "outputs": [], + "source": [ + "c2.fluid.val" + ] + }, + { + "cell_type": "code", + "execution_count": null, "id": "3fc42bdf", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "##### RESULTS (MovingBoundaryHeatExchanger) #####\n", - "+----------------+-----------+----------+----------+----------+----------+-----------+----------+----------+----------+----------+----------+----------+------------+-----------+-----------+----------+------------+\n", - "| | Q | kA | td_log | ttd_u | ttd_l | ttd_min | pr1 | pr2 | dp1 | dp2 | zeta1 | zeta2 | eff_cold | eff_hot | eff_max | UA | td_pinch |\n", - "|----------------+-----------+----------+----------+----------+----------+-----------+----------+----------+----------+----------+----------+----------+------------+-----------+-----------+----------+------------|\n", - "| heat exchanger | -1.88e+03 | 1.88e+05 | 1.00e+01 | 1.00e+01 | 1.00e+01 | 1.00e+01 | 1.00e+00 | 1.00e+00 | \u001b[94m0.00e+00\u001b[0m | \u001b[94m0.00e+00\u001b[0m | 0.00e+00 | 0.00e+00 | 9.76e-01 | 3.33e-01 | 9.76e-01 | 1.53e+05 | 1.00e+01 |\n", - "+----------------+-----------+----------+----------+----------+----------+-----------+----------+----------+----------+----------+----------+----------+------------+-----------+-----------+----------+------------+\n", - "##### RESULTS (Connection) #####\n", - "+----+-----------+-----------+------------+------------+-------------+---------+\n", - "| | m | p | h | T | x | phase |\n", - "|----+-----------+-----------+------------+------------+-------------+---------|\n", - "| a1 | \u001b[94m2.000e+00\u001b[0m | 1.900e+00 | 6.529e+05 | -2.000e+01 | \u001b[94m3.000e-01\u001b[0m | tp |\n", - "| a2 | 2.000e+00 | 1.900e+00 | 1.595e+06 | -1.500e+01 | 1.000e+00 | g |\n", - "| c1 | 3.730e+02 | \u001b[94m1.000e+00\u001b[0m | 1.256e+03 | \u001b[94m-5.000e+00\u001b[0m | nan | nan |\n", - "| c2 | 3.730e+02 | 1.000e+00 | -3.796e+03 | \u001b[94m-1.000e+01\u001b[0m | nan | nan |\n", - "+----+-----------+-----------+------------+------------+-------------+---------+\n" - ] - } - ], + "outputs": [], "source": [ "nw.print_results()" ] }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "id": "fd871dc0", "metadata": {}, "outputs": [], @@ -144,31 +141,10 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "id": "2f9ed087", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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48lt2b9/v9f+3nR933zzTa+2FhQUyN/fXzfeeKNXOSte35qaGqWkpOi55547o/IlJSW69tprdeWVV2rHjh1asGCBfvazn3l9aXfm78v54mt7N23apBkzZuif//yntm/friuvvFLXXXedPv30U69yI0eO9Lq2mzdvPhfV95mv7W1TWFjo1Z7IyEhzX2+6vv/1X//l1c6DBw/K6XS2+9216vXtMkYvNmnSJCMzM9N839zcbMTExBg5OTkdlv/hD39oXHvttV7b0tLSjPnz5xuGYRgej8eIiooyHn/8cXN/RUWFYbfbjZdffvkctMA3vrb3VE1NTUZISIixYsUKc9ttt91mXH/99V1d1S7ha3uXLVtmhIaGfufxevv1feqpp4yQkBCjurra3Gbl69tGkrF69ervLfPrX//aGDlypNe2H/3oR8asWbPM92f753e+nEl7OzJixAhj0aJF5vuFCxcaKSkpXVexc+RM2vvhhx8akozjx49/Z5nefH1Xr15t2Gw2o7S01NzWU67v2ei1PSgNDQ3avn27pk+fbm7z8/PT9OnTlZeX1+Fn8vLyvMpL0qxZs8zyJSUlKisr8yoTGhqqtLS07zzm+dKZ9p6qtrZWjY2NcjqdXts3btyoyMhIDR06VD//+c9VXl7epXXvjM62t7q6WvHx8YqLi9P111+vzz//3NzX26/vCy+8oJtuuknBwcFe2614fX11ut/drvjzszKPx6Oqqqp2v7t79+5VTEyMkpKSdPPNN+vAgQPdVMOukZqaqujoaM2YMUMfffSRub23X98XXnhB06dPV3x8vNf23nZ9T9VrA8qxY8fU3NyswYMHe20fPHhwu/uWbcrKyr63fNt/fTnm+dKZ9p7qN7/5jWJiYrx+yWfPnq2VK1fqgw8+0KOPPqrc3FxdffXVam5u7tL6+6oz7R06dKhefPFFrVmzRn/729/k8Xg0efJkffXVV5J69/XdunWrCgoK9LOf/cxru1Wvr6++63fX7Xarrq6uS34/rGzJkiWqrq7WD3/4Q3NbWlqali9frnXr1mnp0qUqKSnR5Zdfrqqqqm6saedER0fr+eef1xtvvKE33nhDcXFxysjI0CeffCKpa/79s6rDhw/rnXfeafe725uu73fpkU8zRtd75JFH9Morr2jjxo1eA0dvuukm8+fRo0drzJgxSk5O1saNGzVt2rTuqGqnXXrppbr00kvN95MnT9bw4cP1pz/9SYsXL+7Gmp17L7zwgkaPHq1JkyZ5be9N17evWrVqlRYtWqQ1a9Z4jcm4+uqrzZ/HjBmjtLQ0xcfH6+9//7vuuOOO7qhqpw0dOlRDhw4130+ePFlFRUV66qmn9Ne//rUba3burVixQmFhYZozZ47X9t50fb9Lr+1BGTRokPz9/fXNN994bf/mm28UFRXV4WeioqK+t3zbf3055vnSmfa2WbJkiR555BG99957GjNmzPeWTUpK0qBBg7Rv376zrvPZOJv2tunXr5/Gjh1rtqW3Xt+amhq98sorZ/SPllWur6++63fX4XAoKCioS/6+WNErr7yin/3sZ/r73//e7hbXqcLCwnTJJZf0uGv7XSZNmmS2pbdeX8Mw9OKLL+rWW29VYGDg95btbddX6sUBJTAwUOPHj9cHH3xgbvN4PPrggw+8/i/6ZJdeeqlXeUlav369WT4xMVFRUVFeZdxut/Lz87/zmOdLZ9ortcxaWbx4sdatW6cJEyac9jxfffWVysvLFR0d3SX17qzOtvdkzc3N+uyzz8y29MbrK7VMna+vr9ctt9xy2vNY5fr66nS/u13x98VqXn75Zd1+++16+eWXvaaOf5fq6moVFRX1uGv7XXbs2GG2pTdeX0nKzc3Vvn37zuh/Lnrb9ZXUu2fxvPLKK4bdbjeWL19ufPHFF8add95phIWFGWVlZYZhGMatt95q/Pa3vzXLf/TRR0ZAQICxZMkS48svvzQWLlxo9OvXz/jss8/MMo888ogRFhZmrFmzxti1a5dx/fXXG4mJiUZdXd15b9+pfG3vI488YgQGBhqvv/668fXXX5uvqqoqwzAMo6qqyvjVr35l5OXlGSUlJcb7779vjBs3zrj44ouNEydOdEsbT+ZrexctWmS8++67RlFRkbF9+3bjpptuMvr37298/vnnZpnedH3bXHbZZcaPfvSjdtutfH2rqqqMTz/91Pj0008NScaTTz5pfPrpp8b+/fsNwzCM3/72t8att95qli8uLjYGDBhg3HfffcaXX35pPPfcc4a/v7+xbt06s8zp/vy6k6/tfemll4yAgADjueee8/rdraioMMv88pe/NDZu3GiUlJQYH330kTF9+nRj0KBBxpEjR857+07la3ufeuop46233jL27t1rfPbZZ8YvfvELw8/Pz3j//ffNMr3p+ra55ZZbjLS0tA6PaeXr21V6dUAxDMN49tlnjSFDhhiBgYHGpEmTjC1btpj7rrjiCuO2227zKv/3v//duOSSS4zAwEBj5MiRxj/+8Q+v/R6Px/jP//xPY/DgwYbdbjemTZtmFBYWno+mnBFf2hsfH29IavdauHChYRiGUVtba8ycOdO44IILjH79+hnx8fHGvHnzLPEL38aX9i5YsMAsO3jwYOOaa64xPvnkE6/j9abraxiGsXv3bkOS8d5777U7lpWvb9u00lNfbe277bbbjCuuuKLdZ1JTU43AwEAjKSnJWLZsWbvjft+fX3fytb1XXHHF95Y3jJZp1tHR0UZgYKBx4YUXGj/60Y+Mffv2nd+GfQdf2/voo48aycnJRv/+/Q2n02lkZGQYGzZsaHfc3nJ9DaNliYOgoCDjz3/+c4fHtPL17So2wzCMc9xJAwAA4JNeOwYFAAD0XAQUAABgOQQUAABgOQQUAABgOQQUAABgOQQUAABgOQQUAABgOQQUAABg2rRpk6677jrFxMTIZrPprbfe8vkYhmFoyZIluuSSS2S323XhhRfq4Ycf9ukYPM0YAACYampqlJKSop/+9Kf6t3/7t04d4xe/+IXee+89LVmyRKNHj5bL5ZLL5fLpGKwkCwAAOmSz2bR69WrNmTPH3FZfX68HHnhAL7/8sioqKjRq1Cg9+uijysjIkCR9+eWXGjNmjAoKCjR06NBOn5tbPAAA4IxlZWUpLy9Pr7zyinbt2qUbb7xRs2fP1t69eyVJb7/9tpKSkrR27VolJiYqISFBP/vZz3zuQSGgAACAM3LgwAEtW7ZMr732mi6//HIlJyfrV7/6lS677DItW7ZMklRcXKz9+/frtdde08qVK7V8+XJt375d//7v/+7TuRiDAgAAzshnn32m5uZmXXLJJV7b6+vrFRERIUnyeDyqr6/XypUrzXIvvPCCxo8fr8LCwjO+7UNAAQAAZ6S6ulr+/v7avn27/P39vfYNHDhQkhQdHa2AgACvEDN8+HBJLT0wBBQAANClxo4dq+bmZh05ckSXX355h2WmTJmipqYmFRUVKTk5WZK0Z88eSVJ8fPwZn4tZPAAAwFRdXa19+/ZJagkkTz75pK688ko5nU4NGTJEt9xyiz766CM98cQTGjt2rI4ePaoPPvhAY8aM0bXXXiuPx6OJEydq4MCBevrpp+XxeJSZmSmHw6H33nvvjOtBQAEAAKaNGzfqyiuvbLf9tttu0/Lly9XY2Kg//OEPWrlypQ4dOqRBgwYpPT1dixYt0ujRoyVJhw8f1t1336333ntPwcHBuvrqq/XEE0/I6XSecT0IKAAAwHKYZgwAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACyHgAIAACzn/wcOYqIOloqfKwAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "from matplotlib import pyplot as plt\n", "\n", From f808a84320832b62e5d66267b79a44ca50c85bb5 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 14 Jan 2026 08:59:57 +0100 Subject: [PATCH 40/69] Fix an issue where vector dependents are not correctly handled by automatic differentiation --- src/tespy/tools/helpers.py | 7 ++++--- tests/test_components/test_combustion.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tespy/tools/helpers.py b/src/tespy/tools/helpers.py index d1e78d381..92cc76478 100644 --- a/src/tespy/tools/helpers.py +++ b/src/tespy/tools/helpers.py @@ -475,9 +475,10 @@ def _solve_jacobian(obj, data, increment_filter, eq_num): for dependent, dx in data._vector_dependents[0].items(): f = data.func - obj._partial_derivative_fluid( - dependent, eq_num, f, dx, increment_filter, **data.func_params - ) + for dx in dx: + obj._partial_derivative_fluid( + dependent, eq_num, f, dx, increment_filter, **data.func_params + ) def _is_variable(var, increment_filter=None): diff --git a/tests/test_components/test_combustion.py b/tests/test_components/test_combustion.py index d0de75753..5b2ed68c6 100644 --- a/tests/test_components/test_combustion.py +++ b/tests/test_components/test_combustion.py @@ -161,7 +161,7 @@ def test_CombustionChamberHighTemperature(self): fuel = {'CH4': 1} self.c1.set_attr(fluid=air, p=1, T=30, m=1) self.c2.set_attr(fluid=fuel, T=30) - self.c3.set_attr(T=1200) + self.c3.set_attr(T=1200, h0=1e6) self.nw.solve('design') assert self.nw.status == 0 # a good guess for outlet fluid composition is necessary From 85465c1c0345d9e323f7f4d5e21355a18b819b13 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Wed, 14 Jan 2026 16:39:15 +0100 Subject: [PATCH 41/69] Add a first draft to consider freezing in energy balance --- .../test_fluid_properties/humidair.ipynb | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/tests/test_tools/test_fluid_properties/humidair.ipynb b/tests/test_tools/test_fluid_properties/humidair.ipynb index abbce7a0a..df8f7a535 100644 --- a/tests/test_tools/test_fluid_properties/humidair.ipynb +++ b/tests/test_tools/test_fluid_properties/humidair.ipynb @@ -46,6 +46,40 @@ " return {\"air\": air, \"water\": 1 - air}" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7351d18", + "metadata": {}, + "outputs": [], + "source": [ + "from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio\n", + "\n", + "\n", + "class MovingBoundaryHeatExchangerWithFreeze(MovingBoundaryHeatExchanger):\n", + "\n", + " def energy_balance_func(self):\n", + " residual = super().energy_balance_func()\n", + " T_evap = self.outl[1].calc_T()\n", + " if T_evap < 273.15:\n", + " T_air = self.outl[0].calc_T()\n", + " if T_air > 273.15:\n", + " dh_water = 4000 * (T_air - 273.15)\n", + " dh_freeze = 333000\n", + " dh_ice = 2000 * (273.15 - T_evap)\n", + " w_actual = _get_humid_air_humidity_ratio(self.outl[0].fluid_data)\n", + " w_saturated = HAPropsSI(\"W\", \"P\", self.outl[0].p.val_SI, \"T\", T_air, \"R\", 1)\n", + "\n", + " m_water_freeze = (w_actual - w_saturated) * self.inl[0].m.val_SI\n", + " dQ_freeze = (dh_water + dh_freeze + dh_ice) * m_water_freeze\n", + " residual -= dQ_freeze\n", + "\n", + " return residual\n", + "\n", + " def energy_balance_dependents(self):\n", + " return [self.inl[0].m, self.inl[0].h, self.outl[0].p, self.outl[0].h, self.inl[1].m, self.inl[1].h, self.outl[1].p, self.outl[1].p]" + ] + }, { "cell_type": "code", "execution_count": null, @@ -54,7 +88,7 @@ "outputs": [], "source": [ "so = Source(\"source\")\n", - "hex = MovingBoundaryHeatExchanger(\"heat exchanger\")\n", + "hex = MovingBoundaryHeatExchangerWithFreeze(\"heat exchanger\")\n", "si = Sink(\"sink\")\n", "\n", "so2 = Source(\"refrigerant source\")\n", @@ -74,51 +108,19 @@ "c1.set_attr(\n", " fluid0={\"water\": 0.0005, \"air\": 0.9995},#get_water_air_mixture_fractions_pTR(p=1e5, T=268.16, R=1),\n", " p=1,\n", - " T=-5,\n", + " T=15,\n", " mixing_rule=\"humidair\",\n", " r=1,\n", " fluid_balance=True,\n", " h0=0\n", ")\n", - "c2.set_attr(T=Ref(c1, 1, -5), h0=0)\n", + "c2.set_attr(T=5, h0=0)\n", "\n", "hex.set_attr(dp1=0, dp2=0)\n", "\n", "nw.solve(\"design\")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c3bb42f", - "metadata": {}, - "outputs": [], - "source": [ - "c2.set_attr(T=None)\n", - "c2.set_attr(T=-10)\n", - "nw.solve(\"design\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a6867709", - "metadata": {}, - "outputs": [], - "source": [ - "c1.fluid.val" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "dcb0ed15", - "metadata": {}, - "outputs": [], - "source": [ - "c2.fluid.val" - ] - }, { "cell_type": "code", "execution_count": null, From 1b35835655bb26260ae2a1642eefab7d3a255691 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Thu, 15 Jan 2026 06:46:06 +0100 Subject: [PATCH 42/69] Externalize identification of dQ to function and fix calculation of heat transfer --- .../test_fluid_properties/humidair.ipynb | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/test_tools/test_fluid_properties/humidair.ipynb b/tests/test_tools/test_fluid_properties/humidair.ipynb index df8f7a535..947991585 100644 --- a/tests/test_tools/test_fluid_properties/humidair.ipynb +++ b/tests/test_tools/test_fluid_properties/humidair.ipynb @@ -56,28 +56,43 @@ "from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio\n", "\n", "\n", + "def _calculate_dQ_freezing_water(T_evap, T_air, air_fluid_data, air_pressure, air_mass_flow):\n", + " dh_water = 4000 * (T_air - 273.15)\n", + " dh_freeze = 333000\n", + " dh_ice = 2000 * (273.15 - T_evap)\n", + " w_actual = _get_humid_air_humidity_ratio(air_fluid_data)\n", + " w_saturated = HAPropsSI(\"W\", \"P\", air_pressure, \"T\", T_air, \"R\", 1)\n", + "\n", + " m_water_freeze = (w_actual - w_saturated) * air_mass_flow\n", + " return (dh_water + dh_freeze + dh_ice) * m_water_freeze\n", + "\n", + "\n", "class MovingBoundaryHeatExchangerWithFreeze(MovingBoundaryHeatExchanger):\n", "\n", " def energy_balance_func(self):\n", " residual = super().energy_balance_func()\n", " T_evap = self.outl[1].calc_T()\n", " if T_evap < 273.15:\n", - " T_air = self.outl[0].calc_T()\n", - " if T_air > 273.15:\n", - " dh_water = 4000 * (T_air - 273.15)\n", - " dh_freeze = 333000\n", - " dh_ice = 2000 * (273.15 - T_evap)\n", - " w_actual = _get_humid_air_humidity_ratio(self.outl[0].fluid_data)\n", - " w_saturated = HAPropsSI(\"W\", \"P\", self.outl[0].p.val_SI, \"T\", T_air, \"R\", 1)\n", - "\n", - " m_water_freeze = (w_actual - w_saturated) * self.inl[0].m.val_SI\n", - " dQ_freeze = (dh_water + dh_freeze + dh_ice) * m_water_freeze\n", + " T_air_out = self.outl[0].calc_T()\n", + " if T_air_out > 273.15:\n", + " dQ_freeze = _calculate_dQ_freezing_water(\n", + " T_evap, T_air_out, self.outl[0].fluid_data, self.outl[0].p.val_SI, self.inl[0].m.val_SI\n", + " )\n", " residual -= dQ_freeze\n", "\n", " return residual\n", "\n", " def energy_balance_dependents(self):\n", - " return [self.inl[0].m, self.inl[0].h, self.outl[0].p, self.outl[0].h, self.inl[1].m, self.inl[1].h, self.outl[1].p, self.outl[1].p]" + " return [\n", + " self.inl[0].m, self.inl[0].h,\n", + " self.outl[0].p, self.outl[0].h,\n", + " self.inl[1].m, self.inl[1].h,\n", + " self.outl[1].p, self.outl[1].p\n", + " ]\n", + "\n", + " def calc_parameters(self):\n", + " super().calc_parameters()\n", + " self.Q.val_SI = -self.inl[1].m.val_SI * (self.outl[1].h.val_SI - self.inl[1].h.val_SI)" ] }, { From 8c24995ddaa0b70aebc5ddfce5302f1aabbc7cf8 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Thu, 15 Jan 2026 10:35:40 +0100 Subject: [PATCH 43/69] Fix compatibility --- src/tespy/tools/optimization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tespy/tools/optimization.py b/src/tespy/tools/optimization.py index 8150729da..5d7cbd340 100644 --- a/src/tespy/tools/optimization.py +++ b/src/tespy/tools/optimization.py @@ -293,8 +293,8 @@ def fitness(self, x): (-1) ** (sense + 1) * f for f, sense in zip(fitness, self.minimize) ] - cu = self.collect_constraints("upper") - cl = self.collect_constraints("lower") + cu = self._evaluate_constraints("upper") + cl = self._evaluate_constraints("lower") return fitness + cu + cl From cd616f35e1aaad75aeea484286264ed21048393f Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 07:47:38 +0100 Subject: [PATCH 44/69] Slightly adjust the convergence helper critereons --- src/tespy/networks/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index e1b09f6b9..39cd92667 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -2915,7 +2915,7 @@ def _adapt_to_variable_bounds(self): # - only in design case if ( self.iter < 3 - and norm(self.increment) > 1e3 + and norm(self.increment) > 1e-1 and self.mode == "design" ): for cp in self.comps['object']: From aafff435da8136d96b5bec5722f2a8fccb176f81 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 07:47:53 +0100 Subject: [PATCH 45/69] Improve combustion convergence helpers --- src/tespy/components/combustion/base.py | 14 +++++++++++--- tests/test_components/test_combustion.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/tespy/components/combustion/base.py b/src/tespy/components/combustion/base.py index da4e67a4f..2bc78664e 100644 --- a/src/tespy/components/combustion/base.py +++ b/src/tespy/components/combustion/base.py @@ -993,9 +993,17 @@ def convergence_check(self): if outl.m.val_SI < 0 and outl.m.is_var: outl.m.set_reference_val_SI(10) - if not outl.good_starting_values: - if outl.h.val_SI < 7.5e5 and outl.h.is_var: - outl.h.set_reference_val_SI(1e6) + if not outl.good_starting_values and outl.h.is_var: + if outl.calc_T() < 800: + h = h_mix_pT( + outl.p.val_SI, 800, outl.fluid_data, outl.mixing_rule + ) + outl.h.set_reference_val_SI(h) + elif outl.calc_T() > 1500: + h = h_mix_pT( + outl.p.val_SI, 1500, outl.fluid_data, outl.mixing_rule + ) + outl.h.set_reference_val_SI(h) ###################################################################### # additional checks for performance improvement diff --git a/tests/test_components/test_combustion.py b/tests/test_components/test_combustion.py index 5b2ed68c6..d0de75753 100644 --- a/tests/test_components/test_combustion.py +++ b/tests/test_components/test_combustion.py @@ -161,7 +161,7 @@ def test_CombustionChamberHighTemperature(self): fuel = {'CH4': 1} self.c1.set_attr(fluid=air, p=1, T=30, m=1) self.c2.set_attr(fluid=fuel, T=30) - self.c3.set_attr(T=1200, h0=1e6) + self.c3.set_attr(T=1200) self.nw.solve('design') assert self.nw.status == 0 # a good guess for outlet fluid composition is necessary From 4cd06e2475ef49e2b3872eec13a0d6c441c3873c Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 10:44:17 +0100 Subject: [PATCH 46/69] Change logging level to debug and adjust error message --- src/tespy/components/component.py | 11 ++++++----- src/tespy/components/heat_exchangers/base.py | 4 ++-- src/tespy/components/heat_exchangers/condenser.py | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/tespy/components/component.py b/src/tespy/components/component.py index 830cd8551..d5a40832a 100644 --- a/src/tespy/components/component.py +++ b/src/tespy/components/component.py @@ -448,12 +448,13 @@ def _setup_user_imposed_constraints(self, row_idx, sum_eq): elif data.is_set: msg = ( - 'All parameters of the component group have to be ' - 'specified! This component group uses the following ' - f'parameters: {", ".join(data.elements)} at ' - f'{self.label}. Group will be set to False.' + f"Not all parameters of the component group {key} " + f"of component {self.label} have to been specified, " + "the group equation will not be applied. This " + "component group uses the following " + f"parameters: {', '.join(data.elements)}." ) - logger.warning(msg) + logger.debug(msg) data.set_attr(is_set=False) else: data.set_attr(is_set=False) diff --git a/src/tespy/components/heat_exchangers/base.py b/src/tespy/components/heat_exchangers/base.py index 54318952d..a787374ce 100644 --- a/src/tespy/components/heat_exchangers/base.py +++ b/src/tespy/components/heat_exchangers/base.py @@ -1019,7 +1019,7 @@ def calc_parameters(self): "because cold side inlet temperature is out of bounds for hot " "side fluid." ) - logger.warning(msg) + logger.debug(msg) try: self.eff_cold.val_SI = ( (self.outl[1].h.val_SI - self.inl[1].h.val_SI) @@ -1032,7 +1032,7 @@ def calc_parameters(self): "because hot side inlet temperature is out of bounds for cold " "side fluid." ) - logger.warning(msg) + logger.debug(msg) self.eff_max.val_SI = max(self.eff_hot.val_SI, self.eff_cold.val_SI) def entropy_balance(self): diff --git a/src/tespy/components/heat_exchangers/condenser.py b/src/tespy/components/heat_exchangers/condenser.py index b4cbc1a2a..c39a3ba1b 100644 --- a/src/tespy/components/heat_exchangers/condenser.py +++ b/src/tespy/components/heat_exchangers/condenser.py @@ -421,7 +421,7 @@ def calc_parameters(self): "because cold side inlet temperature is out of bounds for hot " "side fluid." ) - logger.warning(msg) + logger.debug(msg) try: self.eff_cold.val_SI = ( (self.outl[1].h.val_SI - self.inl[1].h.val_SI) @@ -434,7 +434,7 @@ def calc_parameters(self): "because hot side inlet temperature is out of bounds for cold " "side fluid." ) - logger.warning(msg) + logger.debug(msg) self.eff_max.val_SI = max(self.eff_hot.val_SI, self.eff_cold.val_SI) def convergence_check(self): From 23acfdb2058eebe216aaf2ad6680df71c7fbe5dc Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 10:45:36 +0100 Subject: [PATCH 47/69] Update changelog --- docs/whats_new/v0-9-12.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/whats_new/v0-9-12.rst b/docs/whats_new/v0-9-12.rst index 35a7badbe..c33d6926b 100644 --- a/docs/whats_new/v0-9-12.rst +++ b/docs/whats_new/v0-9-12.rst @@ -74,6 +74,8 @@ Other changes are now named appropriately with a leading underscore. On top, some methods have been renamed to clarify what they do (`PR #875 `__). +- The logging level of some messages has been changed from warning to debug + (`PR #893 `__). Bug Fixes ######### From 9543fa6d23a8e95a370222f7df6e0f2651fb32bd Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 16:14:56 +0100 Subject: [PATCH 48/69] Move humid air context out of Connection into HAConnection --- src/tespy/connections/__init__.py | 2 +- src/tespy/connections/connection.py | 50 -------- src/tespy/connections/humidairconnection.py | 133 ++++++++++++-------- tests/test_connections.py | 4 +- 4 files changed, 81 insertions(+), 108 deletions(-) diff --git a/src/tespy/connections/__init__.py b/src/tespy/connections/__init__.py index db5e15280..2eba79104 100644 --- a/src/tespy/connections/__init__.py +++ b/src/tespy/connections/__init__.py @@ -3,5 +3,5 @@ from .bus import Bus # noqa: F401 from .connection import Connection # noqa: F401 from .connection import Ref # noqa: F401 -from .humidairconnection import HumidAirConnection # noqa: F401 +from .humidairconnection import HAConnection # noqa: F401 from .powerconnection import PowerConnection # noqa: F401 diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 4e2e5752f..540e1f37c 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -1311,13 +1311,6 @@ def get_parameters(self): quantity="temperature_difference", description="temperature difference to boiling point (deprecated)" ), - "r": dc_prop( - func=self.r_func, - dependents=self.r_dependents, - num_eq=1, - quantity="ratio", - description="relative humidity" - ) } def get_fluid_data(self): @@ -1510,25 +1503,6 @@ def v_ref_dependents(self): ref = self.v_ref.ref return self.v_dependents() + ref.obj.v_dependents() - def calc_r(self): - from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio - from CoolProp.CoolProp import HAPropsSI - w = _get_humid_air_humidity_ratio(self.fluid_data) - try: - return HAPropsSI("R", "P", self.p.val_SI, "T", self.T.val_SI, "W", w) - except ValueError as e: - value = str(e).split("value (")[1].split(")")[0] - return float(value) - - def r_func(self): - return self.r.val_SI - self.calc_r() - - def r_dependents(self): - return { - "scalars": [self.p, self.h], - "vectors": [{self.fluid: {"water"}}] - } - def calc_x(self): try: return Q_mix_ph(self.p.val_SI, self.h.val_SI, self.fluid_data) @@ -1861,30 +1835,6 @@ def _adjust_to_property_limits(self, nw): if (self.Td_bp.is_set or self.state.is_set or self.x.is_set or self.td_bubble.is_set or self.td_dew.is_set) and self.it < 10: self._adjust_to_two_phase(fl) - elif self.mixing_rule == "humidair": - # def _adjust_to_property_limits(self, nw): - from CoolProp.CoolProp import HAPropsSI - - if self.p.is_var: - if self.p.val_SI < 100: - self.p.val_SI = 101 - elif self.p.val_SI > 100e5: - self.p.val_SI = 99e5 - - if self.h.is_var: - # TODO: check minimum temperature how it matches minimum humidity ratio - d = self.h._reference_container._d - hmin = HAPropsSI("H", "T", -30 + 273.15, "P", self.p.val_SI, "R", 1) - if self.h.val_SI < hmin: - delta = max(abs(self.h.val_SI * d), d) * 5 - self.set_reference_val_SI(hmin + delta) - - else: - # TODO: where to get reasonable hmax from?! - hmax = HAPropsSI("H", "T", 300 + 273.15, "P", self.p.val_SI, "R", 0) - if self.h.val_SI > hmax: - delta = max(abs(self.h.val_SI * d), d) * 5 - self.set_reference_val_SI(hmax - delta) # mixture elif self.it < 5 and not self.good_starting_values: # pressure diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index cd2ec53fd..2bdd2d900 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -1,20 +1,30 @@ +# -*- coding: utf-8 +"""Module of class Connection and class Ref. + + +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/connections/humidairconnection.py + +SPDX-License-Identifier: MIT +""" + +import numpy as np from CoolProp.CoolProp import HAPropsSI from tespy.tools import fluid_properties as fp from tespy.tools.data_containers import FluidComposition as dc_flu from tespy.tools.data_containers import FluidProperties as dc_prop +from tespy.tools.data_containers import SimpleDataContainer as dc_simple +from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio +from tespy.tools.fluid_properties.mixtures import _get_fluid_alias from .connection import Connection from .connection import connection_registry @connection_registry -class HumidAirConnection(Connection): - - def get_variables(self): - return { - "m": self.m, "p": self.p, "h": self.h, "w": self.w - } +class HAConnection(Connection): def get_parameters(self): return { @@ -24,7 +34,11 @@ def get_parameters(self): ), "m": dc_prop( quantity="mass_flow", - description="mass flow of the fluid (system variable)" + description="mass flow of dry air (system variable)" + ), + "mHA": dc_prop( + quantity="mass_flow", + description="mass flow of humid air" ), "p": dc_prop( quantity="pressure", @@ -32,7 +46,7 @@ def get_parameters(self): ), "h": dc_prop( quantity="enthalpy", - description="mass specific enthalpy of the fluid (system variable)" + description="dry air mass specific enthalpy (system variable)" ), "w": dc_prop( quantity="ratio", @@ -66,50 +80,34 @@ def get_parameters(self): num_eq=1, quantity="ratio", description="relative humidity" - ) + ), + "fluid_balance": dc_simple( + func=self.fluid_balance_func, + deriv=self.fluid_balance_deriv, + _val=False, num_eq_sets=1, + dependents=self.fluid_balance_dependents, + description="apply an equation which closes the fluid balance with at least two unknown fluid mass fractions" + ), } - def calc_T(self, T0=None): - return HAPropsSI( - "T", "P", self.p.val_SI, "H", self.h.val_SI, "W", self.w.val_SI / 1e3 - ) - - def T_dependents(self): - return [self.p, self.h, self.w] - - def calc_vol(self, T0=None): - return HAPropsSI( - "V", "P", self.p.val_SI, "H", self.h.val_SI, "W", self.w.val_SI / 1e3 - ) + # for HAConnection mixing rule cannot be modified, is always humidair + def _get_mixing_rule(self): + return "humidair" - def v_dependents(self): - return [self.m, self.p, self.h, self.w] - - def r_func(self): - return self.w.val_SI / 1e3 - HAPropsSI("W", "P", self.p.val_SI, "H", self.h.val_SI, "R", self.r.val_SI) + def _set_mixing_rule(self, value): + if value is not None and value != self.mixing_rule: + print(value) + msg = ( + "You cannot change the mixing rule specification for a " + f"Connection of type {self.__class__.__name__}" + ) + raise ValueError(msg) - def r_dependents(self): - return [self.p, self.h, self.w] - - def get_fluid_data(self): - return ( - { - "_HUMID_AIR": True, - "w": self.w.val_SI / 1000 - } | - { - fluid: { - "wrapper": self.fluid.wrapper[fluid], - "mass_fraction": self.fluid.val[fluid] - } for fluid in self.fluid.val - } - ) - - fluid_data = property(get_fluid_data) + mixing_rule = property(_get_mixing_rule, _set_mixing_rule) def _guess_starting_values(self, units): - self.h.set_reference_val_SI(4e5) - self.w.set_reference_val_SI(5) + h = fp.h_mix_pT(1e5, 290, self.fluid_data, self.mixing_rule) + self.h.set_reference_val_SI(h) self._precalc_guess_values() def _precalc_guess_values(self): @@ -119,8 +117,9 @@ def _precalc_guess_values(self): if not self.good_starting_values: if self.T.is_set: try: + w = self.calc_w() self.h.set_reference_val_SI( - HAPropsSI("H", "P", self.p.val_SI, "T", self.T.val_SI, "W", self.w.val_SI / 1e3) + HAPropsSI("H", "P", self.p.val_SI, "T", self.T.val_SI, "W", w) ) except ValueError: pass @@ -130,10 +129,6 @@ def _presolve(self): def _adjust_to_property_limits(self, nw): - if self.w.is_var: - if self.w.val_SI < 0.01: - self.w.set_reference_val_SI(0.011) - if self.p.is_var: if self.p.val_SI < 100: self.p.val_SI = 101 @@ -143,7 +138,7 @@ def _adjust_to_property_limits(self, nw): if self.h.is_var: # TODO: check minimum temperature how it matches minimum humidity ratio d = self.h._reference_container._d - hmin = HAPropsSI("H", "T", -30 + 273.15, "P", self.p.val_SI, "R", 1) + hmin = HAPropsSI("H", "T", -50 + 273.15, "P", self.p.val_SI, "R", 1) if self.h.val_SI < hmin: delta = max(abs(self.h.val_SI * d), d) * 5 self.set_reference_val_SI(hmin + delta) @@ -157,16 +152,45 @@ def _adjust_to_property_limits(self, nw): @classmethod def _result_attributes(cls): - return ["m", "p", "h", "T", "w", "s", "vol", "v"] + return ["m", "mHA", "p", "h", "T", "w", "s", "vol", "v", "r"] @classmethod def _print_attributes(cls): - return ["m", "p", "h", "T", "w"] + return ["m", "mHA", "p", "h", "T", "w", "r"] + + def calc_r(self): + w = self.calc_w() + try: + return HAPropsSI("R", "P", self.p.val_SI, "T", self.T.val_SI, "W", w) + except ValueError as e: + value = str(e).split("value (")[1].split(")")[0] + return float(value) + + def r_func(self): + return self.r.val_SI - self.calc_r() + + def r_dependents(self): + water_alias = _get_fluid_alias("H2O", self.fluid_data) + # water alias is already a set + return { + "scalars": [self.p, self.h], + "vectors": [{self.fluid: water_alias}] + } + + def calc_w(self): + return _get_humid_air_humidity_ratio(self.fluid_data) def calc_results(self, units): self.T.val_SI = self.calc_T() self.vol.val_SI = self.calc_vol() self.v.val_SI = self.vol.val_SI * self.m.val_SI + air_alias = list(_get_fluid_alias("air", self.fluid_data))[0] + air_mass_fraction = self.fluid.val[air_alias] + self.mHA.val_SI = self.m.val_SI / air_mass_fraction + self.w.val_SI = self.calc_w() + self.r.val_SI = self.calc_r() + if self.r.val_SI > 1: + self.r.val_SI = np.nan for prop in self._result_attributes(): param = self.get_attr(prop) @@ -176,7 +200,6 @@ def calc_results(self, units): self.m.set_val0_from_SI(units) self.p.set_val0_from_SI(units) self.h.set_val0_from_SI(units) - self.w.set_val0_from_SI(units) self.fluid.val0 = self.fluid.val.copy() return True diff --git a/tests/test_connections.py b/tests/test_connections.py index 13f708885..1a3702047 100644 --- a/tests/test_connections.py +++ b/tests/test_connections.py @@ -29,7 +29,7 @@ from tespy.connections import Ref from tespy.connections.connection import ConnectionBase from tespy.connections.connection import connection_registry -from tespy.connections.humidairconnection import HumidAirConnection +from tespy.connections.humidairconnection import HAConnection from tespy.networks import Network from tespy.tools.data_containers import FluidProperties as dc_prop from tespy.tools.fluid_properties.functions import T_bubble_p @@ -517,7 +517,7 @@ def test_all_classes_in_registry(obj): def make_connection(cls): - if cls == Connection or cls == HumidAirConnection: + if cls == Connection or cls == HAConnection: return cls(Source(""), "out1", Sink(""), "in1") elif cls == PowerConnection: return cls(PowerSource(""), "power", PowerSink(""), "power") From d4b97cde19a5a359aefe4ce715713e3df3ab836a Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 16:15:17 +0100 Subject: [PATCH 49/69] Rename method for better clarity --- src/tespy/tools/fluid_properties/mixtures.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tespy/tools/fluid_properties/mixtures.py b/src/tespy/tools/fluid_properties/mixtures.py index 9abb914e5..cc955efb4 100644 --- a/src/tespy/tools/fluid_properties/mixtures.py +++ b/src/tespy/tools/fluid_properties/mixtures.py @@ -38,7 +38,7 @@ def h_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): def h_mix_pT_ideal_cond(p=None, T=None, fluid_data=None, **kwargs): - water_alias = _fluid_in_mixture("H2O", fluid_data) + water_alias = _get_fluid_alias("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) mass_fractions_gas, molar_fraction_gas, mass_liquid, _, p_sat, pp_water = cond_check(p, T, fluid_data, water_alias) @@ -91,10 +91,10 @@ def h_mix_pT_incompressible(p, T, fluid_data, **kwargs): def _get_humid_air_humidity_ratio(fluid_data): - water_alias = _fluid_in_mixture("H2O", fluid_data) + water_alias = _get_fluid_alias("H2O", fluid_data) water_alias = next(iter(water_alias)) - air_alias = _fluid_in_mixture("air", fluid_data) + air_alias = _get_fluid_alias("air", fluid_data) air_alias = next(iter(air_alias)) return ( @@ -123,7 +123,7 @@ def s_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): def s_mix_pT_ideal_cond(p=None, T=None, fluid_data=None, **kwargs): - water_alias = _fluid_in_mixture("H2O", fluid_data) + water_alias = _get_fluid_alias("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) mass_fractions_gas, molar_fraction_gas, mass_liquid, _, p_sat, pp_water = cond_check(p, T, fluid_data, water_alias) @@ -176,7 +176,7 @@ def v_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): def v_mix_pT_ideal_cond(p=None, T=None, fluid_data=None, **kwargs): - water_alias = _fluid_in_mixture("H2O", fluid_data) + water_alias = _get_fluid_alias("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) _, molar_fraction_gas, mass_liquid, _, p_sat, pp_water = cond_check(p, T, fluid_data, water_alias) @@ -277,7 +277,7 @@ def viscosity_mix_pT_humidair(p, T, fluid_data, **kwargs): def exergy_chemical_ideal_cond(pamb, Tamb, fluid_data, Chem_Ex): molar_fractions = get_molar_fractions(fluid_data) - water_alias = _fluid_in_mixture("H2O", fluid_data) + water_alias = _get_fluid_alias("H2O", fluid_data) if water_alias: water_alias = next(iter(water_alias)) _, molar_fractions_gas, _, molar_liquid, _, _ = cond_check( @@ -312,7 +312,7 @@ def exergy_chemical_ideal_cond(pamb, Tamb, fluid_data, Chem_Ex): return ex_chemical * 1e3 # Data from Chem_Ex are in kJ / mol -def _fluid_in_mixture(fluid, fluid_data): +def _get_fluid_alias(fluid, fluid_data): return ( FLUID_ALIASES.get_fluid(fluid) & set([ From 4792362866656e7b50bb0e31d650d18d5cd4caaf Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 16:15:55 +0100 Subject: [PATCH 50/69] Make it an f string --- src/tespy/connections/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 540e1f37c..0f5c1d8f6 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -760,7 +760,7 @@ def set_attr(self, **kwargs): # other boolean keywords elif key in ['printout', 'local_design', 'local_offdesign']: if not isinstance(kwargs[key], bool): - msg = ('Please provide the ' + key + ' as boolean.') + msg = f"Please provide the {key} as boolean." logger.error(msg) raise TypeError(msg) else: From 72c109d9a6e6b88531e66ef91198e93c04dd52c0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 16:22:31 +0100 Subject: [PATCH 51/69] Make sure water and air are in the fluid vector --- src/tespy/connections/humidairconnection.py | 10 ++++++++++ src/tespy/tools/fluid_properties/mixtures.py | 1 + 2 files changed, 11 insertions(+) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 2bdd2d900..7968f7adf 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -125,6 +125,16 @@ def _precalc_guess_values(self): pass def _presolve(self): + air_alias = _get_fluid_alias("air", self.fluid_data) + water_alias = _get_fluid_alias("water", self.fluid_data) + if not air_alias: + msg = "air must bre present in fluid composition" + raise ValueError(msg) + + elif not water_alias: + msg = "water must bre present in fluid composition" + raise ValueError(msg) + return [] def _adjust_to_property_limits(self, nw): diff --git a/src/tespy/tools/fluid_properties/mixtures.py b/src/tespy/tools/fluid_properties/mixtures.py index cc955efb4..6c0ea0ba9 100644 --- a/src/tespy/tools/fluid_properties/mixtures.py +++ b/src/tespy/tools/fluid_properties/mixtures.py @@ -91,6 +91,7 @@ def h_mix_pT_incompressible(p, T, fluid_data, **kwargs): def _get_humid_air_humidity_ratio(fluid_data): + water_alias = _get_fluid_alias("H2O", fluid_data) water_alias = next(iter(water_alias)) From a4bfb3ac21fe972bf170f4532dfd23fa7a6a1007 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 16:33:02 +0100 Subject: [PATCH 52/69] Allow specification of fluid composition through w --- src/tespy/connections/humidairconnection.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 7968f7adf..d109d90ef 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -90,6 +90,18 @@ def get_parameters(self): ), } + def _parameter_specification(self, key, value): + if key == "w" or key == "w0": + # specification of w is equivalent to specification of fluid + # composition for humid air + air = 1 / (1 + value) + if key == "w": + self.set_attr(fluid={"air": air, "water": 1 - air}) + else: + self.set_attr(fluid0={"air": air, "water": 1 - air}) + else: + super()._parameter_specification(key, value) + # for HAConnection mixing rule cannot be modified, is always humidair def _get_mixing_rule(self): return "humidair" @@ -125,6 +137,7 @@ def _precalc_guess_values(self): pass def _presolve(self): + air_alias = _get_fluid_alias("air", self.fluid_data) water_alias = _get_fluid_alias("water", self.fluid_data) if not air_alias: From 1c8f0d808ce1a24fa765a06065654059ba63fcf4 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 16 Jan 2026 16:33:44 +0100 Subject: [PATCH 53/69] Clean up the example --- .../test_fluid_properties/humidair.ipynb | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/tests/test_tools/test_fluid_properties/humidair.ipynb b/tests/test_tools/test_fluid_properties/humidair.ipynb index 947991585..d93eef43a 100644 --- a/tests/test_tools/test_fluid_properties/humidair.ipynb +++ b/tests/test_tools/test_fluid_properties/humidair.ipynb @@ -7,28 +7,13 @@ "metadata": {}, "outputs": [], "source": [ - "from tespy.connections import HumidAirConnection, Connection, Ref\n", + "from tespy.connections import HAConnection, Connection, Ref\n", "from tespy.components import Source, Sink, MovingBoundaryHeatExchanger\n", "from tespy.networks import Network\n", "\n", "from CoolProp.CoolProp import HAPropsSI" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "d60a9cea", - "metadata": {}, - "outputs": [], - "source": [ - "nw = Network()\n", - "nw.units.set_defaults(\n", - " temperature=\"°C\",\n", - " pressure=\"bar\",\n", - " heat=\"kW\"\n", - ")" - ] - }, { "cell_type": "code", "execution_count": null, @@ -43,7 +28,7 @@ "\n", "def get_water_air_mixture_fractions_w(w):\n", " air = 1 / (1 + w)\n", - " return {\"air\": air, \"water\": 1 - air}" + " return {\"Air\": air, \"water\": 1 - air}" ] }, { @@ -102,8 +87,16 @@ "metadata": {}, "outputs": [], "source": [ + "nw = Network()\n", + "nw.units.set_defaults(\n", + " temperature=\"°C\",\n", + " pressure=\"bar\",\n", + " heat=\"kW\"\n", + ")\n", + "\n", + "\n", "so = Source(\"source\")\n", - "hex = MovingBoundaryHeatExchangerWithFreeze(\"heat exchanger\")\n", + "hex = MovingBoundaryHeatExchanger(\"heat exchanger\")\n", "si = Sink(\"sink\")\n", "\n", "so2 = Source(\"refrigerant source\")\n", @@ -112,8 +105,8 @@ "a1 = Connection(so2, \"out1\", hex, \"in2\", label=\"a1\")\n", "a2 = Connection(hex, \"out2\", si2, \"in1\", label=\"a2\")\n", "\n", - "c1 = Connection(so, \"out1\", hex, \"in1\", label=\"c1\")\n", - "c2 = Connection(hex, \"out1\", si, \"in1\", label=\"c2\")\n", + "c1 = HAConnection(so, \"out1\", hex, \"in1\", label=\"c1\")\n", + "c2 = HAConnection(hex, \"out1\", si, \"in1\", label=\"c2\")\n", "\n", "nw.add_conns(a1, a2, c1, c2)\n", "\n", @@ -121,15 +114,14 @@ "a2.set_attr(td_dew=5, T_dew=-20)\n", "\n", "c1.set_attr(\n", - " fluid0={\"water\": 0.0005, \"air\": 0.9995},#get_water_air_mixture_fractions_pTR(p=1e5, T=268.16, R=1),\n", " p=1,\n", " T=15,\n", - " mixing_rule=\"humidair\",\n", + " w0=0.004, # -> implicitly sets starting value for composition\n", + " #w=0.004, -> use this to directly impose fluid composition, then do not specify r and fluid balance for this example\n", " r=1,\n", " fluid_balance=True,\n", - " h0=0\n", ")\n", - "c2.set_attr(T=5, h0=0)\n", + "c2.set_attr(T=5)\n", "\n", "hex.set_attr(dp1=0, dp2=0)\n", "\n", From 39af317bedbe42f5e4eb223223409cccdf46d9cb Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 20 Jan 2026 16:55:49 +0100 Subject: [PATCH 54/69] Implement presolving of T for the HAConnection --- src/tespy/connections/connection.py | 1 + src/tespy/connections/humidairconnection.py | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 0f5c1d8f6..4c8a7b0b0 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -1039,6 +1039,7 @@ def _presolve(self): raise TESPyNetworkError(msg) presolved_equations = [] + if self.p.is_set: if self.T_dew.is_set or self.T_bubble.is_set: msg = ( diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index d109d90ef..ccac481a7 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -17,6 +17,7 @@ from tespy.tools.data_containers import FluidProperties as dc_prop from tespy.tools.data_containers import SimpleDataContainer as dc_simple from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio +from tespy.tools.fluid_properties.functions import h_mix_pT from tespy.tools.fluid_properties.mixtures import _get_fluid_alias from .connection import Connection @@ -148,7 +149,24 @@ def _presolve(self): msg = "water must bre present in fluid composition" raise ValueError(msg) - return [] + if len(self.fluid.is_var) > 0: + return [] + + presolved_equations = [] + + if self.h.is_var and not self.p.is_var: + if self.T.is_set: + self.h.set_reference_val_SI(h_mix_pT(self.p.val_SI, self.T.val_SI, self.fluid_data, self.mixing_rule)) + self.h._potential_var = False + if "T" in self._equation_set_lookup.values(): + presolved_equations += ["T"] + + presolved_equations = [ + key for parameter in presolved_equations + for key, value in self._equation_set_lookup.items() + if value == parameter + ] + return presolved_equations def _adjust_to_property_limits(self, nw): From 017e20dce008bc6a58d7b02d4a77255d1c8a71b0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 20 Jan 2026 16:56:13 +0100 Subject: [PATCH 55/69] Fix a bug in the enthalpy value limitations --- src/tespy/connections/humidairconnection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index ccac481a7..a002be188 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -182,14 +182,14 @@ def _adjust_to_property_limits(self, nw): hmin = HAPropsSI("H", "T", -50 + 273.15, "P", self.p.val_SI, "R", 1) if self.h.val_SI < hmin: delta = max(abs(self.h.val_SI * d), d) * 5 - self.set_reference_val_SI(hmin + delta) + self.h.set_reference_val_SI(hmin + delta) else: # TODO: where to get reasonable hmax from?! hmax = HAPropsSI("H", "T", 300 + 273.15, "P", self.p.val_SI, "R", 0) if self.h.val_SI > hmax: delta = max(abs(self.h.val_SI * d), d) * 5 - self.set_reference_val_SI(hmax - delta) + self.h.set_reference_val_SI(hmax - delta) @classmethod def _result_attributes(cls): From 71848e0c79b36444600675b19021c7c612c9b7cf Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Tue, 20 Jan 2026 23:37:13 +0100 Subject: [PATCH 56/69] ONly make a guess if there is no value imposed or precalculated and if there is nothing from the previous simulation --- src/tespy/connections/humidairconnection.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index a002be188..d6440ab99 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -119,9 +119,16 @@ def _set_mixing_rule(self, value): mixing_rule = property(_get_mixing_rule, _set_mixing_rule) def _guess_starting_values(self, units): - h = fp.h_mix_pT(1e5, 290, self.fluid_data, self.mixing_rule) - self.h.set_reference_val_SI(h) - self._precalc_guess_values() + if self.h.is_var and not self.good_starting_values: + # make a reproducible random guess on starting value for enthalpy + import numpy as np + seed = abs(hash(self.label)) % (2**32) + rng = np.random.default_rng(seed=seed) + value = float(rng.random()) + T_rand = 280 + value * (300 - 280) + h = fp.h_mix_pT(1e5, T_rand, self.fluid_data, self.mixing_rule) + self.h.set_reference_val_SI(h) + self._precalc_guess_values() def _precalc_guess_values(self): if not self.h.is_var: From 9151ff2c6481e8ff8b7f9730af290ae359468389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20=C3=98stergaard=20Pedersen?= Date: Thu, 22 Jan 2026 22:25:53 +0100 Subject: [PATCH 57/69] Relaxing and helping the UA equation --- src/tespy/components/heat_exchangers/sectioned.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tespy/components/heat_exchangers/sectioned.py b/src/tespy/components/heat_exchangers/sectioned.py index 082961a9f..58bdfe7b2 100644 --- a/src/tespy/components/heat_exchangers/sectioned.py +++ b/src/tespy/components/heat_exchangers/sectioned.py @@ -759,9 +759,9 @@ def UA_cecchinato_func(self): secondary_index = 0 m_r = self.inl[refrigerant_index].m - m_ratio_r = m_r.val_SI / m_r.design + m_ratio_r = max(m_r.val_SI / m_r.design, 1e-6) m_sf = self.inl[secondary_index].m - m_ratio_sf = m_sf.val_SI / m_sf.design + m_ratio_sf = max(m_sf.val_SI / m_sf.design, 1e-6) fUA = ( (1 + alpha_ratio * area_ratio) From c8706ee2f72c3b3cdc6549e2b559a885aa8de7cb Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Sun, 1 Feb 2026 16:35:38 +0100 Subject: [PATCH 58/69] Allow to store design cases as strings instead of files. makes sense in a multiprocess environment and avoids having to handle temporary files. --- src/tespy/networks/network.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index 2295c7a49..b7e895f88 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -16,6 +16,7 @@ import json import math import os +from pathlib import Path import warnings from time import time @@ -2046,17 +2047,30 @@ def _set_starting_values(self): @staticmethod - def _load_network_state(json_path): + def _load_network_state(json_path: str | bytes | bytearray | Path): r""" Read network state from given file. Parameters ---------- - json_path : str + json_path : str | bytes | bytearray | Path Path to network information. """ - with open(json_path, "r") as f: - data = json.load(f) + data = None + if not isinstance(json_path, Path): + try: + data = json.loads(json_path) + except json.JSONDecodeError as e: + msg = ( + "The provided json_path could not be decoded. If this is not " + "a valid json string, please provide a valid file path instead of " + "%s" + ) + logger.debug(msg, str(json_path)) + pass + if data is None: + with open(json_path, "r") as f: + data = json.load(f) dfs = {} if "Connection" in data["Connection"]: From 798758c0bafc00865cec13df16c05478dffe9133 Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Sun, 1 Feb 2026 16:35:38 +0100 Subject: [PATCH 59/69] Allow to store design cases as strings instead of files. makes sense in a multiprocess environment and avoids having to handle temporary files. --- src/tespy/networks/network.py | 39 ++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index 2295c7a49..c6d485d58 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -16,6 +16,7 @@ import json import math import os +from pathlib import Path import warnings from time import time @@ -2046,17 +2047,30 @@ def _set_starting_values(self): @staticmethod - def _load_network_state(json_path): + def _load_network_state(json_path: str | bytes | bytearray | Path): r""" Read network state from given file. Parameters ---------- - json_path : str + json_path : str | bytes | bytearray | Path Path to network information. """ - with open(json_path, "r") as f: - data = json.load(f) + data = None + if not isinstance(json_path, Path): + try: + data = json.loads(json_path) + except json.JSONDecodeError as e: + msg = ( + "The provided json_path could not be decoded. If this is not " + "a valid json string, please provide a valid file path instead of " + "%s" + ) + logger.debug(msg, str(json_path)) + pass + if data is None: + with open(json_path, "r") as f: + data = json.load(f) dfs = {} if "Connection" in data["Connection"]: @@ -3488,18 +3502,26 @@ def export(self, json_file_path=None): return export - def save(self, json_file_path): + def save(self, json_file_path: str | Path | None) -> None | str: r""" Dump the results to a json style output. Parameters ---------- - json_file_path : str + json_file_path : str | Path | None Filename to dump results into. + Returns + ------- + None + If a file path is provided, results are saved to file. + str + If no file path is provided, results are returned as string. + Note ---- - Results will be saved to specified file path + Results will be saved to specified file path in json format. If no + file path is provided, the results will be returned as string. """ dump = {} @@ -3510,6 +3532,9 @@ def save(self, json_file_path): dump = hlp._nested_dict_of_dataframes_to_dict(dump) + if json_file_path is None: + return json.dumps(dump, indent=2) + with open(json_file_path, "w") as f: json.dump(dump, f) From 903ee0e52c6f31a4846f608991db195404bbf0c3 Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Mon, 9 Feb 2026 22:05:39 +0100 Subject: [PATCH 60/69] Limit the humidity to 100% RH --- .../components/nodes/humidity_control.py | 324 ++++++++++++++++++ src/tespy/connections/humidairconnection.py | 4 +- src/tespy/tools/fluid_properties/functions.py | 10 +- src/tespy/tools/fluid_properties/mixtures.py | 34 +- 4 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 src/tespy/components/nodes/humidity_control.py diff --git a/src/tespy/components/nodes/humidity_control.py b/src/tespy/components/nodes/humidity_control.py new file mode 100644 index 000000000..1d63d4279 --- /dev/null +++ b/src/tespy/components/nodes/humidity_control.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 + +"""Module of class HumidityControl. + + +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/humidity_control.py + +SPDX-License-Identifier: MIT +""" + +from tespy.components.component import component_registry +from tespy.components.nodes.base import NodeBase +from tespy.tools.data_containers import ComponentMandatoryConstraints as dc_cmc +from tespy.tools.data_containers import SimpleDataContainer as dc_simple +from tespy.tools.fluid_properties import dT_mix_dph +from tespy.tools.fluid_properties import dT_mix_pdh + +# from tespy.tools.fluid_properties import dT_mix_ph_dfluid + + +@component_registry +class HumidityControl(NodeBase): + r""" + A humidity control handles the water content from a humid air flow. + + **Mandatory Equations** + + - :py:meth:`tespy.components.nodes.base.NodeBase.mass_flow_func` + - :py:meth:`tespy.components.nodes.base.NodeBase.pressure_structure_matrix` + - :py:meth:`tespy.components.nodes.humidity_control.HumidityControl.fluid_func` + - :py:meth:`tespy.components.nodes.humidity_control.HumidityControl.energy_balance_func` + + Inlets/Outlets + + - in1: inlet with humid air + - out1: outlet with humid air + - out2: outlet of liquid water or ice + + Image + + .. image:: /api/_images/Splitter.svg + :alt: flowsheet of the splitter + :align: center + :class: only-light + + .. image:: /api/_images/Splitter_darkmode.svg + :alt: flowsheet of the splitter + :align: center + :class: only-dark + + Note + ---- + Fluid separation requires power and cooling, equations have not been + implemented, yet! + + Parameters + ---------- + label : str + The label of the component. + + design : list + List containing design parameters (stated as String). + + offdesign : list + List containing offdesign parameters (stated as String). + + design_path : str + Path to the components design case. + + local_offdesign : boolean + Treat this component in offdesign mode in a design calculation. + + local_design : boolean + Treat this component in design mode in an offdesign calculation. + + char_warnings : boolean + Ignore warnings on default characteristics usage for this component. + + printout : boolean + Include this component in the network's results printout. + + num_out : float, dict + Number of outlets for this component, default value: 2. + + Example + ------- + The separator is used to split up a single mass flow into a specified + number of different parts at identical pressure and temperature but + different fluid composition. Fluids can be separated from each other. + + >>> from tespy.components import Sink, Source, Separator + >>> from tespy.connections import Connection + >>> from tespy.networks import Network + >>> nw = Network(iterinfo=False) + >>> nw.units.set_defaults(**{ + ... "pressure": "bar", "temperature": "degC" + ... }) + >>> so = Source('source') + >>> si1 = Sink('sink1') + >>> si2 = Sink('sink2') + >>> s = Separator('separator', num_out=2) + >>> inc = Connection(so, 'out1', s, 'in1') + >>> outg1 = Connection(s, 'out1', si1, 'in1') + >>> outg2 = Connection(s, 'out2', si2, 'in1') + >>> nw.add_conns(inc, outg1, outg2) + + An Air (simplified) mass flow of 5 kg/s is split up into two mass flows. + One mass flow of 1 kg/s containing 10 % oxygen and 90 % nitrogen leaves the + separator. It is possible to calculate the fluid composition of the second + mass flow. Specify starting values for the second mass flow fluid + composition for calculation stability. + + >>> inc.set_attr(fluid={'O2': 0.23, 'N2': 0.77}, p=1, T=20, m=5) + >>> outg1.set_attr(fluid={'O2': 0.1, 'N2': 0.9}, m=1) + >>> outg2.set_attr(fluid0={'O2': 0.5, 'N2': 0.5}) + >>> nw.solve('design') + >>> outg2.fluid.val['O2'] + 0.2625 + + In the same way, it is possible to specify one of the fluid components in + the second mass flow instead of the first mass flow. The solver will find + the mass flows matching the desired composition. 65 % of the mass flow + will leave the separator at the second outlet the case of 30 % oxygen + mass fraction for this outlet. + + >>> outg1.set_attr(m=None) + >>> outg2.set_attr(fluid={'O2': 0.3}) + >>> nw.solve('design') + >>> outg2.fluid.val['O2'] + 0.3 + >>> round(outg2.m.val_SI / inc.m.val_SI, 2) + 0.65 + """ + + @staticmethod + def get_parameters(): + return {'num_out': dc_simple(description="number of outlets")} + + def _update_num_eq(self): + self.variable_fluids = set( + [fluid for c in self.inl + self.outl for fluid in c.fluid.is_var] + ) + num_fluid_eq = len(self.variable_fluids) + if num_fluid_eq == 0: + num_fluid_eq = 1 + self.variable_fluids = [list(self.inl[0].fluid.is_set)[0]] + + self.constraints["fluid_constraints"].num_eq = num_fluid_eq + + def get_mandatory_constraints(self): + return { + 'mass_flow_constraints': dc_cmc(**{ + 'num_eq_sets': 1, + 'func': self.mass_flow_func, + 'dependents': self.mass_flow_dependents, + 'description': 'mass balance constraint' + }), + 'fluid_constraints': dc_cmc(**{ + 'num_eq_sets': self.num_o, + 'func': self.fluid_func, + 'deriv': self.fluid_deriv, + 'dependents': self.fluid_dependents, + 'description': 'fluid mass fraction balance constraints' + }), + 'energy_balance_constraints': dc_cmc(**{ + 'num_eq_sets': self.num_o, + 'func': self.energy_balance_func, + 'deriv': self.energy_balance_deriv, + 'dependents': self.energy_balance_dependents, + 'description': 'equal temperature at all outlets constraints' + }), + 'pressure_constraints': dc_cmc(**{ + 'num_eq_sets': self.num_o, + 'structure_matrix': self.pressure_structure_matrix, + 'description': 'pressure equality constraints' + }) + } + + @staticmethod + def inlets(): + return ['in1'] + + def outlets(self): + if self.num_out.is_set: + return [f'out{i + 1}' for i in range(self.num_out.val)] + else: + self.set_attr(num_out=2) + return self.outlets() + + def propagate_wrapper_to_target(self, branch): + branch["components"] += [self] + for outconn in self.outl: + branch["connections"] += [outconn] + outconn.target.propagate_wrapper_to_target(branch) + + def fluid_func(self): + r""" + Calculate the vector of residual values for fluid balance equations. + + Returns + ------- + residual : list + Vector of residual values for component's fluid balance. + + .. math:: + + 0 = \dot{m}_{in} \cdot x_{fl,in} - \dot {m}_{out,j} + \cdot x_{fl,out,j}\\ + \forall fl \in \text{network fluids,} + \; \forall j \in \text{outlets} + """ + i = self.inl[0] + residual = [] + for fluid in self.variable_fluids: + res = i.fluid.val[fluid] * i.m.val_SI + for o in self.outl: + res -= o.fluid.val[fluid] * o.m.val_SI + residual += [res] + return residual + + def fluid_deriv(self, increment_filter, k, dependents=None): + r""" + Calculate partial derivatives of fluid balance. + + Parameters + ---------- + increment_filter : ndarray + Matrix for filtering non-changing variables. + + k : int + Position of derivatives in Jacobian matrix (k-th equation). + """ + i = self.inl[0] + for fluid in self.variable_fluids: + for o in self.outl: + self._partial_derivative(o.m, k, -o.fluid.val[fluid], increment_filter) + if fluid in o.fluid.is_var: + self.jacobian[k, o.fluid.J_col[fluid]] = -o.m.val_SI + + self._partial_derivative(i.m, k, i.fluid.val[fluid], increment_filter) + if fluid in i.fluid.is_var: + self.jacobian[k, i.fluid.J_col[fluid]] = i.m.val_SI + + k += 1 + + def fluid_dependents(self): + return { + "scalars": [ + [c.m for c in self.inl + self.outl] + for f in self.variable_fluids + ], + "vectors": [{ + c.fluid: set(f) & c.fluid.is_var for c in self.inl + self.outl + } for f in self.variable_fluids] + } + + def energy_balance_func(self): + r""" + Calculate energy balance. + + Returns + ------- + residual : list + Residual value of energy balance. + + .. math:: + + 0 = T_{in} - T_{out,j}\\ + \forall j \in \text{outlets} + """ + residual = [] + T_in = self.inl[0].calc_T() + for o in self.outl: + residual += [T_in - o.calc_T()] + return residual + + def energy_balance_deriv(self, increment_filter, k, dependents=None): + r""" + Calculate partial derivatives of energy balance. + + Parameters + ---------- + increment_filter : ndarray + Matrix for filtering non-changing variables. + + k : int + Position of derivatives in Jacobian matrix (k-th equation). + """ + i = self.inl[0] + dT_dp_in = 0 + dT_dh_in = 0 + if i.p.is_var: + # outlet pressure must be variable as well in this case! + dT_dp_in = dT_mix_dph(i.p.val_SI, i.h.val_SI, i.fluid_data, i.mixing_rule) + if i.h.is_var: + dT_dh_in = dT_mix_pdh(i.p.val_SI, i.h.val_SI, i.fluid_data, i.mixing_rule) + + for o in self.outl: + args = (o.p.val_SI, o.h.val_SI, o.fluid_data, o.mixing_rule) + + dT_dp_out = 0 + if o.p.is_var: + dT_dp_out = -dT_mix_dph(*args) + # pressure is always coupled + self._partial_derivative(i.p, k, dT_dp_in - dT_dp_out) + + if o.h.is_var: + dT_dh_out = -dT_mix_pdh(*args) + + # enthalpy is not necessarily coupled + if i.h._reference_container == o.h._reference_container: + self._partial_derivative(i.h, k, dT_dh_in - dT_dh_out) + else: + self._partial_derivative(i.h, k, dT_dh_in) + self._partial_derivative(o.h, k, dT_dh_out) + + k += 1 + + def energy_balance_dependents(self): + return [ + [self.inl[0].p, self.inl[0].h, o.p, o.h] for o in self.outl + ] diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index d6440ab99..4bb2c375d 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -16,7 +16,7 @@ from tespy.tools.data_containers import FluidComposition as dc_flu from tespy.tools.data_containers import FluidProperties as dc_prop from tespy.tools.data_containers import SimpleDataContainer as dc_simple -from tespy.tools.fluid_properties.functions import _get_humid_air_humidity_ratio +from tespy.tools.fluid_properties.functions import w_mix_pT_humidair from tespy.tools.fluid_properties.functions import h_mix_pT from tespy.tools.fluid_properties.mixtures import _get_fluid_alias @@ -226,7 +226,7 @@ def r_dependents(self): } def calc_w(self): - return _get_humid_air_humidity_ratio(self.fluid_data) + return w_mix_pT_humidair(self.p.val_SI, self.T.val_SI, self.fluid_data) def calc_results(self, units): self.T.val_SI = self.calc_T() diff --git a/src/tespy/tools/fluid_properties/functions.py b/src/tespy/tools/fluid_properties/functions.py index d173ac1be..04b3e0135 100644 --- a/src/tespy/tools/fluid_properties/functions.py +++ b/src/tespy/tools/fluid_properties/functions.py @@ -26,7 +26,7 @@ from .mixtures import T_MIX_PS_REVERSE from .mixtures import V_MIX_PT_DIRECT from .mixtures import VISCOSITY_MIX_PT_DIRECT -from .mixtures import _get_humid_air_humidity_ratio +from .mixtures import w_mix_pT_humidair, w_mix_ph_humidair, w_mix_ps_humidair def isentropic(p_1, h_1, p_2, fluid_data, mixing_rule=None, T0=None): @@ -141,7 +141,7 @@ def T_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): else: _check_mixing_rule(mixing_rule, T_MIX_PH_REVERSE, "temperature (from enthalpy)") if mixing_rule == "humidair": - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_ph_humidair(p, h, fluid_data) return HAPropsSI("T", "P", p, "H", h, "W", w) else: kwargs = { @@ -303,7 +303,7 @@ def T_mix_ps(p, s, fluid_data, mixing_rule=None, T0=None): return pure_fluid["wrapper"].T_ps(p, s) else: if mixing_rule == "humidair": - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_ps_humidair(p, s, fluid_data) return HAPropsSI("T", "P", p, "S", s, "W", w) else: _check_mixing_rule(mixing_rule, T_MIX_PS_REVERSE, "temperature (from entropy)") @@ -320,7 +320,7 @@ def v_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): return 1 / pure_fluid["wrapper"].d_ph(p, h) else: if mixing_rule == "humidair": - w = _get_humid_air_humidity_ratio(fluid_data) + w = w = w_mix_ph_humidair(p, h, fluid_data) return HAPropsSI("V", "P", p, "H", h, "W", w) else: T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) @@ -356,7 +356,7 @@ def viscosity_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): return pure_fluid["wrapper"].viscosity_ph(p, h) else: if mixing_rule == "humidair": - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_ph_humidair(p, h, fluid_data) return HAPropsSI("Visc", "P", p, "H", h, "W", w) else: T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) diff --git a/src/tespy/tools/fluid_properties/mixtures.py b/src/tespy/tools/fluid_properties/mixtures.py index 6c0ea0ba9..86eaf0925 100644 --- a/src/tespy/tools/fluid_properties/mixtures.py +++ b/src/tespy/tools/fluid_properties/mixtures.py @@ -103,11 +103,43 @@ def _get_humid_air_humidity_ratio(fluid_data): / fluid_data[air_alias]["mass_fraction"] ) +def w_mix_pTrh_humidair(p, T, rh): + # w = _get_humid_air_humidity_ratio(fluid_data) + return HAPropsSI("W", "P", p, "T", T, "RH", rh) # kg water/kg dry air + +def w_mix_pT_humidair(p, T, fluid_data, **kwargs): + w_def = _get_humid_air_humidity_ratio(fluid_data) + w_max = w_mix_pTrh_humidair(p, T, 1.0) + if w_def > w_max: + _msg = f"Humidity ratio {w_def:.4f} exceeds maximum value of {w_max:.4f} for given p and T. Check fluid composition." + return w_max + return w_def def h_mix_pT_humidair(p, T, fluid_data, **kwargs): - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_pT_humidair(p, T, fluid_data, **kwargs) return HAPropsSI("H", "P", p, "T", T, "W", w) +def w_mix_phrh_humidair(p, h, rh): + return HAPropsSI("W", "P", p, "H", h, "RH", rh) # kg water/kg dry air + +def w_mix_ph_humidair(p, h, fluid_data, **kwargs): + w_def = _get_humid_air_humidity_ratio(fluid_data) + w_max = w_mix_phrh_humidair(p, h, 1.0) + if w_def > w_max: + _msg = f"Humidity ratio {w_def:.4f} exceeds maximum value of {w_max:.4f} for given p and T. Check fluid composition." + return w_max + return w_def + +def w_mix_psrh_humidair(p, s, rh): + return HAPropsSI("W", "P", p, "S", s, "RH", rh) # kg water/kg dry air + +def w_mix_ps_humidair(p, s, fluid_data, **kwargs): + w_def = _get_humid_air_humidity_ratio(fluid_data) + w_max = w_mix_psrh_humidair(p, s, 1.0) + if w_def > w_max: + _msg = f"Humidity ratio {w_def:.4f} exceeds maximum value of {w_max:.4f} for given p and T. Check fluid composition." + return w_max + return w_def def s_mix_pT_ideal(p=None, T=None, fluid_data=None, **kwargs): molar_fractions = get_molar_fractions(fluid_data) From 153c28416ce77915cdb2dcbfb64b3acb988c1c1d Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Mon, 9 Feb 2026 22:45:07 +0100 Subject: [PATCH 61/69] directly calculate condensate flow next to the humid air. Can be used for droplets in pipe flow or for condensate removal and frost formation. --- src/tespy/connections/humidairconnection.py | 30 ++++++++++++------- src/tespy/tools/fluid_properties/functions.py | 2 +- src/tespy/tools/fluid_properties/mixtures.py | 15 +++++----- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 4bb2c375d..810101ae8 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -18,7 +18,7 @@ from tespy.tools.data_containers import SimpleDataContainer as dc_simple from tespy.tools.fluid_properties.functions import w_mix_pT_humidair from tespy.tools.fluid_properties.functions import h_mix_pT -from tespy.tools.fluid_properties.mixtures import _get_fluid_alias +from tespy.tools.fluid_properties.mixtures import _get_fluid_alias, w_mix_fluid_data from .connection import Connection from .connection import connection_registry @@ -41,6 +41,10 @@ def get_parameters(self): quantity="mass_flow", description="mass flow of humid air" ), + "mH2O": dc_prop( + quantity="mass_flow", + description="mass flow of liquid or solid water not contained in humid air" + ), "p": dc_prop( quantity="pressure", description="absolute pressure of the fluid (system variable)" @@ -200,11 +204,11 @@ def _adjust_to_property_limits(self, nw): @classmethod def _result_attributes(cls): - return ["m", "mHA", "p", "h", "T", "w", "s", "vol", "v", "r"] + return ["m", "mHA", "mH2O", "p", "h", "T", "w", "s", "vol", "v", "r"] @classmethod def _print_attributes(cls): - return ["m", "mHA", "p", "h", "T", "w", "r"] + return ["m", "mHA", "mH2O", "p", "h", "T", "w", "r"] def calc_r(self): w = self.calc_w() @@ -230,15 +234,21 @@ def calc_w(self): def calc_results(self, units): self.T.val_SI = self.calc_T() - self.vol.val_SI = self.calc_vol() - self.v.val_SI = self.vol.val_SI * self.m.val_SI - air_alias = list(_get_fluid_alias("air", self.fluid_data))[0] - air_mass_fraction = self.fluid.val[air_alias] - self.mHA.val_SI = self.m.val_SI / air_mass_fraction + self.vol.val_SI = self.calc_vol() # Mixture volume per mass of dry air + self.v.val_SI = self.vol.val_SI * self.m.val_SI # Mixture volume flow rate + # handle the water fraction self.w.val_SI = self.calc_w() + # # Convert from kg water/kg dry air to mass fraction of water in humid air + # x_h2o = self.w.val_SI / (1 + self.w.val_SI) + # x_air = 1 - x_h2o + self.mHA.val_SI = self.m.val_SI * (1 + self.w.val_SI) + w_mixture = w_mix_fluid_data(self.fluid_data) + # Calculate kg water/kg dry air that is not in the humid air + delta_w = w_mixture - self.w.val_SI + self.mH2O.val_SI = self.m.val_SI * delta_w self.r.val_SI = self.calc_r() - if self.r.val_SI > 1: - self.r.val_SI = np.nan + # if self.r.val_SI > 1: + # self.r.val_SI = np.nan for prop in self._result_attributes(): param = self.get_attr(prop) diff --git a/src/tespy/tools/fluid_properties/functions.py b/src/tespy/tools/fluid_properties/functions.py index 04b3e0135..7b19da5bd 100644 --- a/src/tespy/tools/fluid_properties/functions.py +++ b/src/tespy/tools/fluid_properties/functions.py @@ -320,7 +320,7 @@ def v_mix_ph(p, h, fluid_data, mixing_rule=None, T0=None): return 1 / pure_fluid["wrapper"].d_ph(p, h) else: if mixing_rule == "humidair": - w = w = w_mix_ph_humidair(p, h, fluid_data) + w = w_mix_ph_humidair(p, h, fluid_data) return HAPropsSI("V", "P", p, "H", h, "W", w) else: T = T_mix_ph(p, h , fluid_data, mixing_rule, T0) diff --git a/src/tespy/tools/fluid_properties/mixtures.py b/src/tespy/tools/fluid_properties/mixtures.py index 86eaf0925..8b1a42139 100644 --- a/src/tespy/tools/fluid_properties/mixtures.py +++ b/src/tespy/tools/fluid_properties/mixtures.py @@ -90,7 +90,7 @@ def h_mix_pT_incompressible(p, T, fluid_data, **kwargs): return h -def _get_humid_air_humidity_ratio(fluid_data): +def w_mix_fluid_data(fluid_data): water_alias = _get_fluid_alias("H2O", fluid_data) water_alias = next(iter(water_alias)) @@ -104,11 +104,10 @@ def _get_humid_air_humidity_ratio(fluid_data): ) def w_mix_pTrh_humidair(p, T, rh): - # w = _get_humid_air_humidity_ratio(fluid_data) return HAPropsSI("W", "P", p, "T", T, "RH", rh) # kg water/kg dry air def w_mix_pT_humidair(p, T, fluid_data, **kwargs): - w_def = _get_humid_air_humidity_ratio(fluid_data) + w_def = w_mix_fluid_data(fluid_data) w_max = w_mix_pTrh_humidair(p, T, 1.0) if w_def > w_max: _msg = f"Humidity ratio {w_def:.4f} exceeds maximum value of {w_max:.4f} for given p and T. Check fluid composition." @@ -123,7 +122,7 @@ def w_mix_phrh_humidair(p, h, rh): return HAPropsSI("W", "P", p, "H", h, "RH", rh) # kg water/kg dry air def w_mix_ph_humidair(p, h, fluid_data, **kwargs): - w_def = _get_humid_air_humidity_ratio(fluid_data) + w_def = w_mix_fluid_data(fluid_data) w_max = w_mix_phrh_humidair(p, h, 1.0) if w_def > w_max: _msg = f"Humidity ratio {w_def:.4f} exceeds maximum value of {w_max:.4f} for given p and T. Check fluid composition." @@ -134,7 +133,7 @@ def w_mix_psrh_humidair(p, s, rh): return HAPropsSI("W", "P", p, "S", s, "RH", rh) # kg water/kg dry air def w_mix_ps_humidair(p, s, fluid_data, **kwargs): - w_def = _get_humid_air_humidity_ratio(fluid_data) + w_def = w_mix_fluid_data(fluid_data) w_max = w_mix_psrh_humidair(p, s, 1.0) if w_def > w_max: _msg = f"Humidity ratio {w_def:.4f} exceeds maximum value of {w_max:.4f} for given p and T. Check fluid composition." @@ -190,7 +189,7 @@ def s_mix_pT_incompressible(p=None, T=None, fluid_data=None, **kwargs): def s_mix_pT_humidair(p, T, fluid_data, **kwargs): - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_fluid_data(fluid_data) return HAPropsSI("S", "P", p, "T", T, "W", w) @@ -242,7 +241,7 @@ def v_mix_pT_incompressible(p=None, T=None, fluid_data=None, **kwargs): def v_mix_pT_humidair(p, T, fluid_data, **kwargs): - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_fluid_data(fluid_data) return HAPropsSI("V", "P", p, "T", T, "W", w) @@ -303,7 +302,7 @@ def viscosity_mix_pT_incompressible(p=None, T=None, fluid_data=None, **kwargs): def viscosity_mix_pT_humidair(p, T, fluid_data, **kwargs): - w = _get_humid_air_humidity_ratio(fluid_data) + w = w_mix_fluid_data(fluid_data) return HAPropsSI("Visc", "P", p, "T", T, "W", w) From 785e2144662884717fbdbb2a4c786ea0addb0e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20=C3=98stergaard=20Pedersen?= Date: Wed, 11 Feb 2026 12:57:06 +0100 Subject: [PATCH 62/69] Bugfix of seeding in HAConnection init --- src/tespy/connections/humidairconnection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 810101ae8..83e205011 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -126,7 +126,9 @@ def _guess_starting_values(self, units): if self.h.is_var and not self.good_starting_values: # make a reproducible random guess on starting value for enthalpy import numpy as np - seed = abs(hash(self.label)) % (2**32) + # Ensure reproducible seed from label string + label_bytes = self.label.encode("utf-8") + seed = int.from_bytes(label_bytes, "little", signed=False) % (2**32) rng = np.random.default_rng(seed=seed) value = float(rng.random()) T_rand = 280 + value * (300 - 280) From 74af82b001e11ecd259a1273754b1aa5a30fdc83 Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Wed, 11 Feb 2026 13:18:06 +0100 Subject: [PATCH 63/69] Fix all uses of a random number generator --- src/tespy/connections/connection.py | 6 +++--- src/tespy/connections/humidairconnection.py | 9 ++------- src/tespy/tools/helpers.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index 4c8a7b0b0..a1b29f4ca 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -56,6 +56,7 @@ from tespy.tools.helpers import _is_variable from tespy.tools.helpers import _partial_derivative from tespy.tools.helpers import _partial_derivative_vecvar +from tespy.tools.helpers import seeded_random from tespy.tools.units import SI_UNITS @@ -930,9 +931,8 @@ def _guess_starting_value_from_connected_components(self, key, units): # starting value for mass flow is random between 1 and 2 kg/s # (should be generated based on some hash maybe?) if key == 'm': - seed = abs(hash(self.label)) % (2**32) - rng = np.random.default_rng(seed=seed) - value = float(rng.random() + 1) + rndm = seeded_random(self.label) + value = float(rndm + 1) # generic starting values for pressure and enthalpy elif key in ['p', 'h']: diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 83e205011..45ce44574 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -19,6 +19,7 @@ from tespy.tools.fluid_properties.functions import w_mix_pT_humidair from tespy.tools.fluid_properties.functions import h_mix_pT from tespy.tools.fluid_properties.mixtures import _get_fluid_alias, w_mix_fluid_data +from tespy.tools.helpers import seeded_random from .connection import Connection from .connection import connection_registry @@ -124,13 +125,7 @@ def _set_mixing_rule(self, value): def _guess_starting_values(self, units): if self.h.is_var and not self.good_starting_values: - # make a reproducible random guess on starting value for enthalpy - import numpy as np - # Ensure reproducible seed from label string - label_bytes = self.label.encode("utf-8") - seed = int.from_bytes(label_bytes, "little", signed=False) % (2**32) - rng = np.random.default_rng(seed=seed) - value = float(rng.random()) + value = seeded_random(self.label) T_rand = 280 + value * (300 - 280) h = fp.h_mix_pT(1e5, T_rand, self.fluid_data, self.mixing_rule) self.h.set_reference_val_SI(h) diff --git a/src/tespy/tools/helpers.py b/src/tespy/tools/helpers.py index 92cc76478..1a114d8e5 100644 --- a/src/tespy/tools/helpers.py +++ b/src/tespy/tools/helpers.py @@ -16,6 +16,7 @@ from copy import deepcopy import pandas as pd +import numpy as np from tespy import __datapath__ from tespy.tools import logger @@ -700,6 +701,23 @@ def central_difference(function=None, parameter=None, delta=None, **kwargs): return (function(**upper) - function(**lower)) / (2 * delta) +def seeded_random_generator(seed_value: str | bytes | int) -> np.random.Generator: + """Generate a reproducible random number generator based on a seed value.""" + if isinstance(seed_value, str): + seed_value = seed_value.encode("utf-8") + if isinstance(seed_value, bytes): + seed_value = int.from_bytes(seed_value, "little", signed=False) % (2**32) + if not isinstance(seed_value, int): + raise ValueError("Seed value must be of type str, bytes or int.") + return np.random.default_rng(seed=seed_value) + + +def seeded_random(seed_value: str | bytes | int) -> float: + """Generate a reproducible random number between 0 and 1 based on a seed value.""" + rng = seeded_random_generator(seed_value) + return rng.random() + + def get_basic_path(): """ Return the basic tespy path and creates it if necessary. From e8884047f5949ac4e83dd917135b928141259b00 Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Wed, 11 Feb 2026 17:08:40 +0100 Subject: [PATCH 64/69] fix a typo --- src/tespy/connections/humidairconnection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tespy/connections/humidairconnection.py b/src/tespy/connections/humidairconnection.py index 45ce44574..807196845 100644 --- a/src/tespy/connections/humidairconnection.py +++ b/src/tespy/connections/humidairconnection.py @@ -150,11 +150,11 @@ def _presolve(self): air_alias = _get_fluid_alias("air", self.fluid_data) water_alias = _get_fluid_alias("water", self.fluid_data) if not air_alias: - msg = "air must bre present in fluid composition" + msg = "air must be present in fluid composition" raise ValueError(msg) elif not water_alias: - msg = "water must bre present in fluid composition" + msg = "water must be present in fluid composition" raise ValueError(msg) if len(self.fluid.is_var) > 0: From 805e96b042035bcffdad1db3c57246be1827a668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20=C3=98stergaard=20Pedersen?= Date: Thu, 12 Feb 2026 10:51:32 +0100 Subject: [PATCH 65/69] Increasing relaxation --- src/tespy/networks/network.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index c6d485d58..8ef8b0292 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -2799,11 +2799,13 @@ def _update_variables(self): relax = 1 if self.robust_relax: if self.iter < 3: - relax = 0.25 + relax = 0.05 elif self.iter < 5: - relax = 0.5 + relax = 0.1 elif self.iter < 8: - relax = 0.75 + relax = 0.25 + elif self.iter < 15: + relax = 0.5 for _, data in self.variables_dict.items(): if data["variable"] in ["m", "h", "E"]: From ac0a969f1f60e34fa4a25b447900556e2c62f086 Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Thu, 12 Feb 2026 10:43:21 +0100 Subject: [PATCH 66/69] helper function for the sorted residual index values --- src/tespy/networks/network.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index 8ef8b0292..74031c872 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -2311,6 +2311,23 @@ def _get_linear_dependents_by_variable_index(self, idx) -> list: variables = [self._variable_lookup[v] for v in dependents["variables"]] variable_list = [(v["object"].label, v["property"]) for v in variables] return variable_list + + def get_sorted_residual_index(self) -> list[int]: + """Get the sorted array of residual indices. + + Returns + ------- + list[int] + List of variable numbers, the index values. + """ + # vars: dict[tuple[int, str], dict] = self.get_variables() + sidx: list[int] = list(np.argsort(np.abs(self.residual))[::-1]) + # sres = np.array([self.residual[i] for i in sidx]) + # chis = self.residual_history.shape[1] + # for i in range(2, n): + # sres = np.vstack((sres, [self.residual_history[i-2][j] for j in sidx])) + # sres = np.vstack((sres, self.residual_history[-n+1:, :][:, sidx].T)) + return sidx def solve(self, mode, init_path=None, design_path=None, max_iter=50, min_iter=4, init_only=False, init_previous=True, From d65af035403818cc0ab83a07bf964e26de632871 Mon Sep 17 00:00:00 2001 From: Jorrit Wronski Date: Thu, 12 Feb 2026 13:46:19 +0100 Subject: [PATCH 67/69] changed relaxation --- src/tespy/networks/network.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index 74031c872..0ad376f2f 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -2815,14 +2815,13 @@ def _update_variables(self): # get_J_col yet relax = 1 if self.robust_relax: - if self.iter < 3: - relax = 0.05 - elif self.iter < 5: - relax = 0.1 - elif self.iter < 8: - relax = 0.25 - elif self.iter < 15: - relax = 0.5 + # relax_values = [ + # (0.05, 0.05 * self.max_iter), + # (0.10, 0.10 * self.max_iter), + # (0.25, 0.25 * self.max_iter), + # (0.50, 0.50 * self.max_iter) + # ] + relax = 0.05 + 0.95 * min(1, self.iter / (0.25 * self.max_iter)) for _, data in self.variables_dict.items(): if data["variable"] in ["m", "h", "E"]: From 7b97b3ed7c68a4ede86e548f34bda60facfce86e Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 13 Feb 2026 09:40:35 +0100 Subject: [PATCH 68/69] Make a hack to allow local_offdesign to work for the heat exchanger kA --- src/tespy/components/heat_exchangers/base.py | 13 +++++++++++-- src/tespy/networks/network.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/tespy/components/heat_exchangers/base.py b/src/tespy/components/heat_exchangers/base.py index a787374ce..84843f4ba 100644 --- a/src/tespy/components/heat_exchangers/base.py +++ b/src/tespy/components/heat_exchangers/base.py @@ -563,8 +563,17 @@ def kA_char_func(self): """ p1 = self.kA_char1.param p2 = self.kA_char2.param - f1 = self.get_char_expr(p1, **self.kA_char1.char_params) - f2 = self.get_char_expr(p2, **self.kA_char2.char_params) + if self.local_offdesign: + design_value = self._connection_offdesign[self.inl[0].label][p1] + actual_value = getattr(self.inl[0], p1).val_SI + f1 = actual_value / design_value + + design_value = self._connection_offdesign[self.inl[1].label][p2] + actual_value = getattr(self.inl[1], p2).val_SI + f2 = actual_value / design_value + else: + f1 = self.get_char_expr(p1, **self.kA_char1.char_params) + f2 = self.get_char_expr(p2, **self.kA_char2.char_params) fkA1 = self.kA_char1.char_func.evaluate(f1) fkA2 = self.kA_char2.char_func.evaluate(f2) diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py index 0ad376f2f..d2e574adc 100644 --- a/src/tespy/networks/network.py +++ b/src/tespy/networks/network.py @@ -1765,6 +1765,26 @@ def _prepare_design(self): data = _local_designs[path][c] # write data self._write_design_state_to_component(cp, data) + # this is a hack to write the connection specifications of the + # respective component into the component. In the component + # itself, the value from here should be utilized instead of the + # .design value of the connection in case local_offdesign is + # set to True + if cp.inl[0].label in _local_designs[path][cp.inl[0].__class__.__name__].index: + cp._connection_offdesign = { + c.label: _local_designs[path][c.__class__.__name__].loc[c.label] + for c in cp.inl + cp.outl + } + else: + # remap to actual inlet and outlet labels + cp._connection_offdesign = { + c.label: _local_designs[path][c.__class__.__name__].loc[f"hx_in{k + 1}"] + for k, c in enumerate(cp.inl) + } + cp._connection_offdesign.update({ + c.label: _local_designs[path][c.__class__.__name__].loc[f"hx_out{k + 1}"] + for k, c in enumerate(cp.outl) + }) # unset design parameters for var in cp.design: From 16c049b943a3a5c3fb454f5a9a6a3d950180ebf0 Mon Sep 17 00:00:00 2001 From: Francesco Witte Date: Fri, 13 Feb 2026 10:35:22 +0100 Subject: [PATCH 69/69] Slightly adjust the convergence helpers --- src/tespy/components/heat_exchangers/base.py | 4 ++-- src/tespy/connections/connection.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tespy/components/heat_exchangers/base.py b/src/tespy/components/heat_exchangers/base.py index 84843f4ba..d565b912b 100644 --- a/src/tespy/components/heat_exchangers/base.py +++ b/src/tespy/components/heat_exchangers/base.py @@ -465,9 +465,9 @@ def calculate_td_log(self): T_o2 = o2.calc_T() if T_i1 <= T_o2: - T_i1 = T_o2 + 0.01 + T_o2 = T_i1 - 0.1 if T_o1 <= T_i2: - T_o1 = T_i2 + 0.01 + T_o1 = T_i2 + 0.1 ttd_u = T_i1 - T_o2 ttd_l = T_o1 - T_i2 diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py index a1b29f4ca..76eee5f24 100644 --- a/src/tespy/connections/connection.py +++ b/src/tespy/connections/connection.py @@ -1833,7 +1833,7 @@ def _adjust_to_property_limits(self, nw): self._adjust_enthalpy(fl) # two-phase related - if (self.Td_bp.is_set or self.state.is_set or self.x.is_set or self.td_bubble.is_set or self.td_dew.is_set) and self.it < 10: + if (self.Td_bp.is_set or self.state.is_set or self.x.is_set or self.td_bubble.is_set or self.td_dew.is_set) and self.it < 30: self._adjust_to_two_phase(fl) # mixture