flixopt 2.2.0rc2__py3-none-any.whl → 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. flixopt/__init__.py +33 -4
  2. flixopt/aggregation.py +60 -80
  3. flixopt/calculation.py +403 -182
  4. flixopt/commons.py +1 -10
  5. flixopt/components.py +939 -448
  6. flixopt/config.py +553 -191
  7. flixopt/core.py +513 -846
  8. flixopt/effects.py +644 -178
  9. flixopt/elements.py +610 -355
  10. flixopt/features.py +394 -966
  11. flixopt/flow_system.py +736 -219
  12. flixopt/interface.py +1104 -302
  13. flixopt/io.py +103 -79
  14. flixopt/linear_converters.py +387 -95
  15. flixopt/modeling.py +757 -0
  16. flixopt/network_app.py +73 -39
  17. flixopt/plotting.py +294 -138
  18. flixopt/results.py +1254 -300
  19. flixopt/solvers.py +25 -21
  20. flixopt/structure.py +938 -396
  21. flixopt/utils.py +36 -12
  22. flixopt-3.0.1.dist-info/METADATA +209 -0
  23. flixopt-3.0.1.dist-info/RECORD +26 -0
  24. flixopt-3.0.1.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -61
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  37. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  38. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  39. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  40. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  41. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  42. docs/user-guide/Mathematical Notation/index.md +0 -22
  43. docs/user-guide/Mathematical Notation/others.md +0 -3
  44. docs/user-guide/index.md +0 -124
  45. flixopt/config.yaml +0 -10
  46. flixopt-2.2.0rc2.dist-info/METADATA +0 -167
  47. flixopt-2.2.0rc2.dist-info/RECORD +0 -54
  48. flixopt-2.2.0rc2.dist-info/top_level.txt +0 -5
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixOpt_plotting.jpg +0 -0
  52. pics/flixopt-icon.svg +0 -1
  53. pics/pics.pptx +0 -0
  54. scripts/extract_release_notes.py +0 -45
  55. scripts/gen_ref_pages.py +0 -54
  56. tests/ressources/Zeitreihen2020.csv +0 -35137
  57. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.1.dist-info}/WHEEL +0 -0
  58. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.1.dist-info}/licenses/LICENSE +0 -0
flixopt/effects.py CHANGED
@@ -5,19 +5,24 @@ Different Datatypes are used to represent the effects with assigned values by th
5
5
  which are then transformed into the internal data structure.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import logging
9
11
  import warnings
10
- from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union
12
+ from collections import deque
13
+ from typing import TYPE_CHECKING, Literal
11
14
 
12
15
  import linopy
13
16
  import numpy as np
14
- import pandas as pd
17
+ import xarray as xr
15
18
 
16
- from .core import NumericData, NumericDataTS, Scalar, TimeSeries, TimeSeriesCollection
19
+ from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser
17
20
  from .features import ShareAllocationModel
18
- from .structure import Element, ElementModel, Interface, Model, SystemModel, register_class_for_io
21
+ from .structure import Element, ElementModel, FlowSystemModel, Submodel, register_class_for_io
19
22
 
20
23
  if TYPE_CHECKING:
24
+ from collections.abc import Iterator
25
+
21
26
  from .flow_system import FlowSystem
22
27
 
23
28
  logger = logging.getLogger('flixopt')
@@ -26,8 +31,132 @@ logger = logging.getLogger('flixopt')
26
31
  @register_class_for_io
27
32
  class Effect(Element):
