flixopt 2.0.1__py3-none-any.whl → 2.1.1__py3-none-any.whl

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

Potentially problematic release.


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

flixopt/features.py CHANGED
@@ -4,7 +4,7 @@ Features extend the functionality of Elements.
4
4
  """
5
5
 
6
6
  import logging
7
- from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union
7
+ from typing import Dict, List, Optional, Tuple, Union
8
8
 
9
9
  import linopy
10
10
  import numpy as np
@@ -12,7 +12,7 @@ import numpy as np
12
12
  from . import utils
13
13
  from .config import CONFIG
14
14
  from .core import NumericData, Scalar, TimeSeries
15
- from .interface import InvestParameters, OnOffParameters, Piece, Piecewise, PiecewiseConversion, PiecewiseEffects
15
+ from .interface import InvestParameters, OnOffParameters, Piecewise
16
16
  from .structure import Model, SystemModel
17
17
 
18
18
  logger = logging.getLogger('flixopt')
@@ -90,7 +90,7 @@ class InvestmentModel(Model):
90
90
  # share: divest_effects - isInvested * divest_effects
91
91
  self._model.effects.add_share_to_effects(
92
92
  name=self.label_of_element,
93
- expressions={effect: -self.is_invested * factor + factor for effect, factor in fix_effects.items()},
93
+ expressions={effect: -self.is_invested * factor + factor for effect, factor in self.parameters.divest_effects.items()},
94
94
  target='invest',
95
95
  )
96
96
 
@@ -155,7 +155,7 @@ class InvestmentModel(Model):
155
155
  )
156
156
  if self._on_variable is not None:
157
157
  raise ValueError(
158
- f'Flow {self.label} has a fixed relative flow rate and an on_variable.'
158
+ f'Flow {self.label_full} has a fixed relative flow rate and an on_variable.'
159
159
  f'This combination is currently not supported.'
160
160
  )
161
161
  return
@@ -193,53 +193,52 @@ class InvestmentModel(Model):
193
193
  # anmerkung: Glg bei Spezialfall relative_minimum = 0 redundant zu OnOff ??
194
194
 
195
195
 
196
- class OnOffModel(Model):
196
+ class StateModel(Model):
197
197
  """
198
- Class for modeling the on and off state of a variable
199
- If defining_bounds are given, creates sufficient lower bounds
198
+ Handles basic on/off binary states for defining variables
200
199
  """
201
200
 
202
201
  def __init__(
203
202
  self,
204
203
  model: SystemModel,
205
- on_off_parameters: OnOffParameters,
206
204
  label_of_element: str,
207
205
  defining_variables: List[linopy.Variable],
208
206
  defining_bounds: List[Tuple[NumericData, NumericData]],
209
- previous_values: List[Optional[NumericData]],
207
+ previous_values: List[Optional[NumericData]] = None,
208
+ use_off: bool = True,
209
+ on_hours_total_min: Optional[NumericData] = 0,
210
+ on_hours_total_max: Optional[NumericData] = None,
211
+ effects_per_running_hour: Dict[str, NumericData] = None,
210
212
  label: Optional[str] = None,
211
213
  ):
212
214
  """
213
- Constructor for OnOffModel
215
+ Models binary state variables based on a continous variable.
214
216
 
215
217
  Args:
216
- model: Reference to the SystemModel
217
- on_off_parameters: Parameters for the OnOffModel
218
- label_of_element: Label of the Parent
219
- defining_variables: List of Variables that are used to define the OnOffModel
218
+ model: The SystemModel that is used to create the model.
219
+ label_of_element: The label of the parent (Element). Used to construct the full label of the model.
220
+ defining_variables: List of Variables that are used to define the state
220
221
  defining_bounds: List of Tuples, defining the absolute bounds of each defining variable
221
222
  previous_values: List of previous values of the defining variables
223
+ use_off: Whether to use the off state or not
224
+ on_hours_total_min: min. overall sum of operating hours.
225
+ on_hours_total_max: max. overall sum of operating hours.
226
+ effects_per_running_hour: Costs per operating hours
222
227
  label: Label of the OnOffModel
