flixopt 2.1.0__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/effects.py CHANGED
@@ -7,13 +7,13 @@ which are then transformed into the internal data structure.
7
7
 
8
8
  import logging
9
9
  import warnings
10
- from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union
10
+ from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Set, Tuple, Union
11
11
 
12
12
  import linopy
13
13
  import numpy as np
14
- import pandas as pd
14
+ import xarray as xr
15
15
 
16
- from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection
16
+ from .core import NumericDataTS, ScenarioData, TimeSeries, TimeSeriesCollection, TimestepData, extract_data
17
17
  from .features import ShareAllocationModel
18
18
  from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io
19
19
 
@@ -38,16 +38,16 @@ class Effect(Element):
38
38
  meta_data: Optional[Dict] = None,
39
39
  is_standard: bool = False,
40
40
  is_objective: bool = False,
41
- specific_share_to_other_effects_operation: Optional['EffectValuesUser'] = None,
42
- specific_share_to_other_effects_invest: Optional['EffectValuesUser'] = None,
43
- minimum_operation: Optional[Scalar] = None,
44
- maximum_operation: Optional[Scalar] = None,
45
- minimum_invest: Optional[Scalar] = None,
46
- maximum_invest: Optional[Scalar] = None,
41
+ specific_share_to_other_effects_operation: Optional['EffectValuesUserTimestep'] = None,
42
+ specific_share_to_other_effects_invest: Optional['EffectValuesUserScenario'] = None,
43
+ minimum_operation: Optional[ScenarioData] = None,
44
+ maximum_operation: Optional[ScenarioData] = None,
45
+ minimum_invest: Optional[ScenarioData] = None,
46
+ maximum_invest: Optional[ScenarioData] = None,
47
47
  minimum_operation_per_hour: Optional[NumericDataTS] = None,
48
48
  maximum_operation_per_hour: Optional[NumericDataTS] = None,
49
- minimum_total: Optional[Scalar] = None,
50
- maximum_total: Optional[Scalar] = None,
49
+ minimum_total: Optional[ScenarioData] = None,
50
+ maximum_total: Optional[ScenarioData] = None,
51
51
  ):