28
33
  """
29
- Effect, i.g. costs, CO2 emissions, area, ...
30
- Components, FLows, and so on can contribute to an Effect. One Effect is chosen as the Objective of the Optimization
34
+ Represents system-wide impacts like costs, emissions, resource consumption, or other effects.
35
+
36
+ Effects capture the broader impacts of system operation and investment decisions beyond
37
+ the primary energy/material flows. Each Effect accumulates contributions from Components,
38
+ Flows, and other system elements. One Effect is typically chosen as the optimization
39
+ objective, while others can serve as constraints or tracking metrics.
40
+
41
+ Effects support comprehensive modeling including operational and investment contributions,
42
+ cross-effect relationships (e.g., carbon pricing), and flexible constraint formulation.
43
+
44
+ Args:
45
+ label: The label of the Element. Used to identify it in the FlowSystem.
46
+ unit: The unit of the effect (e.g., '€', 'kg_CO2', 'kWh_primary', 'm²').
47
+ This is informative only and does not affect optimization calculations.
48
+ description: Descriptive name explaining what this effect represents.
49
+ is_standard: If True, this is a standard effect allowing direct value input
50
+ without effect dictionaries. Used for simplified effect specification (and less boilerplate code).
51
+ is_objective: If True, this effect serves as the optimization objective function.
52
+ Only one effect can be marked as objective per optimization.
53
+ share_from_temporal: Temporal cross-effect contributions.
54
+ Maps temporal contributions from other effects to this effect
55
+ share_from_periodic: Periodic cross-effect contributions.
56
+ Maps periodic contributions from other effects to this effect.
57
+ minimum_temporal: Minimum allowed total contribution across all timesteps.
58
+ maximum_temporal: Maximum allowed total contribution across all timesteps.
59
+ minimum_per_hour: Minimum allowed contribution per hour.
60
+ maximum_per_hour: Maximum allowed contribution per hour.
61
+ minimum_periodic: Minimum allowed total periodic contribution.
62
+ maximum_periodic: Maximum allowed total periodic contribution.
63
+ minimum_total: Minimum allowed total effect (temporal + periodic combined).
64
+ maximum_total: Maximum allowed total effect (temporal + periodic combined).
65
+ meta_data: Used to store additional information. Not used internally but saved
66
+ in results. Only use Python native types.
67
+
68
+ **Deprecated Parameters** (for backwards compatibility):
69
+ minimum_operation: Use `minimum_temporal` instead.
70
+ maximum_operation: Use `maximum_temporal` instead.
71
+ minimum_invest: Use `minimum_periodic` instead.
72
+ maximum_invest: Use `maximum_periodic` instead.
73
+ minimum_operation_per_hour: Use `minimum_per_hour` instead.
74
+ maximum_operation_per_hour: Use `maximum_per_hour` instead.
75
+
76
+ Examples:
77
+ Basic cost objective:
78
+
79
+ ```python
80
+ cost_effect = Effect(
81
+ label='system_costs',
82
+ unit='€',
83
+ description='Total system costs',
84
+ is_objective=True,
85
+ )
86
+ ```
87
+
88
+ CO2 emissions:
89
+
90
+ ```python
91
+ co2_effect = Effect(
92
+ label='CO2',
93
+ unit='kg_CO2',
94
+ description='Carbon dioxide emissions',
95
+ maximum_total=1_000_000, # 1000 t CO2 annual limit
96
+ )
97
+ ```
98
+
99
+ Land use constraint:
100
+
101
+ ```python
102
+ land_use = Effect(
103
+ label='land_usage',
104
+ unit='m²',
105
+ description='Land area requirement',
106
+ maximum_total=50_000, # Maximum 5 hectares available
107
+ )
108
+ ```
109
+
110
+ Primary energy tracking:
111
+
112
+ ```python
113
+ primary_energy = Effect(
114
+ label='primary_energy',
115
+ unit='kWh_primary',
116
+ description='Primary energy consumption',
117
+ )
118
+ ```
119
+
120
+ Cost objective with carbon and primary energy pricing:
121
+
122
+ ```python
123
+ cost_effect = Effect(
124
+ label='system_costs',
125
+ unit='€',
126
+ description='Total system costs',
127
+ is_objective=True,
128
+ share_from_temporal={
129
+ 'primary_energy': 0.08, # 0.08 €/kWh_primary
130
+ 'CO2': 0.2, # Carbon pricing: 0.2 €/kg_CO2 into costs if used on a cost effect
131
+ },
132
+ )
133
+ ```
134
+
135
+ Water consumption with tiered constraints:
136
+
137
+ ```python
138
+ water_usage = Effect(
139
+ label='water_consumption',
140
+ unit='m³',
141
+ description='Industrial water usage',
142
+ minimum_per_hour=10, # Minimum 10 m³/h for process stability
143
+ maximum_per_hour=500, # Maximum 500 m³/h capacity limit
144
+ maximum_total=100_000, # Annual permit limit: 100,000 m³
145
+ )
146
+ ```
147
+
148
+ Note:
149
+ Effect bounds can be None to indicate no constraint in that direction.
150
+
151
+ Cross-effect relationships enable sophisticated modeling like carbon pricing,
152
+ resource valuation, or multi-criteria optimization with weighted objectives.
153
+
154
+ The unit field is purely informational - ensure dimensional consistency
155
+ across all contributions to each effect manually.
156
+
157
+ Effects are accumulated as:
158
+ - Total = Σ(temporal contributions) + Σ(periodic contributions)
159
+
31
160
  """
32
161
 
33
162
  def __init__(
@@ -35,77 +164,223 @@ class Effect(Element):
35
164
  label: str,
36
165
  unit: str,
37
166
  description: str,
38
- meta_data: Optional[Dict] = None,
167
+ meta_data: dict | None = None,
39
168
  is_standard: bool = False,
40
169
  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,
47
- minimum_operation_per_hour: Optional[NumericDataTS] = None,
48
- maximum_operation_per_hour: Optional[NumericDataTS] = None,
49
- minimum_total: Optional[Scalar] = None,
50
- maximum_total: Optional[Scalar] = None,
170
+ share_from_temporal: TemporalEffectsUser | None = None,
171
+ share_from_periodic: PeriodicEffectsUser | None = None,
172
+ minimum_temporal: PeriodicEffectsUser | None = None,
173
+ maximum_temporal: PeriodicEffectsUser | None = None,
174
+ minimum_periodic: PeriodicEffectsUser | None = None,
175
+ maximum_periodic: PeriodicEffectsUser | None = None,
176
+ minimum_per_hour: TemporalDataUser | None = None,
177
+ maximum_per_hour: TemporalDataUser | None = None,
178
+ minimum_total: Scalar | None = None,
179
+ maximum_total: Scalar | None = None,
180
+ **kwargs,
51
181
  ):
52
- """
53
- Args:
54
- label: The label of the Element. Used to identify it in the FlowSystem
55
- unit: The unit of effect, i.g. €, kg_CO2, kWh_primaryEnergy
56
- description: The long name
57
- is_standard: true, if Standard-Effect (for direct input of value without effect (alternatively to dict)) , else false
58
- is_objective: true, if optimization target
59
- specific_share_to_other_effects_operation: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional
60
- share to other effects (only operation)
61
- specific_share_to_other_effects_invest: {effectType: TS, ...}, i.g. 180 €/t_CO2, input as {costs: 180}, optional
62
- share to other effects (only invest).
63
- minimum_operation: minimal sum (only operation) of the effect.
64
- maximum_operation: maximal sum (nur operation) of the effect.
65
- minimum_operation_per_hour: max. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep!
66
- maximum_operation_per_hour: min. value per hour (only operation) of effect (=sum of all effect-shares) for each timestep!
67
- minimum_invest: minimal sum (only invest) of the effect
68
- maximum_invest: maximal sum (only invest) of the effect
69
- minimum_total: min sum of effect (invest+operation).
70
- maximum_total: max sum of effect (invest+operation).
71
- meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
72
- """
73
182
  super().__init__(label, meta_data=meta_data)