223
228
  """
224
229
  super().__init__(model, label_of_element, label)
225
230
  assert len(defining_variables) == len(defining_bounds), 'Every defining Variable needs bounds to Model OnOff'
226
- self.parameters = on_off_parameters
227
231
  self._defining_variables = defining_variables
228
- # Ensure that no lower bound is below a certain threshold
229
- self._defining_bounds = [(np.maximum(lb, CONFIG.modeling.EPSILON), ub) for lb, ub in defining_bounds]
230
- self._previous_values = previous_values
231
-
232
- self.on: Optional[linopy.Variable] = None
232
+ self._defining_bounds = defining_bounds
233
+ self._previous_values = previous_values or []
234
+ self._on_hours_total_min = on_hours_total_min if on_hours_total_min is not None else 0
235
+ self._on_hours_total_max = on_hours_total_max if on_hours_total_max is not None else np.inf
236
+ self._use_off = use_off
237
+ self._effects_per_running_hour = effects_per_running_hour or {}
238
+
239
+ self.on = None
233
240
  self.total_on_hours: Optional[linopy.Variable] = None
234
-
235
- self.consecutive_on_hours: Optional[linopy.Variable] = None
236
- self.consecutive_off_hours: Optional[linopy.Variable] = None
237
-
238
- self.off: Optional[linopy.Variable] = None
239
-
240
- self.switch_on: Optional[linopy.Variable] = None
241
- self.switch_off: Optional[linopy.Variable] = None
242
- self.switch_on_nr: Optional[linopy.Variable] = None
241
+ self.off = None
243
242
 
244
243
  def do_modeling(self):
245
244
  self.on = self.add(
@@ -253,8 +252,9 @@ class OnOffModel(Model):
253
252
 
254
253
  self.total_on_hours = self.add(
255
254
  self._model.add_variables(
256
- lower=self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0,
257
- upper=self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf,
255
+ lower=self._on_hours_total_min,
256
+ upper=self._on_hours_total_max,
257
+ coords=None,
258
258
  name=f'{self.label_full}|on_hours_total',
259
259
  ),
260
260
  'on_hours_total',
@@ -268,9 +268,10 @@ class OnOffModel(Model):
268
268
  'on_hours_total',
269
269
  )
270
270
 
271
- self._add_on_constraints()
271
+ # Add defining constraints for each variable
272
+ self._add_defining_constraints()
272
273
 
273
- if self.parameters.use_off:
274
+ if self._use_off:
274
275
  self.off = self.add(
275
276
  self._model.add_variables(
276
277
  name=f'{self.label_full}|off',
@@ -280,68 +281,21 @@ class OnOffModel(Model):
280
281
  'off',
281
282
  )
282
283
 
283
- # eq: var_on(t) + var_off(t) = 1
284
+ # Constraint: on + off = 1
284
285
  self.add(self._model.add_constraints(self.on + self.off == 1, name=f'{self.label_full}|off'), 'off')
285
286
 
286
- if self.parameters.use_consecutive_on_hours:
287
- self.consecutive_on_hours = self._get_duration_in_hours(
288
- 'consecutive_on_hours',
289
- self.on,
290
- self.previous_consecutive_on_hours,
291
- self.parameters.consecutive_on_hours_min,
292
- self.parameters.consecutive_on_hours_max,
293
- )
294
-
295
- if self.parameters.use_consecutive_off_hours:
296
- self.consecutive_off_hours = self._get_duration_in_hours(
297
- 'consecutive_off_hours',
298
- self.off,
299
- self.previous_consecutive_off_hours,
300
- self.parameters.consecutive_off_hours_min,
301
- self.parameters.consecutive_off_hours_max,
302
- )
303
-
304
- if self.parameters.use_switch_on:
305
- self.switch_on = self.add(
306
- self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),
307
- 'switch_on',
308
- )
309
-
310
- self.switch_off = self.add(
311
- self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords),
312
- 'switch_off',
313
- )
314
-
315
- self.switch_on_nr = self.add(
316
- self._model.add_variables(
317
- upper=self.parameters.switch_on_total_max
318
- if self.parameters.switch_on_total_max is not None
319
- else np.inf,
320
- name=f'{self.label_full}|switch_on_nr',
321
- ),
322
- 'switch_on_nr',
323
- )
324
-
325
- self._add_switch_constraints()
326
-
327
- self._create_shares()
328
-
329
- def _add_on_constraints(self):
330
- assert self.on is not None, f'On variable of {self.label_full} must be defined to add constraints'
331
- # % Bedingungen 1) und 2) müssen erfüllt sein:
332
-
333
- # % Anmerkung: Falls "abschnittsweise linear" gewählt, dann ist eigentlich nur Bedingung 1) noch notwendig
334
- # % (und dann auch nur wenn erstes Piece bei Q_th=0 beginnt. Dann soll bei Q_th=0 (d.h. die Maschine ist Aus) On = 0 und segment1.onSeg = 0):)
335
- # % Fazit: Wenn kein Performance-Verlust durch mehr Gleichungen, dann egal!
287
+ return self
336
288
 
289
+ def _add_defining_constraints(self):
290
+ """Add constraints that link defining variables to the on state"""
337
291
  nr_of_def_vars = len(self._defining_variables)
338
- assert nr_of_def_vars > 0, 'Achtung: mindestens 1 Flow notwendig'
339
292
 
340
293
  if nr_of_def_vars == 1:
294
+ # Case for a single defining variable
341
295
  def_var = self._defining_variables[0]
342
296
  lb, ub = self._defining_bounds[0]
343
297
 
344
- # eq: On(t) * max(epsilon, lower_bound) <= Q_th(t)
298
+ # Constraint: on * lower_bound <= def_var
345
299
  self.add(
346
300
  self._model.add_constraints(
347
301
  self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1'
@@ -349,20 +303,16 @@ class OnOffModel(Model):
349
303
  'on_con1',
350
304
  )
351
305
 
352
- # eq: Q_th(t) <= Q_th_max * On(t)
306
+ # Constraint: on * upper_bound >= def_var
353
307
  self.add(
354
- self._model.add_constraints(
355
- self.on * np.maximum(CONFIG.modeling.EPSILON, ub) >= def_var, name=f'{self.label_full}|on_con2'
356
- ),
357
- 'on_con2',
308
+ self._model.add_constraints(self.on * ub >= def_var, name=f'{self.label_full}|on_con2'), 'on_con2'
358
309
  )
310
+ else:
311
+ # Case for multiple defining variables
312
+ ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars
313
+ lb = CONFIG.modeling.EPSILON #TODO: Can this be a bigger value? (maybe the smallest bound?)
359
314
 
360
- else: # Bei mehreren Leistungsvariablen:
361
- ub = sum(bound[1] for bound in self._defining_bounds)
362
- lb = CONFIG.modeling.EPSILON
363
-
364
- # When all defining variables are 0, On is 0
365
- # eq: On(t) * Epsilon <= sum(alle Leistungen(t))
315
+ # Constraint: on * epsilon <= sum(all_defining_variables)
366
316
  self.add(
367
317
  self._model.add_constraints(
368
318
  self.on * lb <= sum(self._defining_variables), name=f'{self.label_full}|on_con1'
@@ -370,10 +320,8 @@ class OnOffModel(Model):
370
320
  'on_con1',
371
321
  )
372
322
 
373
- ## sum(alle Leistung) >0 -> On = 1|On=0 -> sum(Leistung)=0
374
- # eq: sum( Leistung(t,i)) - sum(Leistung_max(i)) * On(t) <= 0
375
- # --> damit Gleichungswerte nicht zu groß werden, noch durch nr_of_flows geteilt:
376
- # eq: sum( Leistung(t,i) / nr_of_flows ) - sum(Leistung_max(i)) / nr_of_flows * On(t) <= 0
323
+ # Constraint to ensure all variables are zero when off.
324
+ # Divide by nr_of_def_vars to improve numerical stability (smaller factors)
377
325
  self.add(
378
326
  self._model.add_constraints(
379
327
  self.on * ub >= sum([def_var / nr_of_def_vars for def_var in self._defining_variables]),
@@ -382,282 +330,261 @@ class OnOffModel(Model):
382
330
  'on_con2',
383
331
  )
384
332
 
385
- if np.max(ub) > CONFIG.modeling.BIG_BINARY_BOUND:
386
- logger.warning(
387
- f'In "{self.label_full}", a binary definition was created with a big upper bound '
388
- f'({np.max(ub)}). This can lead to wrong results regarding the on and off variables. '
389
- f'Avoid this warning by reducing the size of {self.label_full} '
390
- f'(or the maximum_size of the corresponding InvestParameters). '
391
- f'If its a Component, you might need to adjust the sizes of all of its flows.'
392
- )
333
+ @property
334
+ def previous_states(self) -> np.ndarray:
335
+ """Computes the previous states {0, 1} of defining variables as a binary array from their previous values."""
336
+ return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON)
393
337
 
394
- def _get_duration_in_hours(
395
- self,
396
- variable_name: str,
397
- binary_variable: linopy.Variable,
398
- previous_duration: Scalar,
399
- minimum_duration: Optional[TimeSeries],
400
- maximum_duration: Optional[TimeSeries],
401
- ) -> linopy.Variable:
402
- """
403
- creates duration variable and adds constraints to a time-series variable to enforce duration limits based on
404
- binary activity.
405
- The minimum duration in the last time step is not restricted.
406
- Previous values before t=0 are not recognised!
338
+ @property
339
+ def previous_on_states(self) -> np.ndarray:
340
+ return self.previous_states
407
341
 
