flixopt 2.2.0rc2__py3-none-any.whl → 3.0.0__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 (58) hide show
  1. flixopt/__init__.py +33 -4
  2. flixopt/aggregation.py +60 -80
  3. flixopt/calculation.py +395 -178
  4. flixopt/commons.py +1 -10
  5. flixopt/components.py +939 -448
  6. flixopt/config.py +553 -191
  7. flixopt/core.py +513 -846
  8. flixopt/effects.py +644 -178
  9. flixopt/elements.py +610 -355
  10. flixopt/features.py +394 -966
  11. flixopt/flow_system.py +736 -219
  12. flixopt/interface.py +1104 -302
  13. flixopt/io.py +103 -79
  14. flixopt/linear_converters.py +387 -95
  15. flixopt/modeling.py +759 -0
  16. flixopt/network_app.py +73 -39
  17. flixopt/plotting.py +294 -138
  18. flixopt/results.py +1253 -299
  19. flixopt/solvers.py +25 -21
  20. flixopt/structure.py +938 -396
  21. flixopt/utils.py +38 -12
  22. flixopt-3.0.0.dist-info/METADATA +209 -0
  23. flixopt-3.0.0.dist-info/RECORD +26 -0
  24. flixopt-3.0.0.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -61
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  37. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  38. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  39. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  40. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  41. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  42. docs/user-guide/Mathematical Notation/index.md +0 -22
  43. docs/user-guide/Mathematical Notation/others.md +0 -3
  44. docs/user-guide/index.md +0 -124
  45. flixopt/config.yaml +0 -10
  46. flixopt-2.2.0rc2.dist-info/METADATA +0 -167
  47. flixopt-2.2.0rc2.dist-info/RECORD +0 -54
  48. flixopt-2.2.0rc2.dist-info/top_level.txt +0 -5
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixOpt_plotting.jpg +0 -0
  52. pics/flixopt-icon.svg +0 -1
  53. pics/pics.pptx +0 -0
  54. scripts/extract_release_notes.py +0 -45
  55. scripts/gen_ref_pages.py +0 -54
  56. tests/ressources/Zeitreihen2020.csv +0 -35137
  57. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/WHEEL +0 -0
  58. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/features.py CHANGED
@@ -3,1054 +3,482 @@ This module contains the features of the flixopt framework.
3
3
  Features extend the functionality of Elements.
