flixopt 2.2.0b0__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flixopt might be problematic. Click here for more details.

Files changed (63) hide show
  1. flixopt/__init__.py +35 -1
  2. flixopt/aggregation.py +60 -81
  3. flixopt/calculation.py +381 -196
  4. flixopt/components.py +1022 -359
  5. flixopt/config.py +553 -191
  6. flixopt/core.py +475 -1315
  7. flixopt/effects.py +477 -214
  8. flixopt/elements.py +591 -344
  9. flixopt/features.py +403 -957
  10. flixopt/flow_system.py +781 -293
  11. flixopt/interface.py +1159 -189
  12. flixopt/io.py +50 -55
  13. flixopt/linear_converters.py +384 -92
  14. flixopt/modeling.py +759 -0
  15. flixopt/network_app.py +789 -0
  16. flixopt/plotting.py +273 -135
  17. flixopt/results.py +639 -383
  18. flixopt/solvers.py +25 -21
  19. flixopt/structure.py +928 -442
  20. flixopt/utils.py +34 -5
  21. flixopt-3.0.0.dist-info/METADATA +209 -0
  22. flixopt-3.0.0.dist-info/RECORD +26 -0
  23. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
  24. flixopt-3.0.0.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -49
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/release-notes/_template.txt +0 -32
  37. docs/release-notes/index.md +0 -7
  38. docs/release-notes/v2.0.0.md +0 -93
  39. docs/release-notes/v2.0.1.md +0 -12
  40. docs/release-notes/v2.1.0.md +0 -31
  41. docs/release-notes/v2.2.0.md +0 -55
  42. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  43. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  44. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  45. docs/user-guide/Mathematical Notation/Investment.md +0 -115
  46. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  47. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  48. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  49. docs/user-guide/Mathematical Notation/index.md +0 -22
  50. docs/user-guide/Mathematical Notation/others.md +0 -3
  51. docs/user-guide/index.md +0 -124
  52. flixopt/config.yaml +0 -10
  53. flixopt-2.2.0b0.dist-info/METADATA +0 -146
  54. flixopt-2.2.0b0.dist-info/RECORD +0 -59
  55. flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
  56. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  57. pics/architecture_flixOpt.png +0 -0
  58. pics/flixOpt_plotting.jpg +0 -0
  59. pics/flixopt-icon.svg +0 -1
  60. pics/pics.pptx +0 -0
  61. scripts/gen_ref_pages.py +0 -54
  62. tests/ressources/Zeitreihen2020.csv +0 -35137
  63. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/interface.py CHANGED
@@ -3,15 +3,24 @@ This module contains classes to collect Parameters for the Investment and OnOff
3
3
  These are tightly connected to features.py
