flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__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 (42) hide show
  1. flixopt/__init__.py +57 -49
  2. flixopt/carrier.py +159 -0
  3. flixopt/clustering/__init__.py +51 -0
  4. flixopt/clustering/base.py +1746 -0
  5. flixopt/clustering/intercluster_helpers.py +201 -0
  6. flixopt/color_processing.py +372 -0
  7. flixopt/comparison.py +819 -0
  8. flixopt/components.py +848 -270
  9. flixopt/config.py +853 -496
  10. flixopt/core.py +111 -98
  11. flixopt/effects.py +294 -284
  12. flixopt/elements.py +484 -223
  13. flixopt/features.py +220 -118
  14. flixopt/flow_system.py +2026 -389
  15. flixopt/interface.py +504 -286
  16. flixopt/io.py +1718 -55
  17. flixopt/linear_converters.py +291 -230
  18. flixopt/modeling.py +304 -181
  19. flixopt/network_app.py +2 -1
  20. flixopt/optimization.py +788 -0
  21. flixopt/optimize_accessor.py +373 -0
  22. flixopt/plot_result.py +143 -0
  23. flixopt/plotting.py +1177 -1034
  24. flixopt/results.py +1331 -372
  25. flixopt/solvers.py +12 -4
  26. flixopt/statistics_accessor.py +2412 -0
  27. flixopt/stats_accessor.py +75 -0
  28. flixopt/structure.py +954 -120
  29. flixopt/topology_accessor.py +676 -0
  30. flixopt/transform_accessor.py +2277 -0
  31. flixopt/types.py +120 -0
  32. flixopt-6.0.0rc7.dist-info/METADATA +290 -0
  33. flixopt-6.0.0rc7.dist-info/RECORD +36 -0
  34. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
  35. flixopt/aggregation.py +0 -382
  36. flixopt/calculation.py +0 -672
  37. flixopt/commons.py +0 -51
  38. flixopt/utils.py +0 -86
  39. flixopt-3.0.1.dist-info/METADATA +0 -209
  40. flixopt-3.0.1.dist-info/RECORD +0 -26
  41. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  42. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
flixopt/effects.py CHANGED
@@ -8,7 +8,6 @@ which are then transformed into the internal data structure.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
- import warnings
12
11
  from collections import deque
13
12
  from typing import TYPE_CHECKING, Literal
14
13
 
@@ -16,52 +15,72 @@ import linopy
16
15
  import numpy as np
17
16
  import xarray as xr
18
17
 
19
- from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser
18
+ from .core import PlausibilityError
20
19
  from .features import ShareAllocationModel
21
- from .structure import Element, ElementModel, FlowSystemModel, Submodel, register_class_for_io
20
+ from .structure import (
21
+ Element,
22
+ ElementContainer,
23
+ ElementModel,
24
+ FlowSystemModel,
25
+ Submodel,
26
+ VariableCategory,
27
+ register_class_for_io,
28
+ )
22
29
 
23
30
  if TYPE_CHECKING:
24
31
  from collections.abc import Iterator
25
32
 
26
- from .flow_system import FlowSystem
33
+ from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_S, Numeric_TPS, Scalar
27
34
 
28
35
  logger = logging.getLogger('flixopt')
29
36
 
37
+ # Penalty effect label constant
38
+ PENALTY_EFFECT_LABEL = 'Penalty'
39
+
30
40
 
31
41
  @register_class_for_io
32
42
  class Effect(Element):
33
- """
34
- Represents system-wide impacts like costs, emissions, resource consumption, or other effects.
43
+ """Represents system-wide impacts like costs, emissions, or resource consumption.
35
44
 
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.
45
+ Effects quantify impacts aggregating contributions from Elements across the FlowSystem.
46
+ One Effect serves as the optimization objective, while others can be constrained or tracked.
47
+ Supports operational and investment contributions, cross-effect relationships (e.g., carbon
48
+ pricing), and flexible constraint formulation.
40
49
 
41
- Effects support comprehensive modeling including operational and investment contributions,
42
- cross-effect relationships (e.g., carbon pricing), and flexible constraint formulation.
50
+ Mathematical Formulation:
51
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/effects-and-dimensions/>
43
52
 