4
4
  """
5
5
 
6
+ from __future__ import annotations
7
+
6
8
  import logging
7
- from typing import Dict, List, Optional, Tuple, Union
9
+ from typing import TYPE_CHECKING
8
10
 
9
11
  import linopy
10
12
  import numpy as np
11
13
 
12
- from . import utils
13
- from .config import CONFIG
14
- from .core import NumericData, Scalar, TimeSeries
15
- from .interface import InvestParameters, OnOffParameters, Piece, Piecewise
16
- from .structure import Model, SystemModel
14
+ from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities
15
+ from .structure import FlowSystemModel, Submodel
16
+
17
+ if TYPE_CHECKING:
18
+ from .core import FlowSystemDimensions, Scalar, TemporalData
19
+ from .interface import InvestParameters, OnOffParameters, Piecewise
17
20
 
18
21
  logger = logging.getLogger('flixopt')
19
22
 
20
23
 
21
- class InvestmentModel(Model):
22
- """Class for modeling an investment"""
24
+ class InvestmentModel(Submodel):
25
+ """
26
+ This feature model is used to model the investment of a variable.
27
+ It applies the corresponding bounds to the variable and the on/off state of the variable.
28
+
29
+ Args:
30
+ model: The optimization model instance
31
+ label_of_element: The label of the parent (Element). Used to construct the full label of the model.
32
+ parameters: The parameters of the feature model.
33
+ label_of_model: The label of the model. This is needed to construct the full label of the model.
34
+
35
+ """
36
+
37
+ parameters: InvestParameters
23
38
 
24
39
  def __init__(
25
40
  self,
26
- model: SystemModel,
41
+ model: FlowSystemModel,
27
42
  label_of_element: str,
28
43
  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,
44
+ label_of_model: str | None = None,
33
45
  ):
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
46
+ self.piecewise_effects: PiecewiseEffectsModel | None = None
47
+ self.parameters = parameters
48
+ super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
37
49
 
38
- self.piecewise_effects: Optional[PiecewiseEffectsModel] = None
50
+ def _do_modeling(self):
51
+ super()._do_modeling()
52
+ self._create_variables_and_constraints()
53
+ self._add_effects()
39
54
 
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
55
+ def _create_variables_and_constraints(self):
56
+ size_min, size_max = (self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size)
57
+ if self.parameters.linked_periods is not None:
58
+ # Mask size bounds: linked_periods is a binary DataArray that zeros out non-linked periods
59
+ size_min = size_min * self.parameters.linked_periods
60
+ size_max = size_max * self.parameters.linked_periods
44
61
 
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',
62
+ self.add_variables(
63
+ short_name='size',
64
+ lower=size_min if self.parameters.mandatory else 0,
65
+ upper=size_max,
66
+ coords=self._model.get_coords(['period', 'scenario']),
67
+ )
68
+
69
+ if not self.parameters.mandatory:
70
+ self.add_variables(
71
+ binary=True,
72
+ coords=self._model.get_coords(['period', 'scenario']),
73
+ short_name='invested',
52
74
  )
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',
75
+ BoundingPatterns.bounds_with_state(
76
+ self,
77
+ variable=self.size,
78
+ variable_state=self._variables['invested'],
79
+ bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size),
61
80
  )
62
81
 
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'
82
+ if self.parameters.linked_periods is not None:
83
+ masked_size = self.size.where(self.parameters.linked_periods, drop=True)
84
+ self.add_constraints(
85
+ masked_size.isel(period=slice(None, -1)) == masked_size.isel(period=slice(1, None)),
86
+ short_name='linked_periods',
67
87
  )
68
88
 
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 != {}:
89
+ def _add_effects(self):
90
+ """Add investment effects"""
91
+ if self.parameters.effects_of_investment:
80
92
  self._model.effects.add_share_to_effects(
81
93
  name=self.label_of_element,
82
94
  expressions={
83
- effect: self.is_invested * factor if self.is_invested is not None else factor
84
- for effect, factor in fix_effects.items()
95
+ effect: self.invested * factor if self.invested is not None else factor
96
+ for effect, factor in self.parameters.effects_of_investment.items()
85
97
  },
86
- target='invest',
98
+ target='periodic',
87
99
  )
88
100
 
89
- if self.parameters.divest_effects != {} and self.parameters.optional:
90
- # share: divest_effects - isInvested * divest_effects
101
+ if self.parameters.effects_of_retirement and not self.parameters.mandatory:
91
102
  self._model.effects.add_share_to_effects(
92
103
  name=self.label_of_element,
93
104
  expressions={
94
- effect: -self.is_invested * factor + factor
95
- for effect, factor in self.parameters.divest_effects.items()
105
+ effect: -self.invested * factor + factor
106
+ for effect, factor in self.parameters.effects_of_retirement.items()
96
107
  },
97
- target='invest',
108
+ target='periodic',
98
109
  )
99
110
 
100
- if self.parameters.specific_effects != {}:
111
+ if self.parameters.effects_of_investment_per_size:
101
112
  self._model.effects.add_share_to_effects(
102
113
  name=self.label_of_element,
103
- expressions={effect: self.size * factor for effect, factor in self.parameters.specific_effects.items()},
104
- target='invest',
114
+ expressions={
115
+ effect: self.size * factor
116
+ for effect, factor in self.parameters.effects_of_investment_per_size.items()
117
+ },
118
+ target='periodic',
105
119
  )
106
120
 
107
- if self.parameters.piecewise_effects:
108
- self.piecewise_effects = self.add(
121
+ if self.parameters.piecewise_effects_of_investment:
122
+ self.piecewise_effects = self.add_submodels(
109
123
  PiecewiseEffectsModel(
110
124
  model=self._model,
111
125
  label_of_element=self.label_of_element,
112
- piecewise_origin=(self.size.name, self.parameters.piecewise_effects.piecewise_origin),
113
- piecewise_shares=self.parameters.piecewise_effects.piecewise_shares,
114
- zero_point=self.is_invested,
115
- ),
116
- 'segments',
117
- )
118
- self.piecewise_effects.do_modeling()
119
-
120
- def _create_bounds_for_optional_investment(self):
121
- if self.parameters.fixed_size:
122
- # eq: investment_size = isInvested * fixed_size
123
- self.add(
124
- self._model.add_constraints(
125
- self.size == self.is_invested * self.parameters.fixed_size, name=f'{self.label_full}|is_invested'
126
- ),
127
- 'is_invested',
128
- )
129
-
130
- else:
131
- # eq1: P_invest <= isInvested * investSize_max
132
- self.add(
133
- self._model.add_constraints(
134
- self.size <= self.is_invested * self.parameters.maximum_size,
135
- name=f'{self.label_full}|is_invested_ub',
136
- ),
137
- 'is_invested_ub',
138
- )
139
-
140
- # eq2: P_invest >= isInvested * max(epsilon, investSize_min)
141
- self.add(
142
- self._model.add_constraints(
143
- self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_size),
144
- name=f'{self.label_full}|is_invested_lb',
145
- ),
146
- 'is_invested_lb',
147
- )
148
-
149
- def _create_bounds_for_defining_variable(self):
150
- variable = self._defining_variable
151
- lb_relative, ub_relative = self._relative_bounds_of_defining_variable
152
- if np.all(lb_relative == ub_relative):
153
- self.add(
154
- self._model.add_constraints(
155
- variable == self.size * ub_relative, name=f'{self.label_full}|fix_{variable.name}'
156
- ),
157
- f'fix_{variable.name}',
158
- )
159
- if self._on_variable is not None:
160
- raise ValueError(
161
- f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.'
162
- f'This combination is currently not supported.'
163
- )
164
- return
165
-
166
- # eq: defining_variable(t) <= size * upper_bound(t)
167
- self.add(
168
- self._model.add_constraints(
169
- variable <= self.size * ub_relative, name=f'{self.label_full}|ub_{variable.name}'
170
- ),
171
- f'ub_{variable.name}',
172
- )
173
-
174
- if self._on_variable is None:
175
- # eq: defining_variable(t) >= investment_size * relative_minimum(t)
176
- self.add(
177
- self._model.add_constraints(
178
- variable >= self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'
179
- ),
180
- f'lb_{variable.name}',
181
- )
182
- else:
183
- ## 2. Gleichung: Minimum durch Investmentgröße und On
184
- # eq: defining_variable(t) >= mega * (On(t)-1) + size * relative_minimum(t)
185
- # ... mit mega = relative_maximum * maximum_size
186
- # äquivalent zu:.
187
- # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega
188
- mega = lb_relative * self.parameters.maximum_size
189
- on = self._on_variable
190
- self.add(
191
- self._model.add_constraints(
192
- variable >= mega * (on - 1) + self.size * lb_relative, name=f'{self.label_full}|lb_{variable.name}'
193
- ),
194
- f'lb_{variable.name}',
195
- )
196
- # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ??
197
-
198
-
199
- class StateModel(Model):
200
- """
201
- Handles basic on/off binary states for defining variables
202
- """
203
-
204
- def __init__(
205
- self,
206
- model: SystemModel,
207
- label_of_element: str,
208
- defining_variables: List[linopy.Variable],
209
- defining_bounds: List[Tuple[NumericData, NumericData]],
210
- previous_values: List[Optional[NumericData]] = None,
211
- use_off: bool = True,
212
- on_hours_total_min: Optional[NumericData] = 0,
213
- on_hours_total_max: Optional[NumericData] = None,
214
- effects_per_running_hour: Dict[str, NumericData] = None,
215
- label: Optional[str] = None,
216
- ):
217
- """
218
- Models binary state variables based on a continous variable.
219
-
220
- Args:
221
- model: The SystemModel that is used to create the model.
222
- label_of_element: The label of the parent (Element). Used to construct the full label of the model.
223
- defining_variables: List of Variables that are used to define the state
224
- defining_bounds: List of Tuples, defining the absolute bounds of each defining variable
225
- previous_values: List of previous values of the defining variables
226
- use_off: Whether to use the off state or not
227
- on_hours_total_min: min. overall sum of operating hours.
228
- on_hours_total_max: max. overall sum of operating hours.
229
- effects_per_running_hour: Costs per operating hours
230
- label: Label of the OnOffModel
231
- """
232
- super().__init__(model, label_of_element, label)
233
- assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff'
234
- self._defining_variables = defining_variables
235
- self._defining_bounds = defining_bounds
236
- self._previous_values = previous_values or []
237
- self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0
238
- self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf
239
- self._use_off = use_off
240
- self._effects_per_running_hour = effects_per_running_hour or {}
241
-
242
- self.on = None
243
- self.total_on_hours: Optional[linopy.Variable] = None
244
- self.off = None
245
-
246
- def do_modeling(self):
247
- self.on = self.add(
248
- self._model.add_variables(
249
- name=f'{self.label_full}|on',
250
- binary=True,
251
- coords=self._model.coords,
252
- ),
253
- 'on',
254
- )
255
-
256
- self.total_on_hours = self.add(
257
- self._model.add_variables(
258
- lower=self._on_hours_total_min,
259
- upper=self._on_hours_total_max,
260
- coords=None,
261
- name=f'{self.label_full}|on_hours_total',
262
- ),
263
- 'on_hours_total',
264
- )
265
-
266
- self.add(
267
- self._model.add_constraints(
268
- self.total_on_hours == (self.on * self._model.hours_per_step).sum(),
269
- name=f'{self.label_full}|on_hours_total',
270
- ),
271
- 'on_hours_total',
272
- )
273
-
274
- # Add defining constraints for each variable
275
- self._add_defining_constraints()
276
-
277
- if self._use_off:
278
- self.off = self.add(
279
- self._model.add_variables(
280
- name=f'{self.label_full}|off',
281
- binary=True,
282
- coords=self._model.coords,
283
- ),
284
- 'off',
285
- )
286
-
287
- # Constraint: on + off = 1
288
- self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off')
289
-
290
- return self
291
-
292
- def _add_defining_constraints(self):
293
- """Add constraints that link defining variables to the on state"""
294
- nr_of_def_vars = len(self._defining_variables)
295
-
296
- if nr_of_def_vars == 1:
297
- # Case for a single defining variable
298
- def_var = self._defining_variables[0]
299
- lb, ub = self._defining_bounds[0]
300
-
301
- # Constraint: on * lower_bound <= def_var
302
- self.add(
303
- self._model.add_constraints(
304
- self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1'
305
- ),
306
- 'on_con1',
307
- )
308
-
309
- # Constraint: on * upper_bound >= def_var
310
- self.add(self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2')
311
- else:
312
- # Case for multiple defining variables
313
- ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars
314
- lb = CONFIG.modeling.EPSILON # TODO: Can this be a bigger value? (maybe the smallest bound?)
315
-
316
- # Constraint: on * epsilon <= sum(all_defining_variables)
317
- self.add(
318
- self._model.add_constraints(
319
- self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1'
320
- ),
321
- 'on_con1',
322
- )
323
-
324
- # Constraint to ensure all variables are zero when off.
325
- # Divide by nr_of_def_vars to improve numerical stability (smaller factors)
326
- self.add(
327
- self._model.add_constraints(
328
- self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]),
329
- name=f'{self.label_full}|on_con2',
126
+ label_of_model=f'{self.label_of_element}|PiecewiseEffects',
127
+ piecewise_origin=(self.size.name, self.parameters.piecewise_effects_of_investment.piecewise_origin),
128
+ piecewise_shares=self.parameters.piecewise_effects_of_investment.piecewise_shares,
129
+ zero_point=self.invested,
330
130
  ),
331
- 'on_con2',
131
+ short_name='segments',
332
132
  )
333
133
 
334
134
  @property
335
- def previous_states(self) -> np.ndarray:
336
- """Computes the previous states {0, 1} of defining variables as a binary array from their previous values."""
337
- return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON)
135
+ def size(self) -> linopy.Variable:
136
+ """Investment size variable"""
137
+ return self._variables['size']
338
138
 
339
139
  @property
340
- def previous_on_states(self) -> np.ndarray:
341
- return self.previous_states
342
-
343
- @property
344
- def previous_off_states(self):
345
- return 1 - self.previous_states
346
-
347
- @staticmethod
348
- def compute_previous_states(previous_values: List[NumericData], epsilon: float = 1e-5) -> np.ndarray:
349
- """Computes the previous states {0, 1} of defining variables as a binary array from their previous values."""
350
- if not previous_values or all([val is None for val in previous_values]):
351
- return np.array([0])
352
-
353
- # Convert to 2D-array and compute binary on/off states
354
- previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None
355
- if previous_values.ndim > 1:
356
- return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int)
357
-
358
- return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int)
359
-
360
-
361
- class SwitchStateModel(Model):
362
- """
363
- Handles switch on/off transitions
364
- """
365
-
366
- def __init__(
367
- self,
368
- model: SystemModel,
369
- label_of_element: str,
370
- state_variable: linopy.Variable,
371
- previous_state=0,
372
- switch_on_max: Optional[Scalar] = None,
373
- label: Optional[str] = None,
374
- ):
375
- super().__init__(model, label_of_element, label)
376
- self._state_variable = state_variable
377
- self.previous_state = previous_state
378
- self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf
379
-
380
- self.switch_on = None
381
- self.switch_off = None
382
- self.switch_on_nr = None
383
-
384
- def do_modeling(self):
385
- """Create switch variables and constraints"""
386
-
387
- # Create switch variables
388
- self.switch_on = self.add(
389
- self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),
390
- 'switch_on',
391
- )
140
+ def invested(self) -> linopy.Variable | None:
141
+ """Binary investment decision variable"""
142
+ if 'invested' not in self._variables:
143
+ return None
144
+ return self._variables['invested']
392
145
 
393
- self.switch_off = self.add(
394
- self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords),
395
- 'switch_off',
396
- )
397
-
398
- # Create count variable for number of switches
399
- self.switch_on_nr = self.add(
400
- self._model.add_variables(
401
- upper=self._switch_on_max,
402
- lower=0,
403
- name=f'{self.label_full}|switch_on_nr',
404
- ),
405
- 'switch_on_nr',
406
- )
407
-
408
- # Add switch constraints for all entries after the first timestep
409
- self.add(
410
- self._model.add_constraints(
411
- self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None))
412
- == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)),
413
- name=f'{self.label_full}|switch_con',
414
- ),
415
- 'switch_con',
416
- )
417
-
418
- # Initial switch constraint
419
- self.add(
420
- self._model.add_constraints(
421
- self.switch_on.isel(time=0) - self.switch_off.isel(time=0)
422
- == self._state_variable.isel(time=0) - self.previous_state,
423
- name=f'{self.label_full}|initial_switch_con',
424
- ),
425
- 'initial_switch_con',
426
- )
427
146
 
428
- # Mutual exclusivity constraint
429
- self.add(
430
- self._model.add_constraints(
431
- self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'
432
- ),
433
- 'switch_on_or_off',
434
- )
435
-
436
- # Total switch-on count constraint
437
- self.add(
438
- self._model.add_constraints(
439
- self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr'
440
- ),
441
- 'switch_on_nr',
442
- )
443
-
444
- return self
445
-
446
-
447
- class ConsecutiveStateModel(Model):
448
- """
449
- Handles tracking consecutive durations in a state
450
- """
147
+ class OnOffModel(Submodel):
148
+ """OnOff model using factory patterns"""
451
149
 
452
150
  def __init__(
453
151
  self,
454
- model: SystemModel,
152
+ model: FlowSystemModel,
455
153
  label_of_element: str,
456
- state_variable: linopy.Variable,
457
- minimum_duration: Optional[NumericData] = None,
458
- maximum_duration: Optional[NumericData] = None,
459
- previous_states: Optional[NumericData] = None,
460
- label: Optional[str] = None,
154
+ parameters: OnOffParameters,
155
+ on_variable: linopy.Variable,
156
+ previous_states: TemporalData | None,
157
+ label_of_model: str | None = None,
461
158
  ):
462
159
  """