4
4
  """
5
5
 
6
+ from __future__ import annotations
7
+
6
8
  import logging
7
- from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union
9
+ import warnings
10
+ from typing import TYPE_CHECKING, Literal, Optional
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import xarray as xr
8
15
 
9
16
  from .config import CONFIG
10
- from .core import NumericDataTS, Scalar, ScenarioData, TimestepData
11
17
  from .structure import Interface, register_class_for_io
12
18
 
13
19
  if TYPE_CHECKING: # for type checking and preventing circular imports
14
- from .effects import EffectValuesUserScenario, EffectValuesUserTimestep
20
+ from collections.abc import Iterator
21
+
22
+ from .core import PeriodicData, PeriodicDataUser, Scalar, TemporalDataUser
23
+ from .effects import PeriodicEffectsUser, TemporalEffectsUser
15
24
  from .flow_system import FlowSystem
16
25
 
17
26
 
@@ -20,36 +29,173 @@ logger = logging.getLogger('flixopt')
20
29
 
21
30
  @register_class_for_io
22
31
  class Piece(Interface):
23
- def __init__(self, start: TimestepData, end: TimestepData):
24
- """
25
- Define a Piece, which is part of a Piecewise object.
32
+ """Define a single linear segment with specified domain boundaries.
26
33
 
27
- Args:
28
- start: The x-values of the piece.
29
- end: The end of the piece.
30
- """
34
+ This class represents one linear segment that will be combined with other
35
+ pieces to form complete piecewise linear functions. Each piece defines
36
+ a domain interval [start, end] where a linear relationship applies.
37
+
38
+ Args:
39
+ start: Lower bound of the domain interval for this linear segment.
40
+ Can be scalar values or time series arrays for time-varying boundaries.
41
+ end: Upper bound of the domain interval for this linear segment.
42
+ Can be scalar values or time series arrays for time-varying boundaries.
43
+
44
+ Examples:
45
+ Basic piece for equipment efficiency curve:
46
+
47
+ ```python
48
+ # Single segment from 40% to 80% load
49
+ efficiency_segment = Piece(start=40, end=80)
50
+ ```
51
+
52
+ Piece with time-varying boundaries:
53
+
54
+ ```python
55
+ # Capacity limits that change seasonally
56
+ seasonal_piece = Piece(
57
+ start=np.array([10, 20, 30, 25]), # Minimum capacity by season
58
+ end=np.array([80, 100, 90, 70]), # Maximum capacity by season
59
+ )
60
+ ```
61
+
62
+ Fixed operating point (start equals end):
63
+
64
+ ```python
65
+ # Equipment that operates at exactly 50 MW
66
+ fixed_output = Piece(start=50, end=50)
67
+ ```
68
+
69
+ Note:
70
+ Individual pieces are building blocks that gain meaning when combined
71
+ into Piecewise functions. See the Piecewise class for information about
72
+ how pieces interact and relate to each other.
73
+
74
+ """
75
+
76
+ def __init__(self, start: TemporalDataUser, end: TemporalDataUser):
31
77
  self.start = start
32
78
  self.end = end
33
79
  self.has_time_dim = False
34
80
 
35
- def transform_data(self, flow_system: 'FlowSystem', name_prefix: str):
36
- self.start = flow_system.create_time_series(
37
- name=f'{name_prefix}|start', data=self.start, has_time_dim=self.has_time_dim, has_scenario_dim=True
38
- )
39
- self.end = flow_system.create_time_series(
40
- name=f'{name_prefix}|end', data=self.end, has_time_dim=self.has_time_dim, has_scenario_dim=True
41
- )
81
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
82
+ dims = None if self.has_time_dim else ['period', 'scenario']
83
+ self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, dims=dims)
84
+ self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, dims=dims)
42
85
 
43
86
 
44
87
  @register_class_for_io
45
88
  class Piecewise(Interface):
46
- def __init__(self, pieces: List[Piece]):
47
- """
48
- Define a Piecewise, consisting of a list of Pieces.
89
+ """
90
+ Define a Piecewise, consisting of a list of Pieces.
49
91
 
50
- Args:
51
- pieces: The pieces of the piecewise.
52
- """
92
+ Args:
93
+ pieces: list of Piece objects defining the linear segments. The arrangement
94
+ and relationships between pieces determine the function behavior:
95
+ - Touching pieces (end of one = start of next) ensure continuity
96
+ - Gaps between pieces create forbidden regions
97
+ - Overlapping pieces provide an extra choice for the optimizer
98
+
99
+ Piece Relationship Patterns:
100
+ **Touching Pieces (Continuous Function)**:
101
+ Pieces that share boundary points create smooth, continuous functions
102
+ without gaps or overlaps.
103
+
104
+ **Gaps Between Pieces (Forbidden Regions)**:
105
+ Non-contiguous pieces with gaps represent forbidden regions.
106
+ For example minimum load requirements or safety zones.
107
+
108
+ **Overlapping Pieces (Flexible Operation)**:
109
+ Pieces with overlapping domains provide optimization flexibility,
110
+ allowing the solver to choose which segment to operate in.
111
+
112
+ Examples:
113
+ Continuous efficiency curve (touching pieces):
114
+
115
+ ```python
116
+ efficiency_curve = Piecewise(
117
+ [
118
+ Piece(start=0, end=25), # Low load: 0-25 MW
119
+ Piece(start=25, end=75), # Medium load: 25-75 MW (touches at 25)
120
+ Piece(start=75, end=100), # High load: 75-100 MW (touches at 75)
121
+ ]
122
+ )
123
+ ```
124
+
125
+ Equipment with forbidden operating range (gap):
126
+
127
+ ```python
128
+ turbine_operation = Piecewise(
129
+ [
130
+ Piece(start=0, end=0), # Off state (point operation)
131
+ Piece(start=40, end=100), # Operating range (gap: 0-40 forbidden)
132
+ ]
133
+ )
134
+ ```
135
+
136
+ Flexible operation with overlapping options:
137
+
138
+ ```python
139
+ flexible_operation = Piecewise(
140
+ [
141
+ Piece(start=20, end=60), # Standard efficiency mode
142
+ Piece(start=50, end=90), # High efficiency mode (overlap: 50-60)
143
+ ]
144
+ )
145
+ ```
146
+
147
+ Tiered pricing structure:
148
+
149
+ ```python
150
+ electricity_pricing = Piecewise(
151
+ [
152
+ Piece(start=0, end=100), # Tier 1: 0-100 kWh
153
+ Piece(start=100, end=500), # Tier 2: 100-500 kWh
154
+ Piece(start=500, end=1000), # Tier 3: 500-1000 kWh
155
+ ]
156
+ )
157
+ ```
158
+
159
+ Seasonal capacity variation:
160
+
161
+ ```python
162
+ seasonal_capacity = Piecewise(
163
+ [
164
+ Piece(start=[10, 15, 20, 12], end=[80, 90, 85, 75]), # Varies by time
165
+ ]
166
+ )
167
+ ```
168
+
169
+ Container Operations:
170
+ The Piecewise class supports standard Python container operations:
171
+
172
+ ```python
173
+ piecewise = Piecewise([piece1, piece2, piece3])
174
+
175
+ len(piecewise) # Returns number of pieces (3)
176
+ piecewise[0] # Access first piece
177
+ for piece in piecewise: # Iterate over all pieces
178
+ print(piece.start, piece.end)
179
+ ```
180
+
181
+ Validation Considerations:
182
+ - Pieces are typically ordered by their start values
183
+ - Check for unintended gaps that might create infeasible regions
184
+ - Consider whether overlaps provide desired flexibility or create ambiguity
185
+ - Ensure time-varying pieces have consistent dimensions
186
+
187
+ Common Use Cases:
188
+ - Power plants: Heat rate curves, efficiency vs load, emissions profiles
189
+ - HVAC systems: COP vs temperature, capacity vs conditions
190
+ - Industrial processes: Conversion rates vs throughput, quality vs speed
191
+ - Financial modeling: Tiered rates, progressive taxes, bulk discounts
192
+ - Transportation: Fuel efficiency curves, capacity vs speed
193
+ - Storage systems: Efficiency vs state of charge, power vs energy
194
+ - Renewable energy: Output vs weather conditions, curtailment strategies
195
+
196
+ """
197
+
198
+ def __init__(self, pieces: list[Piece]):
53
199
  self.pieces = pieces
54
200
  self._has_time_dim = False
55
201
 
@@ -64,6 +210,12 @@ class Piecewise(Interface):
64
210
  piece.has_time_dim = value
65
211
 
66
212
  def __len__(self):
213
+ """
214
+ Return the number of Piece segments in this Piecewise container.
215
+
216
+ Returns:
217
+ int: Count of contained Piece objects.
218
+ """
67
219
  return len(self.pieces)
68
220
 
69
221
  def __getitem__(self, index) -> Piece:
@@ -72,25 +224,212 @@ class Piecewise(Interface):
72
224
  def __iter__(self) -> Iterator[Piece]:
73
225
  return iter(self.pieces) # Enables iteration like for piece in piecewise: ...
74
226
 
75
- def transform_data(self, flow_system: 'FlowSystem', name_prefix: str):
227
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
76
228
  for i, piece in enumerate(self.pieces):
77
229
  piece.transform_data(flow_system, f'{name_prefix}|Piece{i}')
78
230
 
79
231
 
80
232
  @register_class_for_io
81
233
  class PiecewiseConversion(Interface):
82
- def __init__(self, piecewises: Dict[str, Piecewise]):
83
- """
84
- Define a piecewise conversion between multiple Flows.
85
- --> "gaps" can be expressed by a piece not starting at the end of the prior piece: [(1,3), (4,5)]
86
- --> "points" can expressed as piece with same begin and end: [(3,3), (4,4)]
234
+ """Define coordinated piecewise linear relationships between multiple flows.
87
235
 
88
- Args:
89
- piecewises: Dict of Piecewises defining the conversion factors. flow labels as keys, piecewise as values
90
- """
236
+ This class models conversion processes where multiple flows (inputs, outputs,
237
+ auxiliaries) have synchronized piecewise relationships. All flows change
238
+ together based on the same operating point, enabling accurate modeling of
239
+ complex equipment with variable performance characteristics.
240
+
241
+ Multi-Flow Coordination:
242
+ All piecewise functions must have matching piece structures (same number
243
+ of pieces with compatible domains) to ensure synchronized operation.
244
+ When the equipment operates at a given point, ALL flows scale proportionally
245
+ within their respective pieces.
246
+
247
+ Mathematical Formulation:
248
+ See the complete mathematical model in the documentation:
249
+ [Piecewise](../user-guide/mathematical-notation/features/Piecewise.md)
250
+
251
+ Args:
252
+ piecewises: Dictionary mapping flow labels to their Piecewise functions.
253
+ Keys are flow identifiers (e.g., 'electricity_in', 'heat_out', 'fuel_consumed').
254
+ Values are Piecewise objects that define each flow's behavior.
255
+ **Critical Requirement**: All Piecewise objects must have the same
256
+ number of pieces with compatible domains to ensure consistent operation.
257
+
258
+ Operating Point Coordination:
259
+ When equipment operates at any point within a piece, all flows scale
260
+ proportionally within their corresponding pieces. This ensures realistic
261
+ equipment behavior where efficiency, consumption, and production rates
262
+ all change together.
263
+
264
+ Examples:
265
+ Heat pump with coordinated efficiency changes:
266
+
267
+ ```python
268
+ heat_pump_pc = PiecewiseConversion(
269
+ {
270
+ 'electricity_in': Piecewise(
271
+ [
272
+ Piece(0, 10), # Low load: 0-10 kW electricity
273
+ Piece(10, 25), # High load: 10-25 kW electricity
274
+ ]
275
+ ),
276
+ 'heat_out': Piecewise(
277
+ [
278
+ Piece(0, 35), # Low load COP=3.5: 0-35 kW heat
279
+ Piece(35, 75), # High load COP=3.0: 35-75 kW heat
280
+ ]
281
+ ),
282
+ 'cooling_water': Piecewise(
283
+ [
284
+ Piece(0, 2.5), # Low load: 0-2.5 m³/h cooling
285
+ Piece(2.5, 6), # High load: 2.5-6 m³/h cooling
286
+ ]
287
+ ),
288
+ }
289
+ )
290
+ # At 15 kW electricity → 52.5 kW heat + 3.75 m³/h cooling water
291
+ ```
292
+
293
+ Combined cycle power plant with synchronized flows:
294
+
295
+ ```python
296
+ power_plant_pc = PiecewiseConversion(
297
+ {
298
+ 'natural_gas': Piecewise(
299
+ [
300
+ Piece(150, 300), # Part load: 150-300 MW_th fuel
301
+ Piece(300, 500), # Full load: 300-500 MW_th fuel
302
+ ]
303
+ ),
304
+ 'electricity': Piecewise(
305
+ [
306
+ Piece(60, 135), # Part load: 60-135 MW_e (45% efficiency)
307
+ Piece(135, 250), # Full load: 135-250 MW_e (50% efficiency)
308
+ ]
309
+ ),
310
+ 'steam_export': Piecewise(
311
+ [
312
+ Piece(20, 35), # Part load: 20-35 MW_th steam
313
+ Piece(35, 50), # Full load: 35-50 MW_th steam
314
+ ]
315
+ ),
316
+ 'co2_emissions': Piecewise(
317
+ [
318
+ Piece(30, 60), # Part load: 30-60 t/h CO2
319
+ Piece(60, 100), # Full load: 60-100 t/h CO2
320
+ ]
321
+ ),
322
+ }
323
+ )
324
+ ```
325
+
326
+ Chemical reactor with multiple products and waste:
327
+
328
+ ```python
329
+ reactor_pc = PiecewiseConversion(
330
+ {
331
+ 'feedstock': Piecewise(
332
+ [
333
+ Piece(10, 50), # Small batch: 10-50 kg/h
334
+ Piece(50, 200), # Large batch: 50-200 kg/h
335
+ ]
336
+ ),
337
+ 'product_A': Piecewise(
338
+ [
339
+ Piece(7, 35), # Small batch: 70% yield
340
+ Piece(35, 140), # Large batch: 70% yield
341
+ ]
342
+ ),
343
+ 'product_B': Piecewise(
344
+ [
345
+ Piece(2, 10), # Small batch: 20% yield
346
+ Piece(10, 45), # Large batch: 22.5% yield (improved)
347
+ ]
348
+ ),
349
+ 'waste_stream': Piecewise(
350
+ [
351
+ Piece(1, 5), # Small batch: 10% waste
352
+ Piece(5, 15), # Large batch: 7.5% waste (efficiency)
353
+ ]
354
+ ),
355
+ }
356
+ )
357
+ ```
358
+
359
+ Equipment with discrete operating modes:
360
+
361
+ ```python
362
+ compressor_pc = PiecewiseConversion(
363
+ {
364
+ 'electricity': Piecewise(
365
+ [
366
+ Piece(0, 0), # Off mode: no consumption
367
+ Piece(45, 45), # Low mode: fixed 45 kW
368
+ Piece(85, 85), # High mode: fixed 85 kW
369
+ ]
370
+ ),
371
+ 'compressed_air': Piecewise(
372
+ [
373
+ Piece(0, 0), # Off mode: no production
374
+ Piece(250, 250), # Low mode: 250 Nm³/h
375
+ Piece(500, 500), # High mode: 500 Nm³/h
376
+ ]
377
+ ),
378
+ }
379
+ )
380
+ ```
381
+
382
+ Equipment with forbidden operating range:
383
+
384
+ ```python
385
+ steam_turbine_pc = PiecewiseConversion(
386
+ {
387
+ 'steam_in': Piecewise(
388
+ [
389
+ Piece(0, 100), # Low pressure operation
390
+ Piece(200, 500), # High pressure (gap: 100-200 forbidden)
391
+ ]
392
+ ),
393
+ 'electricity_out': Piecewise(
394
+ [
395
+ Piece(0, 30), # Low pressure: poor efficiency
396
+ Piece(80, 220), # High pressure: good efficiency
397
+ ]
398
+ ),
399
+ 'condensate_out': Piecewise(
400
+ [
401
+ Piece(0, 100), # Low pressure condensate
402
+ Piece(200, 500), # High pressure condensate
403
+ ]
404
+ ),
405
+ }
406
+ )
407
+ ```
408
+
409
+ Design Patterns:
410
+ **Forbidden Ranges**: Use gaps between pieces to model equipment that cannot
411
+ operate in certain ranges (e.g., minimum loads, unstable regions).
412
+
413
+ **Discrete Modes**: Use pieces with identical start/end values to model
414
+ equipment with fixed operating points (e.g., on/off, discrete speeds).
415
+
416
+ **Efficiency Changes**: Coordinate input and output pieces to reflect
417
+ changing conversion efficiency across operating ranges.
418
+
419
+ Common Use Cases:
420
+ - Power generation: Multi-fuel plants, cogeneration systems, renewable hybrids
421
+ - HVAC systems: Heat pumps, chillers with variable COP and auxiliary loads
422
+ - Industrial processes: Multi-product reactors, separation units, heat exchangers
423
+ - Transportation: Multi-modal systems, hybrid vehicles, charging infrastructure
424
+ - Water treatment: Multi-stage processes with varying energy and chemical needs
425
+ - Energy storage: Systems with efficiency changes and auxiliary power requirements
426
+
427
+ """
428
+
429
+ def __init__(self, piecewises: dict[str, Piecewise]):
91
430
  self.piecewises = piecewises
92
431
  self._has_time_dim = True
93
- self.has_time_dim = True # Inital propagation
432
+ self.has_time_dim = True # Initial propagation
94
433
 
95
434
  @property
96
435
  def has_time_dim(self):
@@ -103,27 +442,214 @@ class PiecewiseConversion(Interface):
103
442
  piecewise.has_time_dim = value
104
443
 
105
444
  def items(self):
445
+ """
446
+ Return an iterator over (flow_label, Piecewise) pairs stored in this PiecewiseConversion.
447
+
448
+ This is a thin convenience wrapper around the internal mapping and yields the same view
449
+ as dict.items(), where each key is a flow label (str) and each value is a Piecewise.
450
+ """
106
451
  return self.piecewises.items()
107
452
 
108
- def transform_data(self, flow_system: 'FlowSystem', name_prefix: str):
453
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
109
454
  for name, piecewise in self.piecewises.items():
110
455
  piecewise.transform_data(flow_system, f'{name_prefix}|{name}')
111
456
 
112
457
 
113
458
  @register_class_for_io
114
459
  class PiecewiseEffects(Interface):
115
- def __init__(self, piecewise_origin: Piecewise, piecewise_shares: Dict[str, Piecewise]):
116
- """
117
- Define piecewise effects related to a variable.
460
+ """Define how a single decision variable contributes to system effects with piecewise rates.
118
461
 
119
- Args:
120
- piecewise_origin: Piecewise of the related variable
121
- piecewise_shares: Piecewise defining the shares to different Effects
122
- """
462
+ This class models situations where a decision variable (the origin) generates
463
+ different types of system effects (costs, emissions, resource consumption) at
464
+ rates that change non-linearly with the variable's operating level. Unlike
465
+ PiecewiseConversion which coordinates multiple flows, PiecewiseEffects focuses
466
+ on how one variable impacts multiple system-wide effects.
467
+
468
+ Key Concept - Origin vs. Effects:
469
+ - **Origin**: The primary decision variable (e.g., production level, capacity, size)
470
+ - **Shares**: The amounts which this variable contributes to different system effects
471
+
472
+ Relationship to PiecewiseConversion:
473
+ **PiecewiseConversion**: Models synchronized relationships between multiple
474
+ flow variables (e.g., fuel_in, electricity_out, emissions_out all coordinated).
475
+
476
+ **PiecewiseEffects**: Models how one variable contributes to system-wide
477
+ effects at variable rates (e.g., production_level → costs, emissions, resources).
478
+
479
+ Args:
480
+ piecewise_origin: Piecewise function defining the behavior of the primary
481
+ decision variable. This establishes the operating domain and ranges.
482
+ piecewise_shares: Dictionary mapping effect names to their rate functions.
483
+ Keys are effect identifiers (e.g., 'cost_per_unit', 'CO2_intensity').
484
+ Values are Piecewise objects defining the contribution rate per unit
485
+ of the origin variable at different operating levels.
486
+
487
+ Mathematical Relationship:
488
+ For each effect: Total_Effect = Origin_Variable × Share_Rate(Origin_Level)
489
+
490
+ This enables modeling of:
491
+ - Economies of scale (decreasing unit costs with volume)
492
+ - Learning curves (improving efficiency with experience)
493
+ - Threshold effects (changing rates at different scales)
494
+ - Progressive pricing (increasing rates with consumption)
495
+
496
+ Examples:
497
+ Manufacturing with economies of scale:
498
+
499
+ ```python
500
+ production_effects = PiecewiseEffects(
501
+ piecewise_origin=Piecewise(
502
+ [
503
+ Piece(0, 1000), # Small scale: 0-1000 units/month
504
+ Piece(1000, 5000), # Medium scale: 1000-5000 units/month
505
+ Piece(5000, 10000), # Large scale: 5000-10000 units/month
506
+ ]
507
+ ),
508
+ piecewise_shares={
509
+ 'unit_cost': Piecewise(
510
+ [
511
+ Piece(50, 45), # €50-45/unit (scale benefits)
512
+ Piece(45, 35), # €45-35/unit (bulk materials)
513
+ Piece(35, 30), # €35-30/unit (automation benefits)
514
+ ]
515
+ ),
516
+ 'labor_hours': Piecewise(
517
+ [
518
+ Piece(2.5, 2.0), # 2.5-2.0 hours/unit (learning curve)
519
+ Piece(2.0, 1.5), # 2.0-1.5 hours/unit (efficiency gains)
520
+ Piece(1.5, 1.2), # 1.5-1.2 hours/unit (specialization)
521
+ ]
522
+ ),
523
+ 'CO2_intensity': Piecewise(
524
+ [
525
+ Piece(15, 12), # 15-12 kg CO2/unit (process optimization)
526
+ Piece(12, 9), # 12-9 kg CO2/unit (equipment efficiency)
527
+ Piece(9, 7), # 9-7 kg CO2/unit (renewable energy)
528
+ ]
529
+ ),
530
+ },
531
+ )
532
+ ```
533
+
534
+ Power generation with load-dependent characteristics:
535
+
536
+ ```python
537
+ generator_effects = PiecewiseEffects(
538
+ piecewise_origin=Piecewise(
539
+ [
540
+ Piece(50, 200), # Part load operation: 50-200 MW
541
+ Piece(200, 350), # Rated operation: 200-350 MW
542
+ Piece(350, 400), # Overload operation: 350-400 MW
543
+ ]
544
+ ),
545
+ piecewise_shares={
546
+ 'fuel_rate': Piecewise(
547
+ [
548
+ Piece(12.0, 10.5), # Heat rate: 12.0-10.5 GJ/MWh (part load penalty)
549
+ Piece(10.5, 9.8), # Heat rate: 10.5-9.8 GJ/MWh (optimal efficiency)
550
+ Piece(9.8, 11.2), # Heat rate: 9.8-11.2 GJ/MWh (overload penalty)
551
+ ]
552
+ ),
553
+ 'maintenance_factor': Piecewise(
554
+ [
555
+ Piece(0.8, 1.0), # Low stress operation
556
+ Piece(1.0, 1.0), # Design operation
557
+ Piece(1.0, 1.5), # High stress operation
558
+ ]
559
+ ),
560
+ 'NOx_rate': Piecewise(
561
+ [
562
+ Piece(0.20, 0.15), # NOx: 0.20-0.15 kg/MWh
563
+ Piece(0.15, 0.12), # NOx: 0.15-0.12 kg/MWh (optimal combustion)
564
+ Piece(0.12, 0.25), # NOx: 0.12-0.25 kg/MWh (overload penalties)
565
+ ]
566
+ ),
567
+ },
568
+ )
569
+ ```
570
+
571
+ Progressive utility pricing structure:
572
+
573
+ ```python
574
+ electricity_billing = PiecewiseEffects(
575
+ piecewise_origin=Piecewise(
576
+ [
577
+ Piece(0, 200), # Basic usage: 0-200 kWh/month
578
+ Piece(200, 800), # Standard usage: 200-800 kWh/month
579
+ Piece(800, 2000), # High usage: 800-2000 kWh/month
580
+ ]
581
+ ),
582
+ piecewise_shares={
583
+ 'energy_rate': Piecewise(
584
+ [
585
+ Piece(0.12, 0.12), # Basic rate: €0.12/kWh
586
+ Piece(0.18, 0.18), # Standard rate: €0.18/kWh
587
+ Piece(0.28, 0.28), # Premium rate: €0.28/kWh
588
+ ]
589
+ ),
590
+ 'carbon_tax': Piecewise(
591
+ [
592
+ Piece(0.02, 0.02), # Low carbon tax: €0.02/kWh
593
+ Piece(0.03, 0.03), # Medium carbon tax: €0.03/kWh
594
+ Piece(0.05, 0.05), # High carbon tax: €0.05/kWh
595
+ ]
596
+ ),
597
+ },
598
+ )
599
+ ```
600
+
601
+ Data center with capacity-dependent efficiency:
602
+
603
+ ```python
604
+ datacenter_effects = PiecewiseEffects(
605
+ piecewise_origin=Piecewise(
606
+ [
607
+ Piece(100, 500), # Low utilization: 100-500 servers
608
+ Piece(500, 2000), # Medium utilization: 500-2000 servers
609
+ Piece(2000, 5000), # High utilization: 2000-5000 servers
610
+ ]
611
+ ),
612
+ piecewise_shares={
613
+ 'power_per_server': Piecewise(
614
+ [
615
+ Piece(0.8, 0.6), # 0.8-0.6 kW/server (inefficient cooling)
616
+ Piece(0.6, 0.4), # 0.6-0.4 kW/server (optimal efficiency)
617
+ Piece(0.4, 0.5), # 0.4-0.5 kW/server (thermal limits)
618
+ ]
619
+ ),
620
+ 'cooling_overhead': Piecewise(
621
+ [
622
+ Piece(0.4, 0.3), # 40%-30% cooling overhead
623
+ Piece(0.3, 0.2), # 30%-20% cooling overhead
624
+ Piece(0.2, 0.25), # 20%-25% cooling overhead
625
+ ]
626
+ ),
627
+ },
628
+ )
629
+ ```
630
+
631
+ Design Patterns:
632
+ **Economies of Scale**: Decreasing unit costs/impacts with increased scale
633
+ **Learning Curves**: Improving efficiency rates with experience/volume
634
+ **Threshold Effects**: Step changes in rates at specific operating levels
635
+ **Progressive Pricing**: Increasing rates for higher consumption levels
636
+ **Capacity Utilization**: Optimal efficiency at design points, penalties at extremes
637
+
638
+ Common Use Cases:
639
+ - Manufacturing: Production scaling, learning effects, quality improvements
640
+ - Energy systems: Generator efficiency curves, renewable capacity factors
641
+ - Logistics: Transportation rates, warehouse utilization, delivery optimization
642
+ - Utilities: Progressive pricing, infrastructure cost allocation
643
+ - Financial services: Risk premiums, transaction fees, volume discounts
644
+ - Environmental modeling: Pollution intensity, resource consumption rates
645
+
646
+ """
647
+
648
+ def __init__(self, piecewise_origin: Piecewise, piecewise_shares: dict[str, Piecewise]):
123
649
  self.piecewise_origin = piecewise_origin
124
650
  self.piecewise_shares = piecewise_shares
125
651
  self._has_time_dim = False
126
- self.has_time_dim = False # Inital propagation
652
+ self.has_time_dim = False # Initial propagation
127
653
 
128
654
  @property
129
655
  def has_time_dim(self):
@@ -136,7 +662,7 @@ class PiecewiseEffects(Interface):
136
662
  for piecewise in self.piecewise_shares.values():
137
663
  piecewise.has_time_dim = value
138
664
 
139
- def transform_data(self, flow_system: 'FlowSystem', name_prefix: str):
665
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
140
666
  self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin')
141
667
  for effect, piecewise in self.piecewise_shares.items():
142
668
  piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{effect}')
@@ -144,213 +670,657 @@ class PiecewiseEffects(Interface):
144
670
 
145
671
  @register_class_for_io
146
672
  class InvestParameters(Interface):
147
- """
148
- collects arguments for invest-stuff
673
+ """Define investment decision parameters with flexible sizing and effect modeling.
674
+
675
+ This class models investment decisions in optimization problems, supporting
676
+ both binary (invest/don't invest) and continuous sizing choices with
677
+ comprehensive cost structures. It enables realistic representation of
678
+ investment economics including fixed costs, scale effects, and divestment penalties.
679
+
680
+ Investment Decision Types:
681
+ **Binary Investments**: Fixed size investments creating yes/no decisions
682
+ (e.g., install a specific generator, build a particular facility)
683
+
684
+ **Continuous Sizing**: Variable size investments with minimum/maximum bounds
685
+ (e.g., battery capacity from 10-1000 kWh, pipeline diameter optimization)
686
+
687
+ Cost Modeling Approaches:
688
+ - **Fixed Effects**: One-time costs independent of size (permits, connections)
689
+ - **Specific Effects**: Linear costs proportional to size (€/kW, €/m²)
690
+ - **Piecewise Effects**: Non-linear relationships (bulk discounts, learning curves)
691
+ - **Divestment Effects**: Penalties for not investing (demolition, opportunity costs)
692
+
693
+ Mathematical Formulation:
694
+ See the complete mathematical model in the documentation:
695
+ [InvestParameters](../user-guide/mathematical-notation/features/InvestParameters.md)
696
+
697
+ Args:
698
+ fixed_size: Creates binary decision at this exact size. None allows continuous sizing.
699
+ minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon.
700
+ Ignored if fixed_size is specified.
701
+ maximum_size: Upper bound for continuous sizing. Default: CONFIG.Modeling.big.
702
+ Ignored if fixed_size is specified.
703
+ mandatory: Controls whether investment is required. When True, forces investment
704
+ to occur (useful for mandatory upgrades or replacement decisions).
705
+ When False (default), optimization can choose not to invest.
706
+ With multiple periods, at least one period has to have an investment.
707
+ effects_of_investment: Fixed costs if investment is made, regardless of size.
708
+ Dict: {'effect_name': value} (e.g., {'cost': 10000}).
709
+ effects_of_investment_per_size: Variable costs proportional to size (per-unit costs).
710
+ Dict: {'effect_name': value/unit} (e.g., {'cost': 1200}).
711
+ piecewise_effects_of_investment: Non-linear costs using PiecewiseEffects.
712
+ Combinable with effects_of_investment and effects_of_investment_per_size.
713
+ effects_of_retirement: Costs incurred if NOT investing (demolition, penalties).
714
+ Dict: {'effect_name': value}.
715
+
716
+ Deprecated Args:
717
+ fix_effects: **Deprecated**. Use `effects_of_investment` instead.
718
+ Will be removed in version 4.0.
719
+ specific_effects: **Deprecated**. Use `effects_of_investment_per_size` instead.
720
+ Will be removed in version 4.0.
721
+ divest_effects: **Deprecated**. Use `effects_of_retirement` instead.
722
+ Will be removed in version 4.0.
723
+ piecewise_effects: **Deprecated**. Use `piecewise_effects_of_investment` instead.
724
+ Will be removed in version 4.0.
725
+ optional: DEPRECATED. Use `mandatory` instead. Opposite of `mandatory`.
726
+ Will be removed in version 4.0.
727
+ linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods.
728
+
729
+ Cost Annualization Requirements:
730
+ All cost values must be properly weighted to match the optimization model's time horizon.
731
+ For long-term investments, the cost values should be annualized to the corresponding operation time (annuity).
732
+
733
+ - Use equivalent annual cost (capital cost / equipment lifetime)
734
+ - Apply appropriate discount rates for present value calculations
735
+ - Account for inflation, escalation, and financing costs
736
+
737
+ Example: €1M equipment with 20-year life → €50k/year fixed cost
738
+
739
+ Examples:
740
+ Simple binary investment (solar panels):
741
+
742
+ ```python
743
+ solar_investment = InvestParameters(
744
+ fixed_size=100, # 100 kW system (binary decision)
745
+ mandatory=False, # Investment is optional
746
+ effects_of_investment={
747
+ 'cost': 25000, # Installation and permitting costs
748
+ 'CO2': -50000, # Avoided emissions over lifetime
749
+ },
750
+ effects_of_investment_per_size={
751
+ 'cost': 1200, # €1200/kW for panels (annualized)
752
+ 'CO2': -800, # kg CO2 avoided per kW annually
753
+ },
754
+ )
755
+ ```
756
+
757
+ Flexible sizing with economies of scale:
758
+
759
+ ```python
760
+ battery_investment = InvestParameters(
761
+ minimum_size=10, # Minimum viable system size (kWh)
762
+ maximum_size=1000, # Maximum installable capacity
763
+ mandatory=False, # Investment is optional
764
+ effects_of_investment={
765
+ 'cost': 5000, # Grid connection and control system
766
+ 'installation_time': 2, # Days for fixed components
767
+ },
768
+ piecewise_effects_of_investment=PiecewiseEffects(
769
+ piecewise_origin=Piecewise(
770
+ [
771
+ Piece(0, 100), # Small systems
772
+ Piece(100, 500), # Medium systems
773
+ Piece(500, 1000), # Large systems
774
+ ]
775
+ ),
776
+ piecewise_shares={
777
+ 'cost': Piecewise(
778
+ [
779
+ Piece(800, 750), # High cost/kWh for small systems
780
+ Piece(750, 600), # Medium cost/kWh
781
+ Piece(600, 500), # Bulk discount for large systems
782
+ ]
783
+ )
784
+ },
785
+ ),
786
+ )
787
+ ```
788
+
789
+ Mandatory replacement with retirement costs:
790
+
791
+ ```python
792
+ boiler_replacement = InvestParameters(
793
+ minimum_size=50,
794
+ maximum_size=200,
795
+ mandatory=False, # Can choose not to replace
796
+ effects_of_investment={
797
+ 'cost': 15000, # Installation costs
798
+ 'disruption': 3, # Days of downtime
799
+ },
800
+ effects_of_investment_per_size={
801
+ 'cost': 400, # €400/kW capacity
802
+ 'maintenance': 25, # Annual maintenance per kW
803
+ },
804
+ effects_of_retirement={
805
+ 'cost': 8000, # Demolition if not replaced
806
+ 'environmental': 100, # Disposal fees
807
+ },
808
+ )
809
+ ```
810
+
811
+ Multi-technology comparison:
812
+
813
+ ```python
814
+ # Gas turbine option
815
+ gas_turbine = InvestParameters(
816
+ fixed_size=50, # MW
817
+ effects_of_investment={'cost': 2500000, 'CO2': 1250000},
818
+ effects_of_investment_per_size={'fuel_cost': 45, 'maintenance': 12},
819
+ )
820
+
821
+ # Wind farm option
822
+ wind_farm = InvestParameters(
823
+ minimum_size=20,
824
+ maximum_size=100,
825
+ effects_of_investment={'cost': 1000000, 'CO2': -5000000},
826
+ effects_of_investment_per_size={'cost': 1800000, 'land_use': 0.5},
827
+ )
828
+ ```
829
+
830
+ Technology learning curve:
831
+
832
+ ```python
833
+ hydrogen_electrolyzer = InvestParameters(
834
+ minimum_size=1,
835
+ maximum_size=50, # MW
836
+ piecewise_effects_of_investment=PiecewiseEffects(
837
+ piecewise_origin=Piecewise(
838
+ [
839
+ Piece(0, 5), # Small scale: early adoption
840
+ Piece(5, 20), # Medium scale: cost reduction
841
+ Piece(20, 50), # Large scale: mature technology
842
+ ]
843
+ ),
844
+ piecewise_shares={
845
+ 'capex': Piecewise(
846
+ [
847
+ Piece(2000, 1800), # Learning reduces costs
848
+ Piece(1800, 1400), # Continued cost reduction
849
+ Piece(1400, 1200), # Technology maturity
850
+ ]
851
+ ),
852
+ 'efficiency': Piecewise(
853
+ [
854
+ Piece(65, 68), # Improving efficiency
855
+ Piece(68, 72), # with scale and experience
856
+ Piece(72, 75), # Best efficiency at scale
857
+ ]
858
+ ),
859
+ },
860
+ ),
861
+ )
862
+ ```
863
+
864
+ Common Use Cases:
865
+ - Power generation: Plant sizing, technology selection, retrofit decisions
866
+ - Industrial equipment: Capacity expansion, efficiency upgrades, replacements
867
+ - Infrastructure: Network expansion, facility construction, system upgrades
868
+ - Energy storage: Battery sizing, pumped hydro, compressed air systems
869
+ - Transportation: Fleet expansion, charging infrastructure, modal shifts
870
+ - Buildings: HVAC systems, insulation upgrades, renewable integration
871
+
149
872
  """
