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/interface.py CHANGED
@@ -1,28 +1,26 @@
1
1
  """
2
- This module contains classes to collect Parameters for the Investment and OnOff decisions.
2
+ This module contains classes to collect Parameters for the Investment and Status decisions.
3
3
  These are tightly connected to features.py
4
4
  """
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
8
  import logging
9
- import warnings
10
- from typing import TYPE_CHECKING, Literal, Optional
9
+ from typing import TYPE_CHECKING, Any, Literal
11
10
 
12
11
  import numpy as np
13
12
  import pandas as pd
13
+ import plotly.express as px
14
14
  import xarray as xr
15
15
 
16
16
  from .config import CONFIG
17
+ from .plot_result import PlotResult
17
18
  from .structure import Interface, register_class_for_io
18
19
 
19
20
  if TYPE_CHECKING: # for type checking and preventing circular imports
20
21
  from collections.abc import Iterator
21
22
 
22
- from .core import PeriodicData, PeriodicDataUser, Scalar, TemporalDataUser
23
- from .effects import PeriodicEffectsUser, TemporalEffectsUser
24
- from .flow_system import FlowSystem
25
-
23
+ from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_TPS
26
24
 
27
25
  logger = logging.getLogger('flixopt')
28
26
 
@@ -73,21 +71,27 @@ class Piece(Interface):
73
71
 
74
72
  """
75
73
 
76
- def __init__(self, start: TemporalDataUser, end: TemporalDataUser):
74
+ def __init__(self, start: Numeric_TPS, end: Numeric_TPS):
77
75
  self.start = start
78
76
  self.end = end
79
77
  self.has_time_dim = False
80
78
 
81
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
79
+ def transform_data(self) -> None:
82
80
  dims = None if self.has_time_dim else ['period', 'scenario']
83
- self.start = flow_system.fit_to_model_coords(f'{name_prefix}|start', self.start, dims=dims)
84
- self.end = flow_system.fit_to_model_coords(f'{name_prefix}|end', self.end, dims=dims)
81
+ self.start = self._fit_coords(f'{self.prefix}|start', self.start, dims=dims)
82
+ self.end = self._fit_coords(f'{self.prefix}|end', self.end, dims=dims)
85
83
 
86
84
 
87
85
  @register_class_for_io
88
86
  class Piecewise(Interface):
89
- """
90
- Define a Piecewise, consisting of a list of Pieces.
87
+ """Define piecewise linear approximations for modeling non-linear relationships.
88
+
89
+ Enables modeling of non-linear relationships through piecewise linear segments
90
+ while maintaining problem linearity. Consists of a collection of Pieces that
91
+ define valid ranges for variables.
92
+
93
+ Mathematical Formulation:
94
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/features/Piecewise/>
91
95
 
92
96
  Args:
93
97
  pieces: list of Piece objects defining the linear segments. The arrangement
@@ -224,9 +228,15 @@ class Piecewise(Interface):
224
228
  def __iter__(self) -> Iterator[Piece]:
225
229
  return iter(self.pieces) # Enables iteration like for piece in piecewise: ...
226
230
 
227
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
231
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
232
+ """Propagate flow_system reference to nested Piece objects."""
233
+ super().link_to_flow_system(flow_system, prefix)
228
234
  for i, piece in enumerate(self.pieces):
229
- piece.transform_data(flow_system, f'{name_prefix}|Piece{i}')
235
+ piece.link_to_flow_system(flow_system, self._sub_prefix(f'Piece{i}'))
236
+
237
+ def transform_data(self) -> None:
238
+ for piece in self.pieces:
239
+ piece.transform_data()
230
240
 
231
241
 
232
242
  @register_class_for_io
@@ -411,7 +421,7 @@ class PiecewiseConversion(Interface):
411
421
  operate in certain ranges (e.g., minimum loads, unstable regions).
412
422
 
413
423
  **Discrete Modes**: Use pieces with identical start/end values to model
414
- equipment with fixed operating points (e.g., on/off, discrete speeds).
424
+ equipment with fixed operating points (e.g., on/inactive, discrete speeds).
415
425
 
416
426
  **Efficiency Changes**: Coordinate input and output pieces to reflect
417
427
  changing conversion efficiency across operating ranges.
@@ -450,9 +460,151 @@ class PiecewiseConversion(Interface):
450
460
  """
451
461
  return self.piecewises.items()
452
462
 
453
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
463
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
464
+ """Propagate flow_system reference to nested Piecewise objects."""
465
+ super().link_to_flow_system(flow_system, prefix)
454
466
  for name, piecewise in self.piecewises.items():