463
- Model and constraint the consecutive duration of a state variable.
160
+ This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are
161
+ bounded by a size variable or by a hard bound. THe used bound here is the absolute highest/lowest bound!
464
162
 
465
163
  Args:
466
- model: The SystemModel that is used to create the model.
164
+ model: The optimization model instance
467
165
  label_of_element: The label of the parent (Element). Used to construct the full label of the model.
468
- state_variable: The state variable that is used to model the duration. state = {0, 1}
469
- minimum_duration: The minimum duration of the state variable.
470
- maximum_duration: The maximum duration of the state variable.
471
- previous_states: The previous states of the state variable.
472
- label: The label of the model. Used to construct the full label of the model.
166
+ parameters: The parameters of the feature model.
167
+ on_variable: The variable that determines the on state
168
+ previous_states: The previous flow_rates
169
+ label_of_model: The label of the model. This is needed to construct the full label of the model.
473
170
  """
474
- super().__init__(model, label_of_element, label)
475
- self._state_variable = state_variable
171
+ self.on = on_variable
476
172
  self._previous_states = previous_states
477
- self._minimum_duration = minimum_duration
478
- self._maximum_duration = maximum_duration
479
-
480
- if isinstance(self._minimum_duration, TimeSeries):
481
- self._minimum_duration = self._minimum_duration.active_data
482
- if isinstance(self._maximum_duration, TimeSeries):
483
- self._maximum_duration = self._maximum_duration.active_data
484
-
485
- self.duration = None
486
-
487
- def do_modeling(self):
488
- """Create consecutive duration variables and constraints"""
489
- # Get the hours per step
490
- hours_per_step = self._model.hours_per_step
491
- mega = hours_per_step.sum('time') + self.previous_duration
492
-
493
- # Create the duration variable
494
- self.duration = self.add(
495
- self._model.add_variables(
496
- lower=0,
497
- upper=self._maximum_duration if self._maximum_duration is not None else mega,
498
- coords=self._model.coords,
499
- name=f'{self.label_full}|hours',
500
- ),
501
- 'hours',
502
- )
503
-
504
- # Add constraints
505
-
506
- # Upper bound constraint
507
- self.add(
508
- self._model.add_constraints(self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1'),
509
- 'con1',
510
- )
511
-
512
- # Forward constraint
513
- self.add(
514
- self._model.add_constraints(
515
- self.duration.isel(time=slice(1, None))
516
- <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)),
517
- name=f'{self.label_full}|con2a',
518
- ),
519
- 'con2a',
520
- )
521
-
522
- # Backward constraint
523
- self.add(
524
- self._model.add_constraints(
525
- self.duration.isel(time=slice(1, None))
526
- >= self.duration.isel(time=slice(None, -1))
527
- + hours_per_step.isel(time=slice(None, -1))
528
- + (self._state_variable.isel(time=slice(1, None)) - 1) * mega,
529
- name=f'{self.label_full}|con2b',
530
- ),
531
- 'con2b',
532
- )
533
-
534
- # Add minimum duration constraints if specified
535
- if self._minimum_duration is not None:
536
- self.add(
537
- self._model.add_constraints(
538
- self.duration
539
- >= (
540
- self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None))
541
- )
542
- * self._minimum_duration.isel(time=slice(None, -1)),
543
- name=f'{self.label_full}|minimum',
544
- ),
545
- 'minimum',
546
- )
547
-
548
- # Handle initial condition
549
- if 0 < self.previous_duration < self._minimum_duration.isel(time=0):
550
- self.add(
551
- self._model.add_constraints(
552
- self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum'
553
- ),
554
- 'initial_minimum',
555
- )
556
-
557
- # Set initial value
558
- self.add(
559
- self._model.add_constraints(
560
- self.duration.isel(time=0)
561
- == (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0),
562
- name=f'{self.label_full}|initial',
563
- ),
564
- 'initial',
565
- )
566
-
567
- return self
568
-
569
- @property
570
- def previous_duration(self) -> Scalar:
571
- """Computes the previous duration of the state variable"""
572
- # TODO: Allow for other/dynamic timestep resolutions
573
- return ConsecutiveStateModel.compute_consecutive_hours_in_state(
574
- self._previous_states, self._model.hours_per_step.isel(time=0).item()
575
- )
576
-
577
- @staticmethod
578
- def compute_consecutive_hours_in_state(
579
- binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray]
580
- ) -> Scalar:
581
- """
582
- Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array.
583
-
584
- Args:
585
- binary_values: An int or 1D binary array containing only `0`s and `1`s.
586
- hours_per_timestep: The duration of each timestep in hours.
587
- If a scalar is provided, it is used for all timesteps.
588
- If an array is provided, it must be as long as the last consecutive duration in binary_values.
589
-
590
- Returns:
591
- The duration of the binary variable in hours.
592
-
593
- Raises
594
- ------
595
- TypeError
596
- If the length of binary_values and dt_in_hours is not equal, but None is a scalar.
597
- """
598
- if np.isscalar(binary_values) and np.isscalar(hours_per_timestep):
599
- return binary_values * hours_per_timestep
600
- elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep):
601
- return binary_values * hours_per_timestep[-1]
602
-
603
- if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON):
604
- return 0
605
-
606
- if np.isscalar(hours_per_timestep):
607
- hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep
608
- hours_per_timestep: np.ndarray
609
-
610
- indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0]
611
- if len(indexes_with_zero_values) == 0:
612
- nr_of_indexes_with_consecutive_ones = len(binary_values)
613
- else:
614
- nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1
615
-
616
- if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones:
617
- raise ValueError(
618
- f'When trying to calculate the consecutive duration, the length of the last duration '
619
- f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), '
620
- f'as {binary_values=}'
621
- )
622
-
623
- return np.sum(
624
- binary_values[-nr_of_indexes_with_consecutive_ones:]
625
- * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:]
626
- )
627
-
173
+ self.parameters = parameters
174
+ super().__init__(model, label_of_element, label_of_model=label_of_model)
628
175
 
629
- class OnOffModel(Model):
630
- """
631
- Class for modeling the on and off state of a variable
632
- Uses component models to create a modular implementation
633
- """
176
+ def _do_modeling(self):
177
+ super()._do_modeling()
634
178
 
635
- def __init__(
636
- self,
637
- model: SystemModel,
638
- on_off_parameters: OnOffParameters,
639
- label_of_element: str,
640
- defining_variables: List[linopy.Variable],
641
- defining_bounds: List[Tuple[NumericData, NumericData]],
642
- previous_values: List[Optional[NumericData]],
643
- label: Optional[str] = None,
644
- ):
645
- """
646
- Constructor for OnOffModel
179
+ if self.parameters.use_off:
180
+ off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords())
181
+ self.add_constraints(self.on + off == 1, short_name='complementary')
647
182
 
