flixopt 1.0.12__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- docs/examples/00-Minimal Example.md +5 -0
- docs/examples/01-Basic Example.md +5 -0
- docs/examples/02-Complex Example.md +10 -0
- docs/examples/03-Calculation Modes.md +5 -0
- docs/examples/index.md +5 -0
- docs/faq/contribute.md +49 -0
- docs/faq/index.md +3 -0
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +1 -0
- docs/javascripts/mathjax.js +18 -0
- docs/release-notes/_template.txt +32 -0
- docs/release-notes/index.md +7 -0
- docs/release-notes/v2.0.0.md +93 -0
- docs/release-notes/v2.0.1.md +12 -0
- docs/user-guide/Mathematical Notation/Bus.md +33 -0
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
- docs/user-guide/Mathematical Notation/Flow.md +26 -0
- docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
- docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
- docs/user-guide/Mathematical Notation/Storage.md +44 -0
- docs/user-guide/Mathematical Notation/index.md +22 -0
- docs/user-guide/Mathematical Notation/others.md +3 -0
- docs/user-guide/index.md +124 -0
- {flixOpt → flixopt}/__init__.py +5 -2
- {flixOpt → flixopt}/aggregation.py +113 -140
- flixopt/calculation.py +455 -0
- {flixOpt → flixopt}/commons.py +7 -4
- flixopt/components.py +630 -0
- {flixOpt → flixopt}/config.py +9 -8
- {flixOpt → flixopt}/config.yaml +3 -3
- flixopt/core.py +970 -0
- flixopt/effects.py +386 -0
- flixopt/elements.py +534 -0
- flixopt/features.py +1042 -0
- flixopt/flow_system.py +409 -0
- flixopt/interface.py +265 -0
- flixopt/io.py +308 -0
- flixopt/linear_converters.py +331 -0
- flixopt/plotting.py +1340 -0
- flixopt/results.py +898 -0
- flixopt/solvers.py +77 -0
- flixopt/structure.py +630 -0
- flixopt/utils.py +62 -0
- flixopt-2.0.1.dist-info/METADATA +145 -0
- flixopt-2.0.1.dist-info/RECORD +57 -0
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
- flixopt-2.0.1.dist-info/top_level.txt +6 -0
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixopt-icon.svg +1 -0
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +54 -0
- site/release-notes/_template.txt +32 -0
- flixOpt/calculation.py +0 -629
- flixOpt/components.py +0 -614
- flixOpt/core.py +0 -182
- flixOpt/effects.py +0 -410
- flixOpt/elements.py +0 -489
- flixOpt/features.py +0 -942
- flixOpt/flow_system.py +0 -351
- flixOpt/interface.py +0 -203
- flixOpt/linear_converters.py +0 -325
- flixOpt/math_modeling.py +0 -1145
- flixOpt/plotting.py +0 -712
- flixOpt/results.py +0 -563
- flixOpt/solvers.py +0 -21
- flixOpt/structure.py +0 -733
- flixOpt/utils.py +0 -134
- flixopt-1.0.12.dist-info/METADATA +0 -174
- flixopt-1.0.12.dist-info/RECORD +0 -29
- flixopt-1.0.12.dist-info/top_level.txt +0 -3
- {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info/licenses}/LICENSE +0 -0
flixopt/features.py
ADDED
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the features of the flixopt framework.
|
|
3
|
+
Features extend the functionality of Elements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union
|
|
8
|
+
|
|
9
|
+
import linopy
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from . import utils
|
|
13
|
+
from .config import CONFIG
|
|
14
|
+
from .core import NumericData, Scalar, TimeSeries
|
|
15
|
+
from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects
|
|
16
|
+
from .structure import Model, SystemModel
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger('flixopt')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvestmentModel(Model):
|
|
22
|
+
"""Class for modeling an investment"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
model: SystemModel,
|
|
27
|
+
label_of_element: str,
|
|
28
|
+
parameters: InvestParameters,
|
|
29
|
+
defining_variable: [linopy.Variable],
|
|
30
|
+
relative_bounds_of_defining_variable: Tuple[NumericData, NumericData],
|
|
31
|
+
label: Optional[str] = None,
|
|
32
|
+
on_variable: Optional[linopy.Variable] = None,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(model, label_of_element, label)
|
|
35
|
+
self.size: Optional[Union[Scalar, linopy.Variable]] = None
|
|
36
|
+
self.is_invested: Optional[linopy.Variable] = None
|
|
37
|
+
|
|
38
|
+
self.piecewise_effects: Optional[PiecewiseEffectsModel] = None
|
|
39
|
+
|
|
40
|
+
self._on_variable = on_variable
|
|
41
|
+
self._defining_variable = defining_variable
|
|
42
|
+
self._relative_bounds_of_defining_variable = relative_bounds_of_defining_variable
|
|
43
|
+
self.parameters = parameters
|
|
44
|
+
|
|
45
|
+
def do_modeling(self):
|
|
46
|
+
if self.parameters.fixed_size and not self.parameters.optional:
|
|
47
|
+
self.size = self.add(
|
|
48
|
+
self._model.add_variables(
|
|
49
|
+
lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size'
|
|
50
|
+
),
|
|
51
|
+
'size',
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
self.size = self.add(
|
|
55
|
+
self._model.add_variables(
|
|
56
|
+
lower=0 if self.parameters.optional else self.parameters.minimum_size,
|
|
57
|
+
upper=self.parameters.maximum_size,
|
|
58
|
+
name=f'{self.label_full}|size',
|
|
59
|
+
),
|
|
60
|
+
'size',
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Optional
|
|
64
|
+
if self.parameters.optional:
|
|
65
|
+
self.is_invested = self.add(
|
|
66
|
+
self._model.add_variables(binary=True, name=f'{self.label_full}|is_invested'), 'is_invested'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self._create_bounds_for_optional_investment()
|
|
70
|
+
|
|
71
|
+
# Bounds for defining variable
|
|
72
|
+
self._create_bounds_for_defining_variable()
|
|
73
|
+
|
|
74
|
+
self._create_shares()
|
|
75
|
+
|
|
76
|
+
def _create_shares(self):
|
|
77
|
+
# fix_effects:
|
|
78
|
+
fix_effects = self.parameters.fix_effects
|
|
79
|
+
if fix_effects != {}:
|
|
80
|
+
self._model.effects.add_share_to_effects(
|
|
81
|
+
name=self.label_of_element,
|
|
82
|
+
expressions={
|
|
83
|
+
effect: self.is_invested * factor if self.is_invested is not None else factor
|
|
84
|
+
for effect, factor in fix_effects.items()
|
|
85
|
+
},
|
|
86
|
+
target='invest',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if self.parameters.divest_effects != {} and self.parameters.optional:
|
|
90
|
+
# share: divest_effects - isInvested * divest_effects
|
|
91
|
+
self._model.effects.add_share_to_effects(
|
|
92
|
+
name=self.label_of_element,
|
|
93
|
+
expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()},
|
|
94
|
+
target='invest',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if self.parameters.specific_effects != {}:
|
|
98
|
+
self._model.effects.add_share_to_effects(
|
|
99
|
+
name=self.label_of_element,
|
|
100
|
+
expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()},
|
|
101
|
+
target='invest',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if self.parameters.piecewise_effects:
|
|
105
|
+
self.piecewise_effects = self.add(
|
|
106
|
+
PiecewiseEffectsModel(
|
|
107
|
+
model=self._model,
|
|
108
|
+
label_of_element=self.label_of_element,
|
|
109
|
+
piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin),
|
|
110
|
+
piecewise_shares=self.parameters.piecewise_effects.piecewise_shares,
|
|
111
|
+
zero_point=self.is_invested,
|
|
112
|
+
),
|
|
113
|
+
'segments',
|
|
114
|
+
)
|
|
115
|
+
self.piecewise_effects.do_modeling()
|
|
116
|
+
|
|
117
|
+
def _create_bounds_for_optional_investment(self):
|
|
118
|
+
if self.parameters.fixed_size:
|
|
119
|
+
# eq: investment_size = isInvested * fixed_size
|
|
120
|
+
self.add(
|
|
121
|
+
self._model.add_constraints(
|
|
122
|
+
self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested'
|
|
123
|
+
),
|
|
124
|
+
'is_invested',
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
else:
|
|
128
|
+
# eq1: P_invest <= isInvested * investSize_max
|
|
129
|
+
self.add(
|
|
130
|
+
self._model.add_constraints(
|
|
131
|
+
self.size <= self.is_invested * self.parameters.maximum_size,
|
|
132
|
+
name=f'{self.label_full}|is_invested_ub',
|
|
133
|
+
),
|
|
134
|
+
'is_invested_ub',
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# eq2: P_invest >= isInvested * max(epsilon, investSize_min)
|
|
138
|
+
self.add(
|
|
139
|
+
self._model.add_constraints(
|
|
140
|
+
self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_size),
|
|
141
|
+
name=f'{self.label_full}|is_invested_lb',
|
|
142
|
+
),
|
|
143
|
+
'is_invested_lb',
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _create_bounds_for_defining_variable(self):
|
|
147
|
+
variable = self._defining_variable
|
|
148
|
+
lb_relative, ub_relative = self._relative_bounds_of_defining_variable
|
|
149
|
+
if np.all(lb_relative == ub_relative):
|
|
150
|
+
self.add(
|
|
151
|
+
self._model.add_constraints(
|
|
152
|
+
variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}'
|
|
153
|
+
),
|
|
154
|
+
f'fix_{variable.name}',
|
|
155
|
+
)
|
|
156
|
+
if self._on_variable is not None:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f'Flow {self.label} has a fixed relative flow rate and an on_variable.'
|
|
159
|
+
f'This combination is currently not supported.'
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# eq: defining_variable(t) <= size * upper_bound(t)
|
|
164
|
+
self.add(
|
|
165
|
+
self._model.add_constraints(
|
|
166
|
+
variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}'
|
|
167
|
+
),
|
|
168
|
+
f'ub_{variable.name}',
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if self._on_variable is None:
|
|
172
|
+
# eq: defining_variable(t) >= investment_size * relative_minimum(t)
|
|
173
|
+
self.add(
|
|
174
|
+
self._model.add_constraints(
|
|
175
|
+
variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'
|
|
176
|
+
),
|
|
177
|
+
f'lb_{variable.name}',
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
## 2. Gleichung: Minimum durch Investmentgröße und On
|
|
181
|
+
# eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t)
|
|
182
|
+
# ... mit mega = relative_maximum * maximum_size
|
|
183
|
+
# äquivalent zu:.
|
|
184
|
+
# eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega
|
|
185
|
+
mega = lb_relative * self.parameters.maximum_size
|
|
186
|
+
on = self._on_variable
|
|
187
|
+
self.add(
|
|
188
|
+
self._model.add_constraints(
|
|
189
|
+
variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'
|
|
190
|
+
),
|
|
191
|
+
f'lb_{variable.name}',
|
|
192
|
+
)
|
|
193
|
+
# anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ??
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class OnOffModel(Model):
|
|
197
|
+
"""
|
|
198
|
+
Class for modeling the on and off state of a variable
|
|
199
|
+
If defining_bounds are given, creates sufficient lower bounds
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
model: SystemModel,
|
|
205
|
+
on_off_parameters: OnOffParameters,
|
|
206
|
+
label_of_element: str,
|
|
207
|
+
defining_variables: List[linopy.Variable],
|
|
208
|
+
defining_bounds: List[Tuple[NumericData, NumericData]],
|
|
209
|
+
previous_values: List[Optional[NumericData]],
|
|
210
|
+
label: Optional[str] = None,
|
|
211
|
+
):
|
|
212
|
+
"""
|
|
213
|
+
Constructor for OnOffModel
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
model: Reference to the SystemModel
|
|
217
|
+
on_off_parameters: Parameters for the OnOffModel
|
|
218
|
+
label_of_element: Label of the Parent
|
|
219
|
+
defining_variables: List of Variables that are used to define the OnOffModel
|
|
220
|
+
defining_bounds: List of Tuples, defining the absolute bounds of each defining variable
|
|
221
|
+
previous_values: List of previous values of the defining variables
|
|
222
|
+
label: Label of the OnOffModel
|
|
223
|
+
"""
|
|
224
|
+
super().__init__(model, label_of_element, label)
|
|
225
|
+
assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff'
|
|
226
|
+
self.parameters = on_off_parameters
|
|
227
|
+
self._defining_variables = defining_variables
|
|
228
|
+
# Ensure that no lower bound is below a certain threshold
|
|
229
|
+
self._defining_bounds = [(np.maximum(lb, CONFIG.modeling.EPSILON), ub) for lb, ub in defining_bounds]
|
|
230
|
+
self._previous_values = previous_values
|
|
231
|
+
|
|
232
|
+
self.on: Optional[linopy.Variable] = None
|
|
233
|
+
self.total_on_hours: Optional[linopy.Variable] = None
|
|
234
|
+
|
|
235
|
+
self.consecutive_on_hours: Optional[linopy.Variable] = None
|
|
236
|
+
self.consecutive_off_hours: Optional[linopy.Variable] = None
|
|
237
|
+
|
|
238
|
+
self.off: Optional[linopy.Variable] = None
|
|
239
|
+
|
|
240
|
+
self.switch_on: Optional[linopy.Variable] = None
|
|
241
|
+
self.switch_off: Optional[linopy.Variable] = None
|
|
242
|
+
self.switch_on_nr: Optional[linopy.Variable] = None
|
|
243
|
+
|
|
244
|
+
def do_modeling(self):
|
|
245
|
+
self.on = self.add(
|
|
246
|
+
self._model.add_variables(
|
|
247
|
+
name=f'{self.label_full}|on',
|
|
248
|
+
binary=True,
|
|
249
|
+
coords=self._model.coords,
|
|
250
|
+
),
|
|
251
|
+
'on',
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
self.total_on_hours = self.add(
|
|
255
|
+
self._model.add_variables(
|
|
256
|
+
lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0,
|
|
257
|
+
upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf,
|
|
258
|
+
name=f'{self.label_full}|on_hours_total',
|
|
259
|
+
),
|
|
260
|
+
'on_hours_total',
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
self.add(
|
|
264
|
+
self._model.add_constraints(
|
|
265
|
+
self.total_on_hours == (self.on * self._model.hours_per_step).sum(),
|
|
266
|
+
name=f'{self.label_full}|on_hours_total',
|
|
267
|
+
),
|
|
268
|
+
'on_hours_total',
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
self._add_on_constraints()
|
|
272
|
+
|
|
273
|
+
if self.parameters.use_off:
|
|
274
|
+
self.off = self.add(
|
|
275
|
+
self._model.add_variables(
|
|
276
|
+
name=f'{self.label_full}|off',
|
|
277
|
+
binary=True,
|
|
278
|
+
coords=self._model.coords,
|
|
279
|
+
),
|
|
280
|
+
'off',
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# eq: var_on(t) + var_off(t) = 1
|
|
284
|
+
self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off')
|
|
285
|
+
|
|
286
|
+
if self.parameters.use_consecutive_on_hours:
|
|
287
|
+
self.consecutive_on_hours = self._get_duration_in_hours(
|
|
288
|
+
'consecutive_on_hours',
|
|
289
|
+
self.on,
|
|
290
|
+
self.previous_consecutive_on_hours,
|
|
291
|
+
self.parameters.consecutive_on_hours_min,
|
|
292
|
+
self.parameters.consecutive_on_hours_max,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if self.parameters.use_consecutive_off_hours:
|
|
296
|
+
self.consecutive_off_hours = self._get_duration_in_hours(
|
|
297
|
+
'consecutive_off_hours',
|
|
298
|
+
self.off,
|
|
299
|
+
self.previous_consecutive_off_hours,
|
|
300
|
+
self.parameters.consecutive_off_hours_min,
|
|
301
|
+
self.parameters.consecutive_off_hours_max,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if self.parameters.use_switch_on:
|
|
305
|
+
self.switch_on = self.add(
|
|
306
|
+
self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),
|
|
307
|
+
'switch_on',
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
self.switch_off = self.add(
|
|
311
|
+
self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords),
|
|
312
|
+
'switch_off',
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
self.switch_on_nr = self.add(
|
|
316
|
+
self._model.add_variables(
|
|
317
|
+
upper=self.parameters.switch_on_total_max
|
|
318
|
+
if self.parameters.switch_on_total_max is not None
|
|
319
|
+
else np.inf,
|
|
320
|
+
name=f'{self.label_full}|switch_on_nr',
|
|
321
|
+
),
|
|
322
|
+
'switch_on_nr',
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
self._add_switch_constraints()
|
|
326
|
+
|
|
327
|
+
self._create_shares()
|
|
328
|
+
|
|
329
|
+
def _add_on_constraints(self):
|
|
330
|
+
assert self.on is not None, f'On variable of {self.label_full} must be defined to add constraints'
|
|
331
|
+
# % Bedingungen 1) und 2) müssen erfüllt sein:
|
|
332
|
+
|
|
333
|
+
# % Anmerkung: Falls "abschnittsweise linear" gewählt, dann ist eigentlich nur Bedingung 1) noch notwendig
|
|
334
|
+
# % (und dann auch nur wenn erstes Piece bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):)
|
|
335
|
+
# % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal!
|
|
336
|
+
|
|
337
|
+
nr_of_def_vars = len(self._defining_variables)
|
|
338
|
+
assert nr_of_def_vars > 0, 'Achtung: mindestens 1 Flow notwendig'
|
|
339
|
+
|
|
340
|
+
if nr_of_def_vars == 1:
|
|
341
|
+
def_var = self._defining_variables[0]
|
|
342
|
+
lb, ub = self._defining_bounds[0]
|
|
343
|
+
|
|
344
|
+
# eq: On(t) * max(epsilon, lower_bound) <= Q_th(t)
|
|
345
|
+
self.add(
|
|
346
|
+
self._model.add_constraints(
|
|
347
|
+
self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1'
|
|
348
|
+
),
|
|
349
|
+
'on_con1',
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# eq: Q_th(t) <= Q_th_max * On(t)
|
|
353
|
+
self.add(
|
|
354
|
+
self._model.add_constraints(
|
|
355
|
+
self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2'
|
|
356
|
+
),
|
|
357
|
+
'on_con2',
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
else: # Bei mehreren Leistungsvariablen:
|
|
361
|
+
ub = sum(bound[1] for bound in self._defining_bounds)
|
|
362
|
+
lb = CONFIG.modeling.EPSILON
|
|
363
|
+
|
|
364
|
+
# When all defining variables are 0, On is 0
|
|
365
|
+
# eq: On(t) * Epsilon <= sum(alle Leistungen(t))
|
|
366
|
+
self.add(
|
|
367
|
+
self._model.add_constraints(
|
|
368
|
+
self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1'
|
|
369
|
+
),
|
|
370
|
+
'on_con1',
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
## sum(alle Leistung) >0 -> On = 1|On=0 -> sum(Leistung)=0
|
|
374
|
+
# eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0
|
|
375
|
+
# --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt:
|
|
376
|
+
# eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0
|
|
377
|
+
self.add(
|
|
378
|
+
self._model.add_constraints(
|
|
379
|
+
self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]),
|
|
380
|
+
name=f'{self.label_full}|on_con2',
|
|
381
|
+
),
|
|
382
|
+
'on_con2',
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if np.max(ub) > CONFIG.modeling.BIG_BINARY_BOUND:
|
|
386
|
+
logger.warning(
|
|
387
|
+
f'In "{self.label_full}", a binary definition was created with a big upper bound '
|
|
388
|
+
f'({np.max(ub)}). This can lead to wrong results regarding the on and off variables. '
|
|
389
|
+
f'Avoid this warning by reducing the size of {self.label_full} '
|
|
390
|
+
f'(or the maximum_size of the corresponding InvestParameters). '
|
|
391
|
+
f'If its a Component, you might need to adjust the sizes of all of its flows.'
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _get_duration_in_hours(
|
|
395
|
+
self,
|
|
396
|
+
variable_name: str,
|
|
397
|
+
binary_variable: linopy.Variable,
|
|
398
|
+
previous_duration: Scalar,
|
|
399
|
+
minimum_duration: Optional[TimeSeries],
|
|
400
|
+
maximum_duration: Optional[TimeSeries],
|
|
401
|
+
) -> linopy.Variable:
|
|
402
|
+
"""
|
|
403
|
+
creates duration variable and adds constraints to a time-series variable to enforce duration limits based on
|
|
404
|
+
binary activity.
|
|
405
|
+
The minimum duration in the last time step is not restricted.
|
|
406
|
+
Previous values before t=0 are not recognised!
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
variable_name: Label for the duration variable to be created.
|
|
410
|
+
binary_variable: Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states.
|
|
411
|
+
minimum_duration: Minimum duration the activity must remain active once started.
|
|
412
|
+
If None, no minimum duration constraint is applied.
|
|
413
|
+
maximum_duration: Maximum duration the activity can remain active.
|
|
414
|
+
If None, the maximum duration is set to the total available time.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The created duration variable representing consecutive active durations.
|
|
418
|
+
|
|
419
|
+
Example:
|
|
420
|
+
binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...]
|
|
421
|
+
duration_in_hours: [0, 0, 1, 2, 3, 4, 0, 1, 2, 3, 0, ...] (only if dt_in_hours=1)
|
|
422
|
+
|
|
423
|
+
Here, duration_in_hours increments while binary_variable is 1. Minimum and maximum durations
|
|
424
|
+
can be enforced to constrain how long the activity remains active.
|
|
425
|
+
|
|
426
|
+
Notes:
|
|
427
|
+
- To count consecutive zeros instead of ones, use a transformed binary variable
|
|
428
|
+
(e.g., `1 - binary_variable`).
|
|
429
|
+
- Constraints ensure the duration variable properly resets or increments based on activity.
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
AssertionError: If the binary_variable is None, indicating the duration constraints cannot be applied.
|
|
433
|
+
|
|
434
|
+
"""
|
|
435
|
+
assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints'
|
|
436
|
+
|
|
437
|
+
mega = self._model.hours_per_step.sum() + previous_duration
|
|
438
|
+
|
|
439
|
+
if maximum_duration is not None:
|
|
440
|
+
first_step_max: Scalar = maximum_duration.isel(time=0)
|
|
441
|
+
|
|
442
|
+
if previous_duration + self._model.hours_per_step[0] > first_step_max:
|
|
443
|
+
logger.warning(
|
|
444
|
+
f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, '
|
|
445
|
+
f'but the consecutive_duration previous to this model is {previous_duration}h. '
|
|
446
|
+
f'This forces "{binary_variable.name} = 0" in the first time step '
|
|
447
|
+
f'(dt={self._model.hours_per_step[0]}h)!'
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
duration_in_hours = self.add(
|
|
451
|
+
self._model.add_variables(
|
|
452
|
+
lower=0,
|
|
453
|
+
upper=maximum_duration.active_data if maximum_duration is not None else mega,
|
|
454
|
+
coords=self._model.coords,
|
|
455
|
+
name=f'{self.label_full}|{variable_name}',
|
|
456
|
+
),
|
|
457
|
+
variable_name,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# 1) eq: duration(t) - On(t) * BIG <= 0
|
|
461
|
+
self.add(
|
|
462
|
+
self._model.add_constraints(
|
|
463
|
+
duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1'
|
|
464
|
+
),
|
|
465
|
+
f'{variable_name}_con1',
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# 2a) eq: duration(t) - duration(t-1) <= dt(t)
|
|
469
|
+
# on(t)=1 -> duration(t) - duration(t-1) <= dt(t)
|
|
470
|
+
# on(t)=0 -> duration(t-1) >= negat. value
|
|
471
|
+
self.add(
|
|
472
|
+
self._model.add_constraints(
|
|
473
|
+
duration_in_hours.isel(time=slice(1, None))
|
|
474
|
+
<= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)),
|
|
475
|
+
name=f'{self.label_full}|{variable_name}_con2a',
|
|
476
|
+
),
|
|
477
|
+
f'{variable_name}_con2a',
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1)
|
|
481
|
+
# eq: -duration(t) + duration(t-1) + On(t) * BIG <= -dt(t) + BIG
|
|
482
|
+
# with BIG = dt_in_hours_total.
|
|
483
|
+
# on(t)=1 -> duration(t)- duration(t-1) >= dt(t)
|
|
484
|
+
# on(t)=0 -> duration(t)- duration(t-1) >= negat. value
|
|
485
|
+
|
|
486
|
+
self.add(
|
|
487
|
+
self._model.add_constraints(
|
|
488
|
+
duration_in_hours.isel(time=slice(1, None))
|
|
489
|
+
>= duration_in_hours.isel(time=slice(None, -1))
|
|
490
|
+
+ self._model.hours_per_step.isel(time=slice(None, -1))
|
|
491
|
+
+ (binary_variable.isel(time=slice(1, None)) - 1) * mega,
|
|
492
|
+
name=f'{self.label_full}|{variable_name}_con2b',
|
|
493
|
+
),
|
|
494
|
+
f'{variable_name}_con2b',
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# 3) check minimum_duration before switchOff-step
|
|
498
|
+
|
|
499
|
+
if minimum_duration is not None:
|
|
500
|
+
# Note: switchOff-step is when: On(t) - On(t+1) == 1
|
|
501
|
+
# Note: (last on-time period (with last timestep of period t=n) is not checked and can be shorter)
|
|
502
|
+
# Note: (previous values before t=1 are not recognised!)
|
|
503
|
+
# eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1)
|
|
504
|
+
# eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0
|
|
505
|
+
self.add(
|
|
506
|
+
self._model.add_constraints(
|
|
507
|
+
duration_in_hours
|
|
508
|
+
>= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None)))
|
|
509
|
+
* minimum_duration.isel(time=slice(None, -1)),
|
|
510
|
+
name=f'{self.label_full}|{variable_name}_minimum_duration',
|
|
511
|
+
),
|
|
512
|
+
f'{variable_name}_minimum_duration',
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if 0 < previous_duration < minimum_duration.isel(time=0):
|
|
516
|
+
# Force the first step to be = 1, if the minimum_duration is not reached in previous_values
|
|
517
|
+
# Note: Only if the previous consecutive_duration is smaller than the minimum duration
|
|
518
|
+
# and the previous_duration is greater 0!
|
|
519
|
+
# eq: On(t=0) = 1
|
|
520
|
+
self.add(
|
|
521
|
+
self._model.add_constraints(
|
|
522
|
+
binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital'
|
|
523
|
+
),
|
|
524
|
+
f'{variable_name}_minimum_inital',
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# 4) first index:
|
|
528
|
+
# eq: duration(t=0)= dt(0) * On(0)
|
|
529
|
+
self.add(
|
|
530
|
+
self._model.add_constraints(
|
|
531
|
+
duration_in_hours.isel(time=0)
|
|
532
|
+
== self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0),
|
|
533
|
+
name=f'{self.label_full}|{variable_name}_initial',
|
|
534
|
+
),
|
|
535
|
+
f'{variable_name}_initial',
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return duration_in_hours
|
|
539
|
+
|
|
540
|
+
def _add_switch_constraints(self):
|
|
541
|
+
assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints'
|
|
542
|
+
assert self.switch_off is not None, (
|
|
543
|
+
f'Switch Off Variable of {self.label_full} must be defined to add constraints'
|
|
544
|
+
)
|
|
545
|
+
assert self.switch_on_nr is not None, (
|
|
546
|
+
f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints'
|
|
547
|
+
)
|
|
548
|
+
assert self.on is not None, f'On Variable of {self.label_full} must be defined to add constraints'
|
|
549
|
+
# % Schaltänderung aus On-Variable
|
|
550
|
+
# % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1)
|
|
551
|
+
self.add(
|
|
552
|
+
self._model.add_constraints(
|
|
553
|
+
self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None))
|
|
554
|
+
== self.on.isel(time=slice(1, None)) - self.on.isel(time=slice(None, -1)),
|
|
555
|
+
name=f'{self.label_full}|switch_con',
|
|
556
|
+
),
|
|
557
|
+
'switch_con',
|
|
558
|
+
)
|
|
559
|
+
# Initital switch on
|
|
560
|
+
# eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1)
|
|
561
|
+
self.add(
|
|
562
|
+
self._model.add_constraints(
|
|
563
|
+
self.switch_on.isel(time=0) - self.switch_off.isel(time=0)
|
|
564
|
+
== self.on.isel(time=0) - self.previous_on_values[-1],
|
|
565
|
+
name=f'{self.label_full}|initial_switch_con',
|
|
566
|
+
),
|
|
567
|
+
'initial_switch_con',
|
|
568
|
+
)
|
|
569
|
+
## Entweder SwitchOff oder SwitchOn
|
|
570
|
+
# eq: SwitchOn(t) + SwitchOff(t) <= 1.1
|
|
571
|
+
self.add(
|
|
572
|
+
self._model.add_constraints(
|
|
573
|
+
self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'
|
|
574
|
+
),
|
|
575
|
+
'switch_on_or_off',
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
## Anzahl Starts:
|
|
579
|
+
# eq: nrSwitchOn = sum(SwitchOn(t))
|
|
580
|
+
self.add(
|
|
581
|
+
self._model.add_constraints(
|
|
582
|
+
self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr'
|
|
583
|
+
),
|
|
584
|
+
'switch_on_nr',
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def _create_shares(self):
|
|
588
|
+
# Anfahrkosten:
|
|
589
|
+
effects_per_switch_on = self.parameters.effects_per_switch_on
|
|
590
|
+
if effects_per_switch_on != {}:
|
|
591
|
+
self._model.effects.add_share_to_effects(
|
|
592
|
+
name=self.label_of_element,
|
|
593
|
+
expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()},
|
|
594
|
+
target='operation',
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Betriebskosten:
|
|
598
|
+
effects_per_running_hour = self.parameters.effects_per_running_hour
|
|
599
|
+
if effects_per_running_hour != {}:
|
|
600
|
+
self._model.effects.add_share_to_effects(
|
|
601
|
+
name=self.label_of_element,
|
|
602
|
+
expressions={
|
|
603
|
+
effect: self.on * factor * self._model.hours_per_step
|
|
604
|
+
for effect, factor in effects_per_running_hour.items()
|
|
605
|
+
},
|
|
606
|
+
target='operation',
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def previous_on_values(self) -> np.ndarray:
|
|
611
|
+
return self.compute_previous_on_states(self._previous_values)
|
|
612
|
+
|
|
613
|
+
@property
|
|
614
|
+
def previous_off_values(self) -> np.ndarray:
|
|
615
|
+
return 1 - self.previous_on_values
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def previous_consecutive_on_hours(self) -> Scalar:
|
|
619
|
+
return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step)
|
|
620
|
+
|
|
621
|
+
@property
|
|
622
|
+
def previous_consecutive_off_hours(self) -> Scalar:
|
|
623
|
+
return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step)
|
|
624
|
+
|
|
625
|
+
@staticmethod
|
|
626
|
+
def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray:
|
|
627
|
+
"""
|
|
628
|
+
Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored)
|
|
632
|
+
epsilon: Tolerance for equality to determine "off" state, default is 1e-5.
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
A binary array (0 and 1) indicating the previous on/off states of the variables.
|
|
636
|
+
Returns `array([0])` if no previous values are available.
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
if not previous_values or all([val is None for val in previous_values]):
|
|
640
|
+
return np.array([0])
|
|
641
|
+
else: # Convert to 2D-array and compute binary on/off states
|
|
642
|
+
previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None
|
|
643
|
+
if previous_values.ndim > 1:
|
|
644
|
+
return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int)
|
|
645
|
+
else:
|
|
646
|
+
return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int)
|
|
647
|
+
|
|
648
|
+
@staticmethod
|
|
649
|
+
def compute_consecutive_duration(
|
|
650
|
+
binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray]
|
|
651
|
+
) -> Scalar:
|
|
652
|
+
"""
|
|
653
|
+
Computes the final consecutive duration in State 'on' (=1) in hours, from a binary.
|
|
654
|
+
|
|
655
|
+
hours_per_timestep is handled in a way, that maximizes compatability.
|
|
656
|
+
Its length must only be as long as the last consecutive duration in binary_values.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
binary_values: An int or 1D binary array containing only `0`s and `1`s.
|
|
660
|
+
hours_per_timestep: The duration of each timestep in hours.
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
The duration of the binary variable in hours.
|
|
664
|
+
|
|
665
|
+
Raises
|
|
666
|
+
------
|
|
667
|
+
TypeError
|
|
668
|
+
If the length of binary_values and dt_in_hours is not equal, but None is a scalar.
|
|
669
|
+
"""
|
|
670
|
+
if np.isscalar(binary_values) and np.isscalar(hours_per_timestep):
|
|
671
|
+
return binary_values * hours_per_timestep
|
|
672
|
+
elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep):
|
|
673
|
+
return binary_values * hours_per_timestep[-1]
|
|
674
|
+
|
|
675
|
+
# Find the indexes where value=`0` in a 1D-array
|
|
676
|
+
zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0]
|
|
677
|
+
length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values)
|
|
678
|
+
|
|
679
|
+
if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep):
|
|
680
|
+
return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep)
|
|
681
|
+
|
|
682
|
+
elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep):
|
|
683
|
+
if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible
|
|
684
|
+
raise TypeError(
|
|
685
|
+
f'When trying to calculate the consecutive duration, the length of the last duration '
|
|
686
|
+
f'({len(length_of_last_duration)}) is longer than the hours_per_timestep ({len(hours_per_timestep)}), '
|
|
687
|
+
f'as {binary_values=}'
|
|
688
|
+
)
|
|
689
|
+
return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:])
|
|
690
|
+
|
|
691
|
+
else:
|
|
692
|
+
raise Exception(
|
|
693
|
+
f'Unexpected state reached in function get_consecutive_duration(). binary_values={binary_values}; '
|
|
694
|
+
f'hours_per_timestep={hours_per_timestep}'
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class PieceModel(Model):
|
|
699
|
+
"""Class for modeling a linear piece of one or more variables in parallel"""
|
|
700
|
+
|
|
701
|
+
def __init__(
|
|
702
|
+
self,
|
|
703
|
+
model: SystemModel,
|
|
704
|
+
label_of_element: str,
|
|
705
|
+
label: str,
|
|
706
|
+
as_time_series: bool = True,
|
|
707
|
+
):
|
|
708
|
+
super().__init__(model, label_of_element, label)
|
|
709
|
+
self.inside_piece: Optional[linopy.Variable] = None
|
|
710
|
+
self.lambda0: Optional[linopy.Variable] = None
|
|
711
|
+
self.lambda1: Optional[linopy.Variable] = None
|
|
712
|
+
self._as_time_series = as_time_series
|
|
713
|
+
|
|
714
|
+
def do_modeling(self):
|
|
715
|
+
self.inside_piece = self.add(
|
|
716
|
+
self._model.add_variables(
|
|
717
|
+
binary=True,
|
|
718
|
+
name=f'{self.label_full}|inside_piece',
|
|
719
|
+
coords=self._model.coords if self._as_time_series else None,
|
|
720
|
+
),
|
|
721
|
+
'inside_piece',
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
self.lambda0 = self.add(
|
|
725
|
+
self._model.add_variables(
|
|
726
|
+
lower=0,
|
|
727
|
+
upper=1,
|
|
728
|
+
name=f'{self.label_full}|lambda0',
|
|
729
|
+
coords=self._model.coords if self._as_time_series else None,
|
|
730
|
+
),
|
|
731
|
+
'lambda0',
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
self.lambda1 = self.add(
|
|
735
|
+
self._model.add_variables(
|
|
736
|
+
lower=0,
|
|
737
|
+
upper=1,
|
|
738
|
+
name=f'{self.label_full}|lambda1',
|
|
739
|
+
coords=self._model.coords if self._as_time_series else None,
|
|
740
|
+
),
|
|
741
|
+
'lambda1',
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# eq: lambda0(t) + lambda1(t) = inside_piece(t)
|
|
745
|
+
self.add(
|
|
746
|
+
self._model.add_constraints(
|
|
747
|
+
self.inside_piece == self.lambda0 + self.lambda1, name=f'{self.label_full}|inside_piece'
|
|
748
|
+
),
|
|
749
|
+
'inside_piece',
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class PiecewiseModel(Model):
|
|
754
|
+
def __init__(
|
|
755
|
+
self,
|
|
756
|
+
model: SystemModel,
|
|
757
|
+
label_of_element: str,
|
|
758
|
+
label: str,
|
|
759
|
+
piecewise_variables: Dict[str, Piecewise],
|
|
760
|
+
zero_point: Optional[Union[bool, linopy.Variable]],
|
|
761
|
+
as_time_series: bool,
|
|
762
|
+
):
|
|
763
|
+
"""
|
|
764
|
+
Modeling a Piecewise relation between miultiple variables.
|
|
765
|
+
The relation is defined by a list of Pieces, which are assigned to the variables.
|
|
766
|
+
Each Piece is a tuple of (start, end).
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
model: The SystemModel that is used to create the model.
|
|
770
|
+
label_of_element: The label of the parent (Element). Used to construct the full label of the model.
|
|
771
|
+
label: The label of the model. Used to construct the full label of the model.
|
|
772
|
+
piecewise_variables: The variables to which the Pieces are assigned.
|
|
773
|
+
zero_point: A variable that can be used to define a zero point for the Piecewise relation. If None or False, no zero point is defined.
|
|
774
|
+
as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable.
|
|
775
|
+
"""
|
|
776
|
+
super().__init__(model, label_of_element, label)
|
|
777
|
+
self._piecewise_variables = piecewise_variables
|
|
778
|
+
self._zero_point = zero_point
|
|
779
|
+
self._as_time_series = as_time_series
|
|
780
|
+
|
|
781
|
+
self.pieces: List[PieceModel] = []
|
|
782
|
+
self.zero_point: Optional[linopy.Variable] = None
|
|
783
|
+
|
|
784
|
+
def do_modeling(self):
|
|
785
|
+
for i in range(len(list(self._piecewise_variables.values())[0])):
|
|
786
|
+
new_piece = self.add(
|
|
787
|
+
PieceModel(
|
|
788
|
+
model=self._model,
|
|
789
|
+
label_of_element=self.label_of_element,
|
|
790
|
+
label=f'Piece_{i}',
|
|
791
|
+
as_time_series=self._as_time_series,
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
self.pieces.append(new_piece)
|
|
795
|
+
new_piece.do_modeling()
|
|
796
|
+
|
|
797
|
+
for var_name in self._piecewise_variables:
|
|
798
|
+
variable = self._model.variables[var_name]
|
|
799
|
+
self.add(
|
|
800
|
+
self._model.add_constraints(
|
|
801
|
+
variable
|
|
802
|
+
== sum(
|
|
803
|
+
[
|
|
804
|
+
piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end
|
|
805
|
+
for piece_model, piece_bounds in zip(
|
|
806
|
+
self.pieces, self._piecewise_variables[var_name], strict=False
|
|
807
|
+
)
|
|
808
|
+
]
|
|
809
|
+
),
|
|
810
|
+
name=f'{self.label_full}|{var_name}_lambda',
|
|
811
|
+
),
|
|
812
|
+
f'{var_name}_lambda',
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt
|
|
816
|
+
# b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein
|
|
817
|
+
if isinstance(self._zero_point, linopy.Variable):
|
|
818
|
+
self.zero_point = self._zero_point
|
|
819
|
+
rhs = self.zero_point
|
|
820
|
+
elif self._zero_point is True:
|
|
821
|
+
self.zero_point = self.add(
|
|
822
|
+
self._model.add_variables(
|
|
823
|
+
coords=self._model.coords, binary=True, name=f'{self.label_full}|zero_point'
|
|
824
|
+
),
|
|
825
|
+
'zero_point',
|
|
826
|
+
)
|
|
827
|
+
rhs = self.zero_point
|
|
828
|
+
else:
|
|
829
|
+
rhs = 1
|
|
830
|
+
|
|
831
|
+
self.add(
|
|
832
|
+
self._model.add_constraints(
|
|
833
|
+
sum([piece.inside_piece for piece in self.pieces]) <= rhs,
|
|
834
|
+
name=f'{self.label_full}|{variable.name}_single_segment',
|
|
835
|
+
),
|
|
836
|
+
'single_segment',
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class ShareAllocationModel(Model):
|
|
841
|
+
def __init__(
|
|
842
|
+
self,
|
|
843
|
+
model: SystemModel,
|
|
844
|
+
shares_are_time_series: bool,
|
|
845
|
+
label_of_element: Optional[str] = None,
|
|
846
|
+
label: Optional[str] = None,
|
|
847
|
+
label_full: Optional[str] = None,
|
|
848
|
+
total_max: Optional[Scalar] = None,
|
|
849
|
+
total_min: Optional[Scalar] = None,
|
|
850
|
+
max_per_hour: Optional[NumericData] = None,
|
|
851
|
+
min_per_hour: Optional[NumericData] = None,
|
|
852
|
+
):
|
|
853
|
+
super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full)
|
|
854
|
+
if not shares_are_time_series: # If the condition is True
|
|
855
|
+
assert max_per_hour is None and min_per_hour is None, (
|
|
856
|
+
'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False'
|
|
857
|
+
)
|
|
858
|
+
self.total_per_timestep: Optional[linopy.Variable] = None
|
|
859
|
+
self.total: Optional[linopy.Variable] = None
|
|
860
|
+
self.shares: Dict[str, linopy.Variable] = {}
|
|
861
|
+
self.share_constraints: Dict[str, linopy.Constraint] = {}
|
|
862
|
+
|
|
863
|
+
self._eq_total_per_timestep: Optional[linopy.Constraint] = None
|
|
864
|
+
self._eq_total: Optional[linopy.Constraint] = None
|
|
865
|
+
|
|
866
|
+
# Parameters
|
|
867
|
+
self._shares_are_time_series = shares_are_time_series
|
|
868
|
+
self._total_max = total_max if total_min is not None else np.inf
|
|
869
|
+
self._total_min = total_min if total_min is not None else -np.inf
|
|
870
|
+
self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf
|
|
871
|
+
self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf
|
|
872
|
+
|
|
873
|
+
def do_modeling(self):
|
|
874
|
+
self.total = self.add(
|
|
875
|
+
self._model.add_variables(
|
|
876
|
+
lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total'
|
|
877
|
+
),
|
|
878
|
+
'total',
|
|
879
|
+
)
|
|
880
|
+
# eq: sum = sum(share_i) # skalar
|
|
881
|
+
self._eq_total = self.add(
|
|
882
|
+
self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total'
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
if self._shares_are_time_series:
|
|
886
|
+
self.total_per_timestep = self.add(
|
|
887
|
+
self._model.add_variables(
|
|
888
|
+
lower=-np.inf
|
|
889
|
+
if (self._min_per_hour is None)
|
|
890
|
+
else np.multiply(self._min_per_hour, self._model.hours_per_step),
|
|
891
|
+
upper=np.inf
|
|
892
|
+
if (self._max_per_hour is None)
|
|
893
|
+
else np.multiply(self._max_per_hour, self._model.hours_per_step),
|
|
894
|
+
coords=self._model.coords,
|
|
895
|
+
name=f'{self.label_full}|total_per_timestep',
|
|
896
|
+
),
|
|
897
|
+
'total_per_timestep',
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
self._eq_total_per_timestep = self.add(
|
|
901
|
+
self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'),
|
|
902
|
+
'total_per_timestep',
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
# Add it to the total
|
|
906
|
+
self._eq_total.lhs -= self.total_per_timestep.sum()
|
|
907
|
+
|
|
908
|
+
def add_share(
|
|
909
|
+
self,
|
|
910
|
+
name: str,
|
|
911
|
+
expression: linopy.LinearExpression,
|
|
912
|
+
):
|
|
913
|
+
"""
|
|
914
|
+
Add a share to the share allocation model. If the share already exists, the expression is added to the existing share.
|
|
915
|
+
The expression is added to the right hand side (rhs) of the constraint.
|
|
916
|
+
The variable representing the total share is on the left hand side (lhs) of the constraint.
|
|
917
|
+
var_total = sum(expressions)
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
name: The name of the share.
|
|
921
|
+
expression: The expression of the share. Added to the right hand side of the constraint.
|
|
922
|
+
"""
|
|
923
|
+
if name in self.shares:
|
|
924
|
+
self.share_constraints[name].lhs -= expression
|
|
925
|
+
else:
|
|
926
|
+
self.shares[name] = self.add(
|
|
927
|
+
self._model.add_variables(
|
|
928
|
+
coords=None
|
|
929
|
+
if isinstance(expression, linopy.LinearExpression)
|
|
930
|
+
and expression.ndim == 0
|
|
931
|
+
or not isinstance(expression, linopy.LinearExpression)
|
|
932
|
+
else self._model.coords,
|
|
933
|
+
name=f'{name}->{self.label_full}',
|
|
934
|
+
),
|
|
935
|
+
name,
|
|
936
|
+
)
|
|
937
|
+
self.share_constraints[name] = self.add(
|
|
938
|
+
self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name
|
|
939
|
+
)
|
|
940
|
+
if self.shares[name].ndim == 0:
|
|
941
|
+
self._eq_total.lhs -= self.shares[name]
|
|
942
|
+
else:
|
|
943
|
+
self._eq_total_per_timestep.lhs -= self.shares[name]
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class PiecewiseEffectsModel(Model):
|
|
947
|
+
def __init__(
|
|
948
|
+
self,
|
|
949
|
+
model: SystemModel,
|
|
950
|
+
label_of_element: str,
|
|
951
|
+
piecewise_origin: Tuple[str, Piecewise],
|
|
952
|
+
piecewise_shares: Dict[str, Piecewise],
|
|
953
|
+
zero_point: Optional[Union[bool, linopy.Variable]],
|
|
954
|
+
label: str = 'PiecewiseEffects',
|
|
955
|
+
):
|
|
956
|
+
super().__init__(model, label_of_element, label)
|
|
957
|
+
assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), (
|
|
958
|
+
'Piece length of variable_segments and share_segments must be equal'
|
|
959
|
+
)
|
|
960
|
+
self._zero_point = zero_point
|
|
961
|
+
self._piecewise_origin = piecewise_origin
|
|
962
|
+
self._piecewise_shares = piecewise_shares
|
|
963
|
+
self.shares: Dict[str, linopy.Variable] = {}
|
|
964
|
+
|
|
965
|
+
self.piecewise_model: Optional[PiecewiseModel] = None
|
|
966
|
+
|
|
967
|
+
def do_modeling(self):
|
|
968
|
+
self.shares = {
|
|
969
|
+
effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}')
|
|
970
|
+
for effect in self._piecewise_shares
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
piecewise_variables = {
|
|
974
|
+
self._piecewise_origin[0]: self._piecewise_origin[1],
|
|
975
|
+
**{
|
|
976
|
+
self.shares[effect_label].name: self._piecewise_shares[effect_label]
|
|
977
|
+
for effect_label in self._piecewise_shares
|
|
978
|
+
},
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
self.piecewise_model = self.add(
|
|
982
|
+
PiecewiseModel(
|
|
983
|
+
model=self._model,
|
|
984
|
+
label_of_element=self.label_of_element,
|
|
985
|
+
label=f'{self.label_full}|PiecewiseModel',
|
|
986
|
+
piecewise_variables=piecewise_variables,
|
|
987
|
+
zero_point=self._zero_point,
|
|
988
|
+
as_time_series=False,
|
|
989
|
+
)
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
self.piecewise_model.do_modeling()
|
|
993
|
+
|
|
994
|
+
# Shares
|
|
995
|
+
self._model.effects.add_share_to_effects(
|
|
996
|
+
name=self.label_of_element,
|
|
997
|
+
expressions={effect: variable * 1 for effect, variable in self.shares.items()},
|
|
998
|
+
target='invest',
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
class PreventSimultaneousUsageModel(Model):
|
|
1003
|
+
"""
|
|
1004
|
+
Prevents multiple Multiple Binary variables from being 1 at the same time
|
|
1005
|
+
|
|
1006
|
+
Only 'classic type is modeled for now (# "classic" -> alle Flows brauchen Binärvariable:)
|
|
1007
|
+
In 'new', the binary Variables need to be forced beforehand, which is not that straight forward... --> TODO maybe
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
# "new":
|
|
1011
|
+
# eq: flow_1.on(t) + flow_2.on(t) + .. + flow_i.val(t)/flow_i.max <= 1 (1 Flow ohne Binärvariable!)
|
|
1012
|
+
|
|
1013
|
+
# Anmerkung: Patrick Schönfeld (oemof, custom/link.py) macht bei 2 Flows ohne Binärvariable dies:
|
|
1014
|
+
# 1) bin + flow1/flow1_max <= 1
|
|
1015
|
+
# 2) bin - flow2/flow2_max >= 0
|
|
1016
|
+
# 3) geht nur, wenn alle flow.min >= 0
|
|
1017
|
+
# --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen)
|
|
1018
|
+
"""
|
|
1019
|
+
|
|
1020
|
+
def __init__(
|
|
1021
|
+
self,
|
|
1022
|
+
model: SystemModel,
|
|
1023
|
+
variables: List[linopy.Variable],
|
|
1024
|
+
label_of_element: str,
|
|
1025
|
+
label: str = 'PreventSimultaneousUsage',
|
|
1026
|
+
):
|
|
1027
|
+
super().__init__(model, label_of_element, label)
|
|
1028
|
+
self._simultanious_use_variables = variables
|
|
1029
|
+
assert len(self._simultanious_use_variables) >= 2, (
|
|
1030
|
+
f'Model {self.__class__.__name__} must get at least two variables'
|
|
1031
|
+
)
|
|
1032
|
+
for variable in self._simultanious_use_variables: # classic
|
|
1033
|
+
assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}'
|
|
1034
|
+
|
|
1035
|
+
def do_modeling(self):
|
|
1036
|
+
# eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit)
|
|
1037
|
+
self.add(
|
|
1038
|
+
self._model.add_constraints(
|
|
1039
|
+
sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use'
|
|
1040
|
+
),
|
|
1041
|
+
'prevent_simultaneous_use',
|
|
1042
|
+
)
|