flixopt 2.1.1__py3-none-any.whl → 2.2.0b0__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.

flixopt/features.py CHANGED
@@ -9,9 +9,8 @@ from typing import Dict, List, Optional, Tuple, Union
9
9
  import linopy
10
10
  import numpy as np
11
11
 
12
- from . import utils
13
12
  from .config import CONFIG
14
- from .core import NumericData, Scalar, TimeSeries
13
+ from .core import Scalar, ScenarioData, TimeSeries, TimestepData, extract_data
15
14
  from .interface import InvestParameters, OnOffParameters, Piecewise
16
15
  from .structure import Model, SystemModel
17
16
 
@@ -27,13 +26,14 @@ class InvestmentModel(Model):
27
26
  label_of_element: str,
28
27
  parameters: InvestParameters,
29
28
  defining_variable: [linopy.Variable],
30
- relative_bounds_of_defining_variable: Tuple[NumericData, NumericData],
29
+ relative_bounds_of_defining_variable: Tuple[TimestepData, TimestepData],
31
30
  label: Optional[str] = None,
32
31
  on_variable: Optional[linopy.Variable] = None,
33
32
  ):
34
33
  super().__init__(model, label_of_element, label)
35
34
  self.size: Optional[Union[Scalar, linopy.Variable]] = None
36
35
  self.is_invested: Optional[linopy.Variable] = None
36
+ self.scenario_of_investment: Optional[linopy.Variable] = None
37
37
 
38
38
  self.piecewise_effects: Optional[PiecewiseEffectsModel] = None
39
39
 
@@ -43,31 +43,32 @@ class InvestmentModel(Model):
43
43
  self.parameters = parameters
44
44
 
45
45
  def do_modeling(self):
46
- if self.parameters.fixed_size and not self.parameters.optional:
47
- self.size = self.add(
48
- self._model.add_variables(
49
- lower=self.parameters.fixed_size, upper=self.parameters.fixed_size, name=f'{self.label_full}|size'
50
- ),
51
- 'size',
52
- )
53
- else:
54
- self.size = self.add(
55
- self._model.add_variables(
56
- lower=0 if self.parameters.optional else self.parameters.minimum_size,
57
- upper=self.parameters.maximum_size,
58
- name=f'{self.label_full}|size',
59
- ),
60
- 'size',
61
- )
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',
54
+ )
62
55
 
63
56
  # Optional
64
57
  if self.parameters.optional:
65
58
  self.is_invested = self.add(
66
- self._model.add_variables(binary=True, name=f'{self.label_full}|is_invested'), 'is_invested'
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',
67
65
  )
68
66
 
69
67
  self._create_bounds_for_optional_investment()
70
68
 
69
+ if self._model.time_series_collection.scenarios is not None:
70
+ self._create_bounds_for_scenarios()
71
+
71
72
  # Bounds for defining variable
72
73
  self._create_bounds_for_defining_variable()
73
74
 
@@ -153,11 +154,6 @@ class InvestmentModel(Model):
153
154
  ),
154
155
  f'fix_{variable.name}',
155
156
  )
156
- if self._on_variable is not None:
157
- raise ValueError(
158
- f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.'
159
- f'This combination is currently not supported.'
160
- )
161
157
  return
162
158
 
163
159
  # eq: defining_variable(t) <= size * upper_bound(t)
@@ -182,7 +178,7 @@ class InvestmentModel(Model):
182
178
  # ... mit mega = relative_maximum * maximum_size
183
179
  # äquivalent zu:.
184
180
  # eq: - defining_variable(t) + mega * On(t) + size * relative_minimum(t) <= + mega
185
- mega = lb_relative * self.parameters.maximum_size
181
+ mega = self.parameters.maximum_size * lb_relative
186
182
  on = self._on_variable
187
183
  self.add(
188
184
  self._model.add_constraints(
@@ -192,6 +188,50 @@ class InvestmentModel(Model):
192
188
  )
193
189
  # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ??
194
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
+
195
235
 
196
236
  class StateModel(Model):
197
237
  """
@@ -203,12 +243,12 @@ class StateModel(Model):
203
243
  model: SystemModel,
204
244
  label_of_element: str,
205
245
  defining_variables: List[linopy.Variable],
206
- defining_bounds: List[Tuple[NumericData, NumericData]],
207
- previous_values: List[Optional[NumericData]] = None,
246
+ defining_bounds: List[Tuple[TimestepData, TimestepData]],
247
+ previous_values: List[Optional[TimestepData]] = None,
208
248
  use_off: bool = True,
209
- on_hours_total_min: Optional[NumericData] = 0,
210
- on_hours_total_max: Optional[NumericData] = None,
211
- effects_per_running_hour: Dict[str, NumericData] = None,
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,
212
252
  label: Optional[str] = None,
213
253
  ):
214
254
  """
@@ -245,16 +285,16 @@ class StateModel(Model):
245
285
  self._model.add_variables(
246
286
  name=f'{self.label_full}|on',
247
287
  binary=True,
248
- coords=self._model.coords,
288
+ coords=self._model.get_coords(),
249
289
  ),
250
290
  'on',
251
291
  )
