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