408
- Args:
409
- variable_name: Label for the duration variable to be created.
410
- binary_variable: Time-series binary variable (e.g., [0, 0, 1, 1, 1, 0, ...]) representing activity states.
411
- minimum_duration: Minimum duration the activity must remain active once started.
412
- If None, no minimum duration constraint is applied.
413
- maximum_duration: Maximum duration the activity can remain active.
414
- If None, the maximum duration is set to the total available time.
342
+ @property
343
+ def previous_off_states(self):
344
+ return 1 - self.previous_states
415
345
 
416
- Returns:
417
- The created duration variable representing consecutive active durations.
346
+ @staticmethod
347
+ def compute_previous_states(previous_values: List[NumericData], epsilon: float = 1e-5) -> np.ndarray:
348
+ """Computes the previous states {0, 1} of defining variables as a binary array from their previous values."""
349
+ if not previous_values or all([val is None for val in previous_values]):
350
+ return np.array([0])
418
351
 
419
- Example:
420
- binary_variable: [0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, ...]
421
- duration_in_hours: [0, 0, 1, 2, 3, 4, 0, 1, 2, 3, 0, ...] (only if dt_in_hours=1)
352
+ # Convert to 2D-array and compute binary on/off states
353
+ previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None
354
+ if previous_values.ndim > 1:
355
+ return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int)
422
356
 
423
- Here, duration_in_hours increments while binary_variable is 1. Minimum and maximum durations
424
- can be enforced to constrain how long the activity remains active.
357
+ return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int)
425
358
 
426
- Notes:
427
- - To count consecutive zeros instead of ones, use a transformed binary variable
428
- (e.g., `1 - binary_variable`).
429
- - Constraints ensure the duration variable properly resets or increments based on activity.
430
359
 
431
- Raises:
432
- AssertionError: If the binary_variable is None, indicating the duration constraints cannot be applied.
360
+ class SwitchStateModel(Model):
361
+ """
362
+ Handles switch on/off transitions
363
+ """
433
364
 