648
- Args:
649
- model: Reference to the SystemModel
650
- on_off_parameters: Parameters for the OnOffModel
651
- label_of_element: Label of the Parent
652
- defining_variables: List of Variables that are used to define the OnOffModel
653
- defining_bounds: List of Tuples, defining the absolute bounds of each defining variable
654
- previous_values: List of previous values of the defining variables
655
- label: Label of the OnOffModel
656
- """
657
- super().__init__(model, label_of_element, label)
658
- self.parameters = on_off_parameters
659
- self._defining_variables = defining_variables
660
- self._defining_bounds = defining_bounds
661
- self._previous_values = previous_values
662
-
663
- self.state_model = None
664
- self.switch_state_model = None
665
- self.consecutive_on_model = None
666
- self.consecutive_off_model = None
667
-
668
- def do_modeling(self):
669
- """Create all variables and constraints for the OnOffModel"""
670
-
671
- # Create binary state component
672
- self.state_model = StateModel(
673
- model=self._model,
674
- label_of_element=self.label_of_element,
675
- defining_variables=self._defining_variables,
676
- defining_bounds=self._defining_bounds,
677
- previous_values=self._previous_values,
678
- use_off=self.parameters.use_off,
679
- on_hours_total_min=self.parameters.on_hours_total_min,
680
- on_hours_total_max=self.parameters.on_hours_total_max,
681
- effects_per_running_hour=self.parameters.effects_per_running_hour,
183
+ # 3. Total duration tracking using existing pattern
184
+ ModelingPrimitives.expression_tracking_variable(
185
+ self,
186
+ tracked_expression=(self.on * self._model.hours_per_step).sum('time'),
187
+ bounds=(
188
+ self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0,
189
+ self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf,
190
+ ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration())
191
+ short_name='on_hours_total',
192
+ coords=['period', 'scenario'],
682
193
  )
683
- self.add(self.state_model)
684
- self.state_model.do_modeling()
685
194
 
686
- # Create switch component if needed
195
+ # 4. Switch tracking using existing pattern
687
196
  if self.parameters.use_switch_on:
688
- self.switch_state_model = SwitchStateModel(
689
- model=self._model,
690
- label_of_element=self.label_of_element,
691
- state_variable=self.state_model.on,
692
- previous_state=self.state_model.previous_on_states[-1],
693
- switch_on_max=self.parameters.switch_on_total_max,
694
- )
695
- self.add(self.switch_state_model)
696
- self.switch_state_model.do_modeling()
197
+ self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords())
198
+ self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords())
199
+
200
+ BoundingPatterns.state_transition_bounds(
201
+ self,
202
+ state_variable=self.on,
203
+ switch_on=self.switch_on,
204
+ switch_off=self.switch_off,
205
+ name=f'{self.label_of_model}|switch',
206
+ previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0,
207
+ coord='time',
208
+ )
209
+
210
+ if self.parameters.switch_on_total_max is not None:
211
+ count = self.add_variables(
212
+ lower=0,
213
+ upper=self.parameters.switch_on_total_max,
214
+ coords=self._model.get_coords(('period', 'scenario')),
215
+ short_name='switch|count',
216
+ )
217
+ self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count')
697
218
 
698
- # Create consecutive on hours component if needed
219
+ # 5. Consecutive on duration using existing pattern
699
220
  if self.parameters.use_consecutive_on_hours:
700
- self.consecutive_on_model = ConsecutiveStateModel(
701
- model=self._model,
702
- label_of_element=self.label_of_element,
703
- state_variable=self.state_model.on,
221
+ ModelingPrimitives.consecutive_duration_tracking(
222
+ self,
223
+ state_variable=self.on,
224
+ short_name='consecutive_on_hours',
704
225
  minimum_duration=self.parameters.consecutive_on_hours_min,
705
226
  maximum_duration=self.parameters.consecutive_on_hours_max,
706
- previous_states=self.state_model.previous_on_states,
707
- label='ConsecutiveOn',
227
+ duration_per_step=self.hours_per_step,
228
+ duration_dim='time',
229
+ previous_duration=self._get_previous_on_duration(),
708
230
  )
709
- self.add(self.consecutive_on_model)
710
- self.consecutive_on_model.do_modeling()
711
231
 
712
- # Create consecutive off hours component if needed
232
+ # 6. Consecutive off duration using existing pattern
713
233
  if self.parameters.use_consecutive_off_hours:
714
- self.consecutive_off_model = ConsecutiveStateModel(
715
- model=self._model,
716
- label_of_element=self.label_of_element,
717
- state_variable=self.state_model.off,
234
+ ModelingPrimitives.consecutive_duration_tracking(
235
+ self,
236
+ state_variable=self.off,
237
+ short_name='consecutive_off_hours',
718
238
  minimum_duration=self.parameters.consecutive_off_hours_min,
719
239
  maximum_duration=self.parameters.consecutive_off_hours_max,
720
- previous_states=self.state_model.previous_off_states,
721
- label='ConsecutiveOff',
240
+ duration_per_step=self.hours_per_step,
241
+ duration_dim='time',
242
+ previous_duration=self._get_previous_off_duration(),
722
243
  )
723
- self.add(self.consecutive_off_model)
724
- self.consecutive_off_model.do_modeling()
244
+ # TODO:
725
245
 
726
- self._create_shares()
246
+ self._add_effects()
727
247
 
728
- def _create_shares(self):
248
+ def _add_effects(self):
249
+ """Add operational effects"""
729
250
  if self.parameters.effects_per_running_hour:
730
251
  self._model.effects.add_share_to_effects(
731
252
  name=self.label_of_element,
732
253
  expressions={
733
- effect: self.state_model.on * factor * self._model.hours_per_step
254
+ effect: self.on * factor * self._model.hours_per_step
734
255
  for effect, factor in self.parameters.effects_per_running_hour.items()
735
256
  },
736
- target='operation',
257
+ target='temporal',
737
258
  )
738
259
 
739
260
  if self.parameters.effects_per_switch_on:
740
261
  self._model.effects.add_share_to_effects(
741
262
  name=self.label_of_element,
742
263
  expressions={
743
- effect: self.switch_state_model.switch_on * factor
744
- for effect, factor in self.parameters.effects_per_switch_on.items()
264
+ effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items()
745
265
  },
746
- target='operation',
266
+ target='temporal',
747
267
  )
748
268
 
269
+ # Properties access variables from Submodel's tracking system
270
+
749
271
  @property
750
- def on(self):
751
- return self.state_model.on
272
+ def on_hours_total(self) -> linopy.Variable:
273
+ """Total on hours variable"""
274
+ return self['on_hours_total']
752
275
 
753
276
  @property
754
- def off(self):
755
- return self.state_model.off
277
+ def off(self) -> linopy.Variable | None:
278
+ """Binary off state variable"""
279
+ return self.get('off')
756
280
 
757
281
  @property
758
- def switch_on(self):
759
- return self.switch_state_model.switch_on
282
+ def switch_on(self) -> linopy.Variable | None:
283
+ """Switch on variable"""
284
+ return self.get('switch|on')
760
285
 
761
286
  @property
762
- def switch_off(self):
763
- return self.switch_state_model.switch_off
287
+ def switch_off(self) -> linopy.Variable | None:
288
+ """Switch off variable"""
289
+ return self.get('switch|off')
764
290
 
765
291
  @property
766
- def switch_on_nr(self):
767
- return self.switch_state_model.switch_on_nr
292
+ def switch_on_nr(self) -> linopy.Variable | None:
293
+ """Number of switch-ons variable"""
294
+ return self.get('switch|count')
768
295
 
769
296
  @property
770
- def consecutive_on_hours(self):
771
- return self.consecutive_on_model.duration
297
+ def consecutive_on_hours(self) -> linopy.Variable | None:
298
+ """Consecutive on hours variable"""
299
+ return self.get('consecutive_on_hours')
772
300
 
773
301
  @property
774
- def consecutive_off_hours(self):
775
- return self.consecutive_off_model.duration
302
+ def consecutive_off_hours(self) -> linopy.Variable | None:
303
+ """Consecutive off hours variable"""
304
+ return self.get('consecutive_off_hours')
305
+
306
+ def _get_previous_on_duration(self):
307
+ """Get previous on duration. Previously OFF by default, for one timestep"""
308
+ hours_per_step = self._model.hours_per_step.isel(time=0).min().item()
309
+ if self._previous_states is None:
310
+ return 0
311
+ else:
312
+ return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states, hours_per_step)
313
+
314
+ def _get_previous_off_duration(self):
315
+ """Get previous off duration. Previously OFF by default, for one timestep"""
316
+ hours_per_step = self._model.hours_per_step.isel(time=0).min().item()
317
+ if self._previous_states is None:
318
+ return hours_per_step
319
+ else:
320
+ return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step)
776
321
 
777
322
 
778
- class PieceModel(Model):
323
+ class PieceModel(Submodel):
779
324
  """Class for modeling a linear piece of one or more variables in parallel"""
780
325
 
781
326
  def __init__(
782
327
  self,
783
- model: SystemModel,
328
+ model: FlowSystemModel,
784
329
  label_of_element: str,
785
- label: str,
786
- as_time_series: bool = True,
330
+ label_of_model: str,
331
+ dims: FlowSystemDimensions | None,
787
332
  ):
788
- super().__init__(model, label_of_element, label)
789
- self.inside_piece: Optional[linopy.Variable] = None
790
- self.lambda0: Optional[linopy.Variable] = None
791
- self.lambda1: Optional[linopy.Variable] = None
792
- self._as_time_series = as_time_series
793
-
794
- def do_modeling(self):
795
- self.inside_piece = self.add(
796
- self._model.add_variables(
797
- binary=True,
798
- name=f'{self.label_full}|inside_piece',
799
- coords=self._model.coords if self._as_time_series else None,
800
- ),
801
- 'inside_piece',
802
- )
333
+ self.inside_piece: linopy.Variable | None = None
334
+ self.lambda0: linopy.Variable | None = None
335
+ self.lambda1: linopy.Variable | None = None
336
+ self.dims = dims
803
337
 
804
- self.lambda0 = self.add(
805
- self._model.add_variables(
806
- lower=0,
807
- upper=1,
808
- name=f'{self.label_full}|lambda0',
809
- coords=self._model.coords if self._as_time_series else None,
810
- ),
811
- 'lambda0',
338
+ super().__init__(model, label_of_element, label_of_model)
339
+
340
+ def _do_modeling(self):
341
+ super()._do_modeling()
342
+ self.inside_piece = self.add_variables(
343
+ binary=True,
344
+ short_name='inside_piece',
345
+ coords=self._model.get_coords(dims=self.dims),
346
+ )
347
+ self.lambda0 = self.add_variables(
348
+ lower=0,
349
+ upper=1,
350
+ short_name='lambda0',
351
+ coords=self._model.get_coords(dims=self.dims),
812
352
  )
813
353
 
814
- self.lambda1 = self.add(
815
- self._model.add_variables(
816
- lower=0,
817
- upper=1,
818
- name=f'{self.label_full}|lambda1',
819
- coords=self._model.coords if self._as_time_series else None,
820
- ),
821
- 'lambda1',
354
+ self.lambda1 = self.add_variables(
355
+ lower=0,
356
+ upper=1,
357
+ short_name='lambda1',
358
+ coords=self._model.get_coords(dims=self.dims),
822
359
  )
823
360
 
824
361
  # eq: lambda0(t) + lambda1(t) = inside_piece(t)
825
- self.add(
826
- self._model.add_constraints(
827
- self.inside_piece == self.lambda0 + self.lambda1, name=f'{self.label_full}|inside_piece'
828
- ),
829
- 'inside_piece',
830
- )
362
+ self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece')
831
363
 
832
364
 
833
- class PiecewiseModel(Model):
365
+ class PiecewiseModel(Submodel):
834
366
  def __init__(
835
367
  self,
836
- model: SystemModel,
368
+ model: FlowSystemModel,
837
369
  label_of_element: str,
838
- piecewise_variables: Dict[str, Piecewise],
839
- zero_point: Optional[Union[bool, linopy.Variable]],
840
- as_time_series: bool,
841
- label: str = '',
370
+ label_of_model: str,
371
+ piecewise_variables: dict[str, Piecewise],
372
+ zero_point: bool | linopy.Variable | None,
373
+ dims: FlowSystemDimensions | None,
842
374
  ):
843
375
  """