52
52
  """
53
53
  Args:
@@ -76,10 +76,12 @@ class Effect(Element):
76
76
  self.description = description
77
77
  self.is_standard = is_standard
78
78
  self.is_objective = is_objective
79
- self.specific_share_to_other_effects_operation: EffectValuesUser = (
79
+ self.specific_share_to_other_effects_operation: EffectValuesUserTimestep = (
80
80
  specific_share_to_other_effects_operation or {}
81
81
  )
82
- self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {}
82
+ self.specific_share_to_other_effects_invest: EffectValuesUserTimestep = (
83
+ specific_share_to_other_effects_invest or {}
84
+ )
83
85
  self.minimum_operation = minimum_operation
84
86
  self.maximum_operation = maximum_operation
85
87
  self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour
@@ -96,11 +98,33 @@ class Effect(Element):
96
98
  self.maximum_operation_per_hour = flow_system.create_time_series(
97
99
  f'{self.label_full}|maximum_operation_per_hour', self.maximum_operation_per_hour, flow_system
98
100
  )
99
-
100
101
  self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series(
101
102
  f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation'
102
103
  )
103
104
 
105
+ self.minimum_operation = flow_system.create_time_series(
106
+ f'{self.label_full}|minimum_operation', self.minimum_operation, has_time_dim=False
107
+ )
108
+ self.maximum_operation = flow_system.create_time_series(
109
+ f'{self.label_full}|maximum_operation', self.maximum_operation, has_time_dim=False
110
+ )
111
+ self.minimum_invest = flow_system.create_time_series(
112
+ f'{self.label_full}|minimum_invest', self.minimum_invest, has_time_dim=False
113
+ )
114
+ self.maximum_invest = flow_system.create_time_series(
115
+ f'{self.label_full}|maximum_invest', self.maximum_invest, has_time_dim=False
116
+ )
117
+ self.minimum_total = flow_system.create_time_series(
118
+ f'{self.label_full}|minimum_total', self.minimum_total, has_time_dim=False,
119
+ )
120
+ self.maximum_total = flow_system.create_time_series(
121
+ f'{self.label_full}|maximum_total', self.maximum_total, has_time_dim=False
122
+ )
123
+ self.specific_share_to_other_effects_invest = flow_system.create_effect_time_series(
124
+ f'{self.label_full}|invest->', self.specific_share_to_other_effects_invest, 'invest',
125
+ has_time_dim=False
126
+ )
127
+
104
128
  def create_model(self, model: SystemModel) -> 'EffectModel':
105
129
  self._plausibility_checks()
106
130
  self.model = EffectModel(model, self)
@@ -118,31 +142,29 @@ class EffectModel(ElementModel):
118
142
  self.total: Optional[linopy.Variable] = None
119
143
  self.invest: ShareAllocationModel = self.add(
120
144
  ShareAllocationModel(
121
- self._model,
122
- False,
123
- self.label_of_element,
124
- 'invest',
145
+ model=self._model,
146
+ has_time_dim=False,
147
+ has_scenario_dim=True,
148
+ label_of_element=self.label_of_element,
149
+ label='invest',
125
150
  label_full=f'{self.label_full}(invest)',
126
- total_max=self.element.maximum_invest,
127
- total_min=self.element.minimum_invest,
151
+ total_max=extract_data(self.element.maximum_invest),
152
+ total_min=extract_data(self.element.minimum_invest),
128
153
  )
129
154
  )
130
155
 
131
156
  self.operation: ShareAllocationModel = self.add(
132
157
  ShareAllocationModel(
133
- self._model,
134
- True,
135
- self.label_of_element,
136
- 'operation',
158
+ model=self._model,
159
+ has_time_dim=True,
160
+ has_scenario_dim=True,
161
+ label_of_element=self.label_of_element,
162
+ label='operation',
137
163
  label_full=f'{self.label_full}(operation)',
138
- total_max=self.element.maximum_operation,
139
- total_min=self.element.minimum_operation,
140
- min_per_hour=self.element.minimum_operation_per_hour.active_data
141
- if self.element.minimum_operation_per_hour is not None
142
- else None,
143
- max_per_hour=self.element.maximum_operation_per_hour.active_data
144
- if self.element.maximum_operation_per_hour is not None
145
- else None,
164
+ total_max=extract_data(self.element.maximum_operation),
165
+ total_min=extract_data(self.element.minimum_operation),
166
+ min_per_hour=extract_data(self.element.minimum_operation_per_hour),
167
+ max_per_hour=extract_data(self.element.maximum_operation_per_hour),
146
168
  )
147
169
  )
148
170
 
@@ -152,9 +174,9 @@ class EffectModel(ElementModel):
152
174
 
153
175
  self.total = self.add(
154
176
  self._model.add_variables(
155
- lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf,
156
- upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf,
157
- coords=None,
177
+ lower=extract_data(self.element.minimum_total, if_none=-np.inf),
178
+ upper=extract_data(self.element.maximum_total, if_none=np.inf),
179
+ coords=self._model.get_coords(time_dim=False),
158
180
  name=f'{self.label_full}|total',
159
181
  ),
160
182
  'total',
@@ -162,7 +184,7 @@ class EffectModel(ElementModel):
162
184
 
163
185
  self.add(
164
186
  self._model.add_constraints(
165
- self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total'
187
+ self.total == self.operation.total + self.invest.total, name=f'{self.label_full}|total'
166
188
  ),
167
189
  'total',
168
190
  )
@@ -171,11 +193,12 @@ class EffectModel(ElementModel):
171
193
  EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares
172
194
  EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values
173
195
  EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored
174
- EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects
175
- """ This datatype is used to define the share to an effect by a certain attribute. """
176
196
 
177
- EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects
178
- """ This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """
197
+ EffectValuesUserScenario = Union[ScenarioData, Dict[str, ScenarioData]]
198
+ """ This datatype is used to define the share to an effect for every scenario. """
199
+
200
+ EffectValuesUserTimestep = Union[TimestepData, Dict[str, TimestepData]]
201
+ """ This datatype is used to define the share to an effect for every timestep. """
179
202
 
180
203
 
181
204
  class EffectCollection:
@@ -207,7 +230,9 @@ class EffectCollection:
207
230
  self._effects[effect.label] = effect
208
231
  logger.info(f'Registered new Effect: {effect.label}')
209
232
 
210
- def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]:
233
+ def create_effect_values_dict(
234
+ self, effect_values_user: Union[EffectValuesUserScenario, EffectValuesUserTimestep]
235
+ ) -> Optional[EffectValuesDict]:
211
236
  """