150
873
 
151
874
  def __init__(
152
875
  self,
153
- fixed_size: Optional[ScenarioData] = None,
154
- minimum_size: Optional[ScenarioData] = None,
155
- maximum_size: Optional[ScenarioData] = None,
156
- optional: bool = True, # Investition ist weglassbar
157
- fix_effects: Optional['EffectValuesUserScenario'] = None,
158
- specific_effects: Optional['EffectValuesUserScenario'] = None, # costs per Flow-Unit/Storage-Size/...
159
- piecewise_effects: Optional[PiecewiseEffects] = None,
160
- divest_effects: Optional['EffectValuesUserScenario'] = None,
161
- investment_scenarios: Optional[Union[Literal['individual'], List[Union[int, str]]]] = None,
876
+ fixed_size: PeriodicDataUser | None = None,
877
+ minimum_size: PeriodicDataUser | None = None,
878
+ maximum_size: PeriodicDataUser | None = None,
879
+ mandatory: bool = False,
880
+ effects_of_investment: PeriodicEffectsUser | None = None,
881
+ effects_of_investment_per_size: PeriodicEffectsUser | None = None,
882
+ effects_of_retirement: PeriodicEffectsUser | None = None,
883
+ piecewise_effects_of_investment: PiecewiseEffects | None = None,
884
+ linked_periods: PeriodicDataUser | tuple[int, int] | None = None,
885
+ **kwargs,
162
886
  ):