455
- piecewise.transform_data(flow_system, f'{name_prefix}|{name}')
467
+ piecewise.link_to_flow_system(flow_system, self._sub_prefix(name))
468
+
469
+ def transform_data(self) -> None:
470
+ for piecewise in self.piecewises.values():
471
+ piecewise.transform_data()
472
+
473
+ def plot(
474
+ self,
475
+ x_flow: str | None = None,
476
+ title: str = '',
477
+ select: dict[str, Any] | None = None,
478
+ colorscale: str | None = None,
479
+ show: bool | None = None,
480
+ ) -> PlotResult:
481
+ """Plot multi-flow piecewise conversion with time variation visualization.
482
+
483
+ Visualizes the piecewise linear relationships between flows. Each flow
484
+ is shown in a separate subplot (faceted by flow). Pieces are distinguished
485
+ by line dash style. If boundaries vary over time, color shows time progression.
486
+
487
+ Note:
488
+ Requires FlowSystem to be connected and transformed (call
489
+ flow_system.connect_and_transform() first).
490
+
491
+ Args:
492
+ x_flow: Flow label to use for X-axis. Defaults to first flow in dict.
493
+ title: Plot title.
494
+ select: xarray-style selection dict to filter data,
495
+ e.g. {'time': slice('2024-01-01', '2024-01-02')}.
496
+ colorscale: Colorscale name for time coloring (e.g., 'RdYlBu_r', 'viridis').
497
+ Defaults to CONFIG.Plotting.default_sequential_colorscale.
498
+ show: Whether to display the figure.
499
+ Defaults to CONFIG.Plotting.default_show.
500
+
501
+ Returns:
502
+ PlotResult containing the figure and underlying piecewise data.
503
+
504
+ Examples:
505
+ >>> flow_system.connect_and_transform()
506
+ >>> chp.piecewise_conversion.plot(x_flow='Gas', title='CHP Curves')
507
+ >>> # Select specific time range
508
+ >>> chp.piecewise_conversion.plot(select={'time': slice(0, 12)})
509
+ """
510
+ if not self.flow_system.connected_and_transformed:
511
+ logger.debug('Connecting flow_system for plotting PiecewiseConversion')
512
+ self.flow_system.connect_and_transform()
513
+
514
+ colorscale = colorscale or CONFIG.Plotting.default_sequential_colorscale
515
+
516
+ flow_labels = list(self.piecewises.keys())
517
+ x_label = x_flow if x_flow is not None else flow_labels[0]
518
+ if x_label not in flow_labels:
519
+ raise ValueError(f"x_flow '{x_label}' not found. Available: {flow_labels}")
520
+
521
+ y_flows = [label for label in flow_labels if label != x_label]
522
+ if not y_flows:
523
+ raise ValueError('Need at least two flows to plot')
524
+
525
+ x_piecewise = self.piecewises[x_label]
526
+
527
+ # Build Dataset with all piece data
528
+ datasets = []
529
+ for y_label in y_flows:
530
+ y_piecewise = self.piecewises[y_label]
531
+ for i, (x_piece, y_piece) in enumerate(zip(x_piecewise, y_piecewise, strict=False)):
532
+ ds = xr.Dataset(
533
+ {
534
+ x_label: xr.concat([x_piece.start, x_piece.end], dim='point'),
535
+ 'output': xr.concat([y_piece.start, y_piece.end], dim='point'),
536
+ }
537
+ )
538
+ ds = ds.assign_coords(point=['start', 'end'])
539
+ ds['flow'] = y_label
540
+ ds['piece'] = f'Piece {i}'
541
+ datasets.append(ds)
542
+
543
+ combined = xr.concat(datasets, dim='trace')
544
+
545
+ # Apply selection if provided
546
+ if select:
547
+ valid_select = {k: v for k, v in select.items() if k in combined.dims or k in combined.coords}
548
+ if valid_select:
549
+ combined = combined.sel(valid_select)
550
+
551
+ df = combined.to_dataframe().reset_index()
552
+
553
+ # Check if values vary over time
554
+ has_time = 'time' in df.columns
555
+ varies_over_time = False
556
+ if has_time:
557
+ varies_over_time = df.groupby(['trace', 'point'])[[x_label, 'output']].nunique().max().max() > 1
558
+
559
+ if varies_over_time:
560
+ # Time-varying: color by time, dash by piece
561
+ df['time_idx'] = df.groupby('time').ngroup()
562
+ df['line_id'] = df['trace'].astype(str) + '_' + df['time_idx'].astype(str)
563
+ n_times = df['time_idx'].nunique()
564
+ colors = px.colors.sample_colorscale(colorscale, n_times)
565
+
566
+ fig = px.line(
567
+ df,
568
+ x=x_label,
569
+ y='output',
570
+ color='time_idx',
571
+ line_dash='piece',
572
+ line_group='line_id',
573
+ facet_col='flow' if len(y_flows) > 1 else None,
574
+ title=title or 'Piecewise Conversion',
575
+ markers=True,
576
+ color_discrete_sequence=colors,
577
+ )
578
+ else:
579
+ # Static: dash by piece
580
+ if has_time:
581
+ df = df.groupby(['trace', 'point', 'flow', 'piece']).first().reset_index()
582
+ df['line_id'] = df['trace'].astype(str)
583
+
584
+ fig = px.line(
585
+ df,
586
+ x=x_label,
587
+ y='output',
588
+ line_dash='piece',
589
+ line_group='line_id',
590
+ facet_col='flow' if len(y_flows) > 1 else None,
591
+ title=title or 'Piecewise Conversion',
592
+ markers=True,
593
+ )
594
+
595
+ # Clean up facet titles and axis labels
596
+ fig.for_each_annotation(lambda a: a.update(text=a.text.replace('flow=', '')))
597
+ fig.update_yaxes(title_text='')
598
+ fig.update_xaxes(title_text=x_label)
599
+
600
+ result = PlotResult(data=combined, figure=fig)
601
+
602
+ if show is None:
603
+ show = CONFIG.Plotting.default_show
604
+ if show:
605
+ result.show()
606
+
607
+ return result
456
608
 
457
609
 
458
610
  @register_class_for_io
@@ -662,10 +814,142 @@ class PiecewiseEffects(Interface):
662
814
  for piecewise in self.piecewise_shares.values():
663
815
  piecewise.has_time_dim = value
664
816
 
665
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
666
- self.piecewise_origin.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|origin')
817
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
818
+ """Propagate flow_system reference to nested Piecewise objects."""
819
+ super().link_to_flow_system(flow_system, prefix)
820
+ self.piecewise_origin.link_to_flow_system(flow_system, self._sub_prefix('origin'))
667
821
  for effect, piecewise in self.piecewise_shares.items():