252
292
 
253
293
  self.total_on_hours = self.add(
254
294
  self._model.add_variables(
255
- lower=self._on_hours_total_min,
256
- upper=self._on_hours_total_max,
257
- coords=None,
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),
258
298
  name=f'{self.label_full}|on_hours_total',
259
299
  ),
260
300
  'on_hours_total',
@@ -262,7 +302,7 @@ class StateModel(Model):
262
302
 
263
303
  self.add(
264
304
  self._model.add_constraints(
265
- self.total_on_hours == (self.on * self._model.hours_per_step).sum(),
305
+ self.total_on_hours == (self.on * self._model.hours_per_step).sum('time'),
266
306
  name=f'{self.label_full}|on_hours_total',
267
307
  ),
268
308
  'on_hours_total',
@@ -276,7 +316,7 @@ class StateModel(Model):
276
316
  self._model.add_variables(
277
317
  name=f'{self.label_full}|off',
278
318
  binary=True,
279
- coords=self._model.coords,
319
+ coords=self._model.get_coords(),
280
320
  ),
281
321
  'off',
282
322
  )
@@ -344,7 +384,7 @@ class StateModel(Model):
344
384
  return 1 - self.previous_states
345
385
 
346
386
  @staticmethod
347
- def compute_previous_states(previous_values: List[NumericData], epsilon: float = 1e-5) -> np.ndarray:
387
+ def compute_previous_states(previous_values: List[TimestepData], epsilon: float = 1e-5) -> np.ndarray:
348
388
  """Computes the previous states {0, 1} of defining variables as a binary array from their previous values."""
349
389
  if not previous_values or all([val is None for val in previous_values]):
350
390
  return np.array([0])
@@ -385,19 +425,19 @@ class SwitchStateModel(Model):
385
425
 
386
426
  # Create switch variables
387
427
  self.switch_on = self.add(
388
- self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),
428
+ self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.get_coords()),
389
429
  'switch_on',
390
430
  )
391
431
 
392
432
  self.switch_off = self.add(
393
- self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords),
433
+ self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.get_coords()),
394
434
  'switch_off',
395
435
  )
396
436
 
397
437
  # Create count variable for number of switches
398
438
  self.switch_on_nr = self.add(
399
439
  self._model.add_variables(
400
- upper=self._switch_on_max,
440
+ upper=extract_data(self._switch_on_max),
401
441
  lower=0,
402
442
  name=f'{self.label_full}|switch_on_nr',
403
443
  ),
@@ -451,9 +491,9 @@ class ConsecutiveStateModel(Model):
451
491
  model: SystemModel,
452
492
  label_of_element: str,
453
493
  state_variable: linopy.Variable,
454
- minimum_duration: Optional[NumericData] = None,
455
- maximum_duration: Optional[NumericData] = None,
456
- previous_states: Optional[NumericData] = None,
494
+ minimum_duration: Optional[TimestepData] = None,
495
+ maximum_duration: Optional[TimestepData] = None,
496
+ previous_states: Optional[TimestepData] = None,
457
497
  label: Optional[str] = None,
458
498
  ):
459
499
  """
@@ -475,9 +515,9 @@ class ConsecutiveStateModel(Model):
475
515
  self._maximum_duration = maximum_duration
476
516
 
477
517
  if isinstance(self._minimum_duration, TimeSeries):
478
- self._minimum_duration = self._minimum_duration.active_data
518
+ self._minimum_duration = self._minimum_duration.selected_data
479
519
  if isinstance(self._maximum_duration, TimeSeries):
480
- self._maximum_duration = self._maximum_duration.active_data
520
+ self._maximum_duration = self._maximum_duration.selected_data
481
521
 
482
522
  self.duration = None
483
523
 
@@ -491,8 +531,8 @@ class ConsecutiveStateModel(Model):
491
531
  self.duration = self.add(
492
532
  self._model.add_variables(
493
533
  lower=0,
494
- upper=self._maximum_duration if self._maximum_duration is not None else mega,
495
- coords=self._model.coords,
534
+ upper=extract_data(self._maximum_duration, mega),
535
+ coords=self._model.get_coords(),
496
536
  name=f'{self.label_full}|hours',
497
537
  ),
498
538
  'hours',
@@ -545,7 +585,7 @@ class ConsecutiveStateModel(Model):
545
585
  )
546
586
 
547
587
  # Handle initial condition
548
- if 0 < self.previous_duration < self._minimum_duration.isel(time=0):
588
+ if 0 < self.previous_duration < self._minimum_duration.isel(time=0).max():
549
589
  self.add(
550
590
  self._model.add_constraints(
551
591
  self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum'
@@ -570,12 +610,12 @@ class ConsecutiveStateModel(Model):
570
610
  """Computes the previous duration of the state variable"""
571
611
  #TODO: Allow for other/dynamic timestep resolutions
572
612
  return ConsecutiveStateModel.compute_consecutive_hours_in_state(
573
- self._previous_states, self._model.hours_per_step.isel(time=0).item()
613
+ self._previous_states, self._model.hours_per_step.isel(time=0).values.flatten()[0]
574
614
  )
575
615
 
576
616
  @staticmethod
577
617
  def compute_consecutive_hours_in_state(
578
- binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray]
618
+ binary_values: TimestepData, hours_per_timestep: Union[int, float, np.ndarray]
579
619
  ) -> Scalar:
580
620
  """