844
- Modeling a Piecewise relation between multiple variables.
376
+ Modeling a Piecewise relation between miultiple variables.
845
377
  The relation is defined by a list of Pieces, which are assigned to the variables.
846
378
  Each Piece is a tuple of (start, end).
847
379
 
848
380
  Args:
849
- model: The SystemModel that is used to create the model.
381
+ model: The FlowSystemModel that is used to create the model.
850
382
  label_of_element: The label of the parent (Element). Used to construct the full label of the model.
851
- label: The label of the model. Used to construct the full label of the model.
383
+ label_of_model: The label of the model. Used to construct the full label of the model.
852
384
  piecewise_variables: The variables to which the Pieces are assigned.
853
- zero_point: A variable that can be used to define a zero point for the Piecewise relation.
854
- If None or False, no zero point is defined. THis leads to 0 not being possible,
855
- unless its its contained in a Piece.
856
- as_time_series: Whether the Piecewise relation is defined for a TimeSeries or a single variable.
385
+ 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.
386
+ dims: The dimensions used for variable creation. If None, all dimensions are used.
857
387
  """
858
- super().__init__(model, label_of_element, label)
859
388
  self._piecewise_variables = piecewise_variables
860
389
  self._zero_point = zero_point
861
- self._as_time_series = as_time_series
390
+ self.dims = dims
391
+
392
+ self.pieces: list[PieceModel] = []
393
+ self.zero_point: linopy.Variable | None = None
394
+ super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
862
395
 
863
- self.pieces: List[PieceModel] = []
864
- self.zero_point: Optional[linopy.Variable] = None
396
+ def _do_modeling(self):
397
+ super()._do_modeling()
398
+ # Validate all piecewise variables have the same number of segments
399
+ segment_counts = [len(pw) for pw in self._piecewise_variables.values()]
400
+ if not all(count == segment_counts[0] for count in segment_counts):
401
+ raise ValueError(f'All piecewises must have the same number of pieces, got {segment_counts}')
865
402
 
866
- def do_modeling(self):
867
403
  for i in range(len(list(self._piecewise_variables.values())[0])):
868
- new_piece = self.add(
404
+ new_piece = self.add_submodels(
869
405
  PieceModel(
870
406
  model=self._model,
871
407
  label_of_element=self.label_of_element,
872
- label=f'Piece_{i}',
873
- as_time_series=self._as_time_series,
874
- )
408
+ label_of_model=f'{self.label_of_element}|Piece_{i}',
409
+ dims=self.dims,
410
+ ),
411
+ short_name=f'Piece_{i}',
875
412
  )
876
413
  self.pieces.append(new_piece)
877
- new_piece.do_modeling()
878
414
 
879
415
  for var_name in self._piecewise_variables:
880
416
  variable = self._model.variables[var_name]
881
- self.add(
882
- self._model.add_constraints(
883
- variable
884
- == sum(
885
- [
886
- piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end
887
- for piece_model, piece_bounds in zip(
888
- self.pieces, self._piecewise_variables[var_name], strict=False
889
- )
890
- ]
891
- ),
892
- name=f'{self.label_full}|{var_name}|lambda',
417
+ self.add_constraints(
418
+ variable
419
+ == sum(
420
+ [
421
+ piece_model.lambda0 * piece_bounds.start + piece_model.lambda1 * piece_bounds.end
422
+ for piece_model, piece_bounds in zip(
423
+ self.pieces, self._piecewise_variables[var_name], strict=False
424
+ )
425
+ ]
893
426
  ),
894
- f'{var_name}|lambda',
427
+ name=f'{self.label_full}|{var_name}|lambda',
428
+ short_name=f'{var_name}|lambda',
895
429
  )
896
430
 
897
431
  # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt
898
432
  # b) eq: -On(t) + Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 0 zusätzlich kann alles auch Null sein
899
433
  if isinstance(self._zero_point, linopy.Variable):
900
434
  self.zero_point = self._zero_point
901
- sign, rhs = '<=', self.zero_point
435
+ rhs = self.zero_point
902
436
  elif self._zero_point is True:
903
- self.zero_point = self.add(
904
- self._model.add_variables(
905
- coords=self._model.coords, binary=True, name=f'{self.label_full}|zero_point'
906
- ),
907
- 'zero_point',
437
+ self.zero_point = self.add_variables(
438
+ coords=self._model.get_coords(self.dims),
439
+ binary=True,
440
+ short_name='zero_point',
908
441
  )
909
- sign, rhs = '<=', self.zero_point
442
+ rhs = self.zero_point
910
443
  else:
911
- sign, rhs = '=', 1
912
-
913
- self.add(
914
- self._model.add_constraints(
915
- sum([piece.inside_piece for piece in self.pieces]),
916
- sign,
917
- rhs,
918
- name=f'{self.label_full}|{variable.name}|single_segment',
919
- ),
920
- f'{var_name}|single_segment',
921
- )
444
+ rhs = 1
922
445
 
923
-
924
- class ShareAllocationModel(Model):
925
- def __init__(
926
- self,
927
- model: SystemModel,
928
- shares_are_time_series: bool,
929
- label_of_element: Optional[str] = None,
930
- label: Optional[str] = None,
931
- label_full: Optional[str] = None,
932
- total_max: Optional[Scalar] = None,
933
- total_min: Optional[Scalar] = None,
934
- max_per_hour: Optional[NumericData] = None,
935
- min_per_hour: Optional[NumericData] = None,
936
- ):
937
- super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full)
938
- if not shares_are_time_series: # If the condition is True
939
- assert max_per_hour is None and min_per_hour is None, (
940
- 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False'
941
- )
942
- self.total_per_timestep: Optional[linopy.Variable] = None
943
- self.total: Optional[linopy.Variable] = None
944
- self.shares: Dict[str, linopy.Variable] = {}
945
- self.share_constraints: Dict[str, linopy.Constraint] = {}
946
-
947
- self._eq_total_per_timestep: Optional[linopy.Constraint] = None
948
- self._eq_total: Optional[linopy.Constraint] = None
949
-
950
- # Parameters
951
- self._shares_are_time_series = shares_are_time_series
952
- self._total_max = total_max if total_min is not None else np.inf
953
- self._total_min = total_min if total_min is not None else -np.inf
954
- self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf
955
- self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf
956
-
957
- def do_modeling(self):
958
- self.total = self.add(
959
- self._model.add_variables(
960
- lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total'
961
- ),
962
- 'total',
963
- )
964
- # eq: sum = sum(share_i) # skalar
965
- self._eq_total = self.add(
966
- self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total'
967
- )
968
-
969
- if self._shares_are_time_series:
970
- self.total_per_timestep = self.add(
971
- self._model.add_variables(
972
- lower=-np.inf
973
- if (self._min_per_hour is None)
974
- else np.multiply(self._min_per_hour, self._model.hours_per_step),
975
- upper=np.inf
976
- if (self._max_per_hour is None)
977
- else np.multiply(self._max_per_hour, self._model.hours_per_step),
978
- coords=self._model.coords,
979
- name=f'{self.label_full}|total_per_timestep',
980
- ),
981
- 'total_per_timestep',
982
- )
983
-
984
- self._eq_total_per_timestep = self.add(
985
- self._model.add_constraints(self.total_per_timestep == 0, name=f'{self.label_full}|total_per_timestep'),
986
- 'total_per_timestep',
987
- )
988
-
989
- # Add it to the total
990
- self._eq_total.lhs -= self.total_per_timestep.sum()
991
-
992
- def add_share(
993
- self,
994
- name: str,
995
- expression: linopy.LinearExpression,
996
- ):
997
- """
998
- Add a share to the share allocation model. If the share already exists, the expression is added to the existing share.
999
- The expression is added to the right hand side (rhs) of the constraint.
1000
- The variable representing the total share is on the left hand side (lhs) of the constraint.
1001
- var_total = sum(expressions)
1002
-
1003
- Args:
1004
- name: The name of the share.
1005
- expression: The expression of the share. Added to the right hand side of the constraint.
1006
- """
1007
- if name in self.shares:
1008
- self.share_constraints[name].lhs -= expression
1009
- else:
1010
- self.shares[name] = self.add(
1011
- self._model.add_variables(
1012
- coords=None
1013
- if isinstance(expression, linopy.LinearExpression)
1014
- and expression.ndim == 0
1015
- or not isinstance(expression, linopy.LinearExpression)
1016
- else self._model.coords,
1017
- name=f'{name}->{self.label_full}',
1018
- ),
1019
- name,
1020
- )
1021
- self.share_constraints[name] = self.add(
1022
- self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name
446
+ self.add_constraints(
447
+ sum([piece.inside_piece for piece in self.pieces]) <= rhs,
448
+ name=f'{self.label_full}|{variable.name}|single_segment',
449
+ short_name=f'{var_name}|single_segment',
1023
450
  )
1024
- if self.shares[name].ndim == 0:
1025
- self._eq_total.lhs -= self.shares[name]
1026
- else:
1027
- self._eq_total_per_timestep.lhs -= self.shares[name]
1028
451
 
1029
452
 
1030
- class PiecewiseEffectsModel(Model):
453
+ class PiecewiseEffectsModel(Submodel):
1031
454
  def __init__(
1032
455
  self,
1033
- model: SystemModel,
456
+ model: FlowSystemModel,
1034
457
  label_of_element: str,
1035
- piecewise_origin: Tuple[str, Piecewise],
1036
- piecewise_shares: Dict[str, Piecewise],
1037
- zero_point: Optional[Union[bool, linopy.Variable]],
1038
- label: str = 'PiecewiseEffects',
458
+ label_of_model: str,
459
+ piecewise_origin: tuple[str, Piecewise],
460
+ piecewise_shares: dict[str, Piecewise],
461
+ zero_point: bool | linopy.Variable | None,
1039
462
  ):
1040
- super().__init__(model, label_of_element, label)
1041
- assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), (
1042
- 'Piece length of variable_segments and share_segments must be equal'
1043
- )
463
+ origin_count = len(piecewise_origin[1])
464
+ share_counts = [len(pw) for pw in piecewise_shares.values()]
465
+ if not all(count == origin_count for count in share_counts):
466
+ raise ValueError(
467
+ f'Piece count mismatch: piecewise_origin has {origin_count} segments, '
468
+ f'but piecewise_shares have {share_counts}'
469
+ )
1044
470
  self._zero_point = zero_point
1045
471
  self._piecewise_origin = piecewise_origin
1046
472
  self._piecewise_shares = piecewise_shares
1047
- self.shares: Dict[str, linopy.Variable] = {}
473
+ self.shares: dict[str, linopy.Variable] = {}
474
+
475
+ self.piecewise_model: PiecewiseModel | None = None
1048
476
 
1049
- self.piecewise_model: Optional[PiecewiseModel] = None
477
+ super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
1050
478
 
1051
- def do_modeling(self):
479
+ def _do_modeling(self):
1052
480
  self.shares = {
1053
- effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}')
481
+ effect: self.add_variables(coords=self._model.get_coords(['period', 'scenario']), short_name=effect)
1054
482
  for effect in self._piecewise_shares
1055
483
  }
1056
484
 
@@ -1062,124 +490,124 @@ class PiecewiseEffectsModel(Model):
1062
490
  },
1063
491
  }
1064
492
 
1065
- self.piecewise_model = self.add(
493
+ self.piecewise_model = self.add_submodels(
1066
494
  PiecewiseModel(
1067
495
  model=self._model,
1068
496
  label_of_element=self.label_of_element,
1069
497
  piecewise_variables=piecewise_variables,
1070
498
  zero_point=self._zero_point,
1071
- as_time_series=False,
1072
- label='PiecewiseEffects',
1073
- )
499
+ dims=('period', 'scenario'),
500
+ label_of_model=f'{self.label_of_element}|PiecewiseEffects',
501
+ ),
502
+ short_name='PiecewiseEffects',
1074
503
  )
1075
504
 
1076
- self.piecewise_model.do_modeling()
1077
-
1078
505
  # Shares
1079
506
  self._model.effects.add_share_to_effects(
1080
507
  name=self.label_of_element,
1081
508
  expressions={effect: variable * 1 for effect, variable in self.shares.items()},
1082
- target='invest',
509
+ target='periodic',
1083
510
  )
1084
511
 
1085
512
 
1086
- class PiecewiseEffectsPerFlowHourModel(Model):
513
+ class ShareAllocationModel(Submodel):
1087
514
  def __init__(
1088
515
  self,
1089
- model: SystemModel,
1090
- label_of_element: str,
1091
- piecewise_origin: Tuple[str, Piecewise],
1092
- piecewise_shares: Dict[str, Piecewise],
1093
- zero_point: Optional[Union[bool, linopy.Variable]],
1094
- label: str = 'PiecewiseEffectsPerFlowHour',
516
+ model: FlowSystemModel,
517
+ dims: list[FlowSystemDimensions],
518
+ label_of_element: str | None = None,
519
+ label_of_model: str | None = None,
520
+ total_max: Scalar | None = None,
521
+ total_min: Scalar | None = None,
522
+ max_per_hour: TemporalData | None = None,
523
+ min_per_hour: TemporalData | None = None,
1095
524
  ):
1096
- super().__init__(model, label_of_element, label)
1097
- assert len(piecewise_origin[1]) == len(list(piecewise_shares.values())[0]), (
1098
- 'Piece length of variable_segments and share_segments must be equal'
1099
- )
1100
- self._zero_point = zero_point
1101
- self._piecewise_origin = piecewise_origin
1102
- self._piecewise_shares = piecewise_shares
525
+ if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None):
526
+ raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False')
1103
527
 
1104
- self.shares: Dict[str, linopy.Variable] = {}
528
+ self._dims = dims
529
+ self.total_per_timestep: linopy.Variable | None = None
530
+ self.total: linopy.Variable | None = None
531
+ self.shares: dict[str, linopy.Variable] = {}
532
+ self.share_constraints: dict[str, linopy.Constraint] = {}
1105
533
 
1106
- self.piecewise_model: Optional[PiecewiseModel] = None
534
+ self._eq_total_per_timestep: linopy.Constraint | None = None
535
+ self._eq_total: linopy.Constraint | None = None
1107
536
 
1108
- def do_modeling(self):
1109
- self.shares = {
1110
- effect: self.add(
1111
- self._model.add_variables(coords=self._model.coords, name=f'{self.label_full}|{effect}'), f'{effect}'
1112
- )
1113
- for effect in self._piecewise_shares
1114
- }
537
+ # Parameters
538
+ self._total_max = total_max if total_max is not None else np.inf
539
+ self._total_min = total_min if total_min is not None else -np.inf
540
+ self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf
541
+ self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf
1115
542
 
1116
- piecewise_variables = {
1117
- self._piecewise_origin[0]: self._piecewise_origin[1],
1118
- **{
1119
- self.shares[effect_label].name: self._piecewise_shares[effect_label]
1120
- for effect_label in self._piecewise_shares
1121
- },
1122
- }
543
+ super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
1123
544
 
1124
- self.piecewise_model = self.add(
1125
- PiecewiseModel(
1126
- model=self._model,
1127
- label_of_element=self.label_of_element,
1128
- piecewise_variables=piecewise_variables,
1129
- zero_point=self._zero_point,
1130
- as_time_series=True,
1131
- label='PiecewiseEffectsPerFlowHour',
1132
- )
545
+ def _do_modeling(self):
546
+ super()._do_modeling()
547
+ self.total = self.add_variables(
548
+ lower=self._total_min,
549
+ upper=self._total_max,
550
+ coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']),
551
+ name=self.label_full,
552
+ short_name='total',
1133
553
  )
554
+ # eq: sum = sum(share_i) # skalar
555
+ self._eq_total = self.add_constraints(self.total == 0, name=self.label_full)
1134
556
 
1135
- self.piecewise_model.do_modeling()
1136
-
1137
- # Shares
1138
- self._model.effects.add_share_to_effects(
1139
- name=self.label_of_element,
1140
- expressions={effect: variable * self._model.hours_per_step for effect, variable in self.shares.items()},
1141
- target='operation',
1142
- )
557
+ if 'time' in self._dims:
558
+ self.total_per_timestep = self.add_variables(
559
+ lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step,
560
+ upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step,
561
+ coords=self._model.get_coords(self._dims),
562
+ short_name='per_timestep',
563
+ )
1143
564
 
565
+ self._eq_total_per_timestep = self.add_constraints(self.total_per_timestep == 0, short_name='per_timestep')
1144
566
 
1145
- class PreventSimultaneousUsageModel(Model):
1146
- """
1147
- Prevents multiple Multiple Binary variables from being 1 at the same time
567
+ # Add it to the total
568
+ self._eq_total.lhs -= self.total_per_timestep.sum(dim='time')
1148
569
 