212
237
  Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
213
238
 
@@ -244,26 +269,18 @@ class EffectCollection:
244
269
 
245
270
  def _plausibility_checks(self) -> None:
246
271
  # Check circular loops in effects:
247
- # TODO: Improve checks!! Only most basic case covered...
272
+ operation, invest = self.calculate_effect_share_factors()
248
273
 
249
- def error_str(effect_label: str, share_ffect_label: str):
250
- return (
251
- f' {effect_label} -> has share in: {share_ffect_label}\n'
252
- f' {share_ffect_label} -> has share in: {effect_label}'
253
- )
274
+ operation_cycles = detect_cycles(tuples_to_adjacency_list([key for key in operation]))
275
+ invest_cycles = detect_cycles(tuples_to_adjacency_list([key for key in invest]))
254
276
 
255
- for effect in self.effects.values():
256
- # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen:
257
- # operation:
258
- for target_effect in effect.specific_share_to_other_effects_operation.keys():
259
- assert effect not in self[target_effect].specific_share_to_other_effects_operation.keys(), (
260
- f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}'
261
- )
262
- # invest:
263
- for target_effect in effect.specific_share_to_other_effects_invest.keys():
264
- assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), (
265
- f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}'
266
- )
277
+ if operation_cycles:
278
+ cycle_str = "\n".join([" -> ".join(cycle) for cycle in operation_cycles])
279
+ raise ValueError(f'Error: circular operation-shares detected:\n{cycle_str}')
280
+
281
+ if invest_cycles:
282
+ cycle_str = "\n".join([" -> ".join(cycle) for cycle in invest_cycles])
283
+ raise ValueError(f'Error: circular invest-shares detected:\n{cycle_str}')
267
284
 
268
285
  def __getitem__(self, effect: Union[str, Effect]) -> 'Effect':
269
286
  """
@@ -327,6 +344,30 @@ class EffectCollection:
327
344
  raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})')
328
345
  self._objective_effect = value
329
346
 
347
+ def calculate_effect_share_factors(self) -> Tuple[
348
+ Dict[Tuple[str, str], xr.DataArray],
349
+ Dict[Tuple[str, str], xr.DataArray],
350
+ ]:
351
+ shares_invest = {}
352
+ for name, effect in self.effects.items():
353
+ if effect.specific_share_to_other_effects_invest:
354
+ shares_invest[name] = {
355
+ target: extract_data(data)
356
+ for target, data in effect.specific_share_to_other_effects_invest.items()
357
+ }
358
+ shares_invest = calculate_all_conversion_paths(shares_invest)
359
+
360
+ shares_operation = {}
361
+ for name, effect in self.effects.items():
362
+ if effect.specific_share_to_other_effects_operation:
363
+ shares_operation[name] = {
364
+ target: extract_data(data)
365
+ for target, data in effect.specific_share_to_other_effects_operation.items()
366
+ }
367
+ shares_operation = calculate_all_conversion_paths(shares_operation)
368
+
369
+ return shares_operation, shares_invest
370
+
330
371
 
331
372
  class EffectCollectionModel(Model):
332
373
  """
@@ -346,29 +387,42 @@ class EffectCollectionModel(Model):
346
387
  ) -> None:
347
388
  for effect, expression in expressions.items():
348
389
  if target == 'operation':
