flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. flixopt/__init__.py +57 -49
  2. flixopt/carrier.py +159 -0
  3. flixopt/clustering/__init__.py +51 -0
  4. flixopt/clustering/base.py +1746 -0
  5. flixopt/clustering/intercluster_helpers.py +201 -0
  6. flixopt/color_processing.py +372 -0
  7. flixopt/comparison.py +819 -0
  8. flixopt/components.py +848 -270
  9. flixopt/config.py +853 -496
  10. flixopt/core.py +111 -98
  11. flixopt/effects.py +294 -284
  12. flixopt/elements.py +484 -223
  13. flixopt/features.py +220 -118
  14. flixopt/flow_system.py +2026 -389
  15. flixopt/interface.py +504 -286
  16. flixopt/io.py +1718 -55
  17. flixopt/linear_converters.py +291 -230
  18. flixopt/modeling.py +304 -181
  19. flixopt/network_app.py +2 -1
  20. flixopt/optimization.py +788 -0
  21. flixopt/optimize_accessor.py +373 -0
  22. flixopt/plot_result.py +143 -0
  23. flixopt/plotting.py +1177 -1034
  24. flixopt/results.py +1331 -372
  25. flixopt/solvers.py +12 -4
  26. flixopt/statistics_accessor.py +2412 -0
  27. flixopt/stats_accessor.py +75 -0
  28. flixopt/structure.py +954 -120
  29. flixopt/topology_accessor.py +676 -0
  30. flixopt/transform_accessor.py +2277 -0
  31. flixopt/types.py +120 -0
  32. flixopt-6.0.0rc7.dist-info/METADATA +290 -0
  33. flixopt-6.0.0rc7.dist-info/RECORD +36 -0
  34. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
  35. flixopt/aggregation.py +0 -382
  36. flixopt/calculation.py +0 -672
  37. flixopt/commons.py +0 -51
  38. flixopt/utils.py +0 -86
  39. flixopt-3.0.1.dist-info/METADATA +0 -209
  40. flixopt-3.0.1.dist-info/RECORD +0 -26
  41. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
  42. {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/top_level.txt +0 -0
flixopt/components.py CHANGED
@@ -4,6 +4,7 @@ This module contains the basic components of the flixopt framework.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import functools
7
8
  import logging
8
9
  import warnings
9
10
  from typing import TYPE_CHECKING, Literal
@@ -11,17 +12,18 @@ from typing import TYPE_CHECKING, Literal
11
12
  import numpy as np
12
13
  import xarray as xr
13
14
 
14
- from .core import PeriodicDataUser, PlausibilityError, TemporalData, TemporalDataUser
15
+ from . import io as fx_io
16
+ from .core import PlausibilityError
15
17
  from .elements import Component, ComponentModel, Flow
16
18
  from .features import InvestmentModel, PiecewiseModel
17
- from .interface import InvestParameters, OnOffParameters, PiecewiseConversion
18
- from .modeling import BoundingPatterns
19
- from .structure import FlowSystemModel, register_class_for_io
19
+ from .interface import InvestParameters, PiecewiseConversion, StatusParameters
20
+ from .modeling import BoundingPatterns, _scalar_safe_isel, _scalar_safe_isel_drop, _scalar_safe_reduce
21
+ from .structure import FlowSystemModel, VariableCategory, register_class_for_io
20
22
 
21
23
  if TYPE_CHECKING:
22
24
  import linopy
23
25
 
24
- from .flow_system import FlowSystem
26
+ from .types import Numeric_PS, Numeric_TPS
25
27
 
26
28
  logger = logging.getLogger('flixopt')
27
29
 
@@ -41,16 +43,15 @@ class LinearConverter(Component):
41
43
  behavior approximated through piecewise linear segments.
42
44
 
43
45
  Mathematical Formulation:
44
- See the complete mathematical model in the documentation:
45
- [LinearConverter](../user-guide/mathematical-notation/elements/LinearConverter.md)
46
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/LinearConverter/>
46
47
 
47
48
  Args:
48
49
  label: The label of the Element. Used to identify it in the FlowSystem.
49
50
  inputs: list of input Flows that feed into the converter.
50
51
  outputs: list of output Flows that are produced by the converter.
51
- on_off_parameters: Information about on and off state of LinearConverter.
52
- Component is On/Off if all connected Flows are On/Off. This induces an
53
- On-Variable (binary) in all Flows! If possible, use OnOffParameters in a
52
+ status_parameters: Information about active and inactive state of LinearConverter.
53
+ Component is active/inactive if all connected Flows are active/inactive. This induces a
54
+ status variable (binary) in all Flows! If possible, use StatusParameters in a
54
55
  single Flow instead to keep the number of binary variables low.
55
56
  conversion_factors: Linear relationships between flows expressed as a list of
56
57
  dictionaries. Each dictionary maps flow labels to their coefficients in one
@@ -160,17 +161,20 @@ class LinearConverter(Component):
160
161
 
161
162
  """
162
163
 
164
+ submodel: LinearConverterModel | None
165
+
163
166
  def __init__(
164
167
  self,
165
168
  label: str,
166
169
  inputs: list[Flow],
167
170
  outputs: list[Flow],
168
- on_off_parameters: OnOffParameters | None = None,
169
- conversion_factors: list[dict[str, TemporalDataUser]] | None = None,
171
+ status_parameters: StatusParameters | None = None,
172
+ conversion_factors: list[dict[str, Numeric_TPS]] | None = None,
170
173
  piecewise_conversion: PiecewiseConversion | None = None,
171
174
  meta_data: dict | None = None,
175
+ color: str | None = None,
172
176
  ):
173
- super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data)
177
+ super().__init__(label, inputs, outputs, status_parameters, meta_data=meta_data, color=color)
174
178
  self.conversion_factors = conversion_factors or []
175
179
  self.piecewise_conversion = piecewise_conversion
176
180
 
@@ -179,6 +183,12 @@ class LinearConverter(Component):
179
183
  self.submodel = LinearConverterModel(model, self)
180
184
  return self.submodel
181
185
 
186
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
187
+ """Propagate flow_system reference to parent Component and piecewise_conversion."""
188
+ super().link_to_flow_system(flow_system, prefix)
189
+ if self.piecewise_conversion is not None:
190
+ self.piecewise_conversion.link_to_flow_system(flow_system, self._sub_prefix('PiecewiseConversion'))
191
+
182
192
  def _plausibility_checks(self) -> None:
183
193
  super()._plausibility_checks()
184
194
  if not self.conversion_factors and not self.piecewise_conversion:
@@ -209,23 +219,22 @@ class LinearConverter(Component):
209
219
  f'({flow.label_full}).'
210
220
  )
211
221
 
212
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
213
- prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
214
- super().transform_data(flow_system, prefix)
222
+ def transform_data(self) -> None:
223
+ super().transform_data()
215
224
  if self.conversion_factors:
216
- self.conversion_factors = self._transform_conversion_factors(flow_system)
225
+ self.conversion_factors = self._transform_conversion_factors()
217
226
  if self.piecewise_conversion:
218
227
  self.piecewise_conversion.has_time_dim = True
219
- self.piecewise_conversion.transform_data(flow_system, f'{prefix}|PiecewiseConversion')
228
+ self.piecewise_conversion.transform_data()
220
229
 
221
- def _transform_conversion_factors(self, flow_system: FlowSystem) -> list[dict[str, xr.DataArray]]:
230
+ def _transform_conversion_factors(self) -> list[dict[str, xr.DataArray]]:
222
231
  """Converts all conversion factors to internal datatypes"""
223
232
  list_of_conversion_factors = []
224
233
  for idx, conversion_factor in enumerate(self.conversion_factors):
225
234
  transformed_dict = {}
226
235
  for flow, values in conversion_factor.items():
227
236
  # TODO: Might be better to use the label of the component instead of the flow
228
- ts = flow_system.fit_to_model_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values)
237
+ ts = self._fit_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values)
229
238
  if ts is None:
230
239
  raise PlausibilityError(f'{self.label_full}: conversion factor for flow "{flow}" must not be None')
231
240
  transformed_dict[flow] = ts
@@ -252,28 +261,19 @@ class Storage(Component):
252
261
  and investment-optimized storage systems with comprehensive techno-economic modeling.
253
262
 
254
263
  Mathematical Formulation:
255
- See the complete mathematical model in the documentation:
256
- [Storage](../user-guide/mathematical-notation/elements/Storage.md)
257
-
258
- - Equation (1): Charge state bounds
259
- - Equation (3): Storage balance (charge state evolution)
260
-
261
- Variable Mapping:
262
- - ``capacity_in_flow_hours`` → C (storage capacity)
263
- - ``charge_state`` → c(t_i) (state of charge at time t_i)
264
- - ``relative_loss_per_hour`` → ċ_rel,loss (self-discharge rate)
265
- - ``eta_charge`` → η_in (charging efficiency)
266
- - ``eta_discharge`` → η_out (discharging efficiency)
264
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/Storage/>
267
265
 
268
266
  Args:
269
267
  label: Element identifier used in the FlowSystem.
270
268
  charging: Incoming flow for loading the storage.
271
269
  discharging: Outgoing flow for unloading the storage.
272
270
  capacity_in_flow_hours: Storage capacity in flow-hours (kWh, m³, kg).
273
- Scalar for fixed size or InvestParameters for optimization.
271
+ Scalar for fixed size, InvestParameters for optimization, or None (unbounded).
272
+ Default: None (unbounded capacity). When using InvestParameters,
273
+ maximum_size (or fixed_size) must be explicitly set for proper model scaling.
274
274
  relative_minimum_charge_state: Minimum charge state (0-1). Default: 0.
275
275
  relative_maximum_charge_state: Maximum charge state (0-1). Default: 1.
276
- initial_charge_state: Charge at start. Numeric or 'lastValueOfSim'. Default: 0.
276
+ initial_charge_state: Charge at start. Numeric, 'equals_final', or None (free). Default: 0.
277
277
  minimal_final_charge_state: Minimum absolute charge required at end (optional).
278
278
  maximal_final_charge_state: Maximum absolute charge allowed at end (optional).
279
279
  relative_minimum_final_charge_state: Minimum relative charge at end.
@@ -285,6 +285,21 @@ class Storage(Component):
285
285
  relative_loss_per_hour: Self-discharge per hour (0-0.1). Default: 0.
286
286
  prevent_simultaneous_charge_and_discharge: Prevent charging and discharging
287
287
  simultaneously. Adds binary variables. Default: True.
288
+ cluster_mode: How this storage is treated during clustering optimization.
289
+ Only relevant when using ``transform.cluster()``. Options:
290
+
291
+ - ``'independent'``: Clusters are fully decoupled. No constraints between
292
+ clusters, each cluster has free start/end SOC. Fast but ignores
293
+ seasonal storage value.
294
+ - ``'cyclic'``: Each cluster is self-contained. The SOC at the start of
295
+ each cluster equals its end (cluster returns to initial state).
296
+ Good for "average day" modeling.
297
+ - ``'intercluster'``: Link storage state across the original timeline using
298
+ SOC boundary variables (Kotzur et al. approach). Properly values
299
+ seasonal storage patterns. Overall SOC can drift.
300
+ - ``'intercluster_cyclic'`` (default): Like 'intercluster' but also enforces
301
+ that overall SOC returns to initial state (yearly cyclic).
302
+
288
303
  meta_data: Additional information stored in results. Python native types only.
289
304
 
290
305
  Examples:
@@ -337,7 +352,7 @@ class Storage(Component):
337
352
  ),
338
353
  eta_charge=0.85, # Pumping efficiency
339
354
  eta_discharge=0.90, # Turbine efficiency
340
- initial_charge_state='lastValueOfSim', # Ensuring no deficit compared to start
355
+ initial_charge_state='equals_final', # Ensuring no deficit compared to start
341
356
  relative_loss_per_hour=0.0001, # Minimal evaporation
342
357
  )
343
358
  ```
@@ -371,30 +386,39 @@ class Storage(Component):
371
386
  variables enforce mutual exclusivity, increasing solution time but preventing unrealistic
372
387
  simultaneous charging and discharging.
373
388
 
389
+ **Unbounded capacity**: When capacity_in_flow_hours is None (default), the storage has
390
+ unlimited capacity. Note that prevent_simultaneous_charge_and_discharge requires the
391
+ charging and discharging flows to have explicit sizes. Use prevent_simultaneous_charge_and_discharge=False
392
+ with unbounded storages, or set flow sizes explicitly.
393
+
374
394
  **Units**: Flow rates and charge states are related by the concept of 'flow hours' (=flow_rate * time).
375
395
  With flow rates in kW, the charge state is therefore (usually) kWh.
376
396
  With flow rates in m3/h, the charge state is therefore in m3.
377
397
  """
378
398
 
399
+ submodel: StorageModel | None
400
+
379
401
  def __init__(
380
402
  self,
381
403
  label: str,
382
404
  charging: Flow,
383
405
  discharging: Flow,
384
- capacity_in_flow_hours: PeriodicDataUser | InvestParameters,
385
- relative_minimum_charge_state: TemporalDataUser = 0,
386
- relative_maximum_charge_state: TemporalDataUser = 1,
387
- initial_charge_state: PeriodicDataUser | Literal['lastValueOfSim'] = 0,
388
- minimal_final_charge_state: PeriodicDataUser | None = None,
389
- maximal_final_charge_state: PeriodicDataUser | None = None,
390
- relative_minimum_final_charge_state: PeriodicDataUser | None = None,
391
- relative_maximum_final_charge_state: PeriodicDataUser | None = None,
392
- eta_charge: TemporalDataUser = 1,
393
- eta_discharge: TemporalDataUser = 1,
394
- relative_loss_per_hour: TemporalDataUser = 0,
406
+ capacity_in_flow_hours: Numeric_PS | InvestParameters | None = None,
407
+ relative_minimum_charge_state: Numeric_TPS = 0,
408
+ relative_maximum_charge_state: Numeric_TPS = 1,
409
+ initial_charge_state: Numeric_PS | Literal['equals_final'] | None = 0,
410
+ minimal_final_charge_state: Numeric_PS | None = None,
411
+ maximal_final_charge_state: Numeric_PS | None = None,
412
+ relative_minimum_final_charge_state: Numeric_PS | None = None,
413
+ relative_maximum_final_charge_state: Numeric_PS | None = None,
414
+ eta_charge: Numeric_TPS = 1,
415
+ eta_discharge: Numeric_TPS = 1,
416
+ relative_loss_per_hour: Numeric_TPS = 0,
395
417
  prevent_simultaneous_charge_and_discharge: bool = True,
396
418
  balanced: bool = False,
419
+ cluster_mode: Literal['independent', 'cyclic', 'intercluster', 'intercluster_cyclic'] = 'intercluster_cyclic',
397
420
  meta_data: dict | None = None,
421
+ color: str | None = None,
398
422
  ):
399
423
  # TODO: fixed_relative_chargeState implementieren
400
424
  super().__init__(
@@ -403,13 +427,14 @@ class Storage(Component):
403
427
  outputs=[discharging],
404
428
  prevent_simultaneous_flows=[charging, discharging] if prevent_simultaneous_charge_and_discharge else None,
405
429
  meta_data=meta_data,
430
+ color=color,
406
431
  )
407
432
 
408
433
  self.charging = charging
409
434
  self.discharging = discharging
410
435
  self.capacity_in_flow_hours = capacity_in_flow_hours
411
- self.relative_minimum_charge_state: TemporalDataUser = relative_minimum_charge_state
412
- self.relative_maximum_charge_state: TemporalDataUser = relative_maximum_charge_state
436
+ self.relative_minimum_charge_state: Numeric_TPS = relative_minimum_charge_state
437
+ self.relative_maximum_charge_state: Numeric_TPS = relative_maximum_charge_state
413
438
 
414
439
  self.relative_minimum_final_charge_state = relative_minimum_final_charge_state
415
440
  self.relative_maximum_final_charge_state = relative_maximum_final_charge_state
@@ -418,58 +443,86 @@ class Storage(Component):
418
443
  self.minimal_final_charge_state = minimal_final_charge_state
419
444
  self.maximal_final_charge_state = maximal_final_charge_state
420
445
 
421
- self.eta_charge: TemporalDataUser = eta_charge
422
- self.eta_discharge: TemporalDataUser = eta_discharge
423
- self.relative_loss_per_hour: TemporalDataUser = relative_loss_per_hour
446
+ self.eta_charge: Numeric_TPS = eta_charge
447
+ self.eta_discharge: Numeric_TPS = eta_discharge
448
+ self.relative_loss_per_hour: Numeric_TPS = relative_loss_per_hour
424
449
  self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge
425
450
  self.balanced = balanced
451
+ self.cluster_mode = cluster_mode
426
452
 
427
453
  def create_model(self, model: FlowSystemModel) -> StorageModel:
454
+ """Create the appropriate storage model based on cluster_mode and flow system state.
455
+
456
+ For intercluster modes ('intercluster', 'intercluster_cyclic'), uses
457
+ :class:`InterclusterStorageModel` which implements S-N linking.
458
+ For other modes, uses the base :class:`StorageModel`.
459
+
460
+ Args:
461
+ model: The FlowSystemModel to add constraints to.
462
+
463
+ Returns:
464
+ StorageModel or InterclusterStorageModel instance.
465
+ """
428
466
  self._plausibility_checks()
429
- self.submodel = StorageModel(model, self)
467
+
468
+ # Use InterclusterStorageModel for intercluster modes when clustering is active
469
+ clustering = model.flow_system.clustering
470
+ is_intercluster = clustering is not None and self.cluster_mode in (
471
+ 'intercluster',
472
+ 'intercluster_cyclic',
473
+ )
474
+
475
+ if is_intercluster:
476
+ self.submodel = InterclusterStorageModel(model, self)
477
+ else:
478
+ self.submodel = StorageModel(model, self)
479
+
430
480
  return self.submodel
431
481
 
432
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
433
- prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
434
- super().transform_data(flow_system, prefix)
435
- self.relative_minimum_charge_state = flow_system.fit_to_model_coords(
436
- f'{prefix}|relative_minimum_charge_state',
437
- self.relative_minimum_charge_state,
482
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
483
+ """Propagate flow_system reference to parent Component and capacity_in_flow_hours if it's InvestParameters."""
484
+ super().link_to_flow_system(flow_system, prefix)
485
+ if isinstance(self.capacity_in_flow_hours, InvestParameters):
486
+ self.capacity_in_flow_hours.link_to_flow_system(flow_system, self._sub_prefix('InvestParameters'))
487
+
488
+ def transform_data(self) -> None:
489
+ super().transform_data()
490
+ self.relative_minimum_charge_state = self._fit_coords(
491
+ f'{self.prefix}|relative_minimum_charge_state', self.relative_minimum_charge_state
438
492
  )
439
- self.relative_maximum_charge_state = flow_system.fit_to_model_coords(
440
- f'{prefix}|relative_maximum_charge_state',
441
- self.relative_maximum_charge_state,
493
+ self.relative_maximum_charge_state = self._fit_coords(
494
+ f'{self.prefix}|relative_maximum_charge_state', self.relative_maximum_charge_state
442
495
  )
443
- self.eta_charge = flow_system.fit_to_model_coords(f'{prefix}|eta_charge', self.eta_charge)
444
- self.eta_discharge = flow_system.fit_to_model_coords(f'{prefix}|eta_discharge', self.eta_discharge)
445
- self.relative_loss_per_hour = flow_system.fit_to_model_coords(
446
- f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour
496
+ self.eta_charge = self._fit_coords(f'{self.prefix}|eta_charge', self.eta_charge)
497
+ self.eta_discharge = self._fit_coords(f'{self.prefix}|eta_discharge', self.eta_discharge)
498
+ self.relative_loss_per_hour = self._fit_coords(
499
+ f'{self.prefix}|relative_loss_per_hour', self.relative_loss_per_hour
447
500
  )
448
- if not isinstance(self.initial_charge_state, str):
449
- self.initial_charge_state = flow_system.fit_to_model_coords(
450
- f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario']
501
+ if self.initial_charge_state is not None and not isinstance(self.initial_charge_state, str):
502
+ self.initial_charge_state = self._fit_coords(
503
+ f'{self.prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario']
451
504
  )
452
- self.minimal_final_charge_state = flow_system.fit_to_model_coords(
453
- f'{prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario']
505
+ self.minimal_final_charge_state = self._fit_coords(
506
+ f'{self.prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario']
454
507
  )
455
- self.maximal_final_charge_state = flow_system.fit_to_model_coords(
456
- f'{prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario']
508
+ self.maximal_final_charge_state = self._fit_coords(
509
+ f'{self.prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario']
457
510
  )
458
- self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords(
459
- f'{prefix}|relative_minimum_final_charge_state',
511
+ self.relative_minimum_final_charge_state = self._fit_coords(
512
+ f'{self.prefix}|relative_minimum_final_charge_state',
460
513
  self.relative_minimum_final_charge_state,
461
514
  dims=['period', 'scenario'],
462
515
  )
463
- self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords(
464
- f'{prefix}|relative_maximum_final_charge_state',
516
+ self.relative_maximum_final_charge_state = self._fit_coords(
517
+ f'{self.prefix}|relative_maximum_final_charge_state',
465
518
  self.relative_maximum_final_charge_state,
466
519
  dims=['period', 'scenario'],
467
520
  )
468
521
  if isinstance(self.capacity_in_flow_hours, InvestParameters):
469
- self.capacity_in_flow_hours.transform_data(flow_system, f'{prefix}|InvestParameters')
522
+ self.capacity_in_flow_hours.transform_data()
470
523
  else:
471
- self.capacity_in_flow_hours = flow_system.fit_to_model_coords(
472
- f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario']
524
+ self.capacity_in_flow_hours = self._fit_coords(
525
+ f'{self.prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario']
473
526
  )
474
527
 
475
528
  def _plausibility_checks(self) -> None:
@@ -479,38 +532,68 @@ class Storage(Component):
479
532
  super()._plausibility_checks()
480
533
 
481
534
  # Validate string values and set flag
482
- initial_is_last = False
535
+ initial_equals_final = False
483
536
  if isinstance(self.initial_charge_state, str):
484
- if self.initial_charge_state == 'lastValueOfSim':
485
- initial_is_last = True
486
- else:
537
+ if not self.initial_charge_state == 'equals_final':
487
538
  raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}')
539
+ initial_equals_final = True
488
540
 
489
- # Use new InvestParameters methods to get capacity bounds
490
- if isinstance(self.capacity_in_flow_hours, InvestParameters):
491
- minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
492
- maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
493
- else:
494
- maximum_capacity = self.capacity_in_flow_hours
495
- minimum_capacity = self.capacity_in_flow_hours
496
-
497
- # Initial capacity should not constraint investment decision
498
- minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0)
499
- maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0)
500
-
501
- # Only perform numeric comparisons if not using 'lastValueOfSim'
502
- if not initial_is_last:
503
- if (self.initial_charge_state > maximum_initial_capacity).any():
541
+ # Capacity is required when using non-default relative bounds
542
+ if self.capacity_in_flow_hours is None:
543
+ if np.any(self.relative_minimum_charge_state > 0):
544
+ raise PlausibilityError(
545
+ f'Storage "{self.label_full}" has relative_minimum_charge_state > 0 but no capacity_in_flow_hours. '
546
+ f'A capacity is required because the lower bound is capacity * relative_minimum_charge_state.'
547
+ )
548
+ if np.any(self.relative_maximum_charge_state < 1):
549
+ raise PlausibilityError(
550
+ f'Storage "{self.label_full}" has relative_maximum_charge_state < 1 but no capacity_in_flow_hours. '
551
+ f'A capacity is required because the upper bound is capacity * relative_maximum_charge_state.'
552
+ )
553
+ if self.relative_minimum_final_charge_state is not None:
504
554
  raise PlausibilityError(
505
- f'{self.label_full}: {self.initial_charge_state=} '
506
- f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}'
555
+ f'Storage "{self.label_full}" has relative_minimum_final_charge_state but no capacity_in_flow_hours. '
556
+ f'A capacity is required for relative final charge state constraints.'
507
557
  )