581
621
  Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array.
@@ -634,8 +674,8 @@ class OnOffModel(Model):
634
674
  on_off_parameters: OnOffParameters,
635
675
  label_of_element: str,
636
676
  defining_variables: List[linopy.Variable],
637
- defining_bounds: List[Tuple[NumericData, NumericData]],
638
- previous_values: List[Optional[NumericData]],
677
+ defining_bounds: List[Tuple[TimestepData, TimestepData]],
678
+ previous_values: List[Optional[TimestepData]],
639
679
  label: Optional[str] = None,
640
680
  ):
641
681
  """
@@ -672,8 +712,8 @@ class OnOffModel(Model):
672
712
  defining_bounds=self._defining_bounds,
673
713
  previous_values=self._previous_values,
674
714
  use_off=self.parameters.use_off,
675
- on_hours_total_min=self.parameters.on_hours_total_min,
676
- on_hours_total_max=self.parameters.on_hours_total_max,
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),
677
717
  effects_per_running_hour=self.parameters.effects_per_running_hour,
678
718
  )
679
719
  self.add(self.state_model)
@@ -792,7 +832,7 @@ class PieceModel(Model):
792
832
  self._model.add_variables(
793
833
  binary=True,
794
834
  name=f'{self.label_full}|inside_piece',
795
- coords=self._model.coords if self._as_time_series else None,
835
+ coords=self._model.get_coords(time_dim=self._as_time_series),
796
836
  ),
797
837
  'inside_piece',
798
838
  )
@@ -802,7 +842,7 @@ class PieceModel(Model):
802
842
  lower=0,
803
843
  upper=1,
804
844
  name=f'{self.label_full}|lambda0',
805
- coords=self._model.coords if self._as_time_series else None,
845
+ coords=self._model.get_coords(time_dim=self._as_time_series),
806
846
  ),
807
847
  'lambda0',
808
848
  )
@@ -812,7 +852,7 @@ class PieceModel(Model):
812
852
  lower=0,
813
853
  upper=1,
814
854
  name=f'{self.label_full}|lambda1',
815
- coords=self._model.coords if self._as_time_series else None,
855
+ coords=self._model.get_coords(time_dim=self._as_time_series),
816
856
  ),
817
857
  'lambda1',
818
858
  )
@@ -896,7 +936,7 @@ class PiecewiseModel(Model):
896
936
  elif self._zero_point is True:
897
937
  self.zero_point = self.add(
898
938
  self._model.add_variables(
899
- coords=self._model.coords, binary=True, name=f'{self.label_full}|zero_point'
939
+ coords=self._model.get_coords(), binary=True, name=f'{self.label_full}|zero_point'
900
940
  ),
901
941
  'zero_point',
902
942
  )
@@ -917,19 +957,20 @@ class ShareAllocationModel(Model):
917
957
  def __init__(
918
958
  self,
919
959
  model: SystemModel,
920
- shares_are_time_series: bool,
960
+ has_time_dim: bool,
961
+ has_scenario_dim: bool,
921
962
  label_of_element: Optional[str] = None,
922
963
  label: Optional[str] = None,
923
964
  label_full: Optional[str] = None,
924
- total_max: Optional[Scalar] = None,
925
- total_min: Optional[Scalar] = None,
926
- max_per_hour: Optional[NumericData] = None,
927
- min_per_hour: Optional[NumericData] = 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,
928
969
  ):
929
970
  super().__init__(model, label_of_element=label_of_element, label=label, label_full=label_full)
930
- if not shares_are_time_series: # If the condition is True
971
+ if not has_time_dim: # If the condition is True
931
972
  assert max_per_hour is None and min_per_hour is None, (
932
- 'Both max_per_hour and min_per_hour cannot be used when shares_are_time_series is False'
973
+ 'Both max_per_hour and min_per_hour cannot be used when has_time_dim is False'
933
974
  )
934
975
  self.total_per_timestep: Optional[linopy.Variable] = None
935
976
  self.total: Optional[linopy.Variable] = None
@@ -940,8 +981,9 @@ class ShareAllocationModel(Model):
940
981
  self._eq_total: Optional[linopy.Constraint] = None
941
982
 
942
983
  # Parameters
943
- self._shares_are_time_series = shares_are_time_series
944
- self._total_max = total_max if total_min is not None else np.inf
984
+ self._has_time_dim = has_time_dim
985
+ self._has_scenario_dim = has_scenario_dim
986
+ self._total_max = total_max if total_max is not None else np.inf
945
987
  self._total_min = total_min if total_min is not None else -np.inf
946
988
  self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf
947
989
  self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf
@@ -949,7 +991,10 @@ class ShareAllocationModel(Model):
949
991
  def do_modeling(self):
950
992
  self.total = self.add(
951
993
  self._model.add_variables(
952
- lower=self._total_min, upper=self._total_max, coords=None, name=f'{self.label_full}|total'
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',
953
998
  ),
954
999
  'total',
955
1000
  )
@@ -958,16 +1003,12 @@ class ShareAllocationModel(Model):
958
1003
  self._model.add_constraints(self.total == 0, name=f'{self.label_full}|total'), 'total'
959
1004
  )
960
1005
 
961
- if self._shares_are_time_series:
1006
+ if self._has_time_dim:
962
1007
  self.total_per_timestep = self.add(
963
1008
  self._model.add_variables(
964
- lower=-np.inf
965
- if (self._min_per_hour is None)
966
- else np.multiply(self._min_per_hour, self._model.hours_per_step),
967
- upper=np.inf
968
- if (self._max_per_hour is None)
969
- else np.multiply(self._max_per_hour, self._model.hours_per_step),
970
- coords=self._model.coords,
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),
971
1012
  name=f'{self.label_full}|total_per_timestep',
972
1013
  ),
973
1014
  'total_per_timestep',
@@ -979,12 +1020,14 @@ class ShareAllocationModel(Model):
979
1020
  )
980
1021
 
981
1022
  # Add it to the total
982
- self._eq_total.lhs -= self.total_per_timestep.sum()
1023
+ self._eq_total.lhs -= self.total_per_timestep.sum(dim='time')
983
1024
 
984
1025
  def add_share(
985
1026
  self,
986
1027
  name: str,
987
1028
  expression: linopy.LinearExpression,
1029
+ has_time_dim: bool,
1030
+ has_scenario_dim: bool,
988
1031
  ):
989
1032
  """