1149
- Only 'classic type is modeled for now (# "classic" -> alle Flows brauchen Binärvariable:)
1150
- In 'new', the binary Variables need to be forced beforehand, which is not that straight forward... --> TODO maybe
570
+ def add_share(
571
+ self,
572
+ name: str,
573
+ expression: linopy.LinearExpression,
574
+ dims: list[FlowSystemDimensions] | None = None,
575
+ ):
576
+ """
577
+ Add a share to the share allocation model. If the share already exists, the expression is added to the existing share.
578
+ The expression is added to the right hand side (rhs) of the constraint.
579
+ The variable representing the total share is on the left hand side (lhs) of the constraint.
580
+ var_total = sum(expressions)
1151
581
 
582
+ Args:
583
+ name: The name of the share.
584
+ expression: The expression of the share. Added to the right hand side of the constraint.
585
+ dims: The dimensions of the share. Defaults to all dimensions. Dims are ordered automatically
586
+ """
587
+ if dims is None:
588
+ dims = self._dims
589
+ else:
590
+ if 'time' in dims and 'time' not in self._dims:
591
+ raise ValueError('Cannot add share with time-dim to a model without time-dim')
592
+ if 'period' in dims and 'period' not in self._dims:
593
+ raise ValueError('Cannot add share with period-dim to a model without period-dim')
594
+ if 'scenario' in dims and 'scenario' not in self._dims:
595
+ raise ValueError('Cannot add share with scenario-dim to a model without scenario-dim')
1152
596
 