74
- self.label = label
75
183
  self.unit = unit
76
184
  self.description = description
77
185
  self.is_standard = is_standard
78
186
  self.is_objective = is_objective
79
- self.specific_share_to_other_effects_operation: EffectValuesUser = (
80
- specific_share_to_other_effects_operation or {}
81
- )
82
- self.specific_share_to_other_effects_invest: EffectValuesUser = specific_share_to_other_effects_invest or {}
83
- self.minimum_operation = minimum_operation
84
- self.maximum_operation = maximum_operation
85
- self.minimum_operation_per_hour: NumericDataTS = minimum_operation_per_hour
86
- self.maximum_operation_per_hour: NumericDataTS = maximum_operation_per_hour
87
- self.minimum_invest = minimum_invest
88
- self.maximum_invest = maximum_invest
187
+ self.share_from_temporal: TemporalEffectsUser = share_from_temporal if share_from_temporal is not None else {}
188
+ self.share_from_periodic: PeriodicEffectsUser = share_from_periodic if share_from_periodic is not None else {}
189
+
190
+ # Handle backwards compatibility for deprecated parameters using centralized helper
191
+ minimum_temporal = self._handle_deprecated_kwarg(
192
+ kwargs, 'minimum_operation', 'minimum_temporal', minimum_temporal
193
+ )
194
+ maximum_temporal = self._handle_deprecated_kwarg(
195
+ kwargs, 'maximum_operation', 'maximum_temporal', maximum_temporal
196
+ )
197
+ minimum_periodic = self._handle_deprecated_kwarg(kwargs, 'minimum_invest', 'minimum_periodic', minimum_periodic)
198
+ maximum_periodic = self._handle_deprecated_kwarg(kwargs, 'maximum_invest', 'maximum_periodic', maximum_periodic)
199
+ minimum_per_hour = self._handle_deprecated_kwarg(
200
+ kwargs, 'minimum_operation_per_hour', 'minimum_per_hour', minimum_per_hour
201
+ )
202
+ maximum_per_hour = self._handle_deprecated_kwarg(
203
+ kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour
204
+ )
205
+
206
+ # Validate any remaining unexpected kwargs
207
+ self._validate_kwargs(kwargs)
208
+
209
+ # Set attributes directly
210
+ self.minimum_temporal = minimum_temporal
211
+ self.maximum_temporal = maximum_temporal
212
+ self.minimum_periodic = minimum_periodic
213
+ self.maximum_periodic = maximum_periodic
214
+ self.minimum_per_hour = minimum_per_hour
215
+ self.maximum_per_hour = maximum_per_hour
89
216
  self.minimum_total = minimum_total
90
217
  self.maximum_total = maximum_total
91
218
 