434
- """
435
- assert binary_variable is not None, f'Duration Variable of {self.label_full} must be defined to add constraints'
365
+ def __init__(
366
+ self,
367
+ model: SystemModel,
368
+ label_of_element: str,
369
+ state_variable: linopy.Variable,
370
+ previous_state=0,
371
+ switch_on_max: Optional[Scalar] = None,
372
+ label: Optional[str] = None,
373
+ ):
374
+ super().__init__(model, label_of_element, label)
375
+ self._state_variable = state_variable
376
+ self.previous_state = previous_state
377
+ self._switch_on_max = switch_on_max if switch_on_max is not None else np.inf
436
378
 
437
- mega = self._model.hours_per_step.sum() + previous_duration
379
+ self.switch_on = None
380
+ self.switch_off = None
381
+ self.switch_on_nr = None
438
382
 
439
- if maximum_duration is not None:
440
- first_step_max: Scalar = maximum_duration.isel(time=0)
383
+ def do_modeling(self):
384
+ """Create switch variables and constraints"""
441
385
 
442
- if previous_duration + self._model.hours_per_step[0] > first_step_max:
443
- logger.warning(
444
- f'The maximum duration of "{variable_name}" is set to {maximum_duration.active_data}h, '
445
- f'but the consecutive_duration previous to this model is {previous_duration}h. '
446
- f'This forces "{binary_variable.name} = 0" in the first time step '
447
- f'(dt={self._model.hours_per_step[0]}h)!'
448
- )
386
+ # Create switch variables
387
+ self.switch_on = self.add(
388
+ self._model.add_variables(binary=True, name=f'{self.label_full}|switch_on', coords=self._model.coords),
389
+ 'switch_on',
390
+ )
391
+
392
+ self.switch_off = self.add(
393
+ self._model.add_variables(binary=True, name=f'{self.label_full}|switch_off', coords=self._model.coords),
394
+ 'switch_off',
395
+ )
449
396
 
450
- duration_in_hours = self.add(
397
+ # Create count variable for number of switches
398
+ self.switch_on_nr = self.add(
451
399
  self._model.add_variables(
400
+ upper=self._switch_on_max,
452
401
  lower=0,
453
- upper=maximum_duration.active_data if maximum_duration is not None else mega,
454
- coords=self._model.coords,
455
- name=f'{self.label_full}|{variable_name}',
402
+ name=f'{self.label_full}|switch_on_nr',
456
403
  ),
457
- variable_name,
404
+ 'switch_on_nr',
458
405
  )
459
406
 
460
- # 1) eq: duration(t) - On(t) * BIG <= 0
407
+ # Add switch constraints for all entries after the first timestep
461
408
  self.add(
462
409
  self._model.add_constraints(
463
- duration_in_hours <= binary_variable * mega, name=f'{self.label_full}|{variable_name}_con1'
410
+ self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None))
411
+ == self._state_variable.isel(time=slice(1, None)) - self._state_variable.isel(time=slice(None, -1)),
412
+ name=f'{self.label_full}|switch_con',
464
413
  ),
465
- f'{variable_name}_con1',
414
+ 'switch_con',
466
415
  )
467
416
 
468
- # 2a) eq: duration(t) - duration(t-1) <= dt(t)
469
- # on(t)=1 -> duration(t) - duration(t-1) <= dt(t)
470
- # on(t)=0 -> duration(t-1) >= negat. value
417
+ # Initial switch constraint
471
418
  self.add(
472
419
  self._model.add_constraints(
473
- duration_in_hours.isel(time=slice(1, None))
474
- <= duration_in_hours.isel(time=slice(None, -1)) + self._model.hours_per_step.isel(time=slice(None, -1)),
475
- name=f'{self.label_full}|{variable_name}_con2a',
420
+ self.switch_on.isel(time=0) - self.switch_off.isel(time=0)
421
+ == self._state_variable.isel(time=0) - self.previous_state,
422
+ name=f'{self.label_full}|initial_switch_con',
476
423
  ),
477
- f'{variable_name}_con2a',
424
+ 'initial_switch_con',
478
425
  )
479
426
 
480
- # 2b) eq: dt(t) - BIG * ( 1-On(t) ) <= duration(t) - duration(t-1)
481
- # eq: -duration(t) + duration(t-1) + On(t) * BIG <= -dt(t) + BIG
482
- # with BIG = dt_in_hours_total.
483
- # on(t)=1 -> duration(t)- duration(t-1) >= dt(t)
484
- # on(t)=0 -> duration(t)- duration(t-1) >= negat. value
427
+ # Mutual exclusivity constraint
428
+ self.add(
429
+ self._model.add_constraints(self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'),
430
+ 'switch_on_or_off',
431
+ )
485
432
 
433
+ # Total switch-on count constraint
486
434
  self.add(
487
435
  self._model.add_constraints(
488
- duration_in_hours.isel(time=slice(1, None))
489
- >= duration_in_hours.isel(time=slice(None, -1))
490
- + self._model.hours_per_step.isel(time=slice(None, -1))
491
- + (binary_variable.isel(time=slice(1, None)) - 1) * mega,
492
- name=f'{self.label_full}|{variable_name}_con2b',
436
+ self.switch_on_nr == self.switch_on.sum('time'), name=f'{self.label_full}|switch_on_nr'
493
437
  ),
494
- f'{variable_name}_con2b',
438
+ 'switch_on_nr',
495
439
  )
496
440
 
497
- # 3) check minimum_duration before switchOff-step
441
+ return self
498
442
 
499
- if minimum_duration is not None:
500
- # Note: switchOff-step is when: On(t) - On(t+1) == 1
501
- # Note: (last on-time period (with last timestep of period t=n) is not checked and can be shorter)
502
- # Note: (previous values before t=1 are not recognised!)
503
- # eq: duration(t) >= minimum_duration(t) * [On(t) - On(t+1)] for t=1..(n-1)
504
- # eq: -duration(t) + minimum_duration(t) * On(t) - minimum_duration(t) * On(t+1) <= 0
505
- self.add(
506
- self._model.add_constraints(
507
- duration_in_hours
508
- >= (binary_variable.isel(time=slice(None, -1)) - binary_variable.isel(time=slice(1, None)))
509
- * minimum_duration.isel(time=slice(None, -1)),
510
- name=f'{self.label_full}|{variable_name}_minimum_duration',
511
- ),
512
- f'{variable_name}_minimum_duration',
513
- )
514
443
 
515
- if 0 < previous_duration < minimum_duration.isel(time=0):
516
- # Force the first step to be = 1, if the minimum_duration is not reached in previous_values
517
- # Note: Only if the previous consecutive_duration is smaller than the minimum duration
518
- # and the previous_duration is greater 0!
519
- # eq: On(t=0) = 1
520
- self.add(
521
- self._model.add_constraints(
522
- binary_variable.isel(time=0) == 1, name=f'{self.label_full}|{variable_name}_minimum_inital'
523
- ),
524
- f'{variable_name}_minimum_inital',
525
- )
444
+ class ConsecutiveStateModel(Model):
445
+ """
446
+ Handles tracking consecutive durations in a state
447
+ """
526
448
 
527
- # 4) first index:
528
- # eq: duration(t=0)= dt(0) * On(0)
529
- self.add(
530
- self._model.add_constraints(
531
- duration_in_hours.isel(time=0)
532
- == self._model.hours_per_step.isel(time=0) * binary_variable.isel(time=0),
533
- name=f'{self.label_full}|{variable_name}_initial',
534
- ),
535
- f'{variable_name}_initial',
536
- )
449
+ def __init__(
450
+ self,
451
+ model: SystemModel,
452
+ label_of_element: str,
453
+ state_variable: linopy.Variable,
454
+ minimum_duration: Optional[NumericData] = None,
455
+ maximum_duration: Optional[NumericData] = None,
456
+ previous_states: Optional[NumericData] = None,
457
+ label: Optional[str] = None,
458
+ ):
459
+ """
460
+ Model and constraint the consecutive duration of a state variable.
537
461
 