1153
- # "new":
1154
- # eq: flow_1.on(t) + flow_2.on(t) + .. + flow_i.val(t)/flow_i.max <= 1 (1 Flow ohne Binärvariable!)
597
+ if name in self.shares:
598
+ self.share_constraints[name].lhs -= expression
599
+ else:
600
+ self.shares[name] = self.add_variables(
601
+ coords=self._model.get_coords(dims),
602
+ name=f'{name}->{self.label_full}',
603
+ short_name=name,
604
+ )
1155
605
 
1156
- # Anmerkung: Patrick Schönfeld (oemof, custom/link.py) macht bei 2 Flows ohne Binärvariable dies:
1157
- # 1) bin + flow1/flow1_max <= 1
1158
- # 2) bin - flow2/flow2_max >= 0
1159
- # 3) geht nur, wenn alle flow.min >= 0
1160
- # --> könnte man auch umsetzen (statt force_on_variable() für die Flows, aber sollte aufs selbe wie "new" kommen)
1161
- """
606
+ self.share_constraints[name] = self.add_constraints(
607
+ self.shares[name] == expression, name=f'{name}->{self.label_full}'
608
+ )
1162
609
 
1163
- def __init__(
1164
- self,
1165
- model: SystemModel,
1166
- variables: List[linopy.Variable],
1167
- label_of_element: str,
1168
- label: str = 'PreventSimultaneousUsage',
1169
- ):
1170
- super().__init__(model, label_of_element, label)
1171
- self._simultanious_use_variables = variables
1172
- assert len(self._simultanious_use_variables) >= 2, (
1173
- f'Model {self.__class__.__name__} must get at least two variables'
1174
- )
1175
- for variable in self._simultanious_use_variables: # classic
1176
- assert variable.attrs['binary'], f'Variable {variable} must be binary for use in {self.__class__.__name__}'
1177
-
1178
- def do_modeling(self):
1179
- # eq: sum(flow_i.on(t)) <= 1.1 (1 wird etwas größer gewählt wg. Binärvariablengenauigkeit)
1180
- self.add(
1181
- self._model.add_constraints(
1182
- sum(self._simultanious_use_variables) <= 1.1, name=f'{self.label_full}|prevent_simultaneous_use'
1183
- ),
1184
- 'prevent_simultaneous_use',
1185
- )
610
+ if 'time' not in dims:
611
+ self._eq_total.lhs -= self.shares[name]
612
+ else:
613
+ self._eq_total_per_timestep.lhs -= self.shares[name]