44
53
  Args:
45
54
  label: The label of the Element. Used to identify it in the FlowSystem.
46
55
  unit: The unit of the effect (e.g., '€', 'kg_CO2', 'kWh_primary', 'm²').
47
- This is informative only and does not affect optimization calculations.
56
+ This is informative only and does not affect optimization.
48
57
  description: Descriptive name explaining what this effect represents.
49
58
  is_standard: If True, this is a standard effect allowing direct value input
50
59
  without effect dictionaries. Used for simplified effect specification (and less boilerplate code).
51
60
  is_objective: If True, this effect serves as the optimization objective function.
52
61
  Only one effect can be marked as objective per optimization.
62
+ period_weights: Optional custom weights for periods and scenarios (Numeric_PS).
63
+ If provided, overrides the FlowSystem's default period weights for this effect.
64
+ Useful for effect-specific weighting (e.g., discounting for costs vs equal weights for CO2).
65
+ If None, uses FlowSystem's default weights.
53
66
  share_from_temporal: Temporal cross-effect contributions.
54
- Maps temporal contributions from other effects to this effect
67
+ Maps temporal contributions from other effects to this effect.
55
68
  share_from_periodic: Periodic cross-effect contributions.
56
69
  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.
70
+ minimum_temporal: Minimum allowed total contribution across all timesteps (per period).
71
+ maximum_temporal: Maximum allowed total contribution across all timesteps (per period).
59
72
  minimum_per_hour: Minimum allowed contribution per hour.
60
73
  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).
74
+ minimum_periodic: Minimum allowed total periodic contribution (per period).
75
+ maximum_periodic: Maximum allowed total periodic contribution (per period).
76
+ minimum_total: Minimum allowed total effect (temporal + periodic combined) per period.
77
+ maximum_total: Maximum allowed total effect (temporal + periodic combined) per period.
78
+ minimum_over_periods: Minimum allowed weighted sum of total effect across ALL periods.
79
+ Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights.
80
+ Requires FlowSystem to have a 'period' dimension (i.e., periods must be defined).
81
+ maximum_over_periods: Maximum allowed weighted sum of total effect across ALL periods.
82
+ Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights.
83
+ Requires FlowSystem to have a 'period' dimension (i.e., periods must be defined).
65
84
  meta_data: Used to store additional information. Not used internally but saved
66
85
  in results. Only use Python native types.
67
86
 
@@ -85,14 +104,25 @@ class Effect(Element):
85
104
  )
86
105
  ```
87
106
 
88
- CO2 emissions:
107
+ CO2 emissions with per-period limit:
108
+
109
+ ```python
110
+ co2_effect = Effect(
111
+ label='CO2',
112
+ unit='kg_CO2',
113
+ description='Carbon dioxide emissions',
114
+ maximum_total=100_000, # 100 t CO2 per period
115
+ )
116
+ ```
117
+
118
+ CO2 emissions with total limit across all periods:
89
119
 
90
120
  ```python
91
121
  co2_effect = Effect(
92
122
  label='CO2',
93
123
  unit='kg_CO2',
94
124
  description='Carbon dioxide emissions',
95
- maximum_total=1_000_000, # 1000 t CO2 annual limit
125
+ maximum_over_periods=1_000_000, # 1000 t CO2 total across all periods
96
126
  )
97
127
  ```
98
128
 
@@ -103,7 +133,7 @@ class Effect(Element):
103
133
  label='land_usage',
104
134
  unit='m²',
105
135
  description='Land area requirement',
106
- maximum_total=50_000, # Maximum 5 hectares available
136
+ maximum_total=50_000, # Maximum 5 hectares per period
107
137
  )
108
138
  ```
109
139
 
@@ -141,7 +171,7 @@ class Effect(Element):
141
171
  description='Industrial water usage',
142
172
  minimum_per_hour=10, # Minimum 10 m³/h for process stability
143
173
  maximum_per_hour=500, # Maximum 500 m³/h capacity limit
144
- maximum_total=100_000, # Annual permit limit: 100,000 m³
174
+ maximum_over_periods=100_000, # Annual permit limit: 100,000 m³
145
175
  )