538
- return duration_in_hours
462
+ Args:
463
+ model: The SystemModel that is used to create the model.
464
+ label_of_element: The label of the parent (Element). Used to construct the full label of the model.
465
+ state_variable: The state variable that is used to model the duration. state = {0, 1}
466
+ minimum_duration: The minimum duration of the state variable.
467
+ maximum_duration: The maximum duration of the state variable.
468
+ previous_states: The previous states of the state variable.
469
+ label: The label of the model. Used to construct the full label of the model.
470
+ """
471
+ super().__init__(model, label_of_element, label)
472
+ self._state_variable = state_variable
473
+ self._previous_states = previous_states
474
+ self._minimum_duration = minimum_duration
475
+ self._maximum_duration = maximum_duration
539
476
 
540
- def _add_switch_constraints(self):
541
- assert self.switch_on is not None, f'Switch On Variable of {self.label_full} must be defined to add constraints'
542
- assert self.switch_off is not None, (
543
- f'Switch Off Variable of {self.label_full} must be defined to add constraints'
544
- )
545
- assert self.switch_on_nr is not None, (
546
- f'Nr of Switch On Variable of {self.label_full} must be defined to add constraints'
547
- )
548
- assert self.on is not None, f'On Variable of {self.label_full} must be defined to add constraints'
549
- # % Schaltänderung aus On-Variable
550
- # % SwitchOn(t)-SwitchOff(t) = On(t)-On(t-1)
551
- self.add(
552
- self._model.add_constraints(
553
- self.switch_on.isel(time=slice(1, None)) - self.switch_off.isel(time=slice(1, None))
554
- == self.on.isel(time=slice(1, None)) - self.on.isel(time=slice(None, -1)),
555
- name=f'{self.label_full}|switch_con',
477
+ if isinstance(self._minimum_duration, TimeSeries):
478
+ self._minimum_duration = self._minimum_duration.active_data
479
+ if isinstance(self._maximum_duration, TimeSeries):
480
+ self._maximum_duration = self._maximum_duration.active_data
481
+
482
+ self.duration = None
483
+
484
+ def do_modeling(self):
485
+ """Create consecutive duration variables and constraints"""
486
+ # Get the hours per step
487
+ hours_per_step = self._model.hours_per_step
488
+ mega = hours_per_step.sum('time') + self.previous_duration
489
+
490
+ # Create the duration variable
491
+ self.duration = self.add(
492
+ self._model.add_variables(
493
+ lower=0,
494
+ upper=self._maximum_duration if self._maximum_duration is not None else mega,
495
+ coords=self._model.coords,
496
+ name=f'{self.label_full}|hours',
556
497
  ),
557
- 'switch_con',
498
+ 'hours',
558
499
  )
559
- # Initital switch on
560
- # eq: SwitchOn(t=0)-SwitchOff(t=0) = On(t=0) - On(t=-1)
500
+
501
+ # Add constraints
502
+
503
+ # Upper bound constraint
561
504
  self.add(
562
505
  self._model.add_constraints(
563
- self.switch_on.isel(time=0) - self.switch_off.isel(time=0)
564
- == self.on.isel(time=0) - self.previous_on_values[-1],
565
- name=f'{self.label_full}|initial_switch_con',
506
+ self.duration <= self._state_variable * mega, name=f'{self.label_full}|con1'
566
507
  ),
567
- 'initial_switch_con',
508
+ 'con1',
568
509
  )
569
- ## Entweder SwitchOff oder SwitchOn
570
- # eq: SwitchOn(t) + SwitchOff(t) <= 1.1
510
+
511
+ # Forward constraint
571
512
  self.add(
572
513
  self._model.add_constraints(
573
- self.switch_on + self.switch_off <= 1.1, name=f'{self.label_full}|switch_on_or_off'
514
+ self.duration.isel(time=slice(1, None))
515
+ <= self.duration.isel(time=slice(None, -1)) + hours_per_step.isel(time=slice(None, -1)),
516
+ name=f'{self.label_full}|con2a',
574
517
  ),
575
- 'switch_on_or_off',
518
+ 'con2a',
576
519
  )
577
520
 
578
- ## Anzahl Starts:
579
- # eq: nrSwitchOn = sum(SwitchOn(t))
521
+ # Backward constraint
580
522
  self.add(
581
523
  self._model.add_constraints(
582
- self.switch_on_nr == self.switch_on.sum(), name=f'{self.label_full}|switch_on_nr'
524
+ self.duration.isel(time=slice(1, None))
525
+ >= self.duration.isel(time=slice(None, -1))
526
+ + hours_per_step.isel(time=slice(None, -1))
527
+ + (self._state_variable.isel(time=slice(1, None)) - 1) * mega,
528
+ name=f'{self.label_full}|con2b',
583
529
  ),
584
- 'switch_on_nr',
530
+ 'con2b',
585
531
  )
586
532
 
587
- def _create_shares(self):
588
- # Anfahrkosten:
589
- effects_per_switch_on = self.parameters.effects_per_switch_on
590
- if effects_per_switch_on != {}:
591
- self._model.effects.add_share_to_effects(
592
- name=self.label_of_element,
593
- expressions={effect: self.switch_on * factor for effect, factor in effects_per_switch_on.items()},
594
- target='operation',
595
- )
596
-
597
- # Betriebskosten:
598
- effects_per_running_hour = self.parameters.effects_per_running_hour
599
- if effects_per_running_hour != {}:
600
- self._model.effects.add_share_to_effects(
601
- name=self.label_of_element,
602
- expressions={
603
- effect: self.on * factor * self._model.hours_per_step
604
- for effect, factor in effects_per_running_hour.items()
605
- },
606
- target='operation',
533
+ # Add minimum duration constraints if specified
534
+ if self._minimum_duration is not None:
535
+ self.add(
536
+ self._model.add_constraints(
537
+ self.duration
538
+ >= (
539
+ self._state_variable.isel(time=slice(None, -1)) - self._state_variable.isel(time=slice(1, None))
540
+ )
541
+ * self._minimum_duration.isel(time=slice(None, -1)),
542
+ name=f'{self.label_full}|minimum',
543
+ ),
544
+ 'minimum',
607
545
  )
608
546
 
609
- @property
610
- def previous_on_values(self) -> np.ndarray:
611
- return self.compute_previous_on_states(self._previous_values)
547
+ # Handle initial condition
548
+ if 0 < self.previous_duration < self._minimum_duration.isel(time=0):
549
+ self.add(
550
+ self._model.add_constraints(
551
+ self._state_variable.isel(time=0) == 1, name=f'{self.label_full}|initial_minimum'
552
+ ),
553
+ 'initial_minimum',
554
+ )
612
555
 
613
- @property
614
- def previous_off_values(self) -> np.ndarray:
615
- return 1 - self.previous_on_values
556
+ # Set initial value
557
+ self.add(
558
+ self._model.add_constraints(
559
+ self.duration.isel(time=0) ==
560
+ (hours_per_step.isel(time=0) + self.previous_duration) * self._state_variable.isel(time=0),
561
+ name=f'{self.label_full}|initial',
562
+ ),
563
+ 'initial',
564
+ )
616
565
 
617
- @property
618
- def previous_consecutive_on_hours(self) -> Scalar:
619
- return self.compute_consecutive_duration(self.previous_on_values, self._model.hours_per_step)
566
+ return self
620
567
 
621
568
  @property
622
- def previous_consecutive_off_hours(self) -> Scalar:
623
- return self.compute_consecutive_duration(self.previous_off_values, self._model.hours_per_step)
624
-
625
- @staticmethod
626
- def compute_previous_on_states(previous_values: List[Optional[NumericData]], epsilon: float = 1e-5) -> np.ndarray:
627
- """
628
- Computes the previous 'on' states {0, 1} of defining variables as a binary array from their previous values.
629
-
630
- Args:
631
- previous_values: List of previous values of the defining variables. In Range [0, inf] or None (ignored)
632
- epsilon: Tolerance for equality to determine "off" state, default is 1e-5.
633
-
634
- Returns:
635
- A binary array (0 and 1) indicating the previous on/off states of the variables.
636
- Returns `array([0])` if no previous values are available.
637
- """
638
-
639
- if not previous_values or all([val is None for val in previous_values]):
640
- return np.array([0])
641
- else: # Convert to 2D-array and compute binary on/off states
642
- previous_values = np.array([values for values in previous_values if values is not None]) # Filter out None
643
- if previous_values.ndim > 1:
644
- return np.any(~np.isclose(previous_values, 0, atol=epsilon), axis=0).astype(int)
645
- else:
646
- return (~np.isclose(previous_values, 0, atol=epsilon)).astype(int)
569
+ def previous_duration(self) -> Scalar:
570
+ """Computes the previous duration of the state variable"""
571
+ #TODO: Allow for other/dynamic timestep resolutions
572
+ return ConsecutiveStateModel.compute_consecutive_hours_in_state(
573
+ self._previous_states, self._model.hours_per_step.isel(time=0).item()
574
+ )
647
575
 
648
576
  @staticmethod
649
- def compute_consecutive_duration(
577
+ def compute_consecutive_hours_in_state(
650
578
  binary_values: NumericData, hours_per_timestep: Union[int, float, np.ndarray]
651
579
  ) -> Scalar:
652
580
  """
