# -*- coding: utf-8 -
"""
OffsetConverter and associated individual constraints (blocks) and groupings.
SPDX-FileCopyrightText: Uwe Krien <krien@uni-bremen.de>
SPDX-FileCopyrightText: Simon Hilpert
SPDX-FileCopyrightText: Cord Kaldemeyer
SPDX-FileCopyrightText: Patrik Schönfeldt
SPDX-FileCopyrightText: FranziPl
SPDX-FileCopyrightText: jnnr
SPDX-FileCopyrightText: Stephan Günther
SPDX-FileCopyrightText: FabianTU
SPDX-FileCopyrightText: Johannes Röder
SPDX-FileCopyrightText: Saeed Sayadi
SPDX-FileCopyrightText: Johannes Kochems
SPDX-FileCopyrightText: Francesco Witte
SPDX-License-Identifier: MIT
"""
from warnings import warn
from oemof.network import Node
from pyomo.core import BuildAction
from pyomo.core.base.block import ScalarBlock
from pyomo.environ import Constraint
from pyomo.environ import Set
from oemof.solph._plumbing import sequence
[docs]
class OffsetConverter(Node):
r"""An object with one input and multiple outputs and two coefficients
per output to model part load behaviour.
The output must contain a NonConvex object.
Parameters
----------
conversion_factors : dict, (:math:`m(t)`)
Dict containing the respective bus as key and as value the parameter
:math:`m(t)`. It represents the slope of a linear equation with
respect to the `NonConvex` flow. The value can either be a scalar or a
sequence with length of time horizon for simulation.
normed_offsets : dict, (:math:`y_\text{0,normed}(t)`)
Dict containing the respective bus as key and as value the parameter
:math:`y_\text{0,normed}(t)`. It represents the y-intercept with respect
to the `NonConvex` flow divided by the `nominal_capacity` of the
`NonConvex` flow (this is for internal purposes). The value can either
be a scalar or a sequence with length of time horizon for simulation.
Notes
-----
:math:`m(t)` and :math:`y_\text{0,normed}(t)` can be calculated as follows:
.. _OffsetConverterCoefficients-equations:
.. math::
m = \frac{(l_{max}/\eta_{max}-l_{min}/\eta_{min}}{l_{max}-l_{min}}
y_\text{0,normed} = \frac{1}{\eta_{max}} - m
Where :math:`l_{max}` and :math:`l_{min}` are the maximum and minimum
partload share (e.g. 1.0 and 0.5) with reference to the `NonConvex` flow
and :math:`\eta_{max}` and :math:`\eta_{min}` are the respective
efficiencies/conversion factors at these partloads. Alternatively, you can
use the inbuilt methods:
- If the `NonConvex` flow is at an input of the component:
:py:meth:`oemof.solph.components._offset_converter.slope_offset_from_nonconvex_input`,
- If the `NonConvex` flow is at an output of the component:
:py:meth:`oemof.solph.components._offset_converter.slope_offset_from_nonconvex_output`
You can import these methods from the `oemof.solph.components` level:
>>> from oemof.solph.components import slope_offset_from_nonconvex_input
>>> from oemof.solph.components import slope_offset_from_nonconvex_output
The sets, variables, constraints and objective parts are created
* :py:class:`~oemof.solph.components._offset_converter.OffsetConverterBlock`
Examples
--------
>>> from oemof import solph
>>> bel = solph.buses.Bus(label='bel')
>>> bth = solph.buses.Bus(label='bth')
>>> l_nominal = 60
>>> l_max = 1
>>> l_min = 0.5
>>> eta_max = 0.5
>>> eta_min = 0.3
>>> slope = (l_max / eta_max - l_min / eta_min) / (l_max - l_min)
>>> offset = 1 / eta_max - slope
Or use the provided method as explained in the previous section:
>>> _slope, _offset = slope_offset_from_nonconvex_output(
... l_max, l_min, eta_max, eta_min
... )
>>> slope == _slope
True
>>> offset == _offset
True
>>> ostf = solph.components.OffsetConverter(
... label='ostf',
... inputs={bel: solph.flows.Flow()},
... outputs={bth: solph.flows.Flow(
... nominal_capacity=l_nominal, minimum=l_min, maximum=l_max,
... nonconvex=solph.NonConvex())},
... conversion_factors={bel: slope},
... normed_offsets={bel: offset},
... )
>>> type(ostf)
<class 'oemof.solph.components._offset_converter.OffsetConverter'>
The input required to operate at minimum load, can be computed from the
slope and offset:
>>> input_at_min = ostf.conversion_factors[bel][0] * l_min + ostf.normed_offsets[bel][0] * l_max
>>> input_at_min * l_nominal
100.0
The same can be done for the input at nominal load:
>>> input_at_max = l_max * (ostf.conversion_factors[bel][0] + ostf.normed_offsets[bel][0])
>>> input_at_max * l_nominal
120.0
""" # noqa: E501
def __init__(
self,
inputs,
outputs,
parent_node=None,
label=None,
conversion_factors=None,
normed_offsets=None,
coefficients=None,
custom_properties=None,
):
if custom_properties is None:
custom_properties = {}
super().__init__(
inputs=inputs,
outputs=outputs,
parent_node=parent_node,
label=label,
custom_properties=custom_properties,
)
# --- BEGIN: To be removed for versions >= v0.7 ---
# this part is used for the transition phase from the old
# OffsetConverter API to the new one. It calcualtes the
# conversion_factors and normed_offsets from the coefficients and the
# outputs information on min and max.
if coefficients is not None:
if conversion_factors is not None or normed_offsets is not None:
msg = (
"The deprecated argument `coefficients` cannot be used "
"in combination with its replacements "
"(`conversion_factors` and `normed_offsets`)."
)
raise TypeError(msg)
(
normed_offsets,
conversion_factors,
) = self.normed_offset_and_conversion_factors_from_coefficients(
coefficients
)
# --- END ---
_reference_flow = [v for v in self.inputs.values() if v.nonconvex]
_reference_flow += [v for v in self.outputs.values() if v.nonconvex]
if len(_reference_flow) != 1:
raise ValueError(
"Exactly one flow of the `OffsetConverter` must have the "
"`NonConvex` attribute."
)
if _reference_flow[0] in self.inputs.values():
self._reference_node_at_input = True
self._reference_node = _reference_flow[0].input
else:
self._reference_node_at_input = False
self._reference_node = _reference_flow[0].output
_investment_node = [
v.input for v in self.inputs.values() if v.investment
]
_investment_node += [
v.output for v in self.outputs.values() if v.investment
]
if len(_investment_node) > 0:
if (
len(_investment_node) > 1
or self._reference_node != _investment_node[0]
):
raise TypeError(
"`Investment` attribute must be defined only for the "
"NonConvex flow!"
)
self._reference_flow = _reference_flow[0]
if conversion_factors is None:
conversion_factors = {}
if self._reference_node in conversion_factors:
raise ValueError(
"Conversion factors cannot be specified for the `NonConvex` "
"flow."
)
self.conversion_factors = {
k: sequence(v) for k, v in conversion_factors.items()
}
missing_conversion_factor_keys = (
set(self.outputs) | set(self.inputs)
) - set(self.conversion_factors)
for cf in missing_conversion_factor_keys:
self.conversion_factors[cf] = sequence(1)
if normed_offsets is None:
normed_offsets = {}
if self._reference_node in normed_offsets:
raise ValueError(
"Normed offsets cannot be specified for the `NonConvex` flow."
)
self.normed_offsets = {
k: sequence(v) for k, v in normed_offsets.items()
}
missing_normed_offsets_keys = (
set(self.outputs) | set(self.inputs)
) - set(self.normed_offsets)
for cf in missing_normed_offsets_keys:
self.normed_offsets[cf] = sequence(0)
[docs]
def constraint_group(self):
return OffsetConverterBlock
# --- BEGIN: To be removed for versions >= v0.7 ---
[docs]
def normed_offset_and_conversion_factors_from_coefficients(
self, coefficients
):
"""
Calculate slope and offset for new API from the old API coefficients.
Parameters
----------
coefficients : tuple
tuple holding the coefficients (offset, slope) for the old style
OffsetConverter.
Returns
-------
tuple
A tuple holding the slope and the offset for the new
OffsetConverter API.
"""
coefficients = tuple([sequence(i) for i in coefficients])
if len(coefficients) != 2:
raise ValueError(
"Two coefficients or coefficient series have to be given."
)
input_bus = list(self.inputs.values())[0].input
for flow in self.outputs.values():
if flow.maximum.size is not None:
target_len = flow.maximum.size
else:
target_len = 1
slope = []
offset = []
for i in range(target_len):
eta_at_max = (
flow.maximum[i]
* coefficients[1][i]
/ (flow.maximum[i] - coefficients[0][i])
)
eta_at_min = (
flow.minimum[i]
* coefficients[1][i]
/ (flow.minimum[i] - coefficients[0][i])
)
c0, c1 = slope_offset_from_nonconvex_output(
flow.maximum[i], flow.minimum[i], eta_at_max, eta_at_min
)
slope.append(c0)
offset.append(c1)
if target_len == 1:
slope = slope[0]
offset = offset[0]
conversion_factors = {input_bus: slope}
normed_offsets = {input_bus: offset}
msg = (
"The usage of coefficients is depricated, use "
"conversion_factors and normed_offsets instead."
)
warn(msg, DeprecationWarning)
return normed_offsets, conversion_factors
# --- END ---
[docs]
def plot_partload(self, bus, tstep):
"""Create a matplotlib figure of the flow to nonconvex flow relation.
Parameters
----------
bus : oemof.solph.Bus
Bus, to which the NOT-nonconvex input or output is connected to.
tstep : int
Timestep to generate the figure for.
Returns
-------
tuple
A tuple with the matplotlib figure and axes objects.
"""
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(2, sharex=True)
slope = self.conversion_factors[bus][tstep]
offset = self.normed_offsets[bus][tstep]
min_load = self._reference_flow.minimum[tstep]
max_load = self._reference_flow.maximum[tstep]
infeasible_load = np.linspace(0, min_load)
feasible_load = np.linspace(min_load, max_load)
y_feasible = feasible_load * slope + offset
y_infeasible = infeasible_load * slope + offset
_ = ax[0].plot(feasible_load, y_feasible, label="operational range")
color = _[0].get_color()
ax[0].plot(infeasible_load, y_infeasible, "--", color=color)
ax[0].scatter(
[0, feasible_load[0], feasible_load[-1]],
[y_infeasible[0], y_feasible[0], y_feasible[-1]],
color=color,
)
ax[0].legend()
ratio = y_feasible / feasible_load
ax[1].plot(feasible_load, ratio)
ax[1].scatter(
[feasible_load[0], feasible_load[-1]],
[ratio[0], ratio[-1]],
color=color,
)
ax[0].set_ylabel(f"flow from/to bus '{bus.label}'")
ax[1].set_ylabel("efficiency $\\frac{y}{x}$")
ax[1].set_xlabel("nonconvex flow")
_ = [(_.set_axisbelow(True), _.grid()) for _ in ax]
plt.tight_layout()
return fig, ax
[docs]
class OffsetConverterBlock(ScalarBlock):
r"""Block for the relation of nodes with type
:class:`~oemof.solph.components._offset_converter.OffsetConverter`
**The following constraints are created:**
.. _OffsetConverter-equations:
.. math::
&
P(p, t) = P_\text{ref}(p, t) \cdot m(t)
+ P_\text{nom,ref}(p) \cdot Y_\text{ref}(t) \cdot y_\text{0,normed}(t) \\
The symbols used are defined as follows (with Variables (V) and Parameters (P)):
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
| symbol | attribute | type | explanation |
+==============================+==============================================================+======+=============================================================================+
| :math:`P(t)` | `flow[i,n,p,t]` or `flow[n,o,p,t]` | V | **Non**-nonconvex flows at input or output |
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
| :math:`P_{in}(t)` | `flow[i,n,p,t]` or `flow[n,o,p,t]` | V | nonconvex flow of converter |
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
| :math:`Y(t)` | | V | Binary status variable of nonconvex flow |
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
| :math:`P_{nom}(t)` | | V | Nominal value (max. capacity) of the nonconvex flow |
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
| :math:`m(t)` | `conversion_factors[i][n,t]` or `conversion_factors[o][n,t]` | P | Linear coefficient 1 (slope) of a **Non**-nonconvex flows |
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
| :math:`y_\text{0,normed}(t)` | `normed_offsets[i][n,t]` or `normed_offsets[o][n,t]` | P | Linear coefficient 0 (y-intersection)/P_{nom}(t) of **Non**-nonconvex flows |
+------------------------------+--------------------------------------------------------------+------+-----------------------------------------------------------------------------+
Note that :math:`P_{nom}(t) \cdot Y(t)` is merged into one variable,
called `status_nominal[n, o, p, t]`.
""" # noqa: E501
CONSTRAINT_GROUP = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _create(self, group=None):
"""Creates the relation for the class:`OffsetConverter`.
Parameters
----------
group : list
List of oemof.solph.experimental.OffsetConverter objects for
which the relation of inputs and outputs is created
e.g. group = [ostf1, ostf2, ostf3, ...]. The components inside
the list need to hold an attribute `coefficients` of type dict
containing the conversion factors for all inputs to outputs.
"""
if group is None:
return None
m = self.parent_block()
self.OFFSETCONVERTERS = Set(initialize=[n for n in group])
reference_node = {n: n._reference_node for n in group}
reference_node_at_input = {
n: n._reference_node_at_input for n in group
}
in_flows = {
n: [i for i in n.inputs.keys() if i != n._reference_node]
for n in group
}
out_flows = {
n: [o for o in n.outputs.keys() if o != n._reference_node]
for n in group
}
self.relation = Constraint(
[
(n, reference_node[n], f, t)
for t in m.TIMESTEPS
for n in group
for f in in_flows[n] + out_flows[n]
],
noruleinit=True,
)
def _relation_rule(block):
"""Link binary input and output flow to component outflow."""
for t in m.TIMESTEPS:
for n in group:
if reference_node_at_input[n]:
ref_flow = m.flow[reference_node[n], n, t]
status_nominal_idx = reference_node[n], n, t
else:
ref_flow = m.flow[n, reference_node[n], t]
status_nominal_idx = n, reference_node[n], t
try:
ref_status_nominal = (
m.InvestNonConvexFlowBlock.status_nominal[
status_nominal_idx
]
)
except (AttributeError, KeyError):
ref_status_nominal = (
m.NonConvexFlowBlock.status_nominal[
status_nominal_idx
]
)
for f in in_flows[n] + out_flows[n]:
rhs = 0
if f in in_flows[n]:
rhs += m.flow[f, n, t]
else:
rhs += m.flow[n, f, t]
lhs = 0
lhs += ref_flow * n.conversion_factors[f][t]
lhs += ref_status_nominal * n.normed_offsets[f][t]
block.relation.add(
(n, reference_node[n], f, t), (lhs == rhs)
)
self.relation_build = BuildAction(rule=_relation_rule)
[docs]
def slope_offset_from_nonconvex_output(
max_load, min_load, eta_at_max, eta_at_min
):
r"""Calculate the slope and the offset with max and min given for output.
The reference is the output flow here. That means, the `NonConvex` flow
is specified at one of the output flows. The `max_load` and the `min_load`
are the `max` and the `min` specifications for the `NonConvex` flow.
`eta_at_max` and `eta_at_min` are the efficiency values of a different
flow, e.g. an input, with respect to the `max_load` and `min_load`
operation points.
.. math::
\text{slope} =
\frac{
\frac{\text{max}}{\eta_\text{at max}}
- \frac{\text{min}}{\eta_\text{at min}}
}{\text{max} - \text{min}}\\
\text{offset} = \frac{1}{\eta_\text{at,max}} - \text{slope}
Parameters
----------
max_load : float
Maximum load value, e.g. 1
min_load : float
Minimum load value, e.g. 0.5
eta_at_max : float
Efficiency at maximum load.
eta_at_min : float
Efficiency at minimum load.
Returns
-------
tuple
slope and offset
Example
-------
>>> from oemof import solph
>>> max_load = 1
>>> min_load = 0.5
>>> eta_at_min = 0.7
>>> eta_at_max = 0.8
With the output load being at 100 %, in this example, the efficiency should
be 80 %. With the input load being at 50 %, it should be 70 %. We can
calcualte slope and the offset, which is normed to the nominal capacity of
the referenced flow (in this case the output flow) always.
>>> slope, offset = solph.components.slope_offset_from_nonconvex_output(
... max_load, min_load, eta_at_max, eta_at_min
... )
>>> output_flow = 10
>>> output_flow_nominal = 10
>>> input_flow = slope * output_flow + offset * output_flow_nominal
We can then calculate with the `OffsetConverter` input output relation,
what the resulting efficiency is. At max operating conditions it should be
identical to the efficiency we put in initially. Analogously, we apply this
to the minimal load point.
>>> round(output_flow / input_flow, 3) == eta_at_max
True
>>> output_flow = 5
>>> input_flow = slope * output_flow + offset * output_flow_nominal
>>> round(output_flow / input_flow, 3) == eta_at_min
True
"""
slope = (max_load / eta_at_max - min_load / eta_at_min) / (
max_load - min_load
)
offset = 1 / eta_at_max - slope
return slope, offset