349
- self.effects[effect].model.operation.add_share(name, expression)
390
+ self.effects[effect].model.operation.add_share(
391
+ name,
392
+ expression,
393
+ has_time_dim=True,
394
+ has_scenario_dim=True,
395
+ )
350
396
  elif target == 'invest':
351
- self.effects[effect].model.invest.add_share(name, expression)
397
+ self.effects[effect].model.invest.add_share(
398
+ name,
399
+ expression,
400
+ has_time_dim=False,
401
+ has_scenario_dim=True,
402
+ )
352
403
  else:
353
404
  raise ValueError(f'Target {target} not supported!')
354
405
 
355
406
  def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None:
356
407
  if expression.ndim != 0:
357
408
  raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})')
358
- self.penalty.add_share(name, expression)
409
+ self.penalty.add_share(name, expression, has_time_dim=False, has_scenario_dim=False)
359
410
 
360
411
  def do_modeling(self):
361
412
  for effect in self.effects:
362
413
  effect.create_model(self._model)
363
414
  self.penalty = self.add(
364
- ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')
415
+ ShareAllocationModel(self._model, has_time_dim=False, has_scenario_dim=False, label_of_element='Penalty')
365
416
  )
366
417
  for model in [effect.model for effect in self.effects] + [self.penalty]:
367
418
  model.do_modeling()
368
419
 
369
420
  self._add_share_between_effects()
370
421
 
371
- self._model.add_objective(self.effects.objective_effect.model.total + self.penalty.total)
422
+ self._model.add_objective(
423
+ (self.effects.objective_effect.model.total * self._model.scenario_weights).sum()
424
+ + self.penalty.total.sum()
425
+ )
372
426
 
373
427
  def _add_share_between_effects(self):
374
428
  for origin_effect in self.effects:
@@ -376,11 +430,161 @@ class EffectCollectionModel(Model):
376
430
  for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items():
377
431
  self.effects[target_effect].model.operation.add_share(
378
432
  origin_effect.model.operation.label_full,
379
- origin_effect.model.operation.total_per_timestep * time_series.active_data,
433
+ origin_effect.model.operation.total_per_timestep * time_series.selected_data,
434
+ has_time_dim=True,
435
+ has_scenario_dim=True,
380
436
  )
381
437
  # 2. invest: -> hier ist es Scalar (share)
382
438
  for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items():
383
439
  self.effects[target_effect].model.invest.add_share(
384
440
  origin_effect.model.invest.label_full,
385
441
  origin_effect.model.invest.total * factor,
442
+ has_time_dim=False,
443
+ has_scenario_dim=True,
386
444
  )