653
- Computes the final consecutive duration in State 'on' (=1) in hours, from a binary.
654
-
655
- hours_per_timestep is handled in a way, that maximizes compatability.
656
- Its length must only be as long as the last consecutive duration in binary_values.
581
+ Computes the final consecutive duration in state 'on' (=1) in hours, from a binary array.
657
582
 
658
583
  Args:
659
584
  binary_values: An int or 1D binary array containing only `0`s and `1`s.
660
585
  hours_per_timestep: The duration of each timestep in hours.
586
+ If a scalar is provided, it is used for all timesteps.
587
+ If an array is provided, it must be as long as the last consecutive duration in binary_values.
661
588
 
662
589
  Returns:
663
590
  The duration of the binary variable in hours.
@@ -672,28 +599,177 @@ class OnOffModel(Model):
672
599
  elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep):
673
600
  return binary_values * hours_per_timestep[-1]
674
601
 
675
- # Find the indexes where value=`0` in a 1D-array
676
- zero_indices = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0]
677
- length_of_last_duration = zero_indices[-1] + 1 if zero_indices.size > 0 else len(binary_values)
678
-
679
- if not np.isscalar(binary_values) and np.isscalar(hours_per_timestep):
680
- return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep)
602
+ if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON):
603
+ return 0
681
604
 