92
- def transform_data(self, flow_system: 'FlowSystem'):
93
- self.minimum_operation_per_hour = flow_system.create_time_series(
94
- f'{self.label_full}|minimum_operation_per_hour', self.minimum_operation_per_hour
219
+ # Backwards compatible properties (deprecated)
220
+ @property
221
+ def minimum_operation(self):
222
+ """DEPRECATED: Use 'minimum_temporal' property instead."""
223
+ warnings.warn(
224
+ "Property 'minimum_operation' is deprecated. Use 'minimum_temporal' instead.",
225
+ DeprecationWarning,
226
+ stacklevel=2,
227
+ )
228
+ return self.minimum_temporal
229
+
230
+ @minimum_operation.setter
231
+ def minimum_operation(self, value):
232
+ """DEPRECATED: Use 'minimum_temporal' property instead."""
233
+ warnings.warn(
234
+ "Property 'minimum_operation' is deprecated. Use 'minimum_temporal' instead.",
235
+ DeprecationWarning,
236
+ stacklevel=2,
237
+ )
238
+ self.minimum_temporal = value
239
+
240
+ @property
241
+ def maximum_operation(self):
242
+ """DEPRECATED: Use 'maximum_temporal' property instead."""
243
+ warnings.warn(
244
+ "Property 'maximum_operation' is deprecated. Use 'maximum_temporal' instead.",
245
+ DeprecationWarning,
246
+ stacklevel=2,
95
247
  )
96
- self.maximum_operation_per_hour = flow_system.create_time_series(
97
- f'{self.label_full}|maximum_operation_per_hour',
98
- self.maximum_operation_per_hour,
248
+ return self.maximum_temporal
249
+
250
+ @maximum_operation.setter
251
+ def maximum_operation(self, value):
252
+ """DEPRECATED: Use 'maximum_temporal' property instead."""
253
+ warnings.warn(
254
+ "Property 'maximum_operation' is deprecated. Use 'maximum_temporal' instead.",
255
+ DeprecationWarning,
256
+ stacklevel=2,
99
257
  )
258
+ self.maximum_temporal = value
100
259
 
101
- self.specific_share_to_other_effects_operation = flow_system.create_effect_time_series(
102
- f'{self.label_full}|operation->', self.specific_share_to_other_effects_operation, 'operation'
260
+ @property
261
+ def minimum_invest(self):
262
+ """DEPRECATED: Use 'minimum_periodic' property instead."""
263
+ warnings.warn(
264
+ "Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.",
265
+ DeprecationWarning,
266
+ stacklevel=2,
267
+ )
268
+ return self.minimum_periodic
269
+
270
+ @minimum_invest.setter
271
+ def minimum_invest(self, value):
272
+ """DEPRECATED: Use 'minimum_periodic' property instead."""
273
+ warnings.warn(
274
+ "Property 'minimum_invest' is deprecated. Use 'minimum_periodic' instead.",
275
+ DeprecationWarning,
276
+ stacklevel=2,
277
+ )
278
+ self.minimum_periodic = value
279
+
280
+ @property
281
+ def maximum_invest(self):
282
+ """DEPRECATED: Use 'maximum_periodic' property instead."""
283
+ warnings.warn(
284
+ "Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.",
285
+ DeprecationWarning,
286
+ stacklevel=2,
287
+ )
288
+ return self.maximum_periodic
289
+
290
+ @maximum_invest.setter
291
+ def maximum_invest(self, value):
292
+ """DEPRECATED: Use 'maximum_periodic' property instead."""
293
+ warnings.warn(
294
+ "Property 'maximum_invest' is deprecated. Use 'maximum_periodic' instead.",
295
+ DeprecationWarning,
296
+ stacklevel=2,
103
297
  )
298
+ self.maximum_periodic = value
104
299
 
105
- def create_model(self, model: SystemModel) -> 'EffectModel':
300
+ @property
301
+ def minimum_operation_per_hour(self):
302
+ """DEPRECATED: Use 'minimum_per_hour' property instead."""
303
+ warnings.warn(
304
+ "Property 'minimum_operation_per_hour' is deprecated. Use 'minimum_per_hour' instead.",
305
+ DeprecationWarning,
306
+ stacklevel=2,
307
+ )
308
+ return self.minimum_per_hour
309
+
310
+ @minimum_operation_per_hour.setter
311
+ def minimum_operation_per_hour(self, value):
312
+ """DEPRECATED: Use 'minimum_per_hour' property instead."""
313
+ warnings.warn(
314
+ "Property 'minimum_operation_per_hour' is deprecated. Use 'minimum_per_hour' instead.",
315
+ DeprecationWarning,
316
+ stacklevel=2,
317
+ )
318
+ self.minimum_per_hour = value
319
+
320
+ @property
321
+ def maximum_operation_per_hour(self):
322
+ """DEPRECATED: Use 'maximum_per_hour' property instead."""
323
+ warnings.warn(
324
+ "Property 'maximum_operation_per_hour' is deprecated. Use 'maximum_per_hour' instead.",
325
+ DeprecationWarning,
326
+ stacklevel=2,
327
+ )
328
+ return self.maximum_per_hour
329
+
330
+ @maximum_operation_per_hour.setter
331
+ def maximum_operation_per_hour(self, value):
332
+ """DEPRECATED: Use 'maximum_per_hour' property instead."""
333
+ warnings.warn(
334
+ "Property 'maximum_operation_per_hour' is deprecated. Use 'maximum_per_hour' instead.",
335
+ DeprecationWarning,
336
+ stacklevel=2,
337
+ )
338
+ self.maximum_per_hour = value
339
+
340
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
341
+ prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
342
+ self.minimum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour)
343
+
344
+ self.maximum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour)
345
+
346
+ self.share_from_temporal = flow_system.fit_effects_to_model_coords(
347
+ label_prefix=None,
348
+ effect_values=self.share_from_temporal,
349
+ label_suffix=f'(temporal)->{prefix}(temporal)',
350
+ dims=['time', 'period', 'scenario'],
351
+ )
352
+ self.share_from_periodic = flow_system.fit_effects_to_model_coords(
353
+ label_prefix=None,
354
+ effect_values=self.share_from_periodic,
355
+ label_suffix=f'(periodic)->{prefix}(periodic)',
356
+ dims=['period', 'scenario'],
357
+ )
358
+
359
+ self.minimum_temporal = flow_system.fit_to_model_coords(
360
+ f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario']
361
+ )
362
+ self.maximum_temporal = flow_system.fit_to_model_coords(
363
+ f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario']
364
+ )
365
+ self.minimum_periodic = flow_system.fit_to_model_coords(
366
+ f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario']
367
+ )
368
+ self.maximum_periodic = flow_system.fit_to_model_coords(
369
+ f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario']
370
+ )
371
+ self.minimum_total = flow_system.fit_to_model_coords(
372
+ f'{prefix}|minimum_total',
373
+ self.minimum_total,
374
+ dims=['period', 'scenario'],
375
+ )
376
+ self.maximum_total = flow_system.fit_to_model_coords(
377
+ f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario']
378
+ )
379
+
380
+ def create_model(self, model: FlowSystemModel) -> EffectModel:
106
381
  self._plausibility_checks()
107
- self.model = EffectModel(model, self)
108
- return self.model
382
+ self.submodel = EffectModel(model, self)
383
+ return self.submodel
109
384
 
110
385
  def _plausibility_checks(self) -> None:
111
386
  # TODO: Check for plausibility
@@ -113,70 +388,64 @@ class Effect(Element):
113
388
 
114
389
 
115
390
  class EffectModel(ElementModel):
116
- def __init__(self, model: SystemModel, element: Effect):
391
+ element: Effect # Type hint
392
+
393
+ def __init__(self, model: FlowSystemModel, element: Effect):
117
394
  super().__init__(model, element)
118
- self.element: Effect = element
119
- self.total: Optional[linopy.Variable] = None
120
- self.invest: ShareAllocationModel = self.add(
395
+
396
+ def _do_modeling(self):
397
+ self.total: linopy.Variable | None = None
398
+ self.periodic: ShareAllocationModel = self.add_submodels(
121
399
  ShareAllocationModel(
122
- self._model,
123
- False,
124
- self.label_of_element,
125
- 'invest',
126
- label_full=f'{self.label_full}(invest)',
127
- total_max=self.element.maximum_invest,
128
- total_min=self.element.minimum_invest,
129
- )
400
+ model=self._model,
401
+ dims=('period', 'scenario'),
402
+ label_of_element=self.label_of_element,
403
+ label_of_model=f'{self.label_of_model}(periodic)',
404
+ total_max=self.element.maximum_periodic,
405
+ total_min=self.element.minimum_periodic,
406
+ ),
407
+ short_name='periodic',
130
408
  )
131
409
 
132
- self.operation: ShareAllocationModel = self.add(
410
+ self.temporal: ShareAllocationModel = self.add_submodels(
133
411
  ShareAllocationModel(
134
- self._model,
135
- True,
136
- self.label_of_element,
137
- 'operation',
138
- label_full=f'{self.label_full}(operation)',
139
- total_max=self.element.maximum_operation,
140
- total_min=self.element.minimum_operation,
141
- min_per_hour=self.element.minimum_operation_per_hour.active_data
142
- if self.element.minimum_operation_per_hour is not None
143
- else None,
144
- max_per_hour=self.element.maximum_operation_per_hour.active_data
145
- if self.element.maximum_operation_per_hour is not None
146
- else None,
147
- )
412
+ model=self._model,
413
+ dims=('time', 'period', 'scenario'),
414
+ label_of_element=self.label_of_element,
415
+ label_of_model=f'{self.label_of_model}(temporal)',
416
+ total_max=self.element.maximum_temporal,
417
+ total_min=self.element.minimum_temporal,
418
+ min_per_hour=self.element.minimum_per_hour if self.element.minimum_per_hour is not None else None,
419
+ max_per_hour=self.element.maximum_per_hour if self.element.maximum_per_hour is not None else None,
420
+ ),
421
+ short_name='temporal',
148
422
  )
149
423
 
150
- def do_modeling(self):
151
- for model in self.sub_models:
152
- model.do_modeling()
153
-
154
- self.total = self.add(
155
- self._model.add_variables(
156
- lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf,
157
- upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf,
158
- coords=None,
159
- name=f'{self.label_full}|total',
160
- ),
161
- 'total',
424
+ self.total = self.add_variables(
425
+ lower=self.element.minimum_total if self.element.minimum_total is not None else -np.inf,
426
+ upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf,
427
+ coords=self._model.get_coords(['period', 'scenario']),
428
+ name=self.label_full,
162
429
  )
163
430
 
164
- self.add(
165
- self._model.add_constraints(
166
- self.total == self.operation.total.sum() + self.invest.total.sum(), name=f'{self.label_full}|total'
167
- ),
168
- 'total',
431
+ self.add_constraints(
432
+ self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total'
169
433
  )
170
434
 
171
435
 
172
- EffectValuesExpr = Dict[str, linopy.LinearExpression] # Used to create Shares
173
- EffectTimeSeries = Dict[str, TimeSeries] # Used internally to index values
174
- EffectValuesDict = Dict[str, NumericDataTS] # How effect values are stored
175
- EffectValuesUser = Union[NumericDataTS, Dict[str, NumericDataTS]] # User-specified Shares to Effects
176
- """ This datatype is used to define the share to an effect by a certain attribute. """
436
+ TemporalEffectsUser = TemporalDataUser | dict[str, TemporalDataUser] # User-specified Shares to Effects
437
+ """ This datatype is used to define a temporal share to an effect by a certain attribute. """
438
+
439
+ PeriodicEffectsUser = PeriodicDataUser | dict[str, PeriodicDataUser] # User-specified Shares to Effects
440
+ """ This datatype is used to define a scalar share to an effect by a certain attribute. """
441
+
442
+ TemporalEffects = dict[str, TemporalData] # User-specified Shares to Effects
443
+ """ This datatype is used internally to handle temporal shares to an effect. """
177
444
 
178
- EffectValuesUserScalar = Union[Scalar, Dict[str, Scalar]] # User-specified Shares to Effects
179
- """ This datatype is used to define the share to an effect by a certain attribute. Only scalars are allowed. """
445
+ PeriodicEffects = dict[str, Scalar]
446
+ """ This datatype is used internally to handle scalar shares to an effect. """
447
+
448
+ EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares
180
449
 
181
450
 
182
451
  class EffectCollection:
@@ -184,18 +453,18 @@ class EffectCollection:
184
453
  Handling all Effects
185
454
  """
186
455
 
187
- def __init__(self, *effects: List[Effect]):
456
+ def __init__(self, *effects: Effect):
188
457
  self._effects = {}
189
- self._standard_effect: Optional[Effect] = None
190
- self._objective_effect: Optional[Effect] = None
458
+ self._standard_effect: Effect | None = None
459
+ self._objective_effect: Effect | None = None
191
460
 
192
- self.model: Optional[EffectCollectionModel] = None
461
+ self.submodel: EffectCollectionModel | None = None
193
462
  self.add_effects(*effects)
194
463
 
195
- def create_model(self, model: SystemModel) -> 'EffectCollectionModel':
464
+ def create_model(self, model: FlowSystemModel) -> EffectCollectionModel:
196
465
  self._plausibility_checks()
197
- self.model = EffectCollectionModel(model, self)
198
- return self.model
466
+ self.submodel = EffectCollectionModel(model, self)
467
+ return self.submodel
199
468
 
200
469
  def add_effects(self, *effects: Effect) -> None:
201
470
  for effect in list(effects):
@@ -208,23 +477,27 @@ class EffectCollection:
208
477
  self._effects[effect.label] = effect
209
478
  logger.info(f'Registered new Effect: {effect.label}')
210
479
 
211
- def create_effect_values_dict(self, effect_values_user: EffectValuesUser) -> Optional[EffectValuesDict]:
480
+ def create_effect_values_dict(
481
+ self, effect_values_user: PeriodicEffectsUser | TemporalEffectsUser
482
+ ) -> dict[str, Scalar | TemporalDataUser] | None:
212
483
  """
213
484
  Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
214
485
 
215
486
  Examples
216
487
  --------
217
- effect_values_user = 20 -> {None: 20}
218
- effect_values_user = None -> None
219
- effect_values_user = {effect1: 20, effect2: 0.3} -> {effect1: 20, effect2: 0.3}
488
+ effect_values_user = 20 -> {'<standard_effect_label>': 20}
489
+ effect_values_user = {None: 20} -> {'<standard_effect_label>': 20}
490
+ effect_values_user = None -> None
491
+ effect_values_user = {'effect1': 20, 'effect2': 0.3} -> {'effect1': 20, 'effect2': 0.3}
220
492
 
221
493
  Returns
222
494
  -------
223
495
  dict or None
224
- A dictionary with None or Effect as the key, or None if input is None.
496
+ A dictionary keyed by effect label, or None if input is None.
497
+ Note: a standard effect must be defined when passing scalars or None labels.
225
498
  """
226
499
 
227
- def get_effect_label(eff: Union[Effect, str]) -> str:
500
+ def get_effect_label(eff: Effect | str) -> str:
228
501
  """Temporary function to get the label of an effect and warn for deprecation"""
229
502
  if isinstance(eff, Effect):
230
503
  warnings.warn(
@@ -233,7 +506,9 @@ class EffectCollection:
233
506
  UserWarning,
234
507
  stacklevel=2,
235
508
  )
236
- return eff.label_full
509
+ return eff.label
510
+ elif eff is None:
511
+ return self.standard_effect.label
237
512
  else:
238
513
  return eff
239
514
 
@@ -241,32 +516,29 @@ class EffectCollection:
241
516
  return None
242
517
  if isinstance(effect_values_user, dict):
243
518
  return {get_effect_label(effect): value for effect, value in effect_values_user.items()}
244
- return {self.standard_effect.label_full: effect_values_user}
519
+ return {self.standard_effect.label: effect_values_user}
245
520
 
246
521
  def _plausibility_checks(self) -> None:
247
522
  # Check circular loops in effects:
248
- # TODO: Improve checks!! Only most basic case covered...
523
+ temporal, periodic = self.calculate_effect_share_factors()
249
524
 
250
- def error_str(effect_label: str, share_ffect_label: str):
251
- return (
252
- f' {effect_label} -> has share in: {share_ffect_label}\n'
253
- f' {share_ffect_label} -> has share in: {effect_label}'
254
- )
525
+ # Validate all referenced sources exist
526
+ unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self.effects}
527
+ if unknown:
528
+ raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}')
255
529
 
256
- for effect in self.effects.values():
257
- # Effekt darf nicht selber als Share in seinen ShareEffekten auftauchen:
258
- # operation:
259
- for target_effect in effect.specific_share_to_other_effects_operation.keys():
260
- assert effect not in self[target_effect].specific_share_to_other_effects_operation.keys(), (
261
- f'Error: circular operation-shares \n{error_str(target_effect.label, target_effect.label)}'
262
- )
263
- # invest:
264
- for target_effect in effect.specific_share_to_other_effects_invest.keys():
265
- assert effect not in self[target_effect].specific_share_to_other_effects_invest.keys(), (
266
- f'Error: circular invest-shares \n{error_str(target_effect.label, target_effect.label)}'
267
- )
530
+ temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal]))
531
+ periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic]))
532
+
533
+ if temporal_cycles:
534
+ cycle_str = '\n'.join([' -> '.join(cycle) for cycle in temporal_cycles])
535
+ raise ValueError(f'Error: circular temporal-shares detected:\n{cycle_str}')
536
+
537
+ if periodic_cycles:
538
+ cycle_str = '\n'.join([' -> '.join(cycle) for cycle in periodic_cycles])
539
+ raise ValueError(f'Error: circular periodic-shares detected:\n{cycle_str}')
268
540
 
269
- def __getitem__(self, effect: Union[str, Effect]) -> 'Effect':
541
+ def __getitem__(self, effect: str | Effect | None) -> Effect:
270
542
  """
271
543
  Get an effect by label, or return the standard effect if None is passed
272
544
 
@@ -292,22 +564,28 @@ class EffectCollection:
292
564
  def __len__(self) -> int:
293
565
  return len(self._effects)
294
566
 
295
- def __contains__(self, item: Union[str, 'Effect']) -> bool:
567
+ def __contains__(self, item: str | Effect) -> bool:
296
568
  """Check if the effect exists. Checks for label or object"""
297
569
  if isinstance(item, str):
298
570
  return item in self.effects # Check if the label exists
299
571
  elif isinstance(item, Effect):
300
- return item in self.effects.values() # Check if the object exists
572
+ if item.label_full in self.effects:
573
+ return True
574
+ if item in self.effects.values(): # Check if the object exists
575
+ return True
301
576
  return False
302
577
 
303
578
  @property
304
- def effects(self) -> Dict[str, Effect]:
579
+ def effects(self) -> dict[str, Effect]:
305
580
  return self._effects
306
581
 
307
582
  @property
308
583
  def standard_effect(self) -> Effect:
309
584
  if self._standard_effect is None:
310
- raise KeyError('No standard-effect specified!')
585
+ raise KeyError(
586
+ 'No standard-effect specified! Either set an effect through is_standard=True '
587
+ 'or pass a mapping when specifying effect values: {effect_label: value}.'
588
+ )
311
589
  return self._standard_effect
312
590
 
313
591
  @standard_effect.setter
@@ -328,60 +606,248 @@ class EffectCollection:
328
606
  raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})')
329
607
  self._objective_effect = value
330
608
 
331
-
332
- class EffectCollectionModel(Model):
609
+ def calculate_effect_share_factors(
610
+ self,
611
+ ) -> tuple[
612
+ dict[tuple[str, str], xr.DataArray],
613
+ dict[tuple[str, str], xr.DataArray],
614
+ ]:
615
+ shares_periodic = {}
616
+ for name, effect in self.effects.items():
617
+ if effect.share_from_periodic:
618
+ for source, data in effect.share_from_periodic.items():
619
+ if source not in shares_periodic:
620
+ shares_periodic[source] = {}
621
+ shares_periodic[source][name] = data
622
+ shares_periodic = calculate_all_conversion_paths(shares_periodic)
623
+
624
+ shares_temporal = {}
625
+ for name, effect in self.effects.items():
626
+ if effect.share_from_temporal:
627
+ for source, data in effect.share_from_temporal.items():
628
+ if source not in shares_temporal:
629
+ shares_temporal[source] = {}
630
+ shares_temporal[source][name] = data
631
+ shares_temporal = calculate_all_conversion_paths(shares_temporal)
632
+
633
+ return shares_temporal, shares_periodic
634
+
635
+
636
+ class EffectCollectionModel(Submodel):
333
637
  """
334
638
  Handling all Effects
335
639
  """
336
640
 
337
- def __init__(self, model: SystemModel, effects: EffectCollection):
338
- super().__init__(model, label_of_element='Effects')
641
+ def __init__(self, model: FlowSystemModel, effects: EffectCollection):
339
642
  self.effects = effects
340
- self.penalty: Optional[ShareAllocationModel] = None
643
+ self.penalty: ShareAllocationModel | None = None
644
+ super().__init__(model, label_of_element='Effects')
341
645
 
342
646
  def add_share_to_effects(
343
647
  self,
344
648
  name: str,
345
- expressions: EffectValuesExpr,
346
- target: Literal['operation', 'invest'],
649
+ expressions: EffectExpr,
650
+ target: Literal['temporal', 'periodic'],
347
651
  ) -> None:
348
652
  for effect, expression in expressions.items():
349
- if target == 'operation':
350
- self.effects[effect].model.operation.add_share(name, expression)
351
- elif target == 'invest':
352
- self.effects[effect].model.invest.add_share(name, expression)
653
+ if target == 'temporal':
654
+ self.effects[effect].submodel.temporal.add_share(
655
+ name,
656
+ expression,
657
+ dims=('time', 'period', 'scenario'),
658
+ )
659
+ elif target == 'periodic':
660
+ self.effects[effect].submodel.periodic.add_share(
661
+ name,
662
+ expression,
663
+ dims=('period', 'scenario'),
664
+ )
353
665
  else:
354
666
  raise ValueError(f'Target {target} not supported!')
355
667
 
356
668
  def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None:
357
669
  if expression.ndim != 0:
358
670
  raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})')