508
- if (self.initial_charge_state < minimum_initial_capacity).any():
558
+ if self.relative_maximum_final_charge_state is not None:
509
559
  raise PlausibilityError(
510
- f'{self.label_full}: {self.initial_charge_state=} '
511
- f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}'
560
+ f'Storage "{self.label_full}" has relative_maximum_final_charge_state but no capacity_in_flow_hours. '
561
+ f'A capacity is required for relative final charge state constraints.'
512
562
  )
513
563
 
564
+ # Skip capacity-related checks if capacity is None (unbounded)
565
+ if self.capacity_in_flow_hours is not None:
566
+ # Use new InvestParameters methods to get capacity bounds
567
+ if isinstance(self.capacity_in_flow_hours, InvestParameters):
568
+ minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
569
+ maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
570
+ else:
571
+ maximum_capacity = self.capacity_in_flow_hours
572
+ minimum_capacity = self.capacity_in_flow_hours
573
+
574
+ # Initial charge state should not constrain investment decision
575
+ # If initial > (min_cap * rel_max), investment is forced to increase capacity
576
+ # If initial < (max_cap * rel_min), investment is forced to decrease capacity
577
+ min_initial_at_max_capacity = maximum_capacity * _scalar_safe_isel(
578
+ self.relative_minimum_charge_state, {'time': 0}
579
+ )
580
+ max_initial_at_min_capacity = minimum_capacity * _scalar_safe_isel(
581
+ self.relative_maximum_charge_state, {'time': 0}
582
+ )
583
+
584
+ # Only perform numeric comparisons if using a numeric initial_charge_state
585
+ if not initial_equals_final and self.initial_charge_state is not None:
586
+ if (self.initial_charge_state > max_initial_at_min_capacity).any():
587
+ raise PlausibilityError(
588
+ f'{self.label_full}: {self.initial_charge_state=} '
589
+ f'is constraining the investment decision. Choose a value <= {max_initial_at_min_capacity}.'
590
+ )
591
+ if (self.initial_charge_state < min_initial_at_max_capacity).any():
592
+ raise PlausibilityError(
593
+ f'{self.label_full}: {self.initial_charge_state=} '
594
+ f'is constraining the investment decision. Choose a value >= {min_initial_at_max_capacity}.'
595
+ )
596
+
514
597
  if self.balanced:
515
598
  if not isinstance(self.charging.size, InvestParameters) or not isinstance(
516
599
  self.discharging.size, InvestParameters
@@ -519,15 +602,24 @@ class Storage(Component):
519
602
  f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.'
520
603
  )
521
604
 
522
- if (self.charging.size.minimum_size > self.discharging.size.maximum_size).any() or (
523
- self.charging.size.maximum_size < self.discharging.size.minimum_size
605
+ if (self.charging.size.minimum_or_fixed_size > self.discharging.size.maximum_or_fixed_size).any() or (
606
+ self.charging.size.maximum_or_fixed_size < self.discharging.size.minimum_or_fixed_size
524
607
  ).any():
525
608
  raise PlausibilityError(
526
609
  f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.'
527
- f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and '
528
- f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.'
610
+ f'Got: {self.charging.size.minimum_or_fixed_size=}, {self.charging.size.maximum_or_fixed_size=} and '
611
+ f'{self.discharging.size.minimum_or_fixed_size=}, {self.discharging.size.maximum_or_fixed_size=}.'
529
612
  )
530
613
 
614
+ def __repr__(self) -> str:
615
+ """Return string representation."""
616
+ # Use build_repr_from_init directly to exclude charging and discharging
617
+ return fx_io.build_repr_from_init(
618
+ self,
619
+ excluded_params={'self', 'label', 'charging', 'discharging', 'kwargs'},
620
+ skip_default_size=True,
621
+ ) + fx_io.format_flow_details(self)
622
+
531
623
 
532
624
  @register_class_for_io
533
625
  class Transmission(Component):
@@ -553,8 +645,8 @@ class Transmission(Component):
553
645
  relative_losses: Proportional losses as fraction of throughput (e.g., 0.02 for 2% loss).
554
646
  Applied as: output = input × (1 - relative_losses)
555
647
  absolute_losses: Fixed losses that occur when transmission is active.
556
- Automatically creates binary variables for on/off states.
557
- on_off_parameters: Parameters defining binary operation constraints and costs.
648
+ Automatically creates binary variables for active/inactive states.
649
+ status_parameters: Parameters defining binary operation constraints and costs.
558
650
  prevent_simultaneous_flows_in_both_directions: If True, prevents simultaneous
559
651
  flow in both directions. Increases binary variables but reflects physical
560
652
  reality for most transmission systems. Default is True.
@@ -609,7 +701,7 @@ class Transmission(Component):
609
701
  )
610
702
  ```
611
703
 
612
- Material conveyor with on/off operation:
704
+ Material conveyor with active/inactive status:
613
705
 
614
706
  ```python