682
- elif not np.isscalar(binary_values) and not np.isscalar(hours_per_timestep):
683
- if length_of_last_duration > len(hours_per_timestep): # check that lengths are compatible
684
- raise TypeError(
685
- f'When trying to calculate the consecutive duration, the length of the last duration '
686
- f'({len(length_of_last_duration)}) is longer than the hours_per_timestep ({len(hours_per_timestep)}), '
687
- f'as {binary_values=}'
688
- )
689
- return np.sum(binary_values[-length_of_last_duration:] * hours_per_timestep[-length_of_last_duration:])
605
+ if np.isscalar(hours_per_timestep):
606
+ hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep
607
+ hours_per_timestep: np.ndarray
690
608
 
609
+ indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0]
610
+ if len(indexes_with_zero_values) == 0:
611
+ nr_of_indexes_with_consecutive_ones = len(binary_values)
691
612
  else:
692
- raise Exception(
693
- f'Unexpected state reached in function get_consecutive_duration(). binary_values={binary_values}; '
694
- f'hours_per_timestep={hours_per_timestep}'
613
+ nr_of_indexes_with_consecutive_ones = len(binary_values) - indexes_with_zero_values[-1] - 1
614
+
615
+ if len(hours_per_timestep) < nr_of_indexes_with_consecutive_ones:
616
+ raise ValueError(
617
+ f'When trying to calculate the consecutive duration, the length of the last duration '
618
+ f'({len(nr_of_indexes_with_consecutive_ones)}) is longer than the provided hours_per_timestep ({len(hours_per_timestep)}), '
619
+ f'as {binary_values=}'
695
620
  )
696
621
 
622
+ return np.sum(binary_values[-nr_of_indexes_with_consecutive_ones:] * hours_per_timestep[-nr_of_indexes_with_consecutive_ones:])
623
+
624
+
625
+ class OnOffModel(Model):
626
+ """
627
+ Class for modeling the on and off state of a variable
628
+ Uses component models to create a modular implementation
629
+ """
630
+
631
+ def __init__(
632
+ self,
633
+ model: SystemModel,
634
+ on_off_parameters: OnOffParameters,
635
+ label_of_element: str,
636
+ defining_variables: List[linopy.Variable],
637
+ defining_bounds: List[Tuple[NumericData, NumericData]],
638
+ previous_values: List[Optional[NumericData]],
639
+ label: Optional[str] = None,
640
+ ):
641
+ """
642
+ Constructor for OnOffModel
643
+
644
+ Args:
645
+ model: Reference to the SystemModel
646
+ on_off_parameters: Parameters for the OnOffModel
647
+ label_of_element: Label of the Parent
648
+ defining_variables: List of Variables that are used to define the OnOffModel
649
+ defining_bounds: List of Tuples, defining the absolute bounds of each defining variable
650
+ previous_values: List of previous values of the defining variables
651
+ label: Label of the OnOffModel
652
+ """
653
+ super().__init__(model, label_of_element, label)
654
+ self.parameters = on_off_parameters
655
+ self._defining_variables = defining_variables
656
+ self._defining_bounds = defining_bounds
657
+ self._previous_values = previous_values
658
+
659
+ self.state_model = None
660
+ self.switch_state_model = None
661
+ self.consecutive_on_model = None
662
+ self.consecutive_off_model = None
663
+
664
+ def do_modeling(self):
665
+ """Create all variables and constraints for the OnOffModel"""
666
+
667
+ # Create binary state component
668
+ self.state_model = StateModel(
669
+ model=self._model,
670
+ label_of_element=self.label_of_element,
671
+ defining_variables=self._defining_variables,
672
+ defining_bounds=self._defining_bounds,
673
+ previous_values=self._previous_values,
674
+ use_off=self.parameters.use_off,
675
+ on_hours_total_min=self.parameters.on_hours_total_min,
676
+ on_hours_total_max=self.parameters.on_hours_total_max,
677
+ effects_per_running_hour=self.parameters.effects_per_running_hour,
678
+ )
679
+ self.add(self.state_model)
680
+ self.state_model.do_modeling()
681
+
682
+ # Create switch component if needed
683
+ if self.parameters.use_switch_on:
684
+ self.switch_state_model = SwitchStateModel(
685
+ model=self._model,
686
+ label_of_element=self.label_of_element,
687
+ state_variable=self.state_model.on,
688
+ previous_state=self.state_model.previous_on_states[-1],
689
+ switch_on_max=self.parameters.switch_on_total_max,
690
+ )
691
+ self.add(self.switch_state_model)
692
+ self.switch_state_model.do_modeling()
693
+
694
+ # Create consecutive on hours component if needed
695
+ if self.parameters.use_consecutive_on_hours:
696
+ self.consecutive_on_model = ConsecutiveStateModel(
697
+ model=self._model,
698
+ label_of_element=self.label_of_element,
699
+ state_variable=self.state_model.on,
700
+ minimum_duration=self.parameters.consecutive_on_hours_min,
701
+ maximum_duration=self.parameters.consecutive_on_hours_max,
702
+ previous_states=self.state_model.previous_on_states,
703
+ label='ConsecutiveOn',
704
+ )
705
+ self.add(self.consecutive_on_model)
706
+ self.consecutive_on_model.do_modeling()
707
+
708
+ # Create consecutive off hours component if needed
709
+ if self.parameters.use_consecutive_off_hours:
710
+ self.consecutive_off_model = ConsecutiveStateModel(
711
+ model=self._model,
712
+ label_of_element=self.label_of_element,
713
+ state_variable=self.state_model.off,
714
+ minimum_duration=self.parameters.consecutive_off_hours_min,
715
+ maximum_duration=self.parameters.consecutive_off_hours_max,
716
+ previous_states=self.state_model.previous_off_states,
717
+ label='ConsecutiveOff',
718
+ )
719
+ self.add(self.consecutive_off_model)
720
+ self.consecutive_off_model.do_modeling()
721
+
722
+ self._create_shares()
723
+
724
+ def _create_shares(self):
725
+ if self.parameters.effects_per_running_hour:
726
+ self._model.effects.add_share_to_effects(
727
+ name=self.label_of_element,
728
+ expressions={
729
+ effect: self.state_model.on * factor * self._model.hours_per_step
730
+ for effect, factor in self.parameters.effects_per_running_hour.items()
731
+ },
732
+ target='operation',
733
+ )
734
+
735
+ if self.parameters.effects_per_switch_on:
736
+ self._model.effects.add_share_to_effects(
737
+ name=self.label_of_element,
738
+ expressions={
739
+ effect: self.switch_state_model.switch_on * factor
740
+ for effect, factor in self.parameters.effects_per_switch_on.items()
741
+ },
742
+ target='operation',
743
+ )
744
+
745
+ @property
746
+ def on(self):
747
+ return self.state_model.on
748
+
749
+ @property
750
+ def off(self):
751
+ return self.state_model.off
752
+
753
+ @property
754
+ def switch_on(self):
755
+ return self.switch_state_model.switch_on
756
+
757
+ @property
758
+ def switch_off(self):
759
+ return self.switch_state_model.switch_off
760
+
761
+ @property
762
+ def switch_on_nr(self):
763
+ return self.switch_state_model.switch_on_nr
764
+
765
+ @property
766
+ def consecutive_on_hours(self):
767
+ return self.consecutive_on_model.duration
768
+
769
+ @property
770
+ def consecutive_off_hours(self):
771
+ return self.consecutive_off_model.duration
772
+
697
773
 
698
774
  class PieceModel(Model):
699
775
  """Class for modeling a linear piece of one or more variables in parallel"""
@@ -755,10 +831,10 @@ class PiecewiseModel(Model):
755
831
  self,
756
832
  model: SystemModel,
757
833
  label_of_element: str,
758
- label: str,
759
834
  piecewise_variables: Dict[str, Piecewise],
760
835
  zero_point: Optional[Union[bool, linopy.Variable]],
761
836
  as_time_series: bool,
837
+ label: str = '',
762
838
  ):