668
- piecewise.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects|{effect}')
822
+ piecewise.link_to_flow_system(flow_system, self._sub_prefix(effect))
823
+
824
+ def transform_data(self) -> None:
825
+ self.piecewise_origin.transform_data()
826
+ for piecewise in self.piecewise_shares.values():
827
+ piecewise.transform_data()
828
+
829
+ def plot(
830
+ self,
831
+ title: str = '',
832
+ select: dict[str, Any] | None = None,
833
+ colorscale: str | None = None,
834
+ show: bool | None = None,
835
+ ) -> PlotResult:
836
+ """Plot origin vs effect shares with time variation visualization.
837
+
838
+ Visualizes the piecewise linear relationships between the origin variable
839
+ and its effect shares. Each effect is shown in a separate subplot (faceted
840
+ by effect). Pieces are distinguished by line dash style.
841
+
842
+ Note:
843
+ Requires FlowSystem to be connected and transformed (call
844
+ flow_system.connect_and_transform() first).
845
+
846
+ Args:
847
+ title: Plot title.
848
+ select: xarray-style selection dict to filter data,
849
+ e.g. {'time': slice('2024-01-01', '2024-01-02')}.
850
+ colorscale: Colorscale name for time coloring (e.g., 'RdYlBu_r', 'viridis').
851
+ Defaults to CONFIG.Plotting.default_sequential_colorscale.
852
+ show: Whether to display the figure.
853
+ Defaults to CONFIG.Plotting.default_show.
854
+
855
+ Returns:
856
+ PlotResult containing the figure and underlying piecewise data.
857
+
858
+ Examples:
859
+ >>> flow_system.connect_and_transform()
860
+ >>> invest_params.piecewise_effects_of_investment.plot(title='Investment Effects')
861
+ """
862
+ if not self.flow_system.connected_and_transformed:
863
+ logger.debug('Connecting flow_system for plotting PiecewiseEffects')
864
+ self.flow_system.connect_and_transform()
865
+
866
+ colorscale = colorscale or CONFIG.Plotting.default_sequential_colorscale
867
+
868
+ effect_labels = list(self.piecewise_shares.keys())
869
+ if not effect_labels:
870
+ raise ValueError('Need at least one effect share to plot')
871
+
872
+ # Build Dataset with all piece data
873
+ datasets = []
874
+ for effect_label in effect_labels:
875
+ y_piecewise = self.piecewise_shares[effect_label]
876
+ for i, (x_piece, y_piece) in enumerate(zip(self.piecewise_origin, y_piecewise, strict=False)):
877
+ ds = xr.Dataset(
878
+ {
879
+ 'origin': xr.concat([x_piece.start, x_piece.end], dim='point'),
880
+ 'share': xr.concat([y_piece.start, y_piece.end], dim='point'),
881
+ }
882
+ )
883
+ ds = ds.assign_coords(point=['start', 'end'])
884
+ ds['effect'] = effect_label
885
+ ds['piece'] = f'Piece {i}'
886
+ datasets.append(ds)
887
+
888
+ combined = xr.concat(datasets, dim='trace')
889
+
890
+ # Apply selection if provided
891
+ if select:
892
+ valid_select = {k: v for k, v in select.items() if k in combined.dims or k in combined.coords}
893
+ if valid_select:
894
+ combined = combined.sel(valid_select)
895
+
896
+ df = combined.to_dataframe().reset_index()
897
+
898
+ # Check if values vary over time
899
+ has_time = 'time' in df.columns
900
+ varies_over_time = False
901
+ if has_time:
902
+ varies_over_time = df.groupby(['trace', 'point'])[['origin', 'share']].nunique().max().max() > 1
903
+
904
+ if varies_over_time:
905
+ # Time-varying: color by time, dash by piece
906
+ df['time_idx'] = df.groupby('time').ngroup()
907
+ df['line_id'] = df['trace'].astype(str) + '_' + df['time_idx'].astype(str)
908
+ n_times = df['time_idx'].nunique()
909
+ colors = px.colors.sample_colorscale(colorscale, n_times)
910
+
911
+ fig = px.line(
912
+ df,
913
+ x='origin',
914
+ y='share',
915
+ color='time_idx',
916
+ line_dash='piece',
917
+ line_group='line_id',
918
+ facet_col='effect' if len(effect_labels) > 1 else None,
919
+ title=title or 'Piecewise Effects',
920
+ markers=True,
921
+ color_discrete_sequence=colors,
922
+ )
923
+ else:
924
+ # Static: dash by piece
925
+ if has_time:
926
+ df = df.groupby(['trace', 'point', 'effect', 'piece']).first().reset_index()
927
+ df['line_id'] = df['trace'].astype(str)
928
+
929
+ fig = px.line(
930
+ df,
931
+ x='origin',
932
+ y='share',
933
+ line_dash='piece',
934
+ line_group='line_id',
935
+ facet_col='effect' if len(effect_labels) > 1 else None,
936
+ title=title or 'Piecewise Effects',
937
+ markers=True,
938
+ )
939
+
940
+ # Clean up facet titles and axis labels
941
+ fig.for_each_annotation(lambda a: a.update(text=a.text.replace('effect=', '')))
942
+ fig.update_yaxes(title_text='')
943
+ fig.update_xaxes(title_text='Origin')
944
+
945
+ result = PlotResult(data=combined, figure=fig)
946
+
947
+ if show is None:
948
+ show = CONFIG.Plotting.default_show
949
+ if show:
950
+ result.show()
951
+
952
+ return result
669
953
 
670
954
 
671
955
  @register_class_for_io
@@ -691,14 +975,13 @@ class InvestParameters(Interface):
691
975
  - **Divestment Effects**: Penalties for not investing (demolition, opportunity costs)
692
976
 
693
977
  Mathematical Formulation:
694
- See the complete mathematical model in the documentation:
695
- [InvestParameters](../user-guide/mathematical-notation/features/InvestParameters.md)
978
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/features/InvestParameters/>
696
979
 
697
980
  Args:
698
981
  fixed_size: Creates binary decision at this exact size. None allows continuous sizing.
699
982
  minimum_size: Lower bound for continuous sizing. Default: CONFIG.Modeling.epsilon.
700
983
  Ignored if fixed_size is specified.
701
- maximum_size: Upper bound for continuous sizing. Default: CONFIG.Modeling.big.
984
+ maximum_size: Upper bound for continuous sizing. Required if fixed_size is not set.
702
985
  Ignored if fixed_size is specified.
703
986
  mandatory: Controls whether investment is required. When True, forces investment
704
987
  to occur (useful for mandatory upgrades or replacement decisions).
@@ -712,26 +995,15 @@ class InvestParameters(Interface):
712
995
  Combinable with effects_of_investment and effects_of_investment_per_size.
713
996
  effects_of_retirement: Costs incurred if NOT investing (demolition, penalties).
714
997
  Dict: {'effect_name': value}.
715
-
716
- Deprecated Args:
717
- fix_effects: **Deprecated**. Use `effects_of_investment` instead.
718
- Will be removed in version 4.0.
719
- specific_effects: **Deprecated**. Use `effects_of_investment_per_size` instead.
720
- Will be removed in version 4.0.
721
- divest_effects: **Deprecated**. Use `effects_of_retirement` instead.
722
- Will be removed in version 4.0.
723
- piecewise_effects: **Deprecated**. Use `piecewise_effects_of_investment` instead.
724
- Will be removed in version 4.0.
725
- optional: DEPRECATED. Use `mandatory` instead. Opposite of `mandatory`.
726
- Will be removed in version 4.0.
727
998
  linked_periods: Describes which periods are linked. 1 means linked, 0 means size=0. None means no linked periods.
999
+ For convenience, pass a tuple containing the first and last period (2025, 2039), linking them and those in between
728
1000
 
729
1001
  Cost Annualization Requirements:
730
1002
  All cost values must be properly weighted to match the optimization model's time horizon.
731
1003
  For long-term investments, the cost values should be annualized to the corresponding operation time (annuity).
732
1004
 
733
1005
  - Use equivalent annual cost (capital cost / equipment lifetime)
734
- - Apply appropriate discount rates for present value calculations
1006
+ - Apply appropriate discount rates for present value optimizations
735
1007
  - Account for inflation, escalation, and financing costs
736
1008
 
737
1009
  Example: €1M equipment with 20-year life → €50k/year fixed cost
@@ -873,89 +1145,69 @@ class InvestParameters(Interface):
873
1145
 
874
1146
  def __init__(
875
1147
  self,
876
- fixed_size: PeriodicDataUser | None = None,
877
- minimum_size: PeriodicDataUser | None = None,
878
- maximum_size: PeriodicDataUser | None = None,
1148
+ fixed_size: Numeric_PS | None = None,
1149
+ minimum_size: Numeric_PS | None = None,
1150
+ maximum_size: Numeric_PS | None = None,
879
1151
  mandatory: bool = False,
880
- effects_of_investment: PeriodicEffectsUser | None = None,
881
- effects_of_investment_per_size: PeriodicEffectsUser | None = None,
882
- effects_of_retirement: PeriodicEffectsUser | None = None,
1152
+ effects_of_investment: Effect_PS | Numeric_PS | None = None,
1153
+ effects_of_investment_per_size: Effect_PS | Numeric_PS | None = None,
1154
+ effects_of_retirement: Effect_PS | Numeric_PS | None = None,
883
1155
  piecewise_effects_of_investment: PiecewiseEffects | None = None,
884
- linked_periods: PeriodicDataUser | tuple[int, int] | None = None,
885
- **kwargs,
1156
+ linked_periods: Numeric_PS | tuple[int, int] | None = None,
886
1157
  ):