163
- """
164
- Args:
165
- fix_effects: Fixed investment costs if invested. (Attention: Annualize costs to chosen period!)
166
- divest_effects: Fixed divestment costs (if not invested, e.g., demolition costs or contractual penalty).
167
- fixed_size: Determines if the investment size is fixed.
168
- optional: If True, investment is not forced.
169
- specific_effects: Specific costs, e.g., in €/kW_nominal or €/m²_nominal.
170
- Example: {costs: 3, CO2: 0.3} with costs and CO2 representing an Object of class Effect
171
- (Attention: Annualize costs to chosen period!)
172
- piecewise_effects: Define the effects of the investment as a piecewise function of the size of the investment.
173
- minimum_size: Minimum possible size of the investment.
174
- maximum_size: Maximum possible size of the investment.
175
- investment_scenarios: For which scenarios to optimize the size for.
176
- - 'individual': Optimize the size of each scenario individually
177
- - List of scenario names: Optimize the size for the passed scenario names (equal size in all). All other scenarios will have the size 0.
178
- - None: Equals to a list of all scenarios (default)
179
- """
180
- self.fix_effects: EffectValuesUserScenario = fix_effects if fix_effects is not None else {}
181
- self.divest_effects: EffectValuesUserScenario = divest_effects if divest_effects is not None else {}
887
+ # Handle deprecated parameters using centralized helper
888
+ effects_of_investment = self._handle_deprecated_kwarg(
889
+ kwargs, 'fix_effects', 'effects_of_investment', effects_of_investment
890
+ )
891
+ effects_of_investment_per_size = self._handle_deprecated_kwarg(
892
+ kwargs, 'specific_effects', 'effects_of_investment_per_size', effects_of_investment_per_size
893
+ )
894
+ effects_of_retirement = self._handle_deprecated_kwarg(
895
+ kwargs, 'divest_effects', 'effects_of_retirement', effects_of_retirement
896
+ )
897
+ piecewise_effects_of_investment = self._handle_deprecated_kwarg(
898
+ kwargs, 'piecewise_effects', 'piecewise_effects_of_investment', piecewise_effects_of_investment
899
+ )
900
+ # For mandatory parameter with non-None default, disable conflict checking
901
+ if 'optional' in kwargs:
902
+ warnings.warn(
903
+ 'Deprecated parameter "optional" used. Check conflicts with new parameter "mandatory" manually!',
904
+ DeprecationWarning,
905
+ stacklevel=2,
906
+ )
907
+ mandatory = self._handle_deprecated_kwarg(
908
+ kwargs, 'optional', 'mandatory', mandatory, transform=lambda x: not x, check_conflict=False
909
+ )
910
+
911
+ # Validate any remaining unexpected kwargs
912
+ self._validate_kwargs(kwargs)
913
+
914
+ self.effects_of_investment: PeriodicEffectsUser = (
915
+ effects_of_investment if effects_of_investment is not None else {}
916
+ )
917
+ self.effects_of_retirement: PeriodicEffectsUser = (
918
+ effects_of_retirement if effects_of_retirement is not None else {}
919
+ )
182
920
  self.fixed_size = fixed_size
