# -*- coding: utf-8
"""Module for helper functions used by several other modules.
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/tools/helpers.py
SPDX-License-Identifier: MIT
"""
import logging
import os
from collections import OrderedDict
from collections.abc import Mapping
from copy import deepcopy
import CoolProp as CP
import numpy as np
from tespy.tools.global_vars import err
from tespy.tools.global_vars import fluid_property_data
from tespy.tools.global_vars import molar_masses
[docs]def merge_dicts(dict1, dict2):
"""Return a new dictionary by merging two dictionaries recursively."""
result = deepcopy(dict1)
for key, value in dict2.items():
if isinstance(value, Mapping):
result[key] = merge_dicts(result.get(key, {}), value)
else:
result[key] = deepcopy(dict2[key])
return result
[docs]def nested_OrderedDict(dictionary):
"""Create a nested OrderedDict from a nested dict.
Parameters
----------
dictionary : dict
Nested dict.
Returns
-------
dictionary : collections.OrderedDict
Nested OrderedDict.
"""
dictionary = OrderedDict(dictionary)
for key, value in dictionary.items():
if isinstance(value, dict):
dictionary[key] = nested_OrderedDict(value)
return dictionary
[docs]class TESPyNetworkError(Exception):
"""Custom message for network related errors."""
pass
[docs]class TESPyConnectionError(Exception):
"""Custom message for connection related errors."""
pass
[docs]class TESPyComponentError(Exception):
"""Custom message for component related errors."""
pass
[docs]def convert_to_SI(property, value, unit):
r"""
Convert a value to its SI value.
Parameters
----------
property : str
Fluid property to convert.
value : float
Value to convert.
unit : str
Unit of the value.
Returns
-------
SI_value : float
Specified fluid property in SI value.
"""
if property == 'T':
converters = fluid_property_data['T']['units'][unit]
return (value + converters[0]) * converters[1]
else:
return value * fluid_property_data[property]['units'][unit]
[docs]def convert_from_SI(property, SI_value, unit):
r"""
Get a value in the network's unit system from SI value.
Parameters
----------
property : str
Fluid property to convert.
SI_value : float
SI value to convert.
unit : str
Unit of the value.
Returns
-------
value : float
Specified fluid property value in network's unit system.
"""
if property == 'T':
converters = fluid_property_data['T']['units'][unit]
return SI_value / converters[1] - converters[0]
else:
return SI_value / fluid_property_data[property]['units'][unit]
[docs]def latex_unit(unit):
r"""
Convert unit to LaTeX.
Parameters
----------
unit : str
Value of unit for input, e.g. :code:`m3 / kg`.
Returns
-------
unit : str
Value of unit for output, e.g. :code:`$\unitfrac{m3}{kg}$`.
"""
if '/' in unit:
numerator = unit.split('/')[0].replace(' ', '')
denominator = unit.split('/')[1].replace(' ', '')
return r'$\unitfrac[]{' + numerator + '}{' + denominator + '}$'
else:
if unit == 'C' or unit == 'F':
unit = r'^\circ ' + unit
return r'$\unit[]{' + unit + '}$'
[docs]class UserDefinedEquation:
def __init__(self, label, func, deriv, conns, params={},
latex={}):
r"""
A UserDefinedEquation allows use of generic user specified equations.
Parameters
----------
label : str
Label of the user defined function.
func : function
Equation to evaluate.
deriv : function
Partial derivatives of the equation.
conns : list
List of connections used by the function.
params : dict
Dictionary containing keyword arguments required by the function
and/or derivative.
latex : dict
Dictionary holding LaTeX string of the equation as well as
CharLine and CharMap instances applied in the equation for the
automatic model documentation module.
Example
-------
Consider a pipeline transporting hot water with measurement data on
temperature reduction in the pipeline as function of volumetric flow.
First, we set up the TESPy model. Additionally, we will import the
:py:class:`tespy.tools.helpers.UserDefinedEquation` class as well as
some fluid property functions. We specify fluid property information
for the inflow and assume that no pressure losses occur in the
pipeline.
>>> from tespy.components import Source, Sink, Pipe
>>> from tespy.networks import Network
>>> from tespy.connections import Connection
>>> from tespy.tools.helpers import UserDefinedEquation
>>> from tespy.tools import CharLine
>>> from tespy.tools.fluid_properties import T_mix_ph, v_mix_ph
>>> nw = Network(fluids=['water'], p_unit='bar', T_unit='C')
>>> nw.set_attr(iterinfo=False)
>>> so = Source('source')
>>> si = Sink('sink')
>>> pipeline = Pipe('pipeline')
>>> inflow = Connection(so, 'out1', pipeline, 'in1')
>>> outflow = Connection(pipeline, 'out1', si, 'in1')
>>> nw.add_conns(inflow, outflow)
>>> inflow.set_attr(T=120, p=10, v=1, fluid={'water': 1})
>>> pipeline.set_attr(pr=1)
Let's assume, the temperature reduction is measured from inflow and
outflow temperature. The mathematical description of the relation
we want the model to follow therefore is:
.. math::
0 = T_{in} - T_{out} + f \left( \dot{m}_{in} \cdot v_{in} \right)
We can define a function, describing exactly that relation using a
:py:class:`tespy.tools.characteristics.CharLine` object with volumetric
flow as input values and temperature drop as output values. The
function should look like this:
>>> def myfunc(ude):
... char = ude.params['char']
... return (
... T_mix_ph(ude.conns[0].get_flow()) -
... T_mix_ph(ude.conns[1].get_flow()) - char.evaluate(
... ude.conns[0].m.val_SI *
... v_mix_ph(ude.conns[0].get_flow()))
... )
The function does only take one parameter, we name it :code:`ude` in
this case. This parameter will hold all relevant information you pass
to your UserDefinedEquation later, i.e. a list of the connections
(:code:`.conns`) required by the UserDefinedEquation as well as
a dictionary of arbitrary parameters required for your function
(:code:`.params`). The index of the :code:`.conns` indicates the
position of the connection in the list of connections required for the
UserDefinedEquation (see below).
On top of the equation the solver requires its derivatives with respect
to all relevant primary variables of the network, which are mass flow
pressure, enthalpy and fluid composition. In this case, the derivatives
to the mass flow, pressure and enthalpy of the inflow as well as the
derivatives to the pressure and enthalpy of the outflow will be
required. Similar to the equation definition, define a function
returning the corresponding jacobian matrix. The jacobian is a
dictionary containing numpy arrays for every connection. Therefore
the first key is the connection you want to calculate the derivative
for and the second key is the index of the variable in the jacobian.
The indices correspond to
- 0: mass flow
- 1: pressure
- 2: enthalpy
- 3 until end (:code:`3:`): fluid composition
We can calculate the derivatives numerically, if an easy analytical
solution is not available. Simply use the :code:`numeric_deriv` method
passing the variable ('m', 'p', 'h', 'fluid') as well as the
connection's index.
>>> def myjacobian(ude):
... ude.jacobian[ude.conns[0]][0] = ude.numeric_deriv('m', 0)
... ude.jacobian[ude.conns[0]][1] = ude.numeric_deriv('p', 0)
... ude.jacobian[ude.conns[0]][2] = ude.numeric_deriv('h', 0)
... ude.jacobian[ude.conns[1]][1] = ude.numeric_deriv('p', 1)
... ude.jacobian[ude.conns[1]][2] = ude.numeric_deriv('h', 1)
... return ude.jacobian
After that, we only need to th specify the characteristic line we want
out temperature drop to follow as well as create the
UserDefinedEquation instance and add it to the network. Its equation
is automatically applied. We apply extrapolation for the characteristic
line as it helps with convergence, in case a paramter
>>> char = CharLine(
... x=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 2.0, 3.0],
... y=[17, 12, 9, 6.5, 4.5, 3, 2, 1.5, 1.25, 1.125, 1.1, 1.05],
... extrapolate=True)
>>> my_ude = UserDefinedEquation(
... 'myudelabel', myfunc, myjacobian, [inflow, outflow],
... params={'char': char})
>>> nw.add_ude(my_ude)
>>> nw.solve('design')
Clearly the result is obvious here as the volumetric flow is exactly
at one of the supporting points.
>>> round(inflow.T.val - outflow.T.val, 3)
1.125
So now, let's say, we want to calculate the volumetric flow necessary
to at least maintain a specific temperature at the outflow.
>>> inflow.set_attr(v=None)
>>> outflow.set_attr(T=110)
>>> nw.solve('design')
>>> round(inflow.v.val, 3)
0.267
Or calculate volumetric flow and/or temperature drop corresponding to a
specified heat loss.
>>> outflow.set_attr(T=None)
>>> pipeline.set_attr(Q=-5e6)
>>> nw.solve('design')
>>> round(inflow.v.val, 3)
0.067
"""
if isinstance(label, str):
self.label = label
else:
msg = 'Label of UserDefinedEquation object must be of type String.'
logging.error(msg)
raise TypeError(msg)
if isinstance(conns, list):
self.conns = conns
else:
msg = (
'Parameter conns must be a list of '
'tespy.connections.connection.Connection objects.')
logging.error(msg)
raise TypeError(msg)
self.func = func
self.deriv = deriv
if isinstance(params, dict):
self.params = params
else:
msg = 'The parameter params must be passed as dictionary.'
logging.error(msg)
raise TypeError(msg)
self.latex = {
'equation': r'\text{equation string not available}',
'lines': [],
'maps': []
}
if isinstance(latex, dict):
self.latex.update(latex)
else:
msg = 'The parameter latex must be passed as dictionary.'
logging.error(msg)
raise TypeError(msg)
[docs] def numeric_deriv(self, param, idx):
r"""
Calculate partial derivative of the function func to dx numerically.
Parameters
----------
param : str
Parameter to calculate partial derivative for.
idx : int
Position of the connection to calculate the partial derivative for
within the list of the connections :code:`conns`.
Returns
-------
deriv : float/list
Partial derivative(s) of the function :math:`f` to variable(s)
:math:`x`.
.. math::
\frac{\partial f}{\partial x}=\frac{f(x+d)+f(x-d)}{2\cdot d}
"""
if param == 'fluid':
d = 1e-5
deriv = []
for f in self.conns[0].fluid.val.keys():
val = self.conns[idx].fluid.val[f]
if self.conns[idx].fluid.val[f] + d <= 1:
self.conns[idx].fluid.val[f] += d
else:
self.conns[idx].fluid.val[f] = 1
exp = self.func(self)
if self.conns[idx].fluid.val[f] - 2 * d >= 0:
self.conns[idx].fluid.val[f] -= 2 * d
else:
self.conns[idx].fluid.val[f] = 0
exp -= self.func(self)
self.conns[idx].fluid.val[f] = val
deriv += [exp / (2 * d)]
elif param in ['m', 'p', 'h']:
if param == 'm':
d = 1e-4
else:
d = 1e-1
self.conns[idx].get_attr(param).val_SI += d
exp = self.func(self)
self.conns[idx].get_attr(param).val_SI -= 2 * d
exp -= self.func(self)
self.conns[idx].get_attr(param).val_SI += d
deriv = exp / (2 * d)
else:
msg = (
'Can only calculate numerical derivative to primary variables.'
'Please specify "m", "p", "h" or "fluid" as param.')
logging.error(msg)
raise ValueError(msg)
return deriv
[docs]def newton(func, deriv, params, y, **kwargs):
r"""
Find zero crossings with 1-D newton algorithm.
Parameters
----------
func : function
Function to find zero crossing in,
:math:`0=y-func\left(x,\text{params}\right)`.
deriv : function
First derivative of the function.
params : list
Additional parameters for function, optional.
y : float
Target function value.
val0 : float
Starting value, default: val0=300.
valmin : float
Lower value boundary, default: valmin=70.
valmax : float
Upper value boundary, default: valmax=3000.
max_iter : int
Maximum number of iterations, default: max_iter=10.
tol_rel : float
Maximum relative tolerance :math:`|\frac{y - f(x)}{f(x)}|`, default
value: 1e-6.
tol_abs : float
Maximum absolute tolerance :math:`|y - f(x)|`, default value: 1e-6.
tol_mode : str
Check for relative, absolute or both tolerances:
- :code:`tol_mode='abs'` (default)
- :code:`tol_mode='rel'`
- :code:`tol_mode='both'`
Returns
-------
val : float
x-value of zero crossing.
Note
----
Algorithm
.. math::
x_{i+1} = x_{i} - \frac{f(x_{i})}{\frac{df}{dx}(x_{i})}\\
f(x_{i}) \leq \epsilon
"""
# default valaues
x = kwargs.get('val0', 300)
valmin = kwargs.get('valmin', 70)
valmax = kwargs.get('valmax', 3000)
max_iter = kwargs.get('max_iter', 10)
tol_rel = kwargs.get('tol_rel', err)
tol_abs = kwargs.get('tol_abs', err)
tol_mode = kwargs.get('tol_mode', 'abs')
# start newton loop
expr = True
i = 0
while expr:
# calculate function residual and new value
res = y - func(params, x)
x += res / deriv(params, x)
# check for value ranges
if x < valmin:
x = valmin
if x > valmax:
x = valmax
i += 1
if i > max_iter:
msg = ('Newton algorithm was not able to find a feasible value '
'for function ' + str(func) + '. Current value with x=' +
str(x) + ' is ' + str(func(params, x)) +
', target value is ' + str(y) + '.')
logging.debug(msg)
break
if tol_mode == 'abs':
expr = abs(res) >= tol_abs
elif tol_mode == 'rel':
expr = abs(res / y) >= tol_rel
else:
expr = abs(res / y) >= tol_rel or abs(res) >= tol_abs
return x
# %%
[docs]def reverse_2d(params, y):
r"""
Calculate the residual value of an inverse function.
Parameters
----------
params : list
Variable function parameters.
y : float
Function value of function :math:`y = f \left( x_1, x_2 \right)`.
Returns
-------
deriv : float
Residual value of inverse function :math:`x_2 - f\left(x_1, y \right)`.
"""
func, x1, x2 = params[0], params[1], params[2]
return x2 - func.ev(x1, y)
[docs]def reverse_2d_deriv(params, y):
r"""
Calculate derivative of an inverse function.
Parameters
----------
params : list
Variable function parameters.
y : float
Function value of function :math:`y = f \left( x_1, x_2 \right)`,
so that :math:`x_2 - f\left(x_1, y \right) = 0`
Returns
-------
deriv : float
Partial derivative :math:`\frac{\partial f}{\partial y}`.
"""
func, x1 = params[0], params[1]
return - func.ev(x1, y, dy=1)
[docs]def bus_char_evaluation(params, bus_value):
r"""
Calculate the value of a bus.
Parameters
----------
comp_value : float
Value of the energy transfer at the component.
reference_value : float
Value of the bus in reference state.
char_func : tespy.tools.characteristics.char_line
Characteristic function of the bus.
Returns
-------
residual : float
Residual of the equation.
.. math::
residual = \dot{E}_\mathrm{bus} - \frac{\dot{E}_\mathrm{component}}
{f\left(\frac{\dot{E}_\mathrm{bus}}
{\dot{E}_\mathrm{bus,ref}}\right)}
"""
comp_value = params[0]
reference_value = params[1]
char_func = params[2]
return bus_value - comp_value / char_func.evaluate(
bus_value / reference_value)
[docs]def bus_char_derivative(params, bus_value):
"""Calculate derivative for bus char evaluation."""
reference_value = params[1]
char_func = params[2]
d = 1e-3
return (1 - (
1 / char_func.evaluate((bus_value + d) / reference_value) -
1 / char_func.evaluate((bus_value - d) / reference_value)) / (2 * d))
[docs]def molar_mass_flow(flow):
r"""
Calculate molar mass flow.
Parameters
----------
flow : list
Fluid property vector containing mass flow, pressure, enthalpy and
fluid composition.
Returns
-------
m_m : float
Molar mass flow m_m / (mol/s).
.. math::
\dot{m}_\mathrm{m} = \sum_{i} \left( \frac{x_{i}}{M_{i}} \right)
"""
mm = 0
for fluid, x in flow.items():
if x > err:
mm += x / molar_masses[fluid]
return mm
# %%
[docs]def num_fluids(fluids):
r"""
Return number of fluids in fluid mixture.
Parameters
----------
fluids : dict
Fluid mass fractions.
Returns
-------
n : int
Number of fluids in fluid mixture n / 1.
.. math::
n = \sum_{i} \left( \begin{cases}
0 & x_{i} < \epsilon \\
1 & x_{i} \geq \epsilon
\end{cases} \right)\;
\forall i \in \text{network fluids}
"""
n = 0
for fluid, x in fluids.items():
if x > err:
n += 1
return n
# %%
[docs]def single_fluid(fluids):
r"""
Return the name of the pure fluid in a fluid vector.
Parameters
----------
fluids : dict
Fluid mass fractions.
Returns
-------
fluid : str
Name of the single fluid or None in case of mixtures.
"""
if num_fluids(fluids) == 1:
for fluid, x in fluids.items():
if x > err:
return fluid
else:
return None
# %%
[docs]def fluid_structure(fluid):
r"""
Return the checmical formula of fluid.
Parameters
----------
fluid : str
Name of the fluid.
Returns
-------
parts : dict
Dictionary of the chemical base elements as keys and the number of
atoms in a molecule as values.
Example
-------
Get the chemical formula of methane.
>>> from tespy.tools.helpers import fluid_structure
>>> elements = fluid_structure('methane')
>>> elements['C'], elements['H']
(1, 4)
"""
parts = {}
for element in CP.CoolProp.get_fluid_param_string(
fluid, 'formula').split('}'):
if element != '':
el = element.split('_{')
parts[el[0]] = int(el[1])
return parts
# %%
[docs]def darcy_friction_factor(re, ks, d):
r"""
Calculate the Darcy friction factor.
Parameters
----------
re : float
Reynolds number re / 1.
ks : float
Pipe roughness ks / m.
d : float
Pipe diameter/characteristic lenght d / m.
Returns
-------
darcy_friction_factor : float
Darcy friction factor :math:`\lambda` / 1
Note
----
**Laminar flow** (:math:`re \leq 2320`)
.. math::
\lambda = \frac{64}{re}
**turbulent flow** (:math:`re > 2320`)
*hydraulically smooth:* :math:`\frac{re \cdot k_{s}}{d} < 65`
.. math::
\lambda = \begin{cases}
0.03164 \cdot re^{-0.25} & re \leq 10^4\\
\left(1.8 \cdot \log \left(re\right) -1.5 \right)^{-2} &
10^4 < re < 10^6\\
solve \left(0 = 2 \cdot \log\left(re \cdot \sqrt{\lambda} \right) -0.8
- \frac{1}{\sqrt{\lambda}}\right) & re \geq 10^6\\
\end{cases}
*transition zone and hydraulically rough:*
.. math::
\lambda = solve \left( 0 = 2 \cdot \log \left( \frac{2.51}{re \cdot
\sqrt{\lambda}} + \frac{k_{s}}{d \cdot 3.71} \right) -
\frac{1}{\sqrt{\lambda}} \right)
Reference: :cite:`Nirschl2018`.
Example
-------
Calculate the Darcy friction factor at different hydraulic states.
>>> from tespy.tools.helpers import darcy_friction_factor
>>> ks = 5e-5
>>> d = 0.05
>>> re_laminar = 2000
>>> re_turb_smooth = 5000
>>> re_turb_trans = 70000
>>> re_high = 1000000
>>> d_high = 0.8
>>> re_very_high = 6000000
>>> d_very_high = 1
>>> ks_low = 1e-5
>>> ks_rough = 1e-3
>>> darcy_friction_factor(re_laminar, ks, d)
0.032
>>> round(darcy_friction_factor(re_turb_smooth, ks, d), 3)
0.038
>>> round(darcy_friction_factor(re_turb_trans, ks, d), 3)
0.023
>>> round(darcy_friction_factor(re_turb_trans, ks_rough, d), 3)
0.049
>>> round(darcy_friction_factor(re_high, ks, d_high), 3)
0.012
>>> round(darcy_friction_factor(re_very_high, ks_low, d_very_high), 3)
0.009
"""
if re <= 2320:
return 64 / re
else:
if re * ks / d < 65:
if re <= 1e4:
return blasius(re)
elif re < 1e6:
return hanakov(re)
else:
l0 = 0.02
return newton(
prandtl_karman, prandtl_karman_derivative, [re],
0, val0=l0, valmin=0.00001, valmax=0.2)
else:
l0 = 0.002
return newton(
colebrook, colebrook_derivative, [re, ks, d], 0,
val0=l0, valmin=0.0001, valmax=0.2)
[docs]def blasius(re):
"""
Calculate friction coefficient according to Blasius.
Parameters
----------
re : float
Reynolds number.
Returns
-------
darcy_friction_factor : float
Darcy friction factor.
"""
return 0.3164 * re ** (-0.25)
[docs]def hanakov(re):
"""
Calculate friction coefficient according to Hanakov.
Parameters
----------
re : float
Reynolds number.
Returns
-------
darcy_friction_factor : float
Darcy friction factor.
"""
return (1.8 * np.log10(re) - 1.5) ** (-2)
[docs]def prandtl_karman(params, darcy_friction_factor):
"""
Calculate friction coefficient according to Prandtl and v. Kármán.
Applied in smooth conditions.
Parameters
----------
re : float
Reynolds number.
darcy_friction_factor : float
Darcy friction factor.
Returns
-------
darcy_friction_factor : float
Darcy friction factor.
"""
re = params[0]
return (
2 * np.log10(re * darcy_friction_factor ** 0.5) - 0.8 -
1 / darcy_friction_factor ** 0.5)
[docs]def prandtl_karman_derivative(params, darcy_friction_factor):
"""Calculate derivative for Prandtl and v. Kármán equation."""
return (
1 / (darcy_friction_factor * np.log(10)) +
1 / 2 * darcy_friction_factor ** (-1.5))
[docs]def colebrook(params, darcy_friction_factor):
"""
Calculate friction coefficient accroding to Colebrook-White equation.
Applied in transition zone and rough conditions.
Parameters
----------
re : float
Reynolds number.
ks : float
Equivalent sand roughness.
d : float
Pipe's diameter.
darcy_friction_factor : float
Darcy friction factor.
Returns
-------
darcy_friction_factor : float
Darcy friction factor.
"""
re, ks, d = params[0], params[1], params[2]
return (
2 * np.log10(
2.51 / (re * darcy_friction_factor ** 0.5) + ks / (3.71 * d)) +
1 / darcy_friction_factor ** 0.5)
[docs]def colebrook_derivative(params, darcy_friction_factor):
"""Calculate derivative for Colebrook-White equation."""
d = 0.001
return (colebrook(params, darcy_friction_factor + d) -
colebrook(params, darcy_friction_factor - d)) / (2 * d)
# %%
[docs]def modify_path_os(path):
"""
Modify a path according the os.
Also detects weather the path specification is absolute or relative and
adjusts the path respectively.
Parameters
----------
path : str
Path to modify.
Returns
-------
path : str
Modified path.
"""
if os.name == 'nt':
# windows
path = path.replace('/', '\\')
if path[0] != '\\' and path[1:2] != ':' and path[0] != '.':
# relative path
path = '.\\' + path
else:
# linux, mac
path = path.replace('\\', '/')
if path[0] != '/' and path[0] != '.':
# relative path
path = './' + path
return path
# %%
[docs]def get_basic_path():
"""
Return the basic tespy path and creates it if necessary.
The basic path is the '.tespy' folder in the $HOME directory.
"""
basicpath = os.path.join(os.path.expanduser('~'), '.tespy')
if not os.path.isdir(basicpath):
os.mkdir(basicpath)
return basicpath
[docs]def extend_basic_path(subfolder):
"""
Return a path based on the basic tespy path and creates it if necessary.
The subfolder is the name of the path extension.
"""
extended_path = os.path.join(get_basic_path(), subfolder)
if not os.path.isdir(extended_path):
os.mkdir(extended_path)
return extended_path