615
707
  conveyor_belt = Transmission(
@@ -617,10 +709,10 @@ class Transmission(Component):
617
709
  in1=loading_station,
618
710
  out1=unloading_station,
619
711
  absolute_losses=25, # 25 kW motor power when running
620
- on_off_parameters=OnOffParameters(
621
- effects_per_switch_on={'maintenance': 0.1},
622
- consecutive_on_hours_min=2, # Minimum 2-hour operation
623
- switch_on_total_max=10, # Maximum 10 starts per day
712
+ status_parameters=StatusParameters(
713
+ effects_per_startup={'maintenance': 0.1},
714
+ min_uptime=2, # Minimum 2-hour operation
715
+ startup_limit=10, # Maximum 10 starts per period
624
716
  ),
625
717
  )
626
718
  ```
@@ -634,12 +726,14 @@ class Transmission(Component):
634
726
  When using InvestParameters on in1, the capacity automatically applies to in2
635
727
  to maintain consistent bidirectional capacity without additional investment variables.
636
728
 
637
- Absolute losses force the creation of binary on/off variables, which increases
729
+ Absolute losses force the creation of binary on/inactive variables, which increases
638
730
  computational complexity but enables realistic modeling of equipment with
639
731
  standby power consumption.
640
732
 
641
733
  """
642
734
 
735
+ submodel: TransmissionModel | None
736
+
643
737
  def __init__(
644
738
  self,
645
739
  label: str,
@@ -647,22 +741,24 @@ class Transmission(Component):
647
741
  out1: Flow,
648
742
  in2: Flow | None = None,
649
743
  out2: Flow | None = None,
650
- relative_losses: TemporalDataUser | None = None,
651
- absolute_losses: TemporalDataUser | None = None,
652
- on_off_parameters: OnOffParameters = None,
744
+ relative_losses: Numeric_TPS | None = None,
745
+ absolute_losses: Numeric_TPS | None = None,
746
+ status_parameters: StatusParameters | None = None,
653
747
  prevent_simultaneous_flows_in_both_directions: bool = True,
654
748
  balanced: bool = False,
655
749
  meta_data: dict | None = None,
750
+ color: str | None = None,
656
751
  ):
657
752
  super().__init__(
658
753
  label,
659
754
  inputs=[flow for flow in (in1, in2) if flow is not None],
660
755
  outputs=[flow for flow in (out1, out2) if flow is not None],
661
- on_off_parameters=on_off_parameters,
756
+ status_parameters=status_parameters,
662
757
  prevent_simultaneous_flows=None
663
758
  if in2 is None or prevent_simultaneous_flows_in_both_directions is False
664
759
  else [in1, in2],
665
760
  meta_data=meta_data,
761
+ color=color,
666
762
  )
667
763
  self.in1 = in1
668
764
  self.out1 = out1
@@ -695,8 +791,8 @@ class Transmission(Component):
695
791
  ).any():
696
792
  raise ValueError(
697
793
  f'Balanced Transmission needs compatible minimum and maximum sizes.'
698
- f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and '
699
- f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.'
794
+ f'Got: {self.in1.size.minimum_or_fixed_size=}, {self.in1.size.maximum_or_fixed_size=} and '
795
+ f'{self.in2.size.minimum_or_fixed_size=}, {self.in2.size.maximum_or_fixed_size=}.'
700
796
  )
701
797
 
702
798
  def create_model(self, model) -> TransmissionModel:
@@ -704,11 +800,10 @@ class Transmission(Component):
704
800
  self.submodel = TransmissionModel(model, self)
705
801
  return self.submodel
706
802
 
707
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
708
- prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
709
- super().transform_data(flow_system, prefix)
710
- self.relative_losses = flow_system.fit_to_model_coords(f'{prefix}|relative_losses', self.relative_losses)
711
- self.absolute_losses = flow_system.fit_to_model_coords(f'{prefix}|absolute_losses', self.absolute_losses)
803
+ def transform_data(self) -> None:
804
+ super().transform_data()
805
+ self.relative_losses = self._fit_coords(f'{self.prefix}|relative_losses', self.relative_losses)
806
+ self.absolute_losses = self._fit_coords(f'{self.prefix}|absolute_losses', self.absolute_losses)
712
807
 
713
808
 
714
809
  class TransmissionModel(ComponentModel):
@@ -717,13 +812,16 @@ class TransmissionModel(ComponentModel):
717
812
  def __init__(self, model: FlowSystemModel, element: Transmission):
718
813
  if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0):
719
814
  for flow in element.inputs + element.outputs:
720
- if flow.on_off_parameters is None:
721
- flow.on_off_parameters = OnOffParameters()
815
+ if flow.status_parameters is None:
816
+ flow.status_parameters = StatusParameters()
817
+ flow.status_parameters.link_to_flow_system(
818
+ model.flow_system, f'{flow.label_full}|status_parameters'
819
+ )
722
820
 
723
821
  super().__init__(model, element)
724
822
 
725
823
  def _do_modeling(self):
726
- """Initiates all FlowModels"""
824
+ """Create transmission efficiency equations and optional absolute loss constraints for both flow directions"""
727
825
  super()._do_modeling()
728
826
 
729
827
  # first direction
@@ -750,13 +848,23 @@ class TransmissionModel(ComponentModel):
750
848
  short_name=name,
751
849
  )
752
850
 
753
- if self.element.absolute_losses is not None:
754
- con_transmission.lhs += in_flow.submodel.on_off.on * self.element.absolute_losses
851
+ if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses != 0):
852
+ con_transmission.lhs += in_flow.submodel.status.status * self.element.absolute_losses
755
853
 
756
854
  return con_transmission
757
855
 
758
856
 
759
857
  class LinearConverterModel(ComponentModel):
858
+ """Mathematical model implementation for LinearConverter components.
859
+
860
+ Creates optimization constraints for linear conversion relationships between
861
+ input and output flows, supporting both simple conversion factors and piecewise
862
+ non-linear approximations.
863
+
864
+ Mathematical Formulation:
865
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/LinearConverter/>
866
+ """
867
+
760
868
  element: LinearConverter
761
869
 
762
870
  def __init__(self, model: FlowSystemModel, element: LinearConverter):
@@ -764,8 +872,10 @@ class LinearConverterModel(ComponentModel):
764
872
  super().__init__(model, element)
765
873
 
766
874
  def _do_modeling(self):
875
+ """Create linear conversion equations or piecewise conversion constraints between input and output flows"""
767
876
  super()._do_modeling()
768
- # conversion_factors:
877
+
878
+ # Create conversion factor constraints if specified
769
879
  if self.element.conversion_factors:
770
880
  all_input_flows = set(self.element.inputs)
771
881
  all_output_flows = set(self.element.outputs)
@@ -783,7 +893,7 @@ class LinearConverterModel(ComponentModel):
783
893
  )
784
894
 
785
895
  else:
786
- # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself
896
+ # TODO: Improve Inclusion of StatusParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself
787
897
  piecewise_conversion = {
788
898
  self.element.flows[flow].submodel.flow_rate.name: piecewise
789
899
  for flow, piecewise in self.element.piecewise_conversion.items()
@@ -795,7 +905,7 @@ class LinearConverterModel(ComponentModel):
795
905
  label_of_element=self.label_of_element,
796
906
  label_of_model=f'{self.label_of_element}',
797
907
  piecewise_variables=piecewise_conversion,
798
- zero_point=self.on_off.on if self.on_off is not None else False,
908
+ zero_point=self.status.status if self.status is not None else False,
799
909
  dims=('time', 'period', 'scenario'),
800
910
  ),
801
911
  short_name='PiecewiseConversion',
@@ -803,7 +913,18 @@ class LinearConverterModel(ComponentModel):
803
913
 
804
914
 
805
915
  class StorageModel(ComponentModel):
806
- """Submodel of Storage"""
916
+ """Mathematical model implementation for Storage components.
917
+
918
+ Creates optimization variables and constraints for charge state tracking,
919
+ storage balance equations, and optional investment sizing.
920
+
921
+ Mathematical Formulation:
922
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/Storage/>
923
+
924
+ Note:
925
+ This class uses a template method pattern. Subclasses (e.g., InterclusterStorageModel)
926
+ can override individual methods to customize behavior without duplicating code.
927
+ """
807
928
 
808
929
  element: Storage
809
930
 
@@ -811,42 +932,54 @@ class StorageModel(ComponentModel):
811
932
  super().__init__(model, element)
812
933
 
813
934
  def _do_modeling(self):
935
+ """Create charge state variables, energy balance equations, and optional investment submodels."""
814
936
  super()._do_modeling()
815
-
937
+ self._create_storage_variables()
938
+ self._add_netto_discharge_constraint()
939
+ self._add_energy_balance_constraint()
940
+ self._add_cluster_cyclic_constraint()
941
+ self._add_investment_model()
942
+ self._add_initial_final_constraints()
943
+ self._add_balanced_sizes_constraint()
944
+
945
+ def _create_storage_variables(self):
946
+ """Create charge_state and netto_discharge variables."""
816
947
  lb, ub = self._absolute_charge_state_bounds
817
948
  self.add_variables(
818
949
  lower=lb,
819
950
  upper=ub,
820
951
  coords=self._model.get_coords(extra_timestep=True),
821
952
  short_name='charge_state',
953
+ category=VariableCategory.CHARGE_STATE,
954
+ )
955
+ self.add_variables(
956
+ coords=self._model.get_coords(),
957
+ short_name='netto_discharge',
958
+ category=VariableCategory.NETTO_DISCHARGE,
822
959
  )
823
960
 
824
- self.add_variables(coords=self._model.get_coords(), short_name='netto_discharge')
825
-
826
- # netto_discharge:
827
- # eq: nettoFlow(t) - discharging(t) + charging(t) = 0
961
+ def _add_netto_discharge_constraint(self):
962
+ """Add constraint: netto_discharge = discharging - charging."""
828
963
  self.add_constraints(
829
964
  self.netto_discharge
830
965
  == self.element.discharging.submodel.flow_rate - self.element.charging.submodel.flow_rate,
831
966
  short_name='netto_discharge',
832
967
  )
833
968
 
834
- charge_state = self.charge_state
835
- rel_loss = self.element.relative_loss_per_hour
836
- hours_per_step = self._model.hours_per_step
837
- charge_rate = self.element.charging.submodel.flow_rate
838
- discharge_rate = self.element.discharging.submodel.flow_rate
839
- eff_charge = self.element.eta_charge
840
- eff_discharge = self.element.eta_discharge
969
+ def _add_energy_balance_constraint(self):
970
+ """Add energy balance constraint linking charge states across timesteps."""
971
+ self.add_constraints(self._build_energy_balance_lhs() == 0, short_name='charge_state')
841
972
 
842
- self.add_constraints(
843
- charge_state.isel(time=slice(1, None))
844
- == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step)
845
- + charge_rate * eff_charge * hours_per_step
846
- - discharge_rate * hours_per_step / eff_discharge,
847
- short_name='charge_state',
848
- )
973
+ def _add_cluster_cyclic_constraint(self):
974
+ """For 'cyclic' cluster mode: each cluster's start equals its end."""
975
+ if self._model.flow_system.clusters is not None and self.element.cluster_mode == 'cyclic':
976
+ self.add_constraints(
977
+ self.charge_state.isel(time=0) == self.charge_state.isel(time=-2),
978
+ short_name='cluster_cyclic',
979
+ )
849
980
 
981
+ def _add_investment_model(self):
982
+ """Create InvestmentModel and add capacity-scaled bounds if using investment sizing."""
850
983
  if isinstance(self.element.capacity_in_flow_hours, InvestParameters):
851
984
  self.add_submodels(
852
985
  InvestmentModel(
@@ -854,10 +987,10 @@ class StorageModel(ComponentModel):
854
987
  label_of_element=self.label_of_element,
855
988
  label_of_model=self.label_of_element,
856
989
  parameters=self.element.capacity_in_flow_hours,
990
+ size_category=VariableCategory.STORAGE_SIZE,
857
991
  ),
858
992
  short_name='investment',
859
993
  )
860
-
861
994
  BoundingPatterns.scaled_bounds(
862
995
  self,
863
996
  variable=self.charge_state,
@@ -865,21 +998,28 @@ class StorageModel(ComponentModel):
865
998
  relative_bounds=self._relative_charge_state_bounds,
866
999
  )
867
1000
 
868
- # Initial charge state
869
- self._initial_and_final_charge_state()
1001
+ def _add_initial_final_constraints(self):
1002
+ """Add initial and final charge state constraints.
870
1003
 
871
- if self.element.balanced:
872
- self.add_constraints(
873
- self.element.charging.submodel._investment.size * 1
874
- == self.element.discharging.submodel._investment.size * 1,
875
- short_name='balanced_sizes',
876
- )
1004
+ For clustered systems with 'independent' or 'cyclic' mode, these constraints
1005
+ are skipped because:
1006
+ - 'independent': Each cluster has free start/end SOC
1007
+ - 'cyclic': Start == end is handled by _add_cluster_cyclic_constraint,
1008
+ but no specific initial value is enforced
1009
+ """
1010
+ # Skip initial/final constraints for clustered systems with independent/cyclic mode
1011
+ # These modes should have free or cyclic SOC, not a fixed initial value per cluster
1012
+ if self._model.flow_system.clusters is not None and self.element.cluster_mode in (
1013
+ 'independent',
1014
+ 'cyclic',
1015
+ ):
1016
+ return
877
1017
 
878
- def _initial_and_final_charge_state(self):
879
1018
  if self.element.initial_charge_state is not None:
880
1019
  if isinstance(self.element.initial_charge_state, str):
881
1020
  self.add_constraints(
882
- self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), short_name='initial_charge_state'
1021
+ self.charge_state.isel(time=0) == self.charge_state.isel(time=-1),
1022
+ short_name='initial_charge_state',
883
1023
  )
884
1024
  else:
885
1025
  self.add_constraints(
@@ -899,21 +1039,76 @@ class StorageModel(ComponentModel):
899
1039
  short_name='final_charge_min',
900
1040
  )
901
1041
 
1042
+ def _add_balanced_sizes_constraint(self):
1043
+ """Add constraint ensuring charging and discharging capacities are equal."""
1044
+ if self.element.balanced:
1045
+ self.add_constraints(
1046
+ self.element.charging.submodel._investment.size - self.element.discharging.submodel._investment.size
1047
+ == 0,
1048
+ short_name='balanced_sizes',
1049
+ )
1050
+
1051
+ def _build_energy_balance_lhs(self):
1052
+ """Build the left-hand side of the energy balance constraint.
1053
+
1054
+ The energy balance equation is:
1055
+ charge_state[t+1] = charge_state[t] * (1 - loss)^dt
1056
+ + charge_rate * eta_charge * dt
1057
+ - discharge_rate / eta_discharge * dt
1058
+
1059
+ Rearranged as LHS = 0:
1060
+ charge_state[t+1] - charge_state[t] * (1 - loss)^dt
1061
+ - charge_rate * eta_charge * dt
1062
+ + discharge_rate / eta_discharge * dt = 0
1063
+
1064
+ Returns:
1065
+ The LHS expression (should equal 0).
1066
+ """
1067
+ charge_state = self.charge_state
1068
+ rel_loss = self.element.relative_loss_per_hour
1069
+ timestep_duration = self._model.timestep_duration
1070
+ charge_rate = self.element.charging.submodel.flow_rate
1071
+ discharge_rate = self.element.discharging.submodel.flow_rate
1072
+ eff_charge = self.element.eta_charge
1073
+ eff_discharge = self.element.eta_discharge
1074
+
1075
+ return (
1076
+ charge_state.isel(time=slice(1, None))
1077
+ - charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** timestep_duration)
1078
+ - charge_rate * eff_charge * timestep_duration
1079
+ + discharge_rate * timestep_duration / eff_discharge
1080
+ )
1081
+
902
1082
  @property
903
- def _absolute_charge_state_bounds(self) -> tuple[TemporalData, TemporalData]:
1083
+ def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
1084
+ """Get absolute bounds for charge_state variable.
1085
+
1086
+ For base StorageModel, charge_state represents absolute SOC with bounds
1087
+ derived from relative bounds scaled by capacity.
1088
+
1089
+ Note:
1090
+ InterclusterStorageModel overrides this to provide symmetric bounds
1091
+ since charge_state represents ΔE (relative change from cluster start).
1092
+ """
904
1093
  relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds
905
- if not isinstance(self.element.capacity_in_flow_hours, InvestParameters):
1094
+
1095
+ if self.element.capacity_in_flow_hours is None:
1096
+ return 0, np.inf
1097
+ elif isinstance(self.element.capacity_in_flow_hours, InvestParameters):
1098
+ cap_min = self.element.capacity_in_flow_hours.minimum_or_fixed_size
1099
+ cap_max = self.element.capacity_in_flow_hours.maximum_or_fixed_size
906
1100
  return (
907
- relative_lower_bound * self.element.capacity_in_flow_hours,
908
- relative_upper_bound * self.element.capacity_in_flow_hours,
1101
+ relative_lower_bound * cap_min,
1102
+ relative_upper_bound * cap_max,
909
1103
  )
910
1104
  else:
1105
+ cap = self.element.capacity_in_flow_hours
911
1106
  return (
912
- relative_lower_bound * self.element.capacity_in_flow_hours.minimum_size,
913
- relative_upper_bound * self.element.capacity_in_flow_hours.maximum_size,
1107
+ relative_lower_bound * cap,
1108
+ relative_upper_bound * cap,
914
1109
  )
915
1110
 
916
- @property
1111
+ @functools.cached_property
917
1112
  def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
918
1113
  """
919
1114
  Get relative charge state bounds with final timestep values.
@@ -921,26 +1116,51 @@ class StorageModel(ComponentModel):
921
1116
  Returns:
922
1117
  Tuple of (minimum_bounds, maximum_bounds) DataArrays extending to final timestep
923
1118
  """
924
- final_coords = {'time': [self._model.flow_system.timesteps_extra[-1]]}
1119
+ timesteps_extra = self._model.flow_system.timesteps_extra
1120
+
1121
+ # Get the original bounds (may be scalar or have time dim)
1122
+ rel_min = self.element.relative_minimum_charge_state
1123
+ rel_max = self.element.relative_maximum_charge_state
925
1124
 
926
1125
  # Get final minimum charge state
927
1126
  if self.element.relative_minimum_final_charge_state is None:
928
- min_final = self.element.relative_minimum_charge_state.isel(time=-1, drop=True)
1127
+ min_final_value = _scalar_safe_isel_drop(rel_min, 'time', -1)
929
1128
  else:
930
- min_final = self.element.relative_minimum_final_charge_state
931
- min_final = min_final.expand_dims('time').assign_coords(time=final_coords['time'])
1129
+ min_final_value = self.element.relative_minimum_final_charge_state
932
1130
 
933
1131
  # Get final maximum charge state
934
1132
  if self.element.relative_maximum_final_charge_state is None:
935
- max_final = self.element.relative_maximum_charge_state.isel(time=-1, drop=True)
1133
+ max_final_value = _scalar_safe_isel_drop(rel_max, 'time', -1)
936
1134
  else:
937
- max_final = self.element.relative_maximum_final_charge_state
938
- max_final = max_final.expand_dims('time').assign_coords(time=final_coords['time'])
939
- # Concatenate with original bounds
940
- min_bounds = xr.concat([self.element.relative_minimum_charge_state, min_final], dim='time')
941
- max_bounds = xr.concat([self.element.relative_maximum_charge_state, max_final], dim='time')
1135
+ max_final_value = self.element.relative_maximum_final_charge_state
1136
+
1137
+ # Build bounds arrays for timesteps_extra (includes final timestep)
1138
+ # Handle case where original data may be scalar (no time dim)
1139
+ if 'time' in rel_min.dims:
1140
+ # Original has time dim - concat with final value
1141
+ min_final_da = (
1142
+ min_final_value.expand_dims('time') if 'time' not in min_final_value.dims else min_final_value
1143
+ )
1144
+ min_final_da = min_final_da.assign_coords(time=[timesteps_extra[-1]])
1145
+ min_bounds = xr.concat([rel_min, min_final_da], dim='time')
1146
+ else:
1147
+ # Original is scalar - broadcast to full time range (constant value)
1148
+ min_bounds = rel_min.expand_dims(time=timesteps_extra)
1149
+
1150
+ if 'time' in rel_max.dims:
1151
+ # Original has time dim - concat with final value
1152
+ max_final_da = (
1153
+ max_final_value.expand_dims('time') if 'time' not in max_final_value.dims else max_final_value
1154
+ )
1155
+ max_final_da = max_final_da.assign_coords(time=[timesteps_extra[-1]])
1156
+ max_bounds = xr.concat([rel_max, max_final_da], dim='time')
1157
+ else:
1158
+ # Original is scalar - broadcast to full time range (constant value)
1159
+ max_bounds = rel_max.expand_dims(time=timesteps_extra)
942
1160
 
943
- return min_bounds, max_bounds
1161
+ # Ensure both bounds have matching dimensions (broadcast once here,
1162
+ # so downstream code doesn't need to handle dimension mismatches)
1163
+ return xr.broadcast(min_bounds, max_bounds)
944
1164
 
945
1165
  @property
946
1166
  def _investment(self) -> InvestmentModel | None:
@@ -949,7 +1169,7 @@ class StorageModel(ComponentModel):
949
1169
 
950
1170
  @property
951
1171
  def investment(self) -> InvestmentModel | None:
952
- """OnOff feature"""
1172
+ """Investment feature"""
953
1173
  if 'investment' not in self.submodels:
954
1174
  return None
955
1175
  return self.submodels['investment']
@@ -965,6 +1185,435 @@ class StorageModel(ComponentModel):
965
1185
  return self['netto_discharge']
966
1186
 
967
1187
 
1188
+ class InterclusterStorageModel(StorageModel):
1189
+ """Storage model with inter-cluster linking for clustered optimization.
1190
+
1191
+ This class extends :class:`StorageModel` to support inter-cluster storage linking
1192
+ when using time series aggregation (clustering). It implements the S-N linking model
1193
+ from Blanke et al. (2022) to properly value seasonal storage in clustered optimizations.
1194
+
1195
+ The Problem with Naive Clustering
1196
+ ---------------------------------
1197
+ When time series are clustered (e.g., 365 days → 8 typical days), storage behavior
1198
+ is fundamentally misrepresented if each cluster operates independently:
1199
+
1200
+ - **Seasonal patterns are lost**: A battery might charge in summer and discharge in
1201
+ winter, but with independent clusters, each "typical summer day" cannot transfer
1202
+ energy to the "typical winter day".
1203
+ - **Storage value is underestimated**: Without inter-cluster linking, storage can only
1204
+ provide intra-day flexibility, not seasonal arbitrage.
1205
+
1206
+ The S-N Linking Model
1207
+ ---------------------
1208
+ This model introduces two key concepts:
1209
+
1210
+ 1. **SOC_boundary**: Absolute state-of-charge at the boundary between original periods.
1211
+ With N original periods, there are N+1 boundary points (including start and end).
1212
+
1213
+ 2. **charge_state (ΔE)**: Relative change in SOC within each representative cluster,
1214
+ measured from the cluster start (where ΔE = 0).
1215
+
1216
+ The actual SOC at any timestep t within original period d is::
1217
+
1218
+ SOC(t) = SOC_boundary[d] + ΔE(t)
1219
+
1220
+ Key Constraints
1221
+ ---------------
1222
+ 1. **Cluster start constraint**: ``ΔE(cluster_start) = 0``
1223
+ Each representative cluster starts with zero relative charge.
1224
+
1225
+ 2. **Linking constraint**: ``SOC_boundary[d+1] = SOC_boundary[d] + delta_SOC[cluster_assignments[d]]``
1226
+ The boundary SOC after period d equals the boundary before plus the net
1227
+ charge/discharge of the representative cluster for that period.
1228
+
1229
+ 3. **Combined bounds**: ``0 ≤ SOC_boundary[d] + ΔE(t) ≤ capacity``
1230
+ The actual SOC must stay within physical bounds.
1231
+
1232
+ 4. **Cyclic constraint** (for ``intercluster_cyclic`` mode):
1233
+ ``SOC_boundary[0] = SOC_boundary[N]``
1234
+ The storage returns to its initial state over the full time horizon.
1235
+
1236
+ Variables Created
1237
+ -----------------
1238
+ - ``SOC_boundary``: Absolute SOC at each original period boundary.
1239
+ Shape: (n_original_clusters + 1,) plus any period/scenario dimensions.
1240
+
1241
+ Constraints Created
1242
+ -------------------
1243
+ - ``cluster_start``: Forces ΔE = 0 at start of each representative cluster.
1244
+ - ``link``: Links consecutive SOC_boundary values via delta_SOC.
1245
+ - ``cyclic`` or ``initial_SOC_boundary``: Initial/final boundary condition.
1246
+ - ``soc_lb_start/mid/end``: Lower bound on combined SOC at sample points.
1247
+ - ``soc_ub_start/mid/end``: Upper bound on combined SOC (if investment).
1248
+ - ``SOC_boundary_ub``: Links SOC_boundary to investment size (if investment).
1249
+ - ``charge_state|lb/ub``: Symmetric bounds on ΔE for intercluster modes.
1250
+
1251
+ References
1252
+ ----------
1253
+ - Blanke, T., et al. (2022). "Inter-Cluster Storage Linking for Time Series
1254
+ Aggregation in Energy System Optimization Models."
1255
+ - Kotzur, L., et al. (2018). "Time series aggregation for energy system design:
1256
+ Modeling seasonal storage."
1257
+
1258
+ See Also
1259
+ --------
1260
+ :class:`StorageModel` : Base storage model without inter-cluster linking.
1261
+ :class:`Storage` : The element class that creates this model.
1262
+
1263
+ Example
1264
+ -------
1265
+ The model is automatically used when a Storage has ``cluster_mode='intercluster'``
1266
+ or ``cluster_mode='intercluster_cyclic'`` and the FlowSystem has been clustered::
1267
+
1268
+ storage = Storage(
1269
+ label='seasonal_storage',
1270
+ charging=charge_flow,
1271
+ discharging=discharge_flow,
1272
+ capacity_in_flow_hours=InvestParameters(maximum_size=10000),
1273
+ cluster_mode='intercluster_cyclic', # Enable inter-cluster linking
1274
+ )
1275
+
1276
+ # Cluster the flow system
1277
+ fs_clustered = flow_system.transform.cluster(n_clusters=8)
1278
+ fs_clustered.optimize(solver)
1279
+
1280
+ # Access the SOC_boundary in results
1281
+ soc_boundary = fs_clustered.solution['seasonal_storage|SOC_boundary']
1282
+ """
1283
+
1284
+ @property
1285
+ def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
1286
+ """Get symmetric bounds for charge_state (ΔE) variable.
1287
+
1288
+ For InterclusterStorageModel, charge_state represents ΔE (relative change
1289
+ from cluster start), which can be negative. Therefore, we need symmetric
1290
+ bounds: -capacity <= ΔE <= capacity.
1291
+
1292
+ Note that for investment-based sizing, additional constraints are added
1293
+ in _add_investment_model to link bounds to the actual investment size.
1294
+ """
1295
+ _, relative_upper_bound = self._relative_charge_state_bounds
1296
+
1297
+ if self.element.capacity_in_flow_hours is None:
1298
+ return -np.inf, np.inf
1299
+ elif isinstance(self.element.capacity_in_flow_hours, InvestParameters):
1300
+ cap_max = self.element.capacity_in_flow_hours.maximum_or_fixed_size * relative_upper_bound
1301
+ # Adding 0.0 converts -0.0 to 0.0 (linopy LP writer bug workaround)
1302
+ return -cap_max + 0.0, cap_max + 0.0
1303
+ else:
1304
+ cap = self.element.capacity_in_flow_hours * relative_upper_bound
1305
+ # Adding 0.0 converts -0.0 to 0.0 (linopy LP writer bug workaround)
1306
+ return -cap + 0.0, cap + 0.0
1307
+
1308
+ def _do_modeling(self):
1309
+ """Create storage model with inter-cluster linking constraints.
1310
+
1311
+ Uses template method pattern: calls parent's _do_modeling, then adds
1312
+ inter-cluster linking. Overrides specific methods to customize behavior.
1313
+ """
1314
+ super()._do_modeling()
1315
+ self._add_intercluster_linking()
1316
+
1317
+ def _add_cluster_cyclic_constraint(self):
1318
+ """Skip cluster cyclic constraint - handled by inter-cluster linking."""
1319
+ pass
1320
+
1321
+ def _add_investment_model(self):
1322
+ """Create InvestmentModel with symmetric bounds for ΔE."""
1323
+ if isinstance(self.element.capacity_in_flow_hours, InvestParameters):
1324
+ self.add_submodels(
1325
+ InvestmentModel(
1326
+ model=self._model,
1327
+ label_of_element=self.label_of_element,
1328
+ label_of_model=self.label_of_element,
1329
+ parameters=self.element.capacity_in_flow_hours,
1330
+ size_category=VariableCategory.STORAGE_SIZE,
1331
+ ),
1332
+ short_name='investment',
1333
+ )
1334
+ # Symmetric bounds: -size <= charge_state <= size
1335
+ self.add_constraints(
1336
+ self.charge_state >= -self.investment.size,
1337
+ short_name='charge_state|lb',
1338
+ )
1339
+ self.add_constraints(
1340
+ self.charge_state <= self.investment.size,
1341
+ short_name='charge_state|ub',
1342
+ )
1343
+
1344
+ def _add_initial_final_constraints(self):
1345
+ """Skip initial/final constraints - handled by SOC_boundary in inter-cluster linking."""
1346
+ pass
1347
+
1348
+ def _add_intercluster_linking(self) -> None:
1349
+ """Add inter-cluster storage linking following the S-K model from Blanke et al. (2022).
1350
+
1351
+ This method implements the core inter-cluster linking logic:
1352
+
1353
+ 1. Constrains charge_state (ΔE) at each cluster start to 0
1354
+ 2. Creates SOC_boundary variables to track absolute SOC at period boundaries
1355
+ 3. Links boundaries via Eq. 5: SOC_boundary[d+1] = SOC_boundary[d] * (1-loss)^N + delta_SOC
1356
+ 4. Adds combined bounds per Eq. 9: 0 ≤ SOC_boundary * (1-loss)^t + ΔE ≤ capacity
1357
+ 5. Enforces initial/cyclic constraint on SOC_boundary
1358
+ """
1359
+ from .clustering.intercluster_helpers import (
1360
+ build_boundary_coords,
1361
+ extract_capacity_bounds,
1362
+ )
1363
+
1364
+ clustering = self._model.flow_system.clustering
1365
+ if clustering is None:
1366
+ return
1367
+
1368
+ n_clusters = clustering.n_clusters
1369
+ timesteps_per_cluster = clustering.timesteps_per_cluster
1370
+ n_original_clusters = clustering.n_original_clusters
1371
+ cluster_assignments = clustering.cluster_assignments
1372
+
1373
+ # 1. Constrain ΔE = 0 at cluster starts
1374
+ self._add_cluster_start_constraints(n_clusters, timesteps_per_cluster)
1375
+
1376
+ # 2. Create SOC_boundary variable
1377
+ flow_system = self._model.flow_system
1378
+ boundary_coords, boundary_dims = build_boundary_coords(n_original_clusters, flow_system)
1379
+ capacity_bounds = extract_capacity_bounds(self.element.capacity_in_flow_hours, boundary_coords, boundary_dims)
1380
+
1381
+ soc_boundary = self.add_variables(
1382
+ lower=capacity_bounds.lower,
1383
+ upper=capacity_bounds.upper,
1384
+ coords=boundary_coords,
1385
+ dims=boundary_dims,
1386
+ short_name='SOC_boundary',
1387
+ category=VariableCategory.SOC_BOUNDARY,
1388
+ )
1389
+
1390
+ # 3. Link SOC_boundary to investment size
1391
+ if capacity_bounds.has_investment and self.investment is not None:
1392
+ self.add_constraints(
1393
+ soc_boundary <= self.investment.size,
1394
+ short_name='SOC_boundary_ub',
1395
+ )
1396
+
1397
+ # 4. Compute delta_SOC for each cluster
1398
+ delta_soc = self._compute_delta_soc(n_clusters, timesteps_per_cluster)
1399
+
1400
+ # 5. Add linking constraints
1401
+ self._add_linking_constraints(
1402
+ soc_boundary, delta_soc, cluster_assignments, n_original_clusters, timesteps_per_cluster
1403
+ )
1404
+
1405
+ # 6. Add cyclic or initial constraint
1406
+ if self.element.cluster_mode == 'intercluster_cyclic':
1407
+ self.add_constraints(
1408
+ soc_boundary.isel(cluster_boundary=0) == soc_boundary.isel(cluster_boundary=n_original_clusters),
1409
+ short_name='cyclic',
1410
+ )
1411
+ else:
1412
+ # Apply initial_charge_state to SOC_boundary[0]
1413
+ initial = self.element.initial_charge_state
1414
+ if initial is not None:
1415
+ if isinstance(initial, str):
1416
+ # 'equals_final' means cyclic
1417
+ self.add_constraints(
1418
+ soc_boundary.isel(cluster_boundary=0)
1419
+ == soc_boundary.isel(cluster_boundary=n_original_clusters),
1420
+ short_name='initial_SOC_boundary',
1421
+ )
1422
+ else:
1423
+ self.add_constraints(
1424
+ soc_boundary.isel(cluster_boundary=0) == initial,
1425
+ short_name='initial_SOC_boundary',
1426
+ )
1427
+
1428
+ # 7. Add combined bound constraints
1429
+ self._add_combined_bound_constraints(
1430
+ soc_boundary,
1431
+ cluster_assignments,
1432
+ capacity_bounds.has_investment,
1433
+ n_original_clusters,
1434
+ timesteps_per_cluster,
1435
+ )
1436
+
1437
+ def _add_cluster_start_constraints(self, n_clusters: int, timesteps_per_cluster: int) -> None:
1438
+ """Constrain ΔE = 0 at the start of each representative cluster.
1439
+
1440
+ This ensures that the relative charge state is measured from a known
1441
+ reference point (the cluster start).
1442
+
1443
+ With 2D (cluster, time) structure, time=0 is the start of every cluster,
1444
+ so we simply select isel(time=0) which broadcasts across the cluster dimension.
1445
+
1446
+ Args:
1447
+ n_clusters: Number of representative clusters (unused with 2D structure).
1448
+ timesteps_per_cluster: Timesteps in each cluster (unused with 2D structure).
1449
+ """
1450
+ # With 2D structure: time=0 is start of every cluster
1451
+ self.add_constraints(
1452
+ self.charge_state.isel(time=0) == 0,
1453
+ short_name='cluster_start',
1454
+ )
1455
+
1456
+ def _compute_delta_soc(self, n_clusters: int, timesteps_per_cluster: int) -> xr.DataArray:
1457
+ """Compute net SOC change (delta_SOC) for each representative cluster.
1458
+
1459
+ The delta_SOC is the difference between the charge_state at the end
1460
+ and start of each cluster: delta_SOC[c] = ΔE(end_c) - ΔE(start_c).
1461
+
1462
+ Since ΔE(start) = 0 by constraint, this simplifies to delta_SOC[c] = ΔE(end_c).
1463
+
1464
+ With 2D (cluster, time) structure, we can simply select isel(time=-1) and isel(time=0),
1465
+ which already have the 'cluster' dimension.
1466
+
1467
+ Args:
1468
+ n_clusters: Number of representative clusters (unused with 2D structure).
1469
+ timesteps_per_cluster: Timesteps in each cluster (unused with 2D structure).
1470
+
1471
+ Returns:
1472
+ DataArray with 'cluster' dimension containing delta_SOC for each cluster.
1473
+ """
1474
+ # With 2D structure: result already has cluster dimension
1475
+ return self.charge_state.isel(time=-1) - self.charge_state.isel(time=0)
1476
+
1477
+ def _add_linking_constraints(
1478
+ self,
1479
+ soc_boundary: xr.DataArray,
1480
+ delta_soc: xr.DataArray,
1481
+ cluster_assignments: xr.DataArray,
1482
+ n_original_clusters: int,
1483
+ timesteps_per_cluster: int,
1484
+ ) -> None:
1485
+ """Add constraints linking consecutive SOC_boundary values.
1486
+
1487
+ Per Blanke et al. (2022) Eq. 5, implements:
1488
+ SOC_boundary[d+1] = SOC_boundary[d] * (1-loss)^N + delta_SOC[cluster_assignments[d]]
1489
+
1490
+ where N is timesteps_per_cluster and loss is self-discharge rate per timestep.
1491
+
1492
+ This connects the SOC at the end of original period d to the SOC at the
1493
+ start of period d+1, accounting for self-discharge decay over the period.
1494
+
1495
+ Args:
1496
+ soc_boundary: SOC_boundary variable.
1497
+ delta_soc: Net SOC change per cluster.
1498
+ cluster_assignments: Mapping from original periods to representative clusters.
1499
+ n_original_clusters: Number of original (non-clustered) periods.
1500
+ timesteps_per_cluster: Number of timesteps in each cluster period.
1501
+ """
1502
+ soc_after = soc_boundary.isel(cluster_boundary=slice(1, None))
1503
+ soc_before = soc_boundary.isel(cluster_boundary=slice(None, -1))
1504
+
1505
+ # Rename for alignment
1506
+ soc_after = soc_after.rename({'cluster_boundary': 'original_cluster'})
1507
+ soc_after = soc_after.assign_coords(original_cluster=np.arange(n_original_clusters))
1508
+ soc_before = soc_before.rename({'cluster_boundary': 'original_cluster'})
1509
+ soc_before = soc_before.assign_coords(original_cluster=np.arange(n_original_clusters))
1510
+
1511
+ # Get delta_soc for each original period using cluster_assignments
1512
+ delta_soc_ordered = delta_soc.isel(cluster=cluster_assignments)
1513
+
1514
+ # Apply self-discharge decay factor (1-loss)^hours to soc_before per Eq. 5
1515
+ # relative_loss_per_hour is per-hour, so we need total hours per cluster
1516
+ # Use sum over time to get total duration (handles both regular and segmented systems)
1517
+ # Keep as DataArray to respect per-period/scenario values
1518
+ rel_loss = _scalar_safe_reduce(self.element.relative_loss_per_hour, 'time', 'mean')
1519
+ total_hours_per_cluster = _scalar_safe_reduce(self._model.timestep_duration, 'time', 'sum')
1520
+ decay_n = (1 - rel_loss) ** total_hours_per_cluster
1521
+
1522
+ lhs = soc_after - soc_before * decay_n - delta_soc_ordered
1523
+ self.add_constraints(lhs == 0, short_name='link')
1524
+
1525
+ def _add_combined_bound_constraints(
1526
+ self,
1527
+ soc_boundary: xr.DataArray,
1528
+ cluster_assignments: xr.DataArray,
1529
+ has_investment: bool,
1530
+ n_original_clusters: int,
1531
+ timesteps_per_cluster: int,
1532
+ ) -> None:
1533
+ """Add constraints ensuring actual SOC stays within bounds.
1534
+
1535
+ Per Blanke et al. (2022) Eq. 9, the actual SOC at time t in period d is:
1536
+ SOC(t) = SOC_boundary[d] * (1-loss)^t + ΔE(t)
1537
+
1538
+ This must satisfy: 0 ≤ SOC(t) ≤ capacity
1539
+
1540
+ Since checking every timestep is expensive, we sample at the start,
1541
+ middle, and end of each cluster.
1542
+
1543
+ With 2D (cluster, time) structure, we simply select charge_state at a
1544
+ given time offset, then reorder by cluster_assignments to get original_cluster order.
1545
+
1546
+ Args:
1547
+ soc_boundary: SOC_boundary variable.
1548
+ cluster_assignments: Mapping from original periods to clusters.
1549
+ has_investment: Whether the storage has investment sizing.
1550
+ n_original_clusters: Number of original periods.
1551
+ timesteps_per_cluster: Timesteps in each cluster.
1552
+ """
1553
+ charge_state = self.charge_state
1554
+
1555
+ # soc_d: SOC at start of each original period
1556
+ soc_d = soc_boundary.isel(cluster_boundary=slice(None, -1))
1557
+ soc_d = soc_d.rename({'cluster_boundary': 'original_cluster'})
1558
+ soc_d = soc_d.assign_coords(original_cluster=np.arange(n_original_clusters))
1559
+
1560
+ # Get self-discharge rate for decay calculation
1561
+ # relative_loss_per_hour is per-hour, so we need to convert offsets to hours
1562
+ # Keep as DataArray to respect per-period/scenario values
1563
+ rel_loss = _scalar_safe_reduce(self.element.relative_loss_per_hour, 'time', 'mean')
1564
+
1565
+ # Compute cumulative hours for accurate offset calculation with non-uniform timesteps
1566
+ timestep_duration = self._model.timestep_duration
1567
+ if isinstance(timestep_duration, xr.DataArray) and 'time' in timestep_duration.dims:
1568
+ # Use cumsum for accurate hours offset with non-uniform timesteps
1569
+ # Build cumulative_hours with N+1 elements to match charge_state's extra timestep:
1570
+ # index 0 = 0 hours, index i = sum of durations[0:i], index N = total duration
1571
+ cumsum = timestep_duration.cumsum('time')
1572
+ # Prepend 0 at the start, giving [0, cumsum[0], cumsum[1], ..., cumsum[N-1]]
1573
+ cumulative_hours = xr.concat(
1574
+ [xr.zeros_like(timestep_duration.isel(time=0)), cumsum],
1575
+ dim='time',
1576
+ )
1577
+ else:
1578
+ # Scalar or no time dim: fall back to mean-based calculation
1579
+ mean_timestep_duration = _scalar_safe_reduce(timestep_duration, 'time', 'mean')
1580
+ cumulative_hours = None
1581
+
1582
+ # Use actual time dimension size (may be smaller than timesteps_per_cluster for segmented systems)
1583
+ actual_time_size = charge_state.sizes['time']
1584
+ sample_offsets = [0, actual_time_size // 2, actual_time_size - 1]
1585
+
1586
+ for sample_name, offset in zip(['start', 'mid', 'end'], sample_offsets, strict=False):
1587
+ # With 2D structure: select time offset, then reorder by cluster_assignments
1588
+ cs_at_offset = charge_state.isel(time=offset) # Shape: (cluster, ...)
1589
+ # Reorder to original_cluster order using cluster_assignments indexer
1590
+ cs_t = cs_at_offset.isel(cluster=cluster_assignments)
1591
+ # Suppress xarray warning about index loss - we immediately assign new coords anyway
1592
+ with warnings.catch_warnings():
1593
+ warnings.filterwarnings('ignore', message='.*does not create an index anymore.*')
1594
+ cs_t = cs_t.rename({'cluster': 'original_cluster'})
1595
+ cs_t = cs_t.assign_coords(original_cluster=np.arange(n_original_clusters))
1596
+
1597
+ # Apply decay factor (1-loss)^hours to SOC_boundary per Eq. 9
1598
+ # Convert timestep offset to hours using cumulative duration for non-uniform timesteps
1599
+ if cumulative_hours is not None:
1600
+ hours_offset = cumulative_hours.isel(time=offset)
1601
+ else:
1602
+ hours_offset = offset * mean_timestep_duration
1603
+ decay_t = (1 - rel_loss) ** hours_offset
1604
+ combined = soc_d * decay_t + cs_t
1605
+
1606
+ self.add_constraints(combined >= 0, short_name=f'soc_lb_{sample_name}')
1607
+
1608
+ if has_investment and self.investment is not None:
1609
+ self.add_constraints(combined <= self.investment.size, short_name=f'soc_ub_{sample_name}')
1610
+ elif not has_investment and isinstance(self.element.capacity_in_flow_hours, (int, float)):
1611
+ # Fixed-capacity storage: upper bound is the fixed capacity
1612
+ self.add_constraints(
1613
+ combined <= self.element.capacity_in_flow_hours, short_name=f'soc_ub_{sample_name}'
1614
+ )
1615
+
1616
+
968
1617
  @register_class_for_io
969
1618
  class SourceAndSink(Component):
970
1619
  """
@@ -1058,58 +1707,18 @@ class SourceAndSink(Component):
1058
1707
  outputs: list[Flow] | None = None,
1059
1708
  prevent_simultaneous_flow_rates: bool = True,
1060
1709
  meta_data: dict | None = None,
1061
- **kwargs,
1710
+ color: str | None = None,
1062
1711
  ):
1063
- # Handle deprecated parameters using centralized helper
1064
- outputs = self._handle_deprecated_kwarg(kwargs, 'source', 'outputs', outputs, transform=lambda x: [x])
1065
- inputs = self._handle_deprecated_kwarg(kwargs, 'sink', 'inputs', inputs, transform=lambda x: [x])
1066
- prevent_simultaneous_flow_rates = self._handle_deprecated_kwarg(
1067
- kwargs,
1068
- 'prevent_simultaneous_sink_and_source',
1069
- 'prevent_simultaneous_flow_rates',
1070
- prevent_simultaneous_flow_rates,
1071
- check_conflict=False,
1072
- )
1073
-
1074
- # Validate any remaining unexpected kwargs
1075
- self._validate_kwargs(kwargs)
1076
-
1077
1712
  super().__init__(
1078
1713
  label,
1079
1714
  inputs=inputs,
1080
1715
  outputs=outputs,
1081
1716
  prevent_simultaneous_flows=(inputs or []) + (outputs or []) if prevent_simultaneous_flow_rates else None,
1082
1717
  meta_data=meta_data,
1718
+ color=color,
1083
1719
  )
1084
1720
  self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
1085
1721
 
1086
- @property
1087
- def source(self) -> Flow:
1088
- warnings.warn(
1089
- 'The source property is deprecated. Use the outputs property instead.',
1090
- DeprecationWarning,
1091
- stacklevel=2,
1092
- )
1093
- return self.outputs[0]
1094
-
1095
- @property
1096
- def sink(self) -> Flow:
1097
- warnings.warn(
1098
- 'The sink property is deprecated. Use the inputs property instead.',
1099
- DeprecationWarning,
1100
- stacklevel=2,
1101
- )
1102
- return self.inputs[0]
1103
-
1104
- @property
1105
- def prevent_simultaneous_sink_and_source(self) -> bool:
1106
- warnings.warn(
1107
- 'The prevent_simultaneous_sink_and_source property is deprecated. Use the prevent_simultaneous_flow_rates property instead.',
1108
- DeprecationWarning,
1109
- stacklevel=2,
1110
- )
1111
- return self.prevent_simultaneous_flow_rates
1112
-
1113
1722
 
1114
1723
  @register_class_for_io
1115
1724
  class Source(Component):
@@ -1193,31 +1802,17 @@ class Source(Component):
1193
1802
  outputs: list[Flow] | None = None,
1194
1803
  meta_data: dict | None = None,
1195
1804
  prevent_simultaneous_flow_rates: bool = False,
1196
- **kwargs,
1805
+ color: str | None = None,
1197
1806
  ):
1198
- # Handle deprecated parameter using centralized helper
1199
- outputs = self._handle_deprecated_kwarg(kwargs, 'source', 'outputs', outputs, transform=lambda x: [x])
1200
-
1201
- # Validate any remaining unexpected kwargs
1202
- self._validate_kwargs(kwargs)
1203
-
1204
1807
  self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
1205
1808
  super().__init__(
1206
1809
  label,
1207
1810
  outputs=outputs,
1208
1811
  meta_data=meta_data,
1209
1812
  prevent_simultaneous_flows=outputs if prevent_simultaneous_flow_rates else None,
1813
+ color=color,
1210
1814
  )
1211
1815
 
1212
- @property
1213
- def source(self) -> Flow:
1214
- warnings.warn(
1215
- 'The source property is deprecated. Use the outputs property instead.',
1216
- DeprecationWarning,
1217
- stacklevel=2,
1218
- )
1219
- return self.outputs[0]
1220
-
1221
1816
 
1222
1817
  @register_class_for_io
1223
1818
  class Sink(Component):
@@ -1302,27 +1897,18 @@ class Sink(Component):
1302
1897
  inputs: list[Flow] | None = None,
1303
1898
  meta_data: dict | None = None,
1304
1899
  prevent_simultaneous_flow_rates: bool = False,
1305
- **kwargs,
1900
+ color: str | None = None,
1306
1901
  ):
1902
+ """Initialize a Sink (consumes flow from the system).
1903
+
1904
+ Args:
1905
+ label: Unique element label.
1906
+ inputs: Input flows for the sink.
1907
+ meta_data: Arbitrary metadata attached to the element.
1908
+ prevent_simultaneous_flow_rates: If True, prevents simultaneous nonzero flow rates
1909
+ across the element's inputs by wiring that restriction into the base Component setup.
1910
+ color: Optional color for visualizations.
1307
1911
  """
1308
- Initialize a Sink (consumes flow from the system).
1309
-
1310
- Supports legacy `sink=` keyword for backward compatibility (deprecated): if `sink` is provided it is used as the single input flow and a DeprecationWarning is issued; specifying both `inputs` and `sink` raises ValueError.
1311
-
1312
- Parameters:
1313
- label (str): Unique element label.
1314
- inputs (list[Flow], optional): Input flows for the sink.
1315
- meta_data (dict, optional): Arbitrary metadata attached to the element.
1316
- prevent_simultaneous_flow_rates (bool, optional): If True, prevents simultaneous nonzero flow rates across the element's inputs by wiring that restriction into the base Component setup.
1317
-
1318
- Note:
1319
- The deprecated `sink` kwarg is accepted for compatibility but will be removed in future releases.
1320
- """
1321
- # Handle deprecated parameter using centralized helper
1322
- inputs = self._handle_deprecated_kwarg(kwargs, 'sink', 'inputs', inputs, transform=lambda x: [x])
1323
-
1324
- # Validate any remaining unexpected kwargs
1325
- self._validate_kwargs(kwargs)
1326
1912
 
1327
1913
  self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
1328
1914
  super().__init__(
@@ -1330,13 +1916,5 @@ class Sink(Component):
1330
1916
  inputs=inputs,
1331
1917
  meta_data=meta_data,
1332
1918
  prevent_simultaneous_flows=inputs if prevent_simultaneous_flow_rates else None,
1919
+ color=color,
1333
1920
  )
1334
-
1335
- @property
1336
- def sink(self) -> Flow:
1337
- warnings.warn(
1338
- 'The sink property is deprecated. Use the inputs property instead.',
1339
- DeprecationWarning,
1340
- stacklevel=2,
1341
- )
1342
- return self.inputs[0]