887
- # Handle deprecated parameters using centralized helper
888
- effects_of_investment = self._handle_deprecated_kwarg(
889
- kwargs, 'fix_effects', 'effects_of_investment', effects_of_investment
890
- )
891
- effects_of_investment_per_size = self._handle_deprecated_kwarg(
892
- kwargs, 'specific_effects', 'effects_of_investment_per_size', effects_of_investment_per_size
893
- )
894
- effects_of_retirement = self._handle_deprecated_kwarg(
895
- kwargs, 'divest_effects', 'effects_of_retirement', effects_of_retirement
896
- )
897
- piecewise_effects_of_investment = self._handle_deprecated_kwarg(
898
- kwargs, 'piecewise_effects', 'piecewise_effects_of_investment', piecewise_effects_of_investment
899
- )
900
- # For mandatory parameter with non-None default, disable conflict checking
901
- if 'optional' in kwargs:
902
- warnings.warn(
903
- 'Deprecated parameter "optional" used. Check conflicts with new parameter "mandatory" manually!',
904
- DeprecationWarning,
905
- stacklevel=2,
906
- )
907
- mandatory = self._handle_deprecated_kwarg(
908
- kwargs, 'optional', 'mandatory', mandatory, transform=lambda x: not x, check_conflict=False
909
- )
910
-
911
- # Validate any remaining unexpected kwargs
912
- self._validate_kwargs(kwargs)
913
-
914
- self.effects_of_investment: PeriodicEffectsUser = (
915
- effects_of_investment if effects_of_investment is not None else {}
916
- )
917
- self.effects_of_retirement: PeriodicEffectsUser = (
918
- effects_of_retirement if effects_of_retirement is not None else {}
919
- )
1158
+ self.effects_of_investment = effects_of_investment if effects_of_investment is not None else {}
1159
+ self.effects_of_retirement = effects_of_retirement if effects_of_retirement is not None else {}
920
1160
  self.fixed_size = fixed_size
921
1161
  self.mandatory = mandatory