990
1033
  Add a share to the share allocation model. If the share already exists, the expression is added to the existing share.
@@ -996,16 +1039,17 @@ class ShareAllocationModel(Model):
996
1039
  name: The name of the share.
997
1040
  expression: The expression of the share. Added to the right hand side of the constraint.
998
1041
  """
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')
1046
+
999
1047
  if name in self.shares:
1000
1048
  self.share_constraints[name].lhs -= expression
1001
1049
  else:
1002
1050
  self.shares[name] = self.add(
1003
1051
  self._model.add_variables(
1004
- coords=None
1005
- if isinstance(expression, linopy.LinearExpression)
1006
- and expression.ndim == 0
1007
- or not isinstance(expression, linopy.LinearExpression)
1008
- else self._model.coords,
1052
+ coords=self._model.get_coords(time_dim=has_time_dim, scenario_dim=has_scenario_dim),
1009
1053
  name=f'{name}->{self.label_full}',
1010
1054
  ),
1011
1055
  name,
@@ -1013,7 +1057,7 @@ class ShareAllocationModel(Model):
1013
1057
  self.share_constraints[name] = self.add(
1014
1058
  self._model.add_constraints(self.shares[name] == expression, name=f'{name}->{self.label_full}'), name
1015
1059
  )
1016
- if self.shares[name].ndim == 0:
1060
+ if not has_time_dim:
1017
1061
  self._eq_total.lhs -= self.shares[name]
1018
1062
  else:
1019
1063
  self._eq_total_per_timestep.lhs -= self.shares[name]
@@ -1042,7 +1086,12 @@ class PiecewiseEffectsModel(Model):
1042
1086
 
1043
1087
  def do_modeling(self):
1044
1088
  self.shares = {
1045
- effect: self.add(self._model.add_variables(coords=None, name=f'{self.label_full}|{effect}'), f'{effect}')
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
+ )
1046
1095
  for effect in self._piecewise_shares
1047
1096
  }
1048
1097