359
- self.penalty.add_share(name, expression)
671
+ self.penalty.add_share(name, expression, dims=())
360
672
 
361
- def do_modeling(self):
673
+ def _do_modeling(self):
674
+ super()._do_modeling()
362
675
  for effect in self.effects:
363
676
  effect.create_model(self._model)
364
- self.penalty = self.add(
365
- ShareAllocationModel(self._model, shares_are_time_series=False, label_of_element='Penalty')
677
+ self.penalty = self.add_submodels(
678
+ ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'),
679
+ short_name='penalty',
366
680
  )
367
- for model in [effect.model for effect in self.effects] + [self.penalty]:
368
- model.do_modeling()
369
681
 
370
682
  self._add_share_between_effects()
371
683
 
372
- self._model.add_objective(self.effects.objective_effect.model.total + self.penalty.total)
684
+ self._model.add_objective(
685
+ (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum()
686
+ )
373
687
 
374
688
  def _add_share_between_effects(self):
375
- for origin_effect in self.effects:
376
- # 1. operation: -> hier sind es Zeitreihen (share_TS)
377
- for target_effect, time_series in origin_effect.specific_share_to_other_effects_operation.items():
378
- self.effects[target_effect].model.operation.add_share(
379
- origin_effect.model.operation.label_full,
380
- origin_effect.model.operation.total_per_timestep * time_series.active_data,
689
+ for target_effect in self.effects:
690
+ # 1. temporal: <- receiving temporal shares from other effects
691
+ for source_effect, time_series in target_effect.share_from_temporal.items():
692
+ target_effect.submodel.temporal.add_share(
693
+ self.effects[source_effect].submodel.temporal.label_full,
694
+ self.effects[source_effect].submodel.temporal.total_per_timestep * time_series,
695
+ dims=('time', 'period', 'scenario'),
381
696
  )
382
- # 2. invest: -> hier ist es Scalar (share)
383
- for target_effect, factor in origin_effect.specific_share_to_other_effects_invest.items():
384
- self.effects[target_effect].model.invest.add_share(
385
- origin_effect.model.invest.label_full,
386
- origin_effect.model.invest.total * factor,
697
+ # 2. periodic: <- receiving periodic shares from other effects
698
+ for source_effect, factor in target_effect.share_from_periodic.items():
699
+ target_effect.submodel.periodic.add_share(
700
+ self.effects[source_effect].submodel.periodic.label_full,
701
+ self.effects[source_effect].submodel.periodic.total * factor,
702
+ dims=('period', 'scenario'),
387
703
  )
704
+
705
+
706
+ def calculate_all_conversion_paths(
707
+ conversion_dict: dict[str, dict[str, Scalar | xr.DataArray]],
708
+ ) -> dict[tuple[str, str], xr.DataArray]:
709
+ """
710
+ Calculates all possible direct and indirect conversion factors between units/domains.
711
+ This function uses Breadth-First Search (BFS) to find all possible conversion paths
712
+ between different units or domains in a conversion graph. It computes both direct
713
+ conversions (explicitly provided in the input) and indirect conversions (derived
714
+ through intermediate units).
715
+ Args:
716
+ conversion_dict: A nested dictionary where:
717
+ - Outer keys represent origin units/domains
718
+ - Inner dictionaries map target units/domains to their conversion factors
719
+ - Conversion factors can be integers, floats, or numpy arrays
720
+ Returns:
721
+ A dictionary mapping (origin, target) tuples to their respective conversion factors.
722
+ Each key is a tuple of strings representing the origin and target units/domains.
723
+ Each value is the conversion factor (int, float, or numpy array) from origin to target.
724
+ """
725
+ # Initialize the result dictionary to accumulate all paths
726
+ result = {}
727
+
728
+ # Add direct connections to the result first
729
+ for origin, targets in conversion_dict.items():
730
+ for target, factor in targets.items():
731
+ result[(origin, target)] = factor
732
+
733
+ # Track all paths by keeping path history to avoid cycles
734
+ # Iterate over each domain in the dictionary
735
+ for origin in conversion_dict:
736
+ # Keep track of visited paths to avoid repeating calculations
737
+ processed_paths = set()
738
+ # Use a queue with (current_domain, factor, path_history)
739
+ queue = deque([(origin, 1, [origin])])
740
+
741
+ while queue:
742
+ current_domain, factor, path = queue.popleft()
743
+
744
+ # Skip if we've processed this exact path before
745
+ path_key = tuple(path)
746
+ if path_key in processed_paths:
747
+ continue
748
+ processed_paths.add(path_key)
749
+
750
+ # Iterate over the neighbors of the current domain
751
+ for target, conversion_factor in conversion_dict.get(current_domain, {}).items():
752
+ # Skip if target would create a cycle
753
+ if target in path:
754
+ continue
755
+
756
+ # Calculate the indirect conversion factor
757
+ indirect_factor = factor * conversion_factor
758
+ new_path = path + [target]
759
+
760
+ # Only consider paths starting at origin and ending at some target
761
+ if len(new_path) > 2 and new_path[0] == origin:
762
+ # Update the result dictionary - accumulate factors from different paths
763
+ if (origin, target) in result:
764
+ result[(origin, target)] = result[(origin, target)] + indirect_factor
765
+ else:
766
+ result[(origin, target)] = indirect_factor
767
+
768
+ # Add new path to queue for further exploration
769
+ queue.append((target, indirect_factor, new_path))
770
+
771
+ # Convert all values to DataArrays
772
+ result = {key: value if isinstance(value, xr.DataArray) else xr.DataArray(value) for key, value in result.items()}
773
+
774
+ return result
775
+
776
+
777
+ def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
778
+ """
779
+ Detects cycles in a directed graph using DFS.
780
+
781
+ Args:
782
+ graph: Adjacency list representation of the graph
783
+
784
+ Returns:
785
+ List of cycles found, where each cycle is a list of nodes
786
+ """
787
+ # Track nodes in current recursion stack
788
+ visiting = set()
789
+ # Track nodes that have been fully explored
790
+ visited = set()
791
+ # Store all found cycles
792
+ cycles = []
793
+
794
+ def dfs_find_cycles(node, path=None):
795
+ if path is None:
796
+ path = []
797
+
798
+ # Current path to this node
799
+ current_path = path + [node]
800
+ # Add node to current recursion stack
801
+ visiting.add(node)
802
+
803
+ # Check all neighbors
804
+ for neighbor in graph.get(node, []):
805
+ # If neighbor is in current path, we found a cycle
806
+ if neighbor in visiting:
807
+ # Get the cycle by extracting the relevant portion of the path
808
+ cycle_start = current_path.index(neighbor)
809
+ cycle = current_path[cycle_start:] + [neighbor]
810
+ cycles.append(cycle)
811
+ # If neighbor hasn't been fully explored, check it
812
+ elif neighbor not in visited:
813
+ dfs_find_cycles(neighbor, current_path)
814
+
815
+ # Remove node from current path and mark as fully explored
816
+ visiting.remove(node)
817
+ visited.add(node)
818
+
819
+ # Check each unvisited node
820
+ for node in graph:
821
+ if node not in visited:
822
+ dfs_find_cycles(node)
823
+
824
+ return cycles
825
+
826
+
827
+ def tuples_to_adjacency_list(edges: list[tuple[str, str]]) -> dict[str, list[str]]:
828
+ """
829
+ Converts a list of edge tuples (source, target) to an adjacency list representation.
830
+
831
+ Args:
832
+ edges: List of (source, target) tuples representing directed edges
833
+
834
+ Returns:
835
+ Dictionary mapping each source node to a list of its target nodes
836
+ """
837
+ graph = {}
838
+
839
+ for source, target in edges:
840
+ if source not in graph:
841
+ graph[source] = []
842
+ graph[source].append(target)
843
+
844
+ # Ensure target nodes with no outgoing edges are in the graph
845
+ if target not in graph:
846
+ graph[target] = []
847
+
848
+ return graph
849
+
850
+
851
+ # Backward compatibility aliases
852
+ NonTemporalEffectsUser = PeriodicEffectsUser
853
+ NonTemporalEffects = PeriodicEffects