922
- self.effects_of_investment_per_size: PeriodicEffectsUser = (
1162
+ self.effects_of_investment_per_size = (
923
1163
  effects_of_investment_per_size if effects_of_investment_per_size is not None else {}
924
1164
  )
925
1165
  self.piecewise_effects_of_investment = piecewise_effects_of_investment
926
1166
  self.minimum_size = minimum_size if minimum_size is not None else CONFIG.Modeling.epsilon
927
- self.maximum_size = maximum_size if maximum_size is not None else CONFIG.Modeling.big # default maximum
1167
+ self.maximum_size = maximum_size
928
1168
  self.linked_periods = linked_periods
929
1169
 
930
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
931
- self.effects_of_investment = flow_system.fit_effects_to_model_coords(
932
- label_prefix=name_prefix,
1170
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
1171
+ """Propagate flow_system reference to nested PiecewiseEffects object if present."""
1172
+ super().link_to_flow_system(flow_system, prefix)
1173
+ if self.piecewise_effects_of_investment is not None:
1174
+ self.piecewise_effects_of_investment.link_to_flow_system(flow_system, self._sub_prefix('PiecewiseEffects'))
1175
+
1176
+ def transform_data(self) -> None:
1177
+ # Validate that either fixed_size or maximum_size is set
1178
+ if self.fixed_size is None and self.maximum_size is None:
1179
+ raise ValueError(
1180
+ f'InvestParameters in "{self.prefix}" requires either fixed_size or maximum_size to be set. '
1181
+ f'An upper bound is needed to properly scale the optimization model.'
1182
+ )
1183
+ self.effects_of_investment = self._fit_effect_coords(
1184
+ prefix=self.prefix,
933
1185
  effect_values=self.effects_of_investment,
934
- label_suffix='effects_of_investment',
1186
+ suffix='effects_of_investment',
935
1187
  dims=['period', 'scenario'],
936
1188
  )
937
- self.effects_of_retirement = flow_system.fit_effects_to_model_coords(
938
- label_prefix=name_prefix,
1189
+ self.effects_of_retirement = self._fit_effect_coords(
1190
+ prefix=self.prefix,
939
1191
  effect_values=self.effects_of_retirement,
940
- label_suffix='effects_of_retirement',
1192
+ suffix='effects_of_retirement',
941
1193
  dims=['period', 'scenario'],
942
1194
  )
943
- self.effects_of_investment_per_size = flow_system.fit_effects_to_model_coords(
944
- label_prefix=name_prefix,
1195
+ self.effects_of_investment_per_size = self._fit_effect_coords(
1196
+ prefix=self.prefix,
945
1197
  effect_values=self.effects_of_investment_per_size,
946
- label_suffix='effects_of_investment_per_size',
1198
+ suffix='effects_of_investment_per_size',
947
1199
  dims=['period', 'scenario'],
948
1200
  )
949
1201
 
950
1202
  if self.piecewise_effects_of_investment is not None:
951
1203
  self.piecewise_effects_of_investment.has_time_dim = False
952
- self.piecewise_effects_of_investment.transform_data(flow_system, f'{name_prefix}|PiecewiseEffects')
1204
+ self.piecewise_effects_of_investment.transform_data()
953
1205
 
954
- self.minimum_size = flow_system.fit_to_model_coords(
955
- f'{name_prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario']
1206
+ self.minimum_size = self._fit_coords(
1207
+ f'{self.prefix}|minimum_size', self.minimum_size, dims=['period', 'scenario']
956
1208
  )
957
- self.maximum_size = flow_system.fit_to_model_coords(
958
- f'{name_prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario']
1209
+ self.maximum_size = self._fit_coords(
1210
+ f'{self.prefix}|maximum_size', self.maximum_size, dims=['period', 'scenario']
959
1211
  )
960
1212
  # Convert tuple (first_period, last_period) to DataArray if needed
961
1213
  if isinstance(self.linked_periods, (tuple, list)):
@@ -963,87 +1215,57 @@ class InvestParameters(Interface):
963
1215
  raise TypeError(
964
1216
  f'If you provide a tuple to "linked_periods", it needs to be len=2. Got {len(self.linked_periods)=}'
965
1217
  )
1218
+ if self.flow_system.periods is None:
1219
+ raise ValueError(
1220
+ f'Cannot use linked_periods={self.linked_periods} when FlowSystem has no periods defined. '
1221
+ f'Please define periods in FlowSystem or use linked_periods=None.'
1222
+ )
966
1223
  logger.debug(f'Computing linked_periods from {self.linked_periods}')
967
1224
  start, end = self.linked_periods
968
- if start not in flow_system.periods.values:
1225
+ if start not in self.flow_system.periods.values:
969
1226
  logger.warning(
970
- f'Start of linked periods ({start} not found in periods directly: {flow_system.periods.values}'
1227
+ f'Start of linked periods ({start} not found in periods directly: {self.flow_system.periods.values}'
971
1228
  )
972
- if end not in flow_system.periods.values:
1229
+ if end not in self.flow_system.periods.values:
973
1230
  logger.warning(
974
- f'End of linked periods ({end} not found in periods directly: {flow_system.periods.values}'
1231
+ f'End of linked periods ({end} not found in periods directly: {self.flow_system.periods.values}'
975
1232
  )
976
- self.linked_periods = self.compute_linked_periods(start, end, flow_system.periods)
1233
+ self.linked_periods = self.compute_linked_periods(start, end, self.flow_system.periods)
977
1234
  logger.debug(f'Computed {self.linked_periods=}')
978
1235
 
979
- self.linked_periods = flow_system.fit_to_model_coords(
980
- f'{name_prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario']
981
- )
982
- self.fixed_size = flow_system.fit_to_model_coords(
983
- f'{name_prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario']
1236
+ self.linked_periods = self._fit_coords(
1237
+ f'{self.prefix}|linked_periods', self.linked_periods, dims=['period', 'scenario']
984
1238
  )
1239
+ self.fixed_size = self._fit_coords(f'{self.prefix}|fixed_size', self.fixed_size, dims=['period', 'scenario'])
985
1240
 
986
1241
  @property
987
- def optional(self) -> bool:
988
- """DEPRECATED: Use 'mandatory' property instead. Returns the opposite of 'mandatory'."""
989
- import warnings
990
-
991
- warnings.warn("Property 'optional' is deprecated. Use 'mandatory' instead.", DeprecationWarning, stacklevel=2)
992
- return not self.mandatory
993
-
994
- @optional.setter
995
- def optional(self, value: bool):
996
- """DEPRECATED: Use 'mandatory' property instead. Sets the opposite of the given value to 'mandatory'."""
997
- warnings.warn("Property 'optional' is deprecated. Use 'mandatory' instead.", DeprecationWarning, stacklevel=2)
998
- self.mandatory = not value
999
-
1000
- @property
1001
- def fix_effects(self) -> PeriodicEffectsUser:
1002
- """Deprecated property. Use effects_of_investment instead."""
1003
- warnings.warn(
1004
- 'The fix_effects property is deprecated. Use effects_of_investment instead.',
1005
- DeprecationWarning,
1006
- stacklevel=2,
1007
- )
1008
- return self.effects_of_investment
1242
+ def minimum_or_fixed_size(self) -> Numeric_PS:
1243
+ return self.fixed_size if self.fixed_size is not None else self.minimum_size
1009
1244
 
1010
1245
  @property
1011
- def specific_effects(self) -> PeriodicEffectsUser:
1012
- """Deprecated property. Use effects_of_investment_per_size instead."""
1013
- warnings.warn(
1014
- 'The specific_effects property is deprecated. Use effects_of_investment_per_size instead.',
1015
- DeprecationWarning,
1016
- stacklevel=2,
1017
- )
1018
- return self.effects_of_investment_per_size
1246
+ def maximum_or_fixed_size(self) -> Numeric_PS:
1247
+ return self.fixed_size if self.fixed_size is not None else self.maximum_size
1019
1248
 
1020
- @property
1021
- def divest_effects(self) -> PeriodicEffectsUser:
1022
- """Deprecated property. Use effects_of_retirement instead."""
1023
- warnings.warn(
1024
- 'The divest_effects property is deprecated. Use effects_of_retirement instead.',
1025
- DeprecationWarning,
1026
- stacklevel=2,
1027
- )
1028
- return self.effects_of_retirement
1249
+ def format_for_repr(self) -> str:
1250
+ """Format InvestParameters for display in repr methods.
1029
1251
 
1030
- @property
1031
- def piecewise_effects(self) -> PiecewiseEffects | None:
1032
- """Deprecated property. Use piecewise_effects_of_investment instead."""
1033
- warnings.warn(
1034
- 'The piecewise_effects property is deprecated. Use piecewise_effects_of_investment instead.',
1035
- DeprecationWarning,
1036
- stacklevel=2,
1037
- )
1038
- return self.piecewise_effects_of_investment
1252
+ Returns:
1253
+ Formatted string showing size information
1254
+ """
1255
+ from .io import numeric_to_str_for_repr
1039
1256
 
1040
- @property
1041
- def minimum_or_fixed_size(self) -> PeriodicData:
1042
- return self.fixed_size if self.fixed_size is not None else self.minimum_size
1257
+ if self.fixed_size is not None:
1258
+ val = numeric_to_str_for_repr(self.fixed_size)
1259
+ status = 'mandatory' if self.mandatory else 'optional'
1260
+ return f'{val} ({status})'
1043
1261
 
1044
- @property
1045
- def maximum_or_fixed_size(self) -> PeriodicData:
1046
- return self.fixed_size if self.fixed_size is not None else self.maximum_size
1262
+ # Show range if available
1263
+ parts = []
1264
+ if self.minimum_size is not None:
1265
+ parts.append(f'min: {numeric_to_str_for_repr(self.minimum_size)}')
1266
+ if self.maximum_size is not None:
1267
+ parts.append(f'max: {numeric_to_str_for_repr(self.maximum_size)}')
1268
+ return ', '.join(parts) if parts else 'invest'
1047
1269
 
1048
1270
  @staticmethod
1049
1271
  def compute_linked_periods(first_period: int, last_period: int, periods: pd.Index | list[int]) -> xr.DataArray:
@@ -1058,19 +1280,19 @@ class InvestParameters(Interface):
1058
1280
 
1059
1281
 
1060
1282
  @register_class_for_io
1061
- class OnOffParameters(Interface):
1062
- """Define operational constraints and effects for binary on/off equipment behavior.
1283
+ class StatusParameters(Interface):
1284
+ """Define operational constraints and effects for binary status equipment behavior.
1063
1285
 
1064
- This class models equipment that operates in discrete states (on/off) rather than
1286
+ This class models equipment that operates in discrete states (active/inactive) rather than
1065
1287
  continuous operation, capturing realistic operational constraints and associated
1066
1288
  costs. It handles complex equipment behavior including startup costs, minimum
1067
1289
  run times, cycling limitations, and maintenance scheduling requirements.
1068
1290
 
1069
1291
  Key Modeling Capabilities:
1070
- **Switching Costs**: One-time costs for starting equipment (fuel, wear, labor)
1071
- **Runtime Constraints**: Minimum and maximum continuous operation periods
1072
- **Cycling Limits**: Maximum number of starts to prevent excessive wear
1073
- **Operating Hours**: Total runtime limits and requirements over time horizon
1292
+ **Startup Costs**: One-time costs for starting equipment (fuel, wear, labor)
1293
+ **Runtime Constraints**: Minimum and maximum continuous operation periods (uptime/downtime)
1294
+ **Cycling Limits**: Maximum number of startups to prevent excessive wear
1295
+ **Operating Hours**: Total active hours limits and requirements over time horizon
1074
1296
 
1075
1297
  Typical Equipment Applications:
1076
1298
  - **Power Plants**: Combined cycle units, steam turbines with startup costs
@@ -1080,46 +1302,53 @@ class OnOffParameters(Interface):
1080
1302
  - **Process Equipment**: Compressors, pumps with operational constraints
1081
1303
 
1082
1304
  Mathematical Formulation:
1083
- See the complete mathematical model in the documentation:
1084
- [OnOffParameters](../user-guide/mathematical-notation/features/OnOffParameters.md)
1305
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/features/StatusParameters/>
1085
1306
 
1086
1307
  Args:
1087
- effects_per_switch_on: Costs or impacts incurred for each transition from
1088
- off state (var_on=0) to on state (var_on=1). Represents startup costs,
1308
+ effects_per_startup: Costs or impacts incurred for each transition from
1309
+ inactive state (status=0) to active state (status=1). Represents startup costs,
1089
1310
  wear and tear, or other switching impacts. Dictionary mapping effect
1090
1311
  names to values (e.g., {'cost': 500, 'maintenance_hours': 2}).
1091
- effects_per_running_hour: Ongoing costs or impacts while equipment operates
1092
- in the on state. Includes fuel costs, labor, consumables, or emissions.
1312
+ effects_per_active_hour: Ongoing costs or impacts while equipment operates
1313
+ in the active state. Includes fuel costs, labor, consumables, or emissions.
1093
1314
  Dictionary mapping effect names to hourly values (e.g., {'fuel_cost': 45}).
1094
- on_hours_total_min: Minimum total operating hours across the entire time horizon.
1315
+ active_hours_min: Minimum total active hours across the entire time horizon per period.
1095
1316
  Ensures equipment meets minimum utilization requirements or contractual
1096
1317
  obligations (e.g., power purchase agreements, maintenance schedules).
1097
- on_hours_total_max: Maximum total operating hours across the entire time horizon.
1318
+ active_hours_max: Maximum total active hours across the entire time horizon per period.
1098
1319
  Limits equipment usage due to maintenance schedules, fuel availability,
1099
1320
  environmental permits, or equipment lifetime constraints.
1100
- consecutive_on_hours_min: Minimum continuous operating duration once started.
1321
+ min_uptime: Minimum continuous operating duration once started (unit commitment term).
1101
1322
  Models minimum run times due to thermal constraints, process stability,
1102
1323
  or efficiency considerations. Can be time-varying to reflect different
1103
1324
  constraints across the planning horizon.
1104
- consecutive_on_hours_max: Maximum continuous operating duration in one campaign.
1325
+ max_uptime: Maximum continuous operating duration in one campaign (unit commitment term).
1105
1326
  Models mandatory maintenance intervals, process batch sizes, or
1106
1327
  equipment thermal limits requiring periodic shutdowns.
1107
- consecutive_off_hours_min: Minimum continuous shutdown duration between operations.
1328
+ min_downtime: Minimum continuous shutdown duration between operations (unit commitment term).
1108
1329
  Models cooling periods, maintenance requirements, or process constraints
1109
1330
  that prevent immediate restart after shutdown.
1110
- consecutive_off_hours_max: Maximum continuous shutdown duration before mandatory
1331
+ max_downtime: Maximum continuous shutdown duration before mandatory
1111
1332
  restart. Models equipment preservation, process stability, or contractual
1112
1333
  requirements for minimum activity levels.
1113
- switch_on_total_max: Maximum number of startup operations across the time horizon.
1334
+ startup_limit: Maximum number of startup operations across the time horizon per period..
1114
1335
  Limits equipment cycling to reduce wear, maintenance costs, or comply
1115
1336
  with operational constraints (e.g., grid stability requirements).
1116
- force_switch_on: When True, creates switch-on variables even without explicit
1117
- switch_on_total_max constraint. Useful for tracking or reporting startup
1337
+ force_startup_tracking: When True, creates startup variables even without explicit
1338
+ startup_limit constraint. Useful for tracking or reporting startup
1118
1339
  events without enforcing limits.
1340
+ cluster_mode: How inter-timestep constraints are handled at cluster boundaries.
1341
+ Only relevant when using ``transform.cluster()``. Options:
1342
+
1343
+ - ``'relaxed'``: No constraint at cluster boundaries. Startups at the first
1344
+ timestep of each cluster are not forced - the optimizer is free to choose.
1345
+ This prevents clustering from inducing "phantom" startups. (default)
1346
+ - ``'cyclic'``: Each cluster's final status equals its initial status.
1347
+ Ensures consistent behavior within each representative period.
1119
1348
 
1120
1349
  Note:
1121
1350
  **Time Series Boundary Handling**: The final time period constraints for
1122
- consecutive_on_hours_min/max and consecutive_off_hours_min/max are not
1351
+ min_uptime/max_uptime and min_downtime/max_downtime are not
1123
1352
  enforced, allowing the optimization to end with ongoing campaigns that
1124
1353
  may be shorter than the specified minimums or longer than maximums.
1125
1354
 
@@ -1127,105 +1356,105 @@ class OnOffParameters(Interface):
1127
1356
  Combined cycle power plant with startup costs and minimum run time:
1128
1357
 
1129
1358
  ```python
1130
- power_plant_operation = OnOffParameters(
1131
- effects_per_switch_on={
1359
+ power_plant_operation = StatusParameters(
1360
+ effects_per_startup={
1132
1361
  'startup_cost': 25000, # €25,000 per startup
1133
1362
  'startup_fuel': 150, # GJ natural gas for startup
1134
1363
  'startup_time': 4, # Hours to reach full output
1135
1364
  'maintenance_impact': 0.1, # Fractional life consumption
1136
1365
  },
1137
- effects_per_running_hour={
1138
- 'fixed_om': 125, # Fixed O&M costs while running
1366
+ effects_per_active_hour={
1367
+ 'fixed_om': 125, # Fixed O&M costs while active
1139
1368
  'auxiliary_power': 2.5, # MW parasitic loads
1140
1369
  },
1141
- consecutive_on_hours_min=8, # Minimum 8-hour run once started
1142
- consecutive_off_hours_min=4, # Minimum 4-hour cooling period
1143
- on_hours_total_max=6000, # Annual operating limit
1370
+ min_uptime=8, # Minimum 8-hour run once started
1371
+ min_downtime=4, # Minimum 4-hour cooling period
1372
+ active_hours_max=6000, # Annual operating limit
1144
1373
  )
1145
1374
  ```
1146
1375
 
1147
1376
  Industrial batch process with cycling limits:
1148
1377
 
1149
1378
  ```python
1150
- batch_reactor = OnOffParameters(
1151
- effects_per_switch_on={
1379
+ batch_reactor = StatusParameters(
1380
+ effects_per_startup={
1152
1381
  'setup_cost': 1500, # Labor and materials for startup
1153
1382
  'catalyst_consumption': 5, # kg catalyst per batch
1154
1383
  'cleaning_chemicals': 200, # L cleaning solution
1155
1384
  },
1156
- effects_per_running_hour={
1385
+ effects_per_active_hour={
1157
1386
  'steam': 2.5, # t/h process steam
1158
1387
  'electricity': 150, # kWh electrical load
1159
1388
  'cooling_water': 50, # m³/h cooling water
1160
1389
  },
1161
- consecutive_on_hours_min=12, # Minimum batch size (12 hours)
1162
- consecutive_on_hours_max=24, # Maximum batch size (24 hours)
1163
- consecutive_off_hours_min=6, # Cleaning and setup time
1164
- switch_on_total_max=200, # Maximum 200 batches per period
1165
- on_hours_total_max=4000, # Maximum production time
1390
+ min_uptime=12, # Minimum batch size (12 hours)
1391
+ max_uptime=24, # Maximum batch size (24 hours)
1392
+ min_downtime=6, # Cleaning and setup time
1393
+ startup_limit=200, # Maximum 200 batches per period
1394
+ active_hours_max=4000, # Maximum production time
1166
1395
  )
1167
1396
  ```
1168
1397
 
1169
1398
  HVAC system with thermostat control and maintenance:
1170
1399
 
1171
1400
  ```python
1172
- hvac_operation = OnOffParameters(
1173
- effects_per_switch_on={
1401
+ hvac_operation = StatusParameters(
1402
+ effects_per_startup={
1174
1403
  'compressor_wear': 0.5, # Hours of compressor life per start
1175
1404
  'inrush_current': 15, # kW peak demand on startup
1176
1405
  },
1177
- effects_per_running_hour={
1406
+ effects_per_active_hour={
1178
1407
  'electricity': 25, # kW electrical consumption
1179
1408
  'maintenance': 0.12, # €/hour maintenance reserve
1180
1409
  },
1181
- consecutive_on_hours_min=1, # Minimum 1-hour run to avoid cycling
1182
- consecutive_off_hours_min=0.5, # 30-minute minimum off time
1183
- switch_on_total_max=2000, # Limit cycling for compressor life
1184
- on_hours_total_min=2000, # Minimum operation for humidity control
1185
- on_hours_total_max=5000, # Maximum operation for energy budget
1410
+ min_uptime=1, # Minimum 1-hour run to avoid cycling
1411
+ min_downtime=0.5, # 30-minute minimum inactive time
1412
+ startup_limit=2000, # Limit cycling for compressor life
1413
+ active_hours_min=2000, # Minimum operation for humidity control
1414
+ active_hours_max=5000, # Maximum operation for energy budget
1186
1415
  )
1187
1416
  ```
1188
1417
 
1189
1418
  Backup generator with testing and maintenance requirements:
1190
1419
 
1191
1420
  ```python
1192
- backup_generator = OnOffParameters(
1193
- effects_per_switch_on={
1421
+ backup_generator = StatusParameters(
1422
+ effects_per_startup={
1194
1423
  'fuel_priming': 50, # L diesel for system priming
1195
1424
  'wear_factor': 1.0, # Start cycles impact on maintenance
1196
1425
  'testing_labor': 2, # Hours technician time per test
1197
1426
  },
1198
- effects_per_running_hour={
1427
+ effects_per_active_hour={
1199
1428
  'fuel_consumption': 180, # L/h diesel consumption
1200
1429
  'emissions_permit': 15, # € emissions allowance cost
1201
1430
  'noise_penalty': 25, # € noise compliance cost
1202
1431
  },
1203
- consecutive_on_hours_min=0.5, # Minimum test duration (30 min)
1204
- consecutive_off_hours_max=720, # Maximum 30 days between tests
1205
- switch_on_total_max=52, # Weekly testing limit
1206
- on_hours_total_min=26, # Minimum annual testing (0.5h × 52)
1207
- on_hours_total_max=200, # Maximum runtime (emergencies + tests)
1432
+ min_uptime=0.5, # Minimum test duration (30 min)
1433
+ max_downtime=720, # Maximum 30 days between tests
1434
+ startup_limit=52, # Weekly testing limit
1435
+ active_hours_min=26, # Minimum annual testing (0.5h × 52)
1436
+ active_hours_max=200, # Maximum runtime (emergencies + tests)
1208
1437
  )
1209
1438
  ```
1210
1439
 
1211
1440
  Peak shaving battery with cycling degradation:
1212
1441
 
1213
1442
  ```python
1214
- battery_cycling = OnOffParameters(
1215
- effects_per_switch_on={
1443
+ battery_cycling = StatusParameters(
1444
+ effects_per_startup={
1216
1445
  'cycle_degradation': 0.01, # % capacity loss per cycle
1217
1446
  'inverter_startup': 0.5, # kWh losses during startup
1218
1447
  },
1219
- effects_per_running_hour={
1448
+ effects_per_active_hour={
1220
1449
  'standby_losses': 2, # kW standby consumption
1221
1450
  'cooling': 5, # kW thermal management
1222
1451
  'inverter_losses': 8, # kW conversion losses
1223
1452
  },
1224
- consecutive_on_hours_min=1, # Minimum discharge duration
1225
- consecutive_on_hours_max=4, # Maximum continuous discharge
1226
- consecutive_off_hours_min=1, # Minimum rest between cycles
1227
- switch_on_total_max=365, # Daily cycling limit
1228
- force_switch_on=True, # Track all cycling events
1453
+ min_uptime=1, # Minimum discharge duration
1454
+ max_uptime=4, # Maximum continuous discharge
1455
+ min_downtime=1, # Minimum rest between cycles
1456
+ startup_limit=365, # Daily cycling limit
1457
+ force_startup_tracking=True, # Track all cycling events
1229
1458
  )
1230
1459
  ```
1231
1460
 
@@ -1241,86 +1470,75 @@ class OnOffParameters(Interface):
1241
1470
 
1242
1471
  def __init__(
1243
1472
  self,
1244
- effects_per_switch_on: TemporalEffectsUser | None = None,
1245
- effects_per_running_hour: TemporalEffectsUser | None = None,
1246
- on_hours_total_min: int | None = None,
1247
- on_hours_total_max: int | None = None,
1248
- consecutive_on_hours_min: TemporalDataUser | None = None,
1249
- consecutive_on_hours_max: TemporalDataUser | None = None,
1250
- consecutive_off_hours_min: TemporalDataUser | None = None,
1251
- consecutive_off_hours_max: TemporalDataUser | None = None,
1252
- switch_on_total_max: int | None = None,
1253
- force_switch_on: bool = False,
1473
+ effects_per_startup: Effect_TPS | Numeric_TPS | None = None,
1474
+ effects_per_active_hour: Effect_TPS | Numeric_TPS | None = None,
1475
+ active_hours_min: Numeric_PS | None = None,
1476
+ active_hours_max: Numeric_PS | None = None,
1477
+ min_uptime: Numeric_TPS | None = None,
1478
+ max_uptime: Numeric_TPS | None = None,
1479
+ min_downtime: Numeric_TPS | None = None,
1480
+ max_downtime: Numeric_TPS | None = None,
1481
+ startup_limit: Numeric_PS | None = None,
1482
+ force_startup_tracking: bool = False,
1483
+ cluster_mode: Literal['relaxed', 'cyclic'] = 'relaxed',
1254
1484
  ):
1255
- self.effects_per_switch_on: TemporalEffectsUser = (
1256
- effects_per_switch_on if effects_per_switch_on is not None else {}
1257
- )
1258
- self.effects_per_running_hour: TemporalEffectsUser = (
1259
- effects_per_running_hour if effects_per_running_hour is not None else {}
1260
- )
1261
- self.on_hours_total_min: Scalar = on_hours_total_min
1262
- self.on_hours_total_max: Scalar = on_hours_total_max
1263
- self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min
1264
- self.consecutive_on_hours_max: TemporalDataUser = consecutive_on_hours_max
1265
- self.consecutive_off_hours_min: TemporalDataUser = consecutive_off_hours_min
1266
- self.consecutive_off_hours_max: TemporalDataUser = consecutive_off_hours_max
1267
- self.switch_on_total_max: Scalar = switch_on_total_max
1268
- self.force_switch_on: bool = force_switch_on
1269
-
1270
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
1271
- self.effects_per_switch_on = flow_system.fit_effects_to_model_coords(
1272
- name_prefix, self.effects_per_switch_on, 'per_switch_on'
1273
- )
1274
- self.effects_per_running_hour = flow_system.fit_effects_to_model_coords(
1275
- name_prefix, self.effects_per_running_hour, 'per_running_hour'
1485
+ self.effects_per_startup = effects_per_startup if effects_per_startup is not None else {}
1486
+ self.effects_per_active_hour = effects_per_active_hour if effects_per_active_hour is not None else {}
1487
+ self.active_hours_min = active_hours_min
1488
+ self.active_hours_max = active_hours_max
1489
+ self.min_uptime = min_uptime
1490
+ self.max_uptime = max_uptime
1491
+ self.min_downtime = min_downtime
1492
+ self.max_downtime = max_downtime
1493
+ self.startup_limit = startup_limit
1494
+ self.force_startup_tracking: bool = force_startup_tracking
1495
+ self.cluster_mode = cluster_mode
1496
+
1497
+ def transform_data(self) -> None:
1498
+ self.effects_per_startup = self._fit_effect_coords(
1499
+ prefix=self.prefix,
1500
+ effect_values=self.effects_per_startup,
1501
+ suffix='per_startup',
1276
1502
  )
1277
- self.consecutive_on_hours_min = flow_system.fit_to_model_coords(
1278
- f'{name_prefix}|consecutive_on_hours_min', self.consecutive_on_hours_min
1503
+ self.effects_per_active_hour = self._fit_effect_coords(
1504
+ prefix=self.prefix,
1505
+ effect_values=self.effects_per_active_hour,
1506
+ suffix='per_active_hour',
1279
1507
  )
1280
- self.consecutive_on_hours_max = flow_system.fit_to_model_coords(
1281
- f'{name_prefix}|consecutive_on_hours_max', self.consecutive_on_hours_max
1508
+ self.min_uptime = self._fit_coords(f'{self.prefix}|min_uptime', self.min_uptime)
1509
+ self.max_uptime = self._fit_coords(f'{self.prefix}|max_uptime', self.max_uptime)
1510
+ self.min_downtime = self._fit_coords(f'{self.prefix}|min_downtime', self.min_downtime)
1511
+ self.max_downtime = self._fit_coords(f'{self.prefix}|max_downtime', self.max_downtime)
1512
+ self.active_hours_max = self._fit_coords(
1513
+ f'{self.prefix}|active_hours_max', self.active_hours_max, dims=['period', 'scenario']
1282
1514
  )
1283
- self.consecutive_off_hours_min = flow_system.fit_to_model_coords(
1284
- f'{name_prefix}|consecutive_off_hours_min', self.consecutive_off_hours_min
1515
+ self.active_hours_min = self._fit_coords(
1516
+ f'{self.prefix}|active_hours_min', self.active_hours_min, dims=['period', 'scenario']
1285
1517
  )
1286
- self.consecutive_off_hours_max = flow_system.fit_to_model_coords(
1287
- f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max
1518
+ self.startup_limit = self._fit_coords(
1519
+ f'{self.prefix}|startup_limit', self.startup_limit, dims=['period', 'scenario']
1288
1520
  )
1289
- self.on_hours_total_max = flow_system.fit_to_model_coords(
1290
- f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, dims=['period', 'scenario']
1291
- )
1292
- self.on_hours_total_min = flow_system.fit_to_model_coords(
1293
- f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, dims=['period', 'scenario']
1294
- )
1295
- self.switch_on_total_max = flow_system.fit_to_model_coords(
1296
- f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['period', 'scenario']
1297
- )
1298
-
1299
- @property
1300
- def use_off(self) -> bool:
1301
- """Proxy: whether OFF variable is required"""
1302
- return self.use_consecutive_off_hours
1303
1521
 
1304
1522
  @property
1305
- def use_consecutive_on_hours(self) -> bool:
1306
- """Determines whether a Variable for consecutive on hours is needed or not"""
1307
- return any(param is not None for param in [self.consecutive_on_hours_min, self.consecutive_on_hours_max])
1523
+ def use_uptime_tracking(self) -> bool:
1524
+ """Determines whether a Variable for uptime (consecutive active hours) is needed or not"""
1525
+ return any(param is not None for param in [self.min_uptime, self.max_uptime])
1308
1526
 
1309
1527
  @property
1310
- def use_consecutive_off_hours(self) -> bool:
1311
- """Determines whether a Variable for consecutive off hours is needed or not"""
1312
- return any(param is not None for param in [self.consecutive_off_hours_min, self.consecutive_off_hours_max])
1528
+ def use_downtime_tracking(self) -> bool:
1529
+ """Determines whether a Variable for downtime (consecutive inactive hours) is needed or not"""
1530
+ return any(param is not None for param in [self.min_downtime, self.max_downtime])
1313
1531
 
1314
1532
  @property
1315
- def use_switch_on(self) -> bool:
1316
- """Determines whether a variable for switch_on is needed or not"""
1317
- if self.force_switch_on:
1533
+ def use_startup_tracking(self) -> bool:
1534
+ """Determines whether a variable for startup is needed or not"""
1535
+ if self.force_startup_tracking:
1318
1536
  return True
1319
1537
 
1320
1538
  return any(
1321
- param is not None and param != {}
1539
+ self._has_value(param)
1322
1540
  for param in [
1323
- self.effects_per_switch_on,
1324
- self.switch_on_total_max,
1541
+ self.effects_per_startup,
1542
+ self.startup_limit,
1325
1543
  ]
1326
1544
  )