146
176
  ```
147
177
 
@@ -159,52 +189,49 @@ class Effect(Element):
159
189
 
160
190
  """
161
191
 
192
+ submodel: EffectModel | None
193
+
162
194
  def __init__(
163
195
  self,
164
196
  label: str,
165
197
  unit: str,
166
- description: str,
198
+ description: str = '',
167
199
  meta_data: dict | None = None,
168
200
  is_standard: bool = False,
169
201
  is_objective: bool = False,
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,
202
+ period_weights: Numeric_PS | None = None,
203
+ share_from_temporal: Effect_TPS | Numeric_TPS | None = None,
204
+ share_from_periodic: Effect_PS | Numeric_PS | None = None,
205
+ minimum_temporal: Numeric_PS | None = None,
206
+ maximum_temporal: Numeric_PS | None = None,
207
+ minimum_periodic: Numeric_PS | None = None,
208
+ maximum_periodic: Numeric_PS | None = None,
209
+ minimum_per_hour: Numeric_TPS | None = None,
210
+ maximum_per_hour: Numeric_TPS | None = None,
211
+ minimum_total: Numeric_PS | None = None,
212
+ maximum_total: Numeric_PS | None = None,
213
+ minimum_over_periods: Numeric_S | None = None,
214
+ maximum_over_periods: Numeric_S | None = None,
181
215
  ):
182
216
  super().__init__(label, meta_data=meta_data)
183
217
  self.unit = unit
184
218
  self.description = description
185
219
  self.is_standard = is_standard
186
- self.is_objective = is_objective
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
220
 
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
- )
221
+ # Validate that Penalty cannot be set as objective
222
+ if is_objective and label == PENALTY_EFFECT_LABEL:
223
+ raise ValueError(
224
+ f'The Penalty effect ("{PENALTY_EFFECT_LABEL}") cannot be set as the objective effect. '
225
+ f'Please use a different effect as the optimization objective.'
226
+ )
205
227
 
206
- # Validate any remaining unexpected kwargs
207
- self._validate_kwargs(kwargs)
228
+ self.is_objective = is_objective
229
+ self.period_weights = period_weights
230
+ # Share parameters accept Effect_* | Numeric_* unions (dict or single value).
231
+ # Store as-is here; transform_data() will normalize via fit_effects_to_model_coords().
232
+ # Default to {} when None (no shares defined).
233
+ self.share_from_temporal = share_from_temporal if share_from_temporal is not None else {}
234
+ self.share_from_periodic = share_from_periodic if share_from_periodic is not None else {}
208
235
 
209
236
  # Set attributes directly
210
237
  self.minimum_temporal = minimum_temporal
@@ -215,166 +242,58 @@ class Effect(Element):
215
242
  self.maximum_per_hour = maximum_per_hour
216
243
  self.minimum_total = minimum_total
217
244
  self.maximum_total = maximum_total
245
+ self.minimum_over_periods = minimum_over_periods
246
+ self.maximum_over_periods = maximum_over_periods
218
247
 
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
248
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
249
+ """Link this effect to a FlowSystem.
239
250
 
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,
247
- )
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,
257
- )
258
- self.maximum_temporal = value
259
-
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,
297
- )
298
- self.maximum_periodic = value
299
-
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)
251
+ Elements use their label_full as prefix by default, ignoring the passed prefix.
252
+ """
253
+ super().link_to_flow_system(flow_system, self.label_full)
343
254
 
344
- self.maximum_per_hour = flow_system.fit_to_model_coords(f'{prefix}|maximum_per_hour', self.maximum_per_hour)
255
+ def transform_data(self) -> None:
256
+ self.minimum_per_hour = self._fit_coords(f'{self.prefix}|minimum_per_hour', self.minimum_per_hour)
257
+ self.maximum_per_hour = self._fit_coords(f'{self.prefix}|maximum_per_hour', self.maximum_per_hour)
345
258
 
346
- self.share_from_temporal = flow_system.fit_effects_to_model_coords(
347
- label_prefix=None,
259
+ self.share_from_temporal = self._fit_effect_coords(
260
+ prefix=None,
348
261
  effect_values=self.share_from_temporal,
349
- label_suffix=f'(temporal)->{prefix}(temporal)',
350
- dims=['time', 'period', 'scenario'],
262
+ suffix=f'(temporal)->{self.prefix}(temporal)',
351
263
  )
352
- self.share_from_periodic = flow_system.fit_effects_to_model_coords(
353
- label_prefix=None,
264
+ self.share_from_periodic = self._fit_effect_coords(
265
+ prefix=None,
354
266
  effect_values=self.share_from_periodic,
355
- label_suffix=f'(periodic)->{prefix}(periodic)',
267
+ suffix=f'(periodic)->{self.prefix}(periodic)',
356
268
  dims=['period', 'scenario'],
357
269
  )
358
270
 
359
- self.minimum_temporal = flow_system.fit_to_model_coords(
360
- f'{prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario']
271
+ self.minimum_temporal = self._fit_coords(
272
+ f'{self.prefix}|minimum_temporal', self.minimum_temporal, dims=['period', 'scenario']
361
273
  )
362
- self.maximum_temporal = flow_system.fit_to_model_coords(
363
- f'{prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario']
274
+ self.maximum_temporal = self._fit_coords(
275
+ f'{self.prefix}|maximum_temporal', self.maximum_temporal, dims=['period', 'scenario']
364
276
  )
365
- self.minimum_periodic = flow_system.fit_to_model_coords(
366
- f'{prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario']
277
+ self.minimum_periodic = self._fit_coords(
278
+ f'{self.prefix}|minimum_periodic', self.minimum_periodic, dims=['period', 'scenario']
367
279
  )
368
- self.maximum_periodic = flow_system.fit_to_model_coords(
369
- f'{prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario']
280
+ self.maximum_periodic = self._fit_coords(
281
+ f'{self.prefix}|maximum_periodic', self.maximum_periodic, dims=['period', 'scenario']
370
282
  )
371
- self.minimum_total = flow_system.fit_to_model_coords(
372
- f'{prefix}|minimum_total',
373
- self.minimum_total,
374
- dims=['period', 'scenario'],
283
+ self.minimum_total = self._fit_coords(
284
+ f'{self.prefix}|minimum_total', self.minimum_total, dims=['period', 'scenario']
285
+ )
286
+ self.maximum_total = self._fit_coords(
287
+ f'{self.prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario']
288
+ )
289
+ self.minimum_over_periods = self._fit_coords(
290
+ f'{self.prefix}|minimum_over_periods', self.minimum_over_periods, dims=['scenario']
291
+ )
292
+ self.maximum_over_periods = self._fit_coords(
293
+ f'{self.prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario']
375
294
  )
376
- self.maximum_total = flow_system.fit_to_model_coords(
377
- f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario']
295
+ self.period_weights = self._fit_coords(
296
+ f'{self.prefix}|period_weights', self.period_weights, dims=['period', 'scenario']
378
297
  )
379
298
 
380
299
  def create_model(self, model: FlowSystemModel) -> EffectModel:
@@ -383,17 +302,57 @@ class Effect(Element):
383
302
  return self.submodel
384
303
 
385
304
  def _plausibility_checks(self) -> None:
386
- # TODO: Check for plausibility
387
- pass
305
+ # Check that minimum_over_periods and maximum_over_periods require a period dimension
306
+ if (
307
+ self.minimum_over_periods is not None or self.maximum_over_periods is not None
308
+ ) and self.flow_system.periods is None:
309
+ raise PlausibilityError(
310
+ f"Effect '{self.label}': minimum_over_periods and maximum_over_periods require "
311
+ f"the FlowSystem to have a 'period' dimension. Please define periods when creating "
312
+ f'the FlowSystem, or remove these constraints.'
313
+ )
388
314
 
389
315
 
390
316
  class EffectModel(ElementModel):
317
+ """Mathematical model implementation for Effects.
318
+
319
+ Creates optimization variables and constraints for effect aggregation,
320
+ including periodic and temporal tracking, cross-effect contributions,
321
+ and effect bounds.
322
+
323
+ Mathematical Formulation:
324
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/effects-and-dimensions/>
325
+ """
326
+
391
327
  element: Effect # Type hint
392
328
 
393
329
  def __init__(self, model: FlowSystemModel, element: Effect):
394
330
  super().__init__(model, element)
395
331
 
332
+ @property
333
+ def period_weights(self) -> xr.DataArray:
334
+ """
335
+ Get period weights for this effect.
336
+
337
+ Returns effect-specific weights if defined, otherwise falls back to FlowSystem period weights.
338
+ This allows different effects to have different weighting schemes over periods (e.g., discounting for costs,
339
+ equal weights for CO2 emissions).
340
+
341
+ Returns:
342
+ Weights with period dimensions (if applicable)
343
+ """
344
+ effect_weights = self.element.period_weights
345
+ default_weights = self.element._flow_system.period_weights
346
+ if effect_weights is not None: # Use effect-specific weights
347
+ return effect_weights
348
+ elif default_weights is not None: # Fall back to FlowSystem weights
349
+ return default_weights
350
+ return self.element._fit_coords(name='period_weights', data=1, dims=['period'])
351
+
396
352
  def _do_modeling(self):
353
+ """Create variables, constraints, and nested submodels"""
354
+ super()._do_modeling()
355
+
397
356
  self.total: linopy.Variable | None = None
398
357
  self.periodic: ShareAllocationModel = self.add_submodels(
399
358
  ShareAllocationModel(
@@ -426,39 +385,54 @@ class EffectModel(ElementModel):
426
385
  upper=self.element.maximum_total if self.element.maximum_total is not None else np.inf,
427
386
  coords=self._model.get_coords(['period', 'scenario']),
428
387
  name=self.label_full,
388
+ category=VariableCategory.TOTAL,
429
389
  )
430
390
 
431
391
  self.add_constraints(
432
392
  self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total'
433
393
  )
434
394
 
395
+ # Add weighted sum over all periods constraint if minimum_over_periods or maximum_over_periods is defined
396
+ if self.element.minimum_over_periods is not None or self.element.maximum_over_periods is not None:
397
+ # Calculate weighted sum over all periods
398
+ weighted_total = (self.total * self.period_weights).sum('period')
435
399
 
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. """
400
+ # Create tracking variable for the weighted sum
401
+ self.total_over_periods = self.add_variables(
402
+ lower=self.element.minimum_over_periods if self.element.minimum_over_periods is not None else -np.inf,
403
+ upper=self.element.maximum_over_periods if self.element.maximum_over_periods is not None else np.inf,
404
+ coords=self._model.get_coords(['scenario']),
405
+ short_name='total_over_periods',
406
+ category=VariableCategory.TOTAL_OVER_PERIODS,
407
+ )
441
408
 
442
- TemporalEffects = dict[str, TemporalData] # User-specified Shares to Effects
443
- """ This datatype is used internally to handle temporal shares to an effect. """
409
+ self.add_constraints(self.total_over_periods == weighted_total, short_name='total_over_periods')
444
410
 
445
- PeriodicEffects = dict[str, Scalar]
446
- """ This datatype is used internally to handle scalar shares to an effect. """
447
411
 
448
412
  EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares
449
413
 
450
414
 
451
- class EffectCollection:
415
+ class EffectCollection(ElementContainer[Effect]):
452
416
  """
453
417
  Handling all Effects
454
418
  """
455
419
 
456
- def __init__(self, *effects: Effect):
457
- self._effects = {}
420
+ submodel: EffectCollectionModel | None
421
+
422
+ def __init__(self, *effects: Effect, truncate_repr: int | None = None):
423
+ """
424
+ Initialize the EffectCollection.
425
+
426
+ Args:
427
+ *effects: Effects to register in the collection.
428
+ truncate_repr: Maximum number of items to show in repr. If None, show all items. Default: None
429
+ """
430
+ super().__init__(element_type_name='effects', truncate_repr=truncate_repr)
458
431
  self._standard_effect: Effect | None = None
459
432
  self._objective_effect: Effect | None = None
433
+ self._penalty_effect: Effect | None = None
460
434
 
461
- self.submodel: EffectCollectionModel | None = None
435
+ self.submodel = None
462
436
  self.add_effects(*effects)
463
437
 
464
438
  def create_model(self, model: FlowSystemModel) -> EffectCollectionModel:
@@ -466,6 +440,29 @@ class EffectCollection:
466
440
  self.submodel = EffectCollectionModel(model, self)
467
441
  return self.submodel
468
442
 
443
+ def _create_penalty_effect(self) -> Effect:
444
+ """
445
+ Create and register the penalty effect (called internally by FlowSystem).
446
+ Only creates if user hasn't already defined a Penalty effect.
447
+ """
448
+ # Check if user has already defined a Penalty effect
449
+ if PENALTY_EFFECT_LABEL in self:
450
+ self._penalty_effect = self[PENALTY_EFFECT_LABEL]
451
+ logger.info(f'Using user-defined Penalty Effect: {PENALTY_EFFECT_LABEL}')
452
+ return self._penalty_effect
453
+
454
+ # Auto-create penalty effect
455
+ self._penalty_effect = Effect(
456
+ label=PENALTY_EFFECT_LABEL,
457
+ unit='penalty_units',
458
+ description='Penalty for constraint violations and modeling artifacts',
459
+ is_standard=False,
460
+ is_objective=False,
461
+ )
462
+ self.add(self._penalty_effect) # Add to container
463
+ logger.info(f'Auto-created Penalty Effect: {PENALTY_EFFECT_LABEL}')
464
+ return self._penalty_effect
465
+
469
466
  def add_effects(self, *effects: Effect) -> None:
470
467
  for effect in list(effects):
471
468
  if effect in self:
@@ -474,43 +471,35 @@ class EffectCollection:
474
471
  self.standard_effect = effect
475
472
  if effect.is_objective:
476
473
  self.objective_effect = effect
477
- self._effects[effect.label] = effect
474
+ self.add(effect) # Use the inherited add() method from ElementContainer
478
475
  logger.info(f'Registered new Effect: {effect.label}')
479
476
 
480
- def create_effect_values_dict(
481
- self, effect_values_user: PeriodicEffectsUser | TemporalEffectsUser
482
- ) -> dict[str, Scalar | TemporalDataUser] | None:
483
- """
484
- Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
485
-
486
- Examples
487
- --------
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}
492
-
493
- Returns
494
- -------
495
- dict or None
477
+ def create_effect_values_dict(self, effect_values_user: Numeric_TPS | Effect_TPS | None) -> Effect_TPS | None:
478
+ """Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type.
479
+
480
+ Examples:
481
+ ```python
482
+ effect_values_user = 20 -> {'<standard_effect_label>': 20}
483
+ effect_values_user = {None: 20} -> {'<standard_effect_label>': 20}
484
+ effect_values_user = None -> None
485
+ effect_values_user = {'effect1': 20, 'effect2': 0.3} -> {'effect1': 20, 'effect2': 0.3}
486
+ ```
487
+
488
+ Returns:
496
489
  A dictionary keyed by effect label, or None if input is None.
497
490
  Note: a standard effect must be defined when passing scalars or None labels.
498
491
  """
499
492
 
500
- def get_effect_label(eff: Effect | str) -> str:
501
- """Temporary function to get the label of an effect and warn for deprecation"""
493
+ def get_effect_label(eff: str | None) -> str:
494
+ """Get the label of an effect"""
495
+ if eff is None:
496
+ return self.standard_effect.label
502
497
  if isinstance(eff, Effect):
503
- warnings.warn(
504
- f'The use of effect objects when specifying EffectValues is deprecated. '
505
- f'Use the label of the effect instead. Used effect: {eff.label_full}',
506
- UserWarning,
507
- stacklevel=2,
498
+ raise TypeError(
499
+ f'Effect objects are no longer accepted when specifying EffectValues. '
500
+ f'Use the label string instead. Got: {eff.label_full}'
508
501
  )
509
- return eff.label
510
- elif eff is None:
511
- return self.standard_effect.label
512
- else:
513
- return eff
502
+ return eff
514
503
 
515
504
  if effect_values_user is None:
516
505
  return None
@@ -522,10 +511,13 @@ class EffectCollection:
522
511
  # Check circular loops in effects:
523
512
  temporal, periodic = self.calculate_effect_share_factors()
524
513
 
525
- # Validate all referenced sources exist
526
- unknown = {src for src, _ in list(temporal.keys()) + list(periodic.keys()) if src not in self.effects}
514
+ # Validate all referenced effects (both sources and targets) exist
515
+ edges = list(temporal.keys()) + list(periodic.keys())
516
+ unknown_sources = {src for src, _ in edges if src not in self}
517
+ unknown_targets = {tgt for _, tgt in edges if tgt not in self}
518
+ unknown = unknown_sources | unknown_targets
527
519
  if unknown:
528
- raise KeyError(f'Unknown effects used in in effect share mappings: {sorted(unknown)}')
520
+ raise KeyError(f'Unknown effects used in effect share mappings: {sorted(unknown)}')
529
521
 
530
522
  temporal_cycles = detect_cycles(tuples_to_adjacency_list([key for key in temporal]))
531
523
  periodic_cycles = detect_cycles(tuples_to_adjacency_list([key for key in periodic]))
@@ -554,31 +546,23 @@ class EffectCollection:
554
546
  else:
555
547
  raise KeyError(f'Effect {effect} not found!')
556
548
  try:
557
- return self.effects[effect]
549
+ return super().__getitem__(effect) # Leverage ContainerMixin suggestions
558
550
  except KeyError as e:
559
- raise KeyError(f'Effect "{effect}" not found! Add it to the FlowSystem first!') from e
560
-
561
- def __iter__(self) -> Iterator[Effect]:
562
- return iter(self._effects.values())
551
+ # Extract the original message and append context for cleaner output
552
+ original_msg = str(e).strip('\'"')
553
+ raise KeyError(f'{original_msg} Add the effect to the FlowSystem first.') from None
563
554
 
564
- def __len__(self) -> int:
565
- return len(self._effects)
555
+ def __iter__(self) -> Iterator[str]:
556
+ return iter(self.keys()) # Iterate over keys like a normal dict
566
557
 
567
558
  def __contains__(self, item: str | Effect) -> bool:
568
559
  """Check if the effect exists. Checks for label or object"""
569
560
  if isinstance(item, str):
570
- return item in self.effects # Check if the label exists
561
+ return super().__contains__(item) # Check if the label exists
571
562
  elif isinstance(item, Effect):
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
563
+ return item.label_full in self and self[item.label_full] is item
576
564
  return False
577
565
 
578
- @property
579
- def effects(self) -> dict[str, Effect]:
580
- return self._effects
581
-
582
566
  @property
583
567
  def standard_effect(self) -> Effect:
584
568
  if self._standard_effect is None:
@@ -602,10 +586,38 @@ class EffectCollection:
602
586
 
603
587
  @objective_effect.setter
604
588
  def objective_effect(self, value: Effect) -> None:
589
+ # Check Penalty first to give users a more specific error message
590
+ if value.label == PENALTY_EFFECT_LABEL:
591
+ raise ValueError(
592
+ f'The Penalty effect ("{PENALTY_EFFECT_LABEL}") cannot be set as the objective effect. '
593
+ f'Please use a different effect as the optimization objective.'
594
+ )
605
595
  if self._objective_effect is not None:
606
596
  raise ValueError(f'An objective-effect already exists! ({self._objective_effect.label=})')
607
597
  self._objective_effect = value
608
598
 
599
+ @property
600
+ def penalty_effect(self) -> Effect:
601
+ """
602
+ The penalty effect (auto-created during modeling if not user-defined).
603
+
604
+ Returns the Penalty effect whether user-defined or auto-created.
605
+ """
606
+ # If already set, return it
607
+ if self._penalty_effect is not None:
608
+ return self._penalty_effect
609
+
610
+ # Check if user has defined a Penalty effect
611
+ if PENALTY_EFFECT_LABEL in self:
612
+ self._penalty_effect = self[PENALTY_EFFECT_LABEL]
613
+ return self._penalty_effect
614
+
615
+ # Not yet created - will be created during modeling
616
+ raise KeyError(
617
+ f'Penalty effect not yet created. It will be auto-created during modeling, '
618
+ f'or you can define your own using: Effect("{PENALTY_EFFECT_LABEL}", ...)'
619
+ )
620
+
609
621
  def calculate_effect_share_factors(
610
622
  self,
611
623
  ) -> tuple[
@@ -613,7 +625,7 @@ class EffectCollection:
613
625
  dict[tuple[str, str], xr.DataArray],
614
626
  ]:
615
627
  shares_periodic = {}
616
- for name, effect in self.effects.items():
628
+ for name, effect in self.items():
617
629
  if effect.share_from_periodic:
618
630
  for source, data in effect.share_from_periodic.items():
619
631
  if source not in shares_periodic:
@@ -622,7 +634,7 @@ class EffectCollection:
622
634
  shares_periodic = calculate_all_conversion_paths(shares_periodic)
623
635
 
624
636
  shares_temporal = {}
625
- for name, effect in self.effects.items():
637
+ for name, effect in self.items():
626
638
  if effect.share_from_temporal:
627
639
  for source, data in effect.share_from_temporal.items():
628
640
  if source not in shares_temporal:
@@ -640,7 +652,6 @@ class EffectCollectionModel(Submodel):
640
652
 
641
653
  def __init__(self, model: FlowSystemModel, effects: EffectCollection):
642
654
  self.effects = effects
643
- self.penalty: ShareAllocationModel | None = None
644
655
  super().__init__(model, label_of_element='Effects')
645
656
 
646
657
  def add_share_to_effects(
@@ -665,28 +676,32 @@ class EffectCollectionModel(Submodel):
665
676
  else:
666
677
  raise ValueError(f'Target {target} not supported!')
667
678
 
668
- def add_share_to_penalty(self, name: str, expression: linopy.LinearExpression) -> None:
669
- if expression.ndim != 0:
670
- raise TypeError(f'Penalty shares must be scalar expressions! ({expression.ndim=})')
671
- self.penalty.add_share(name, expression, dims=())
672
-
673
679
  def _do_modeling(self):
680
+ """Create variables, constraints, and nested submodels"""
674
681
  super()._do_modeling()
675
- for effect in self.effects:
682
+
683
+ # Ensure penalty effect exists (auto-create if user hasn't defined one)
684
+ if self.effects._penalty_effect is None:
685
+ penalty_effect = self.effects._create_penalty_effect()
686
+ # Link to FlowSystem (should already be linked, but ensure it)
687
+ if penalty_effect._flow_system is None:
688
+ penalty_effect.link_to_flow_system(self._model.flow_system)
689
+
690
+ # Create EffectModel for each effect
691
+ for effect in self.effects.values():
676
692
  effect.create_model(self._model)
677
- self.penalty = self.add_submodels(
678
- ShareAllocationModel(self._model, dims=(), label_of_element='Penalty'),
679
- short_name='penalty',
680
- )
681
693
 
694
+ # Add cross-effect shares
682
695
  self._add_share_between_effects()
683
696
 
697
+ # Use objective weights with objective effect and penalty effect
684
698
  self._model.add_objective(
685
- (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum()
699
+ (self.effects.objective_effect.submodel.total * self._model.objective_weights).sum()
700
+ + (self.effects.penalty_effect.submodel.total * self._model.objective_weights).sum()
686
701
  )
687
702
 
688
703
  def _add_share_between_effects(self):
689
- for target_effect in self.effects:
704
+ for target_effect in self.effects.values():
690
705
  # 1. temporal: <- receiving temporal shares from other effects
691
706
  for source_effect, time_series in target_effect.share_from_temporal.items():
692
707
  target_effect.submodel.temporal.add_share(
@@ -846,8 +861,3 @@ def tuples_to_adjacency_list(edges: list[tuple[str, str]]) -> dict[str, list[str
846
861
  graph[target] = []
847
862
 
848
863
  return graph
849
-
850
-
851
- # Backward compatibility aliases
852
- NonTemporalEffectsUser = PeriodicEffectsUser
853
- NonTemporalEffects = PeriodicEffects