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.

Files changed (73) hide show
  1. docs/examples/00-Minimal Example.md +5 -0
  2. docs/examples/01-Basic Example.md +5 -0
  3. docs/examples/02-Complex Example.md +10 -0
  4. docs/examples/03-Calculation Modes.md +5 -0
  5. docs/examples/index.md +5 -0
  6. docs/faq/contribute.md +49 -0
  7. docs/faq/index.md +3 -0
  8. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  9. docs/images/architecture_flixOpt.png +0 -0
  10. docs/images/flixopt-icon.svg +1 -0
  11. docs/javascripts/mathjax.js +18 -0
  12. docs/release-notes/_template.txt +32 -0
  13. docs/release-notes/index.md +7 -0
  14. docs/release-notes/v2.0.0.md +93 -0
  15. docs/release-notes/v2.0.1.md +12 -0
  16. docs/user-guide/Mathematical Notation/Bus.md +33 -0
  17. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +132 -0
  18. docs/user-guide/Mathematical Notation/Flow.md +26 -0
  19. docs/user-guide/Mathematical Notation/LinearConverter.md +21 -0
  20. docs/user-guide/Mathematical Notation/Piecewise.md +49 -0
  21. docs/user-guide/Mathematical Notation/Storage.md +44 -0
  22. docs/user-guide/Mathematical Notation/index.md +22 -0
  23. docs/user-guide/Mathematical Notation/others.md +3 -0
  24. docs/user-guide/index.md +124 -0
  25. {flixOpt → flixopt}/__init__.py +5 -2
  26. {flixOpt → flixopt}/aggregation.py +113 -140
  27. flixopt/calculation.py +455 -0
  28. {flixOpt → flixopt}/commons.py +7 -4
  29. flixopt/components.py +630 -0
  30. {flixOpt → flixopt}/config.py +9 -8
  31. {flixOpt → flixopt}/config.yaml +3 -3
  32. flixopt/core.py +970 -0
  33. flixopt/effects.py +386 -0
  34. flixopt/elements.py +534 -0
  35. flixopt/features.py +1042 -0
  36. flixopt/flow_system.py +409 -0
  37. flixopt/interface.py +265 -0
  38. flixopt/io.py +308 -0
  39. flixopt/linear_converters.py +331 -0
  40. flixopt/plotting.py +1340 -0
  41. flixopt/results.py +898 -0
  42. flixopt/solvers.py +77 -0
  43. flixopt/structure.py +630 -0
  44. flixopt/utils.py +62 -0
  45. flixopt-2.0.1.dist-info/METADATA +145 -0
  46. flixopt-2.0.1.dist-info/RECORD +57 -0
  47. {flixopt-1.0.12.dist-info → flixopt-2.0.1.dist-info}/WHEEL +1 -1
  48. flixopt-2.0.1.dist-info/top_level.txt +6 -0
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixopt-icon.svg +1 -0
  52. pics/pics.pptx +0 -0
  53. scripts/gen_ref_pages.py +54 -0
  54. site/release-notes/_template.txt +32 -0
  55. flixOpt/calculation.py +0 -629
  56. flixOpt/components.py +0 -614
  57. flixOpt/core.py +0 -182
  58. flixOpt/effects.py +0 -410
  59. flixOpt/elements.py +0 -489
  60. flixOpt/features.py +0 -942
  61. flixOpt/flow_system.py +0 -351
  62. flixOpt/interface.py +0 -203
  63. flixOpt/linear_converters.py +0 -325
  64. flixOpt/math_modeling.py +0 -1145
  65. flixOpt/plotting.py +0 -712
  66. flixOpt/results.py +0 -563
  67. flixOpt/solvers.py +0 -21
  68. flixOpt/structure.py +0 -733
  69. flixOpt/utils.py +0 -134
  70. flixopt-1.0.12.dist-info/METADATA +0 -174
  71. flixopt-1.0.12.dist-info/RECORD +0 -29
  72. flixopt-1.0.12.dist-info/top_level.txt +0 -3
  73. {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
+ )