763
839
  """
764
840
  Modeling a Piecewise relation between miultiple variables.
@@ -807,9 +883,9 @@ class PiecewiseModel(Model):
807
883
  )
808
884
  ]
809
885
  ),
810
- name=f'{self.label_full}|{var_name}_lambda',
886
+ name=f'{self.label_full}|{var_name}|lambda',
811
887
  ),
812
- f'{var_name}_lambda',
888
+ f'{var_name}|lambda',
813
889
  )
814
890
 
815
891
  # a) eq: Segment1.onSeg(t) + Segment2.onSeg(t) + ... = 1 Aufenthalt nur in Segmenten erlaubt
@@ -831,9 +907,9 @@ class PiecewiseModel(Model):
831
907
  self.add(
832
908
  self._model.add_constraints(
833
909
  sum([piece.inside_piece for piece in self.pieces]) <= rhs,
834
- name=f'{self.label_full}|{variable.name}_single_segment',
910
+ name=f'{self.label_full}|{variable.name}|single_segment',
835
911
  ),
836
- 'single_segment',
912
+ f'{var_name}|single_segment',
837
913
  )
838
914
 
839
915
 
@@ -982,10 +1058,10 @@ class PiecewiseEffectsModel(Model):
982
1058
  PiecewiseModel(
983
1059
  model=self._model,
984
1060
  label_of_element=self.label_of_element,
985
- label=f'{self.label_full}|PiecewiseModel',
986
1061
  piecewise_variables=piecewise_variables,
987
1062
  zero_point=self._zero_point,
988
1063
  as_time_series=False,
1064
+ label='PiecewiseEffects',
989
1065
  )
990
1066
  )
991
1067