445
+
446
+
447
+ def calculate_all_conversion_paths(
448
+ conversion_dict: Dict[str, Dict[str, xr.DataArray]],
449
+ ) -> Dict[Tuple[str, str], xr.DataArray]:
450
+ """
451
+ Calculates all possible direct and indirect conversion factors between units/domains.
452
+ This function uses Breadth-First Search (BFS) to find all possible conversion paths
453
+ between different units or domains in a conversion graph. It computes both direct
454
+ conversions (explicitly provided in the input) and indirect conversions (derived
455
+ through intermediate units).
456
+ Args:
457
+ conversion_dict: A nested dictionary where:
458
+ - Outer keys represent origin units/domains
459
+ - Inner dictionaries map target units/domains to their conversion factors
460
+ - Conversion factors can be integers, floats, or numpy arrays
461
+ Returns:
462
+ A dictionary mapping (origin, target) tuples to their respective conversion factors.
463
+ Each key is a tuple of strings representing the origin and target units/domains.
464
+ Each value is the conversion factor (int, float, or numpy array) from origin to target.
465
+ """
466
+ # Initialize the result dictionary to accumulate all paths
467
+ result = {}
468
+
469
+ # Add direct connections to the result first
470
+ for origin, targets in conversion_dict.items():
471
+ for target, factor in targets.items():
472
+ result[(origin, target)] = factor
473
+
474
+ # Track all paths by keeping path history to avoid cycles
475
+ # Iterate over each domain in the dictionary
476
+ for origin in conversion_dict:
477
+ # Keep track of visited paths to avoid repeating calculations
478
+ processed_paths = set()
479
+ # Use a queue with (current_domain, factor, path_history)
480
+ queue = [(origin, 1, [origin])]
481
+
482
+ while queue:
483
+ current_domain, factor, path = queue.pop(0)
484
+
485
+ # Skip if we've processed this exact path before
486
+ path_key = tuple(path)
487
+ if path_key in processed_paths:
488
+ continue
489
+ processed_paths.add(path_key)
490
+
491
+ # Iterate over the neighbors of the current domain
492
+ for target, conversion_factor in conversion_dict.get(current_domain, {}).items():
493
+ # Skip if target would create a cycle
494
+ if target in path:
495
+ continue
496
+
497
+ # Calculate the indirect conversion factor
498
+ indirect_factor = factor * conversion_factor
499
+ new_path = path + [target]
500
+
501
+ # Only consider paths starting at origin and ending at some target
502
+ if len(new_path) > 2 and new_path[0] == origin:
503
+ # Update the result dictionary - accumulate factors from different paths
504
+ if (origin, target) in result:
505
+ result[(origin, target)] = result[(origin, target)] + indirect_factor
506
+ else:
507
+ result[(origin, target)] = indirect_factor
508
+
509
+ # Add new path to queue for further exploration
510
+ queue.append((target, indirect_factor, new_path))
511
+
512
+ # Convert all values to DataArrays
513
+ result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value)
514
+ for key, value in result.items()}
515
+
516
+ return result
517
+
518
+
519
+ def detect_cycles(graph: Dict[str, List[str]]) -> List[List[str]]:
520
+ """
521
+ Detects cycles in a directed graph using DFS.
522
+
523
+ Args:
524
+ graph: Adjacency list representation of the graph
525
+
526
+ Returns:
527
+ List of cycles found, where each cycle is a list of nodes
528
+ """
529
+ # Track nodes in current recursion stack
530
+ visiting = set()
531
+ # Track nodes that have been fully explored
532
+ visited = set()
533
+ # Store all found cycles
534
+ cycles = []
535
+
536
+ def dfs_find_cycles(node, path=None):
537
+ if path is None:
538
+ path = []
539
+
540
+ # Current path to this node
541
+ current_path = path + [node]
542
+ # Add node to current recursion stack
543
+ visiting.add(node)
544
+
545
+ # Check all neighbors
546
+ for neighbor in graph.get(node, []):
547
+ # If neighbor is in current path, we found a cycle
548
+ if neighbor in visiting:
549
+ # Get the cycle by extracting the relevant portion of the path
550
+ cycle_start = current_path.index(neighbor)
551
+ cycle = current_path[cycle_start:] + [neighbor]
552
+ cycles.append(cycle)
553
+ # If neighbor hasn't been fully explored, check it
554
+ elif neighbor not in visited:
555
+ dfs_find_cycles(neighbor, current_path)
556
+
557
+ # Remove node from current path and mark as fully explored
558
+ visiting.remove(node)
559
+ visited.add(node)
560
+
561
+ # Check each unvisited node
562
+ for node in graph:
563
+ if node not in visited:
564
+ dfs_find_cycles(node)
565
+
566
+ return cycles
567
+
568
+
569
+ def tuples_to_adjacency_list(edges: List[Tuple[str, str]]) -> Dict[str, List[str]]:
570
+ """
571
+ Converts a list of edge tuples (source, target) to an adjacency list representation.
572
+
573
+ Args:
574
+ edges: List of (source, target) tuples representing directed edges
575
+
576
+ Returns:
577
+ Dictionary mapping each source node to a list of its target nodes
578
+ """
579
+ graph = {}
580
+
581
+ for source, target in edges:
582
+ if source not in graph:
583
+ graph[source] = []
584
+ graph[source].append(target)
585
+
586
+ # Ensure target nodes with no outgoing edges are in the graph
587
+ if target not in graph:
588
+ graph[target] = []
589
+
590
+ return graph