183
- self.optional = optional
184
- self.specific_effects: EffectValuesUserScenario = specific_effects if specific_effects is not None else {}
185
- self.piecewise_effects = piecewise_effects
186
- self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON
187
- self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum
188
- self.investment_scenarios = investment_scenarios
189
-
190
- def transform_data(self, flow_system: 'FlowSystem', name_prefix: str):
191
- self._plausibility_checks(flow_system)
192
- self.fix_effects = flow_system.create_effect_time_series(
921
+ self.mandatory = mandatory
922
+ self.effects_of_investment_per_size: PeriodicEffectsUser = (
923
+ effects_of_investment_per_size if effects_of_investment_per_size is not None else {}
924
+ )
925
+ self.piecewise_effects_of_investment = piecewise_effects_of_investment
926
+ self.minimum_size = minimum_size if minimum_size is not None else CONFIG.Modeling.epsilon
927
+ self.maximum_size = maximum_size if maximum_size is not None else CONFIG.Modeling.big # default maximum
928
+ self.linked_periods = linked_periods
929
+
930
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
931
+ self.effects_of_investment = flow_system.fit_effects_to_model_coords(
193
932
  label_prefix=name_prefix,
194
- effect_values=self.fix_effects,
195
- label_suffix='fix_effects',
196
- has_time_dim=False,
197
- has_scenario_dim=True,
933
+ effect_values=self.effects_of_investment,
934
+ label_suffix='effects_of_investment',
935
+ dims=['period', 'scenario'],
198
936
  )
199
- self.divest_effects = flow_system.create_effect_time_series(
937
+ self.effects_of_retirement = flow_system.fit_effects_to_model_coords(
200
938
  label_prefix=name_prefix,
201
- effect_values=self.divest_effects,
202
- label_suffix='divest_effects',
203
- has_time_dim=False,
204
- has_scenario_dim=True,
939
+ effect_values=self.effects_of_retirement,
940
+ label_suffix='effects_of_retirement',
941
+ dims=['period', 'scenario'],
205
942
  )
206
- self.specific_effects = flow_system.create_effect_time_series(
943
+ self.effects_of_investment_per_size = flow_system.fit_effects_to_model_coords(
207
944
  label_prefix=name_prefix,
208
- effect_values=self.specific_effects,
209
- label_suffix='specific_effects',
210
- has_time_dim=False,
211
- has_scenario_dim=True,
945
+ effect_values=self.effects_of_investment_per_size,
946
+ label_suffix='effects_of_investment_per_size',
947
+ dims=['period', 'scenario'],
212
948
  )
213
- if self.piecewise_effects is not None:
214
- self.piecewise_effects.has_time_dim = False
215
- self.piecewise_effects.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects')
216
949
 
217
- self._minimum_size = flow_system.create_time_series(
218
- f'{name_prefix}|minimum_size', self.minimum_size, has_time_dim=False, has_scenario_dim=True
950
+ if self.piecewise_effects_of_investment is not None:
951
+ self.piecewise_effects_of_investment.has_time_dim = False
952
+ self.piecewise_effects_of_investment.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects')
953
+
954
+ self.minimum_size = flow_system.fit_to_model_coords(
955
+ f'{name_prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario']
219
956
  )
220
- self._maximum_size = flow_system.create_time_series(
221
- f'{name_prefix}|maximum_size', self.maximum_size, has_time_dim=False, has_scenario_dim=True
957
+ self.maximum_size = flow_system.fit_to_model_coords(
958
+ f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario']
222
959
  )
223
- if self.fixed_size is not None:
224
- self.fixed_size = flow_system.create_time_series(
225
- f'{name_prefix}|fixed_size', self.fixed_size, has_time_dim=False, has_scenario_dim=True
226
- )
227
-
228
- def _plausibility_checks(self, flow_system):
229
- if isinstance(self.investment_scenarios, list):
230
- if not set(self.investment_scenarios).issubset(flow_system.time_series_collection.scenarios):
231
- raise ValueError(
232
- f'Some scenarios in investment_scenarios are not present in the time_series_collection: '
233
- f'{set(self.investment_scenarios) - set(flow_system.time_series_collection.scenarios)}'
960
+ # Convert tuple (first_period, last_period) to DataArray if needed
961
+ if isinstance(self.linked_periods, (tuple, list)):
962
+ if len(self.linked_periods) != 2:
963
+ raise TypeError(
964
+ f'If you provide a tuple to "linked_periods", it needs to be len=2. Got {len(self.linked_periods)=}'
234
965
  )
235
- if self.investment_scenarios is not None:
236
- if not self.optional:
237
- if self.minimum_size is not None or self.fixed_size is not None:
238
- logger.warning(
239
- 'When using investment_scenarios, minimum_size and fixed_size should only ne used if optional is True.'
240
- 'Otherwise the investment cannot be 0 incertain scenarios while being non-zero in others.'
241
- )
966
+ logger.debug(f'Computing linked_periods from {self.linked_periods}')
967
+ start, end = self.linked_periods
968
+ if start not in flow_system.periods.values:
969
+ logger.warning(
970
+ f'Start of linked periods ({start} not found in periods directly: {flow_system.periods.values}'
971
+ )
972
+ if end not in flow_system.periods.values:
973
+ logger.warning(
974
+ f'End of linked periods ({end} not found in periods directly: {flow_system.periods.values}'
975
+ )
976
+ self.linked_periods = self.compute_linked_periods(start, end, flow_system.periods)
977
+ logger.debug(f'Computed {self.linked_periods=}')
978
+
979
+ self.linked_periods = flow_system.fit_to_model_coords(
980
+ f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario']
981
+ )
982
+ self.fixed_size = flow_system.fit_to_model_coords(
983
+ f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']
984
+ )
985
+
986
+ @property
987
+ def optional(self) -> bool:
988
+ """DEPRECATED: Use 'mandatory' property instead. Returns the opposite of 'mandatory'."""
989
+ import warnings
990
+
991
+ warnings.warn("Property 'optional' is deprecated. Use 'mandatory' instead.", DeprecationWarning, stacklevel=2)
992
+ return not self.mandatory
993
+
994
+ @optional.setter
995
+ def optional(self, value: bool):
996
+ """DEPRECATED: Use 'mandatory' property instead. Sets the opposite of the given value to 'mandatory'."""
997
+ warnings.warn("Property 'optional' is deprecated. Use 'mandatory' instead.", DeprecationWarning, stacklevel=2)
998
+ self.mandatory = not value
242
999
 
243
1000
  @property
244
- def minimum_size(self):
245
- return self.fixed_size if self.fixed_size is not None else self._minimum_size
1001
+ def fix_effects(self) -> PeriodicEffectsUser:
1002
+ """Deprecated property. Use effects_of_investment instead."""
1003
+ warnings.warn(
1004
+ 'The fix_effects property is deprecated. Use effects_of_investment instead.',
1005
+ DeprecationWarning,
1006
+ stacklevel=2,
1007
+ )
1008
+ return self.effects_of_investment
246
1009
 
247
1010
  @property
248
- def maximum_size(self):
249
- return self.fixed_size if self.fixed_size is not None else self._maximum_size
1011
+ def specific_effects(self) -> PeriodicEffectsUser:
1012
+ """Deprecated property. Use effects_of_investment_per_size instead."""
1013
+ warnings.warn(
1014
+ 'The specific_effects property is deprecated. Use effects_of_investment_per_size instead.',
1015
+ DeprecationWarning,
1016
+ stacklevel=2,
1017
+ )
1018
+ return self.effects_of_investment_per_size
1019
+
1020
+ @property
1021
+ def divest_effects(self) -> PeriodicEffectsUser:
1022
+ """Deprecated property. Use effects_of_retirement instead."""
1023
+ warnings.warn(
1024
+ 'The divest_effects property is deprecated. Use effects_of_retirement instead.',
1025
+ DeprecationWarning,
1026
+ stacklevel=2,
1027
+ )
1028
+ return self.effects_of_retirement
1029
+
1030
+ @property
1031
+ def piecewise_effects(self) -> PiecewiseEffects | None:
1032
+ """Deprecated property. Use piecewise_effects_of_investment instead."""
1033
+ warnings.warn(
1034
+ 'The piecewise_effects property is deprecated. Use piecewise_effects_of_investment instead.',
1035
+ DeprecationWarning,
1036
+ stacklevel=2,
1037
+ )
1038
+ return self.piecewise_effects_of_investment
1039
+
1040
+ @property
1041
+ def minimum_or_fixed_size(self) -> PeriodicData:
1042
+ return self.fixed_size if self.fixed_size is not None else self.minimum_size
1043
+
1044
+ @property
1045
+ def maximum_or_fixed_size(self) -> PeriodicData:
1046
+ return self.fixed_size if self.fixed_size is not None else self.maximum_size
1047
+
1048
+ @staticmethod
1049
+ def compute_linked_periods(first_period: int, last_period: int, periods: pd.Index | list[int]) -> xr.DataArray:
1050
+ return xr.DataArray(
1051
+ xr.where(
1052
+ (first_period <= np.array(periods)) & (np.array(periods) <= last_period),
1053
+ 1,
1054
+ 0,
1055
+ ),
1056
+ coords=(pd.Index(periods, name='period'),),
1057
+ ).rename('linked_periods')
250
1058
 
251
1059
 
252
1060
  @register_class_for_io
253
1061
  class OnOffParameters(Interface):
1062
+ """Define operational constraints and effects for binary on/off equipment behavior.
1063
+
1064
+ This class models equipment that operates in discrete states (on/off) rather than
1065
+ continuous operation, capturing realistic operational constraints and associated
1066
+ costs. It handles complex equipment behavior including startup costs, minimum
1067
+ run times, cycling limitations, and maintenance scheduling requirements.
1068
+
1069
+ Key Modeling Capabilities:
1070
+ **Switching Costs**: One-time costs for starting equipment (fuel, wear, labor)
1071
+ **Runtime Constraints**: Minimum and maximum continuous operation periods
1072
+ **Cycling Limits**: Maximum number of starts to prevent excessive wear
1073
+ **Operating Hours**: Total runtime limits and requirements over time horizon
1074
+
1075
+ Typical Equipment Applications:
1076
+ - **Power Plants**: Combined cycle units, steam turbines with startup costs
1077
+ - **Industrial Processes**: Batch reactors, furnaces with thermal cycling
1078
+ - **HVAC Systems**: Chillers, boilers with minimum run times
1079
+ - **Backup Equipment**: Emergency generators, standby systems
1080
+ - **Process Equipment**: Compressors, pumps with operational constraints
1081
+
1082
+ Mathematical Formulation:
1083
+ See the complete mathematical model in the documentation:
1084
+ [OnOffParameters](../user-guide/mathematical-notation/features/OnOffParameters.md)
1085
+
1086
+ Args:
1087
+ effects_per_switch_on: Costs or impacts incurred for each transition from
1088
+ off state (var_on=0) to on state (var_on=1). Represents startup costs,
1089
+ wear and tear, or other switching impacts. Dictionary mapping effect
1090
+ names to values (e.g., {'cost': 500, 'maintenance_hours': 2}).
1091
+ effects_per_running_hour: Ongoing costs or impacts while equipment operates
1092
+ in the on state. Includes fuel costs, labor, consumables, or emissions.
1093
+ Dictionary mapping effect names to hourly values (e.g., {'fuel_cost': 45}).
1094
+ on_hours_total_min: Minimum total operating hours across the entire time horizon.
1095
+ Ensures equipment meets minimum utilization requirements or contractual
1096
+ obligations (e.g., power purchase agreements, maintenance schedules).
1097
+ on_hours_total_max: Maximum total operating hours across the entire time horizon.
1098
+ Limits equipment usage due to maintenance schedules, fuel availability,
1099
+ environmental permits, or equipment lifetime constraints.
1100
+ consecutive_on_hours_min: Minimum continuous operating duration once started.
1101
+ Models minimum run times due to thermal constraints, process stability,
1102
+ or efficiency considerations. Can be time-varying to reflect different
1103
+ constraints across the planning horizon.
1104
+ consecutive_on_hours_max: Maximum continuous operating duration in one campaign.
1105
+ Models mandatory maintenance intervals, process batch sizes, or
1106
+ equipment thermal limits requiring periodic shutdowns.
1107
+ consecutive_off_hours_min: Minimum continuous shutdown duration between operations.
1108
+ Models cooling periods, maintenance requirements, or process constraints
1109
+ that prevent immediate restart after shutdown.
1110
+ consecutive_off_hours_max: Maximum continuous shutdown duration before mandatory
1111
+ restart. Models equipment preservation, process stability, or contractual
1112
+ requirements for minimum activity levels.
1113
+ switch_on_total_max: Maximum number of startup operations across the time horizon.
1114
+ Limits equipment cycling to reduce wear, maintenance costs, or comply
1115
+ with operational constraints (e.g., grid stability requirements).
1116
+ force_switch_on: When True, creates switch-on variables even without explicit
1117
+ switch_on_total_max constraint. Useful for tracking or reporting startup
1118
+ events without enforcing limits.
1119
+
1120
+ Note:
1121
+ **Time Series Boundary Handling**: The final time period constraints for
1122
+ consecutive_on_hours_min/max and consecutive_off_hours_min/max are not
1123
+ enforced, allowing the optimization to end with ongoing campaigns that
1124
+ may be shorter than the specified minimums or longer than maximums.
1125
+
1126
+ Examples:
1127
+ Combined cycle power plant with startup costs and minimum run time:
1128
+
1129
+ ```python
1130
+ power_plant_operation = OnOffParameters(
1131
+ effects_per_switch_on={
1132
+ 'startup_cost': 25000, # €25,000 per startup
1133
+ 'startup_fuel': 150, # GJ natural gas for startup
1134
+ 'startup_time': 4, # Hours to reach full output
1135
+ 'maintenance_impact': 0.1, # Fractional life consumption
1136
+ },
1137
+ effects_per_running_hour={
1138
+ 'fixed_om': 125, # Fixed O&M costs while running
1139
+ 'auxiliary_power': 2.5, # MW parasitic loads
1140
+ },
1141
+ consecutive_on_hours_min=8, # Minimum 8-hour run once started
1142
+ consecutive_off_hours_min=4, # Minimum 4-hour cooling period
1143
+ on_hours_total_max=6000, # Annual operating limit
1144
+ )
1145
+ ```
1146
+
1147
+ Industrial batch process with cycling limits:
1148
+
1149
+ ```python
1150
+ batch_reactor = OnOffParameters(
1151
+ effects_per_switch_on={
1152
+ 'setup_cost': 1500, # Labor and materials for startup
1153
+ 'catalyst_consumption': 5, # kg catalyst per batch
1154
+ 'cleaning_chemicals': 200, # L cleaning solution
1155
+ },
1156
+ effects_per_running_hour={
1157
+ 'steam': 2.5, # t/h process steam
1158
+ 'electricity': 150, # kWh electrical load
1159
+ 'cooling_water': 50, # m³/h cooling water
1160
+ },
1161
+ consecutive_on_hours_min=12, # Minimum batch size (12 hours)
1162
+ consecutive_on_hours_max=24, # Maximum batch size (24 hours)
1163
+ consecutive_off_hours_min=6, # Cleaning and setup time
1164
+ switch_on_total_max=200, # Maximum 200 batches per period
1165
+ on_hours_total_max=4000, # Maximum production time
1166
+ )
1167
+ ```
1168
+
1169
+ HVAC system with thermostat control and maintenance:
1170
+
1171
+ ```python
1172
+ hvac_operation = OnOffParameters(
1173
+ effects_per_switch_on={
1174
+ 'compressor_wear': 0.5, # Hours of compressor life per start
1175
+ 'inrush_current': 15, # kW peak demand on startup
1176
+ },
1177
+ effects_per_running_hour={
1178
+ 'electricity': 25, # kW electrical consumption
1179
+ 'maintenance': 0.12, # €/hour maintenance reserve
1180
+ },
1181
+ consecutive_on_hours_min=1, # Minimum 1-hour run to avoid cycling
1182
+ consecutive_off_hours_min=0.5, # 30-minute minimum off time
1183
+ switch_on_total_max=2000, # Limit cycling for compressor life
1184
+ on_hours_total_min=2000, # Minimum operation for humidity control
1185
+ on_hours_total_max=5000, # Maximum operation for energy budget
1186
+ )
1187
+ ```
1188
+
1189
+ Backup generator with testing and maintenance requirements:
1190
+
1191
+ ```python
1192
+ backup_generator = OnOffParameters(
1193
+ effects_per_switch_on={
1194
+ 'fuel_priming': 50, # L diesel for system priming
1195
+ 'wear_factor': 1.0, # Start cycles impact on maintenance
1196
+ 'testing_labor': 2, # Hours technician time per test
1197
+ },
1198
+ effects_per_running_hour={
1199
+ 'fuel_consumption': 180, # L/h diesel consumption
1200
+ 'emissions_permit': 15, # € emissions allowance cost
1201
+ 'noise_penalty': 25, # € noise compliance cost
1202
+ },
1203
+ consecutive_on_hours_min=0.5, # Minimum test duration (30 min)
1204
+ consecutive_off_hours_max=720, # Maximum 30 days between tests
1205
+ switch_on_total_max=52, # Weekly testing limit
1206
+ on_hours_total_min=26, # Minimum annual testing (0.5h × 52)
1207
+ on_hours_total_max=200, # Maximum runtime (emergencies + tests)
1208
+ )
1209
+ ```
1210
+
1211
+ Peak shaving battery with cycling degradation:
1212
+
1213
+ ```python
1214
+ battery_cycling = OnOffParameters(
1215
+ effects_per_switch_on={
1216
+ 'cycle_degradation': 0.01, # % capacity loss per cycle
1217
+ 'inverter_startup': 0.5, # kWh losses during startup
1218
+ },
1219
+ effects_per_running_hour={
1220
+ 'standby_losses': 2, # kW standby consumption
1221
+ 'cooling': 5, # kW thermal management
1222
+ 'inverter_losses': 8, # kW conversion losses
1223
+ },
1224
+ consecutive_on_hours_min=1, # Minimum discharge duration
1225
+ consecutive_on_hours_max=4, # Maximum continuous discharge
1226
+ consecutive_off_hours_min=1, # Minimum rest between cycles
1227
+ switch_on_total_max=365, # Daily cycling limit
1228
+ force_switch_on=True, # Track all cycling events
1229
+ )
1230
+ ```
1231
+
1232
+ Common Use Cases:
1233
+ - Power generation: Thermal plant cycling, renewable curtailment, grid services
1234
+ - Industrial processes: Batch production, maintenance scheduling, equipment rotation
1235
+ - Buildings: HVAC control, lighting systems, elevator operations
1236
+ - Transportation: Fleet management, charging infrastructure, maintenance windows
1237
+ - Storage systems: Battery cycling, pumped hydro, compressed air systems
1238
+ - Emergency equipment: Backup generators, safety systems, emergency lighting
1239
+
1240
+ """
1241
+
254
1242
  def __init__(
255
1243
  self,
256
- effects_per_switch_on: Optional['EffectValuesUserTimestep'] = None,
257
- effects_per_running_hour: Optional['EffectValuesUserTimestep'] = None,
258
- on_hours_total_min: Optional[ScenarioData] = None,
259
- on_hours_total_max: Optional[ScenarioData] = None,
260
- consecutive_on_hours_min: Optional[TimestepData] = None,
261
- consecutive_on_hours_max: Optional[TimestepData] = None,
262
- consecutive_off_hours_min: Optional[TimestepData] = None,
263
- consecutive_off_hours_max: Optional[TimestepData] = None,
264
- switch_on_total_max: Optional[ScenarioData] = None,
1244
+ effects_per_switch_on: TemporalEffectsUser | None = None,
1245
+ effects_per_running_hour: TemporalEffectsUser | None = None,
1246
+ on_hours_total_min: int | None = None,
1247
+ on_hours_total_max: int | None = None,
1248
+ consecutive_on_hours_min: TemporalDataUser | None = None,
1249
+ consecutive_on_hours_max: TemporalDataUser | None = None,
1250
+ consecutive_off_hours_min: TemporalDataUser | None = None,
1251
+ consecutive_off_hours_max: TemporalDataUser | None = None,
1252
+ switch_on_total_max: int | None = None,
265
1253
  force_switch_on: bool = False,
266
1254
  ):
267
- """
268
- Bundles information about the on and off state of an Element.
269
- If no parameters are given, the default is to create a binary variable for the on state
270
- without further constraints or effects and a variable for the total on hours.
271
-
272
- Args:
273
- effects_per_switch_on: cost of one switch from off (var_on=0) to on (var_on=1),
274
- unit i.g. in Euro
275
- effects_per_running_hour: costs for operating, i.g. in € per hour
276
- on_hours_total_min: min. overall sum of operating hours.
277
- on_hours_total_max: max. overall sum of operating hours.
278
- consecutive_on_hours_min: min sum of operating hours in one piece
279
- (last on-time period of timeseries is not checked and can be shorter)
280
- consecutive_on_hours_max: max sum of operating hours in one piece
281
- consecutive_off_hours_min: min sum of non-operating hours in one piece
282
- (last off-time period of timeseries is not checked and can be shorter)
283
- consecutive_off_hours_max: max sum of non-operating hours in one piece
284
- switch_on_total_max: max nr of switchOn operations
285
- force_switch_on: force creation of switch on variable, even if there is no switch_on_total_max
286
- """
287
- self.effects_per_switch_on: EffectValuesUserTimestep = effects_per_switch_on or {}
288
- self.effects_per_running_hour: EffectValuesUserTimestep = effects_per_running_hour or {}
1255
+ self.effects_per_switch_on: TemporalEffectsUser = (
1256
+ effects_per_switch_on if effects_per_switch_on is not None else {}
1257
+ )
1258
+ self.effects_per_running_hour: TemporalEffectsUser = (
1259
+ effects_per_running_hour if effects_per_running_hour is not None else {}
1260
+ )
289
1261
  self.on_hours_total_min: Scalar = on_hours_total_min
290
1262
  self.on_hours_total_max: Scalar = on_hours_total_max
291
- self.consecutive_on_hours_min: NumericDataTS = consecutive_on_hours_min
292
- self.consecutive_on_hours_max: NumericDataTS = consecutive_on_hours_max
293
- self.consecutive_off_hours_min: NumericDataTS = consecutive_off_hours_min
294
- self.consecutive_off_hours_max: NumericDataTS = consecutive_off_hours_max
1263
+ self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min
1264
+ self.consecutive_on_hours_max: TemporalDataUser = consecutive_on_hours_max
1265
+ self.consecutive_off_hours_min: TemporalDataUser = consecutive_off_hours_min
1266
+ self.consecutive_off_hours_max: TemporalDataUser = consecutive_off_hours_max
295
1267
  self.switch_on_total_max: Scalar = switch_on_total_max
296
1268
  self.force_switch_on: bool = force_switch_on
297
1269
 
298
- def transform_data(self, flow_system: 'FlowSystem', name_prefix: str):
299
- self.effects_per_switch_on = flow_system.create_effect_time_series(
1270
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
1271
+ self.effects_per_switch_on = flow_system.fit_effects_to_model_coords(
300
1272
  name_prefix, self.effects_per_switch_on, 'per_switch_on'
301
1273
  )
302
- self.effects_per_running_hour = flow_system.create_effect_time_series(
1274
+ self.effects_per_running_hour = flow_system.fit_effects_to_model_coords(
303
1275
  name_prefix, self.effects_per_running_hour, 'per_running_hour'
304
1276
  )
305
- self.consecutive_on_hours_min = flow_system.create_time_series(
1277
+ self.consecutive_on_hours_min = flow_system.fit_to_model_coords(
306
1278
  f'{name_prefix}|consecutive_on_hours_min', self.consecutive_on_hours_min
307
1279
  )
308
- self.consecutive_on_hours_max = flow_system.create_time_series(
1280
+ self.consecutive_on_hours_max = flow_system.fit_to_model_coords(
309
1281
  f'{name_prefix}|consecutive_on_hours_max', self.consecutive_on_hours_max
310
1282
  )
311
- self.consecutive_off_hours_min = flow_system.create_time_series(
1283
+ self.consecutive_off_hours_min = flow_system.fit_to_model_coords(
312
1284
  f'{name_prefix}|consecutive_off_hours_min', self.consecutive_off_hours_min
313
1285
  )
314
- self.consecutive_off_hours_max = flow_system.create_time_series(
1286
+ self.consecutive_off_hours_max = flow_system.fit_to_model_coords(
315
1287
  f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max
316
1288
  )
317
- self.on_hours_total_max = flow_system.create_time_series(
318
- f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, has_time_dim=False
1289
+ self.on_hours_total_max = flow_system.fit_to_model_coords(
1290
+ f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, dims=['period', 'scenario']
319
1291
  )
320
- self.on_hours_total_min = flow_system.create_time_series(
321
- f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, has_time_dim=False
1292
+ self.on_hours_total_min = flow_system.fit_to_model_coords(
1293
+ f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, dims=['period', 'scenario']
322
1294
  )
323
- self.switch_on_total_max = flow_system.create_time_series(
324
- f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, has_time_dim=False
1295
+ self.switch_on_total_max = flow_system.fit_to_model_coords(
1296
+ f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['period', 'scenario']
325
1297
  )
326
1298
 
327
1299
  @property
328
1300
  def use_off(self) -> bool:
329
- """Determines wether the OFF Variable is needed or not"""
1301
+ """Proxy: whether OFF variable is required"""
330
1302
  return self.use_consecutive_off_hours
331
1303
 
332
1304
  @property
333
1305
  def use_consecutive_on_hours(self) -> bool:
334
- """Determines wether a Variable for consecutive off hours is needed or not"""
1306
+ """Determines whether a Variable for consecutive on hours is needed or not"""
335
1307
  return any(param is not None for param in [self.consecutive_on_hours_min, self.consecutive_on_hours_max])
336
1308
 
337
1309
  @property
338
1310
  def use_consecutive_off_hours(self) -> bool:
339
- """Determines wether a Variable for consecutive off hours is needed or not"""
1311
+ """Determines whether a Variable for consecutive off hours is needed or not"""
340
1312
  return any(param is not None for param in [self.consecutive_off_hours_min, self.consecutive_off_hours_max])
341
1313
 
342
1314
  @property
343
1315
  def use_switch_on(self) -> bool:
344
- """Determines wether a Variable for SWITCH-ON is needed or not"""
345
- return (
346
- any(
347
- param not in (None, {})
348
- for param in [
349
- self.effects_per_switch_on,
350
- self.switch_on_total_max,
351
- self.on_hours_total_min,
352
- self.on_hours_total_max,
353
- ]
354
- )
355
- or self.force_switch_on
1316
+ """Determines whether a variable for switch_on is needed or not"""
1317
+ if self.force_switch_on:
1318
+ return True
1319
+
1320
+ return any(
1321
+ param is not None and param != {}
1322
+ for param in [
1323
+ self.effects_per_switch_on,
1324
+ self.switch_on_total_max,
1325
+ ]
356
1326
  )