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/elements.py CHANGED
@@ -4,25 +4,37 @@ This module contains the basic elements of the flixopt framework.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import functools
7
8
  import logging
8
- import warnings
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  import numpy as np
12
12
  import xarray as xr
13
13
 
14
+ from . import io as fx_io
14
15
  from .config import CONFIG
15
- from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser
16
- from .features import InvestmentModel, OnOffModel
17
- from .interface import InvestParameters, OnOffParameters
16
+ from .core import PlausibilityError
17
+ from .features import InvestmentModel, StatusModel
18
+ from .interface import InvestParameters, StatusParameters
18
19
  from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilitiesAbstract
19
- from .structure import Element, ElementModel, FlowSystemModel, register_class_for_io
20
+ from .structure import (
21
+ Element,
22
+ ElementModel,
23
+ FlowSystemModel,
24
+ VariableCategory,
25
+ register_class_for_io,
26
+ )
20
27
 
21
28
  if TYPE_CHECKING:
22
29
  import linopy
23
30
 
24
- from .effects import TemporalEffectsUser
25
- from .flow_system import FlowSystem
31
+ from .types import (
32
+ Effect_TPS,
33
+ Numeric_PS,
34
+ Numeric_S,
35
+ Numeric_TPS,
36
+ Scalar,
37
+ )
26
38
 
27
39
  logger = logging.getLogger('flixopt')
28
40
 
@@ -46,9 +58,9 @@ class Component(Element):
46
58
  energy/material consumption by the component.
47
59
  outputs: list of output Flows leaving the component. These represent
48
60
  energy/material production by the component.
49
- on_off_parameters: Defines binary operation constraints and costs when the
50
- component has discrete on/off states. Creates binary variables for all
51
- connected Flows. For better performance, prefer defining OnOffParameters
61
+ status_parameters: Defines binary operation constraints and costs when the
62
+ component has discrete active/inactive states. Creates binary variables for all
63
+ connected Flows. For better performance, prefer defining StatusParameters
52
64
  on individual Flows when possible.
53
65
  prevent_simultaneous_flows: list of Flows that cannot be active simultaneously.
54
66
  Creates binary variables to enforce mutual exclusivity. Use sparingly as
@@ -58,13 +70,13 @@ class Component(Element):
58
70
 
59
71
  Note:
60
72
  Component operational state is determined by its connected Flows:
61
- - Component is "on" if ANY of its Flows is active (flow_rate > 0)
62
- - Component is "off" only when ALL Flows are inactive (flow_rate = 0)
73
+ - Component is "active" if ANY of its Flows is active (flow_rate > 0)
74
+ - Component is "inactive" only when ALL Flows are inactive (flow_rate = 0)
63
75
 
64
76
  Binary variables and constraints:
65
- - on_off_parameters creates binary variables for ALL connected Flows
77
+ - status_parameters creates binary variables for ALL connected Flows
66
78
  - prevent_simultaneous_flows creates binary variables for specified Flows
67
- - For better computational performance, prefer Flow-level OnOffParameters
79
+ - For better computational performance, prefer Flow-level StatusParameters
68
80
 
69
81
  Component is an abstract base class. In practice, use specialized subclasses:
70
82
  - LinearConverter: Linear input/output relationships
@@ -79,17 +91,20 @@ class Component(Element):
79
91
  label: str,
80
92
  inputs: list[Flow] | None = None,
81
93
  outputs: list[Flow] | None = None,
82
- on_off_parameters: OnOffParameters | None = None,
94
+ status_parameters: StatusParameters | None = None,
83
95
  prevent_simultaneous_flows: list[Flow] | None = None,
84
96
  meta_data: dict | None = None,
97
+ color: str | None = None,
85
98
  ):
86
- super().__init__(label, meta_data=meta_data)
99
+ super().__init__(label, meta_data=meta_data, color=color)
87
100
  self.inputs: list[Flow] = inputs or []
88
101
  self.outputs: list[Flow] = outputs or []
89
- self._check_unique_flow_labels()
90
- self.on_off_parameters = on_off_parameters
102
+ self.status_parameters = status_parameters
91
103
  self.prevent_simultaneous_flows: list[Flow] = prevent_simultaneous_flows or []
92
104
 
105
+ self._check_unique_flow_labels()
106
+ self._connect_flows()
107
+
93
108
  self.flows: dict[str, Flow] = {flow.label: flow for flow in self.inputs + self.outputs}
94
109
 
95
110
  def create_model(self, model: FlowSystemModel) -> ComponentModel:
@@ -97,13 +112,23 @@ class Component(Element):
97
112
  self.submodel = ComponentModel(model, self)
98
113
  return self.submodel
99
114
 
100
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
101
- prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
102
- if self.on_off_parameters is not None:
103
- self.on_off_parameters.transform_data(flow_system, prefix)
115
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
116
+ """Propagate flow_system reference to nested Interface objects and flows.
104
117
 
118
+ Elements use their label_full as prefix by default, ignoring the passed prefix.
119
+ """
120
+ super().link_to_flow_system(flow_system, self.label_full)
121
+ if self.status_parameters is not None:
122
+ self.status_parameters.link_to_flow_system(flow_system, self._sub_prefix('status_parameters'))
105
123
  for flow in self.inputs + self.outputs:
106
- flow.transform_data(flow_system) # Flow doesnt need the name_prefix
124
+ flow.link_to_flow_system(flow_system)
125
+
126
+ def transform_data(self) -> None:
127
+ if self.status_parameters is not None:
128
+ self.status_parameters.transform_data()
129
+
130
+ for flow in self.inputs + self.outputs:
131
+ flow.transform_data()
107
132
 
108
133
  def _check_unique_flow_labels(self):
109
134
  all_flow_labels = [flow.label for flow in self.inputs + self.outputs]
@@ -115,6 +140,59 @@ class Component(Element):
115
140
  def _plausibility_checks(self) -> None:
116
141
  self._check_unique_flow_labels()
117
142
 
143
+ # Component with status_parameters requires all flows to have sizes set
144
+ # (status_parameters are propagated to flows in _do_modeling, which need sizes for big-M constraints)
145
+ if self.status_parameters is not None:
146
+ flows_without_size = [flow.label for flow in self.inputs + self.outputs if flow.size is None]
147
+ if flows_without_size:
148
+ raise PlausibilityError(
149
+ f'Component "{self.label_full}" has status_parameters, but the following flows have no size: '
150
+ f'{flows_without_size}. All flows need explicit sizes when the component uses status_parameters '
151
+ f'(required for big-M constraints).'
152
+ )
153
+
154
+ def _connect_flows(self):
155
+ # Inputs
156
+ for flow in self.inputs:
157
+ if flow.component not in ('UnknownComponent', self.label_full):
158
+ raise ValueError(
159
+ f'Flow "{flow.label}" already assigned to component "{flow.component}". '
160
+ f'Cannot attach to "{self.label_full}".'
161
+ )
162
+ flow.component = self.label_full
163
+ flow.is_input_in_component = True
164
+ # Outputs
165
+ for flow in self.outputs:
166
+ if flow.component not in ('UnknownComponent', self.label_full):
167
+ raise ValueError(
168
+ f'Flow "{flow.label}" already assigned to component "{flow.component}". '
169
+ f'Cannot attach to "{self.label_full}".'
170
+ )
171
+ flow.component = self.label_full
172
+ flow.is_input_in_component = False
173
+
174
+ # Validate prevent_simultaneous_flows: only allow local flows
175
+ if self.prevent_simultaneous_flows:
176
+ # Deduplicate while preserving order
177
+ seen = set()
178
+ self.prevent_simultaneous_flows = [
179
+ f for f in self.prevent_simultaneous_flows if id(f) not in seen and not seen.add(id(f))
180
+ ]
181
+ local = set(self.inputs + self.outputs)
182
+ foreign = [f for f in self.prevent_simultaneous_flows if f not in local]
183
+ if foreign:
184
+ names = ', '.join(f.label_full for f in foreign)
185
+ raise ValueError(
186
+ f'prevent_simultaneous_flows for "{self.label_full}" must reference its own flows. '
187
+ f'Foreign flows detected: {names}'
188
+ )
189
+
190
+ def __repr__(self) -> str:
191
+ """Return string representation with flow information."""
192
+ return fx_io.build_repr_from_init(
193
+ self, excluded_params={'self', 'label', 'inputs', 'outputs', 'kwargs'}, skip_default_size=True
194
+ ) + fx_io.format_flow_details(self)
195
+
118
196
 
119
197
  @register_class_for_io
120
198
  class Bus(Element):
@@ -127,49 +205,51 @@ class Bus(Element):
127
205
  or material flows between different Components.
128
206
 
129
207
  Mathematical Formulation:
130
- See the complete mathematical model in the documentation:
131
- [Bus](../user-guide/mathematical-notation/elements/Bus.md)
208
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/Bus/>
132
209
 
133
210
  Args:
134
211
  label: The label of the Element. Used to identify it in the FlowSystem.
135
- excess_penalty_per_flow_hour: Penalty costs for bus balance violations.
136
- When None, no excess/deficit is allowed (hard constraint). When set to a
137
- value > 0, allows bus imbalances at penalty cost. Default is 1e5 (high penalty).
212
+ carrier: Name of the energy/material carrier type (e.g., 'electricity', 'heat', 'gas').
213
+ Carriers are registered via ``flow_system.add_carrier()`` or available as
214
+ predefined defaults in CONFIG.Carriers. Used for automatic color assignment in plots.
215
+ imbalance_penalty_per_flow_hour: Penalty costs for bus balance violations.
216
+ When None (default), no imbalance is allowed (hard constraint). When set to a
217
+ value > 0, allows bus imbalances at penalty cost.
138
218
  meta_data: Used to store additional information. Not used internally but saved
139
219
  in results. Only use Python native types.
140
220
 
141
221
  Examples:
142
- Electrical bus with strict balance:
222
+ Using predefined carrier names:
143
223
 
144
224
  ```python
145
- electricity_bus = Bus(
146
- label='main_electrical_bus',
147
- excess_penalty_per_flow_hour=None, # No imbalance allowed
148
- )
225
+ electricity_bus = Bus(label='main_grid', carrier='electricity')
226
+ heat_bus = Bus(label='district_heating', carrier='heat')
149
227
  ```
150
228
 
151
- Heat network with penalty for imbalances:
229
+ Registering custom carriers on FlowSystem:
152
230
 
153
231
  ```python
154
- heat_network = Bus(
155
- label='district_heating_network',
156
- excess_penalty_per_flow_hour=1000, # €1000/MWh penalty for imbalance
157
- )
232
+ import flixopt as fx
233
+
234
+ fs = fx.FlowSystem(timesteps)
235
+ fs.add_carrier(fx.Carrier('biogas', '#228B22', 'kW'))
236
+ biogas_bus = fx.Bus(label='biogas_network', carrier='biogas')
158
237
  ```
159
238
 
160
- Material flow with time-varying penalties:
239
+ Heat network with penalty for imbalances:
161
240
 
162
241
  ```python
163
- material_hub = Bus(
164
- label='material_processing_hub',
165
- excess_penalty_per_flow_hour=waste_disposal_costs, # Time series
242
+ heat_bus = Bus(
243
+ label='district_heating',
244
+ carrier='heat',
245
+ imbalance_penalty_per_flow_hour=1000,
166
246
  )
167
247
  ```
168
248
 
169
249
  Note:
170
- The bus balance equation enforced is: Σ(inflows) = Σ(outflows) + excess - deficit
250
+ The bus balance equation enforced is: Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand
171
251
 
172
- When excess_penalty_per_flow_hour is None, excess and deficit are forced to zero.
252
+ When imbalance_penalty_per_flow_hour is None, virtual_supply and virtual_demand are forced to zero.
173
253
  When a penalty cost is specified, the optimization can choose to violate the
174
254
  balance if economically beneficial, paying the penalty.
175
255
  The penalty is added to the objective directly.
@@ -178,14 +258,23 @@ class Bus(Element):
178
258
  by the FlowSystem during system setup.
179
259
  """
180
260
 
261
+ submodel: BusModel | None
262
+
181
263
  def __init__(
182
264
  self,
183
265
  label: str,
184
- excess_penalty_per_flow_hour: TemporalDataUser | None = 1e5,
266
+ carrier: str | None = None,
267
+ imbalance_penalty_per_flow_hour: Numeric_TPS | None = None,
185
268
  meta_data: dict | None = None,
269
+ **kwargs,
186
270
  ):
187
271
  super().__init__(label, meta_data=meta_data)
188
- self.excess_penalty_per_flow_hour = excess_penalty_per_flow_hour
272
+ imbalance_penalty_per_flow_hour = self._handle_deprecated_kwarg(
273
+ kwargs, 'excess_penalty_per_flow_hour', 'imbalance_penalty_per_flow_hour', imbalance_penalty_per_flow_hour
274
+ )
275
+ self._validate_kwargs(kwargs)
276
+ self.carrier = carrier.lower() if carrier else None # Store as lowercase string
277
+ self.imbalance_penalty_per_flow_hour = imbalance_penalty_per_flow_hour
189
278
  self.inputs: list[Flow] = []
190
279
  self.outputs: list[Flow] = []
191
280
 
@@ -194,23 +283,39 @@ class Bus(Element):
194
283
  self.submodel = BusModel(model, self)
195
284
  return self.submodel
196
285
 
197
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
198
- prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
199
- self.excess_penalty_per_flow_hour = flow_system.fit_to_model_coords(
200
- f'{prefix}|excess_penalty_per_flow_hour', self.excess_penalty_per_flow_hour
286
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
287
+ """Propagate flow_system reference to nested flows.
288
+
289
+ Elements use their label_full as prefix by default, ignoring the passed prefix.
290
+ """
291
+ super().link_to_flow_system(flow_system, self.label_full)
292
+ for flow in self.inputs + self.outputs:
293
+ flow.link_to_flow_system(flow_system)
294
+
295
+ def transform_data(self) -> None:
296
+ self.imbalance_penalty_per_flow_hour = self._fit_coords(
297
+ f'{self.prefix}|imbalance_penalty_per_flow_hour', self.imbalance_penalty_per_flow_hour
201
298
  )
202
299
 
203
300
  def _plausibility_checks(self) -> None:
204
- if self.excess_penalty_per_flow_hour is not None:
205
- zero_penalty = np.all(np.equal(self.excess_penalty_per_flow_hour, 0))
301
+ if self.imbalance_penalty_per_flow_hour is not None:
302
+ zero_penalty = np.all(np.equal(self.imbalance_penalty_per_flow_hour, 0))
206
303
  if zero_penalty:
207
304
  logger.warning(
208
- f'In Bus {self.label_full}, the excess_penalty_per_flow_hour is 0. Use "None" or a value > 0.'
305
+ f'In Bus {self.label_full}, the imbalance_penalty_per_flow_hour is 0. Use "None" or a value > 0.'
209
306
  )
307
+ if len(self.inputs) == 0 and len(self.outputs) == 0:
308
+ raise ValueError(
309
+ f'Bus "{self.label_full}" has no Flows connected to it. Please remove it from the FlowSystem'
310
+ )
210
311
 
211
312
  @property
212
- def with_excess(self) -> bool:
213
- return False if self.excess_penalty_per_flow_hour is None else True
313
+ def allows_imbalance(self) -> bool:
314
+ return self.imbalance_penalty_per_flow_hour is not None
315
+
316
+ def __repr__(self) -> str:
317
+ """Return string representation."""
318
+ return super().__repr__() + fx_io.format_flow_details(self)
214
319
 
215
320
 
216
321
  @register_class_for_io
@@ -234,7 +339,7 @@ class Flow(Element):
234
339
  between a Bus and a Component in a specific direction. The flow rate is the
235
340
  primary optimization variable, with constraints and costs defined through
236
341
  various parameters. Flows can have fixed or variable sizes, operational
237
- constraints, and complex on/off behavior.
342
+ constraints, and complex on/inactive behavior.
238
343
 
239
344
  Key Concepts:
240
345
  **Flow Rate**: The instantaneous rate of energy/material transfer (optimization variable) [kW, m³/h, kg/h]
@@ -244,28 +349,31 @@ class Flow(Element):
244
349
 
245
350
  Integration with Parameter Classes:
246
351
  - **InvestParameters**: Used for `size` when flow Size is an investment decision
247
- - **OnOffParameters**: Used for `on_off_parameters` when flow has discrete states
352
+ - **StatusParameters**: Used for `status_parameters` when flow has discrete states
248
353
 
249
354
  Mathematical Formulation:
250
- See the complete mathematical model in the documentation:
251
- [Flow](../user-guide/mathematical-notation/elements/Flow.md)
355
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/Flow/>
252
356
 
253
357
  Args:
254
358
  label: Unique flow identifier within its component.
255
359
  bus: Bus label this flow connects to.
256
- size: Flow capacity. Scalar, InvestParameters, or None (uses CONFIG.Modeling.big).
360
+ size: Flow capacity. Scalar, InvestParameters, or None (unbounded).
257
361
  relative_minimum: Minimum flow rate as fraction of size (0-1). Default: 0.
258
362
  relative_maximum: Maximum flow rate as fraction of size. Default: 1.
259
363
  load_factor_min: Minimum average utilization (0-1). Default: 0.
260
364
  load_factor_max: Maximum average utilization (0-1). Default: 1.
261
365
  effects_per_flow_hour: Operational costs/impacts per flow-hour.
262
366
  Dict mapping effect names to values (e.g., {'cost': 45, 'CO2': 0.8}).
263
- on_off_parameters: Binary operation constraints (OnOffParameters). Default: None.
264
- flow_hours_total_max: Maximum cumulative flow-hours. Alternative to load_factor_max.
265
- flow_hours_total_min: Minimum cumulative flow-hours. Alternative to load_factor_min.
367
+ status_parameters: Binary operation constraints (StatusParameters). Default: None.
368
+ flow_hours_max: Maximum cumulative flow-hours per period. Alternative to load_factor_max.
369
+ flow_hours_min: Minimum cumulative flow-hours per period. Alternative to load_factor_min.
370
+ flow_hours_max_over_periods: Maximum weighted sum of flow-hours across ALL periods.
371
+ Weighted by FlowSystem period weights.
372
+ flow_hours_min_over_periods: Minimum weighted sum of flow-hours across ALL periods.
373
+ Weighted by FlowSystem period weights.
266
374
  fixed_relative_profile: Predetermined pattern as fraction of size.
267
375
  Flow rate = size × fixed_relative_profile(t).
268
- previous_flow_rate: Initial flow state for on/off dynamics. Default: None (off).
376
+ previous_flow_rate: Initial flow state for active/inactive status at model start. Default: None (inactive).
269
377
  meta_data: Additional info stored in results. Python native types only.
270
378
 
271
379
  Examples:
@@ -302,13 +410,13 @@ class Flow(Element):
302
410
  label='heat_output',
303
411
  bus='heating_network',
304
412
  size=50, # 50 kW thermal
305
- relative_minimum=0.3, # Minimum 15 kW output when on
413
+ relative_minimum=0.3, # Minimum 15 kW output when active
306
414
  effects_per_flow_hour={'electricity_cost': 25, 'maintenance': 2},
307
- on_off_parameters=OnOffParameters(
308
- effects_per_switch_on={'startup_cost': 100, 'wear': 0.1},
309
- consecutive_on_hours_min=2, # Must run at least 2 hours
310
- consecutive_off_hours_min=1, # Must stay off at least 1 hour
311
- switch_on_total_max=200, # Maximum 200 starts per period
415
+ status_parameters=StatusParameters(
416
+ effects_per_startup={'startup_cost': 100, 'wear': 0.1},
417
+ min_uptime=2, # Must run at least 2 hours
418
+ min_downtime=1, # Must stay inactive at least 1 hour
419
+ startup_limit=200, # Maximum 200 starts per period
312
420
  ),
313
421
  )
314
422
  ```
@@ -339,17 +447,19 @@ class Flow(Element):
339
447
  ```
340
448
 
341
449
  Design Considerations:
342
- **Size vs Load Factors**: Use `flow_hours_total_min/max` for absolute limits,
343
- `load_factor_min/max` for utilization-based constraints.
450
+ **Size vs Load Factors**: Use `flow_hours_min/max` for absolute limits per period,
451
+ `load_factor_min/max` for utilization-based constraints, or `flow_hours_min/max_over_periods` for
452
+ limits across all periods.
344
453
 
345
454
  **Relative Bounds**: Set `relative_minimum > 0` only when equipment cannot
346
- operate below that level. Use `on_off_parameters` for discrete on/off behavior.
455
+ operate below that level. Use `status_parameters` for discrete active/inactive behavior.
347
456
 
348
457
  **Fixed Profiles**: Use `fixed_relative_profile` for known exact patterns,
349
458
  `relative_maximum` for upper bounds on optimization variables.
350
459
 
351
460
  Notes:
352
- - Default size (CONFIG.Modeling.big) is used when size=None
461
+ - size=None means unbounded (no capacity constraint)
462
+ - size must be set when using status_parameters or fixed_relative_profile
353
463
  - list inputs for previous_flow_rate are converted to NumPy arrays
354
464
  - Flow direction is determined by component input/output designation
355
465
 
@@ -358,114 +468,159 @@ class Flow(Element):
358
468
 
359
469
  """
360
470
 
471
+ submodel: FlowModel | None
472
+
361
473
  def __init__(
362
474
  self,
363
475
  label: str,
364
476
  bus: str,
365
- size: Scalar | InvestParameters = None,
366
- fixed_relative_profile: TemporalDataUser | None = None,
367
- relative_minimum: TemporalDataUser = 0,
368
- relative_maximum: TemporalDataUser = 1,
369
- effects_per_flow_hour: TemporalEffectsUser | None = None,
370
- on_off_parameters: OnOffParameters | None = None,
371
- flow_hours_total_max: Scalar | None = None,
372
- flow_hours_total_min: Scalar | None = None,
373
- load_factor_min: Scalar | None = None,
374
- load_factor_max: Scalar | None = None,
477
+ size: Numeric_PS | InvestParameters | None = None,
478
+ fixed_relative_profile: Numeric_TPS | None = None,
479
+ relative_minimum: Numeric_TPS = 0,
480
+ relative_maximum: Numeric_TPS = 1,
481
+ effects_per_flow_hour: Effect_TPS | Numeric_TPS | None = None,
482
+ status_parameters: StatusParameters | None = None,
483
+ flow_hours_max: Numeric_PS | None = None,
484
+ flow_hours_min: Numeric_PS | None = None,
485
+ flow_hours_max_over_periods: Numeric_S | None = None,
486
+ flow_hours_min_over_periods: Numeric_S | None = None,
487
+ load_factor_min: Numeric_PS | None = None,
488
+ load_factor_max: Numeric_PS | None = None,
375
489
  previous_flow_rate: Scalar | list[Scalar] | None = None,
376
490
  meta_data: dict | None = None,
377
491
  ):
378
492
  super().__init__(label, meta_data=meta_data)
379
- self.size = CONFIG.Modeling.big if size is None else size
493
+ self.size = size
380
494
  self.relative_minimum = relative_minimum
381
495
  self.relative_maximum = relative_maximum
382
496
  self.fixed_relative_profile = fixed_relative_profile
383
497
 
384
498
  self.load_factor_min = load_factor_min
385
499
  self.load_factor_max = load_factor_max
500
+
386
501
  # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self)
387
502
  self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {}
388
- self.flow_hours_total_max = flow_hours_total_max
389
- self.flow_hours_total_min = flow_hours_total_min
390
- self.on_off_parameters = on_off_parameters
503
+ self.flow_hours_max = flow_hours_max
504
+ self.flow_hours_min = flow_hours_min
505
+ self.flow_hours_max_over_periods = flow_hours_max_over_periods
506
+ self.flow_hours_min_over_periods = flow_hours_min_over_periods
507
+ self.status_parameters = status_parameters
391
508
 
392
509
  self.previous_flow_rate = previous_flow_rate
393
510
 
394
511
  self.component: str = 'UnknownComponent'
395
512
  self.is_input_in_component: bool | None = None
396
513
  if isinstance(bus, Bus):
397
- self.bus = bus.label_full
398
- warnings.warn(
399
- f'Bus {bus.label} is passed as a Bus object to {self.label}. This is deprecated and will be removed '
400
- f'in the future. Add the Bus to the FlowSystem instead and pass its label to the Flow.',
401
- UserWarning,
402
- stacklevel=1,
514
+ raise TypeError(
515
+ f'Bus {bus.label} is passed as a Bus object to Flow {self.label}. '
516
+ f'This is no longer supported. Add the Bus to the FlowSystem and pass its label (string) to the Flow.'
403
517
  )
404
- self._bus_object = bus
405
- else:
406
- self.bus = bus
407
- self._bus_object = None
518
+ self.bus = bus
408
519
 
409
520
  def create_model(self, model: FlowSystemModel) -> FlowModel:
410
521
  self._plausibility_checks()
411
522
  self.submodel = FlowModel(model, self)
412
523
  return self.submodel
413
524
 
414
- def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
415
- prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
416
- self.relative_minimum = flow_system.fit_to_model_coords(f'{prefix}|relative_minimum', self.relative_minimum)
417
- self.relative_maximum = flow_system.fit_to_model_coords(f'{prefix}|relative_maximum', self.relative_maximum)
418
- self.fixed_relative_profile = flow_system.fit_to_model_coords(
419
- f'{prefix}|fixed_relative_profile', self.fixed_relative_profile
525
+ def link_to_flow_system(self, flow_system, prefix: str = '') -> None:
526
+ """Propagate flow_system reference to nested Interface objects.
527
+
528
+ Elements use their label_full as prefix by default, ignoring the passed prefix.
529
+ """
530
+ super().link_to_flow_system(flow_system, self.label_full)
531
+ if self.status_parameters is not None:
532
+ self.status_parameters.link_to_flow_system(flow_system, self._sub_prefix('status_parameters'))
533
+ if isinstance(self.size, InvestParameters):
534
+ self.size.link_to_flow_system(flow_system, self._sub_prefix('InvestParameters'))
535
+
536
+ def transform_data(self) -> None:
537
+ self.relative_minimum = self._fit_coords(f'{self.prefix}|relative_minimum', self.relative_minimum)
538
+ self.relative_maximum = self._fit_coords(f'{self.prefix}|relative_maximum', self.relative_maximum)
539
+ self.fixed_relative_profile = self._fit_coords(
540
+ f'{self.prefix}|fixed_relative_profile', self.fixed_relative_profile
541
+ )
542
+ self.effects_per_flow_hour = self._fit_effect_coords(self.prefix, self.effects_per_flow_hour, 'per_flow_hour')
543
+ self.flow_hours_max = self._fit_coords(
544
+ f'{self.prefix}|flow_hours_max', self.flow_hours_max, dims=['period', 'scenario']
420
545
  )
421
- self.effects_per_flow_hour = flow_system.fit_effects_to_model_coords(
422
- prefix, self.effects_per_flow_hour, 'per_flow_hour'
546
+ self.flow_hours_min = self._fit_coords(
547
+ f'{self.prefix}|flow_hours_min', self.flow_hours_min, dims=['period', 'scenario']
423
548
  )
424
- self.flow_hours_total_max = flow_system.fit_to_model_coords(
425
- f'{prefix}|flow_hours_total_max', self.flow_hours_total_max, dims=['period', 'scenario']
549
+ self.flow_hours_max_over_periods = self._fit_coords(
550
+ f'{self.prefix}|flow_hours_max_over_periods', self.flow_hours_max_over_periods, dims=['scenario']
426
551
  )
427
- self.flow_hours_total_min = flow_system.fit_to_model_coords(
428
- f'{prefix}|flow_hours_total_min', self.flow_hours_total_min, dims=['period', 'scenario']
552
+ self.flow_hours_min_over_periods = self._fit_coords(
553
+ f'{self.prefix}|flow_hours_min_over_periods', self.flow_hours_min_over_periods, dims=['scenario']
429
554
  )
430
- self.load_factor_max = flow_system.fit_to_model_coords(
431
- f'{prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario']
555
+ self.load_factor_max = self._fit_coords(
556
+ f'{self.prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario']
432
557
  )
433
- self.load_factor_min = flow_system.fit_to_model_coords(
434
- f'{prefix}|load_factor_min', self.load_factor_min, dims=['period', 'scenario']
558
+ self.load_factor_min = self._fit_coords(
559
+ f'{self.prefix}|load_factor_min', self.load_factor_min, dims=['period', 'scenario']
435
560
  )
436
561
 
437
- if self.on_off_parameters is not None:
438
- self.on_off_parameters.transform_data(flow_system, prefix)
562
+ if self.status_parameters is not None:
563
+ self.status_parameters.transform_data()
439
564
  if isinstance(self.size, InvestParameters):
440
- self.size.transform_data(flow_system, prefix)
441
- else:
442
- self.size = flow_system.fit_to_model_coords(f'{prefix}|size', self.size, dims=['period', 'scenario'])
565
+ self.size.transform_data()
566
+ elif self.size is not None:
567
+ self.size = self._fit_coords(f'{self.prefix}|size', self.size, dims=['period', 'scenario'])
443
568
 
444
569
  def _plausibility_checks(self) -> None:
445
570
  # TODO: Incorporate into Variable? (Lower_bound can not be greater than upper bound
446
571
  if (self.relative_minimum > self.relative_maximum).any():
447
572
  raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!')
448
573
 
449
- if not isinstance(self.size, InvestParameters) and (
450
- np.any(self.size == CONFIG.Modeling.big) and self.fixed_relative_profile is not None
451
- ): # Default Size --> Most likely by accident
452
- logger.warning(
453
- f'Flow "{self.label_full}" has no size assigned, but a "fixed_relative_profile". '
454
- f'The default size is {CONFIG.Modeling.big}. As "flow_rate = size * fixed_relative_profile", '
455
- f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.'
574
+ # Size is required when using StatusParameters (for big-M constraints)
575
+ if self.status_parameters is not None and self.size is None:
576
+ raise PlausibilityError(
577
+ f'Flow "{self.label_full}" has status_parameters but no size defined. '
578
+ f'A size is required when using status_parameters to bound the flow rate.'
579
+ )
580
+
581
+ if self.size is None and self.fixed_relative_profile is not None:
582
+ raise PlausibilityError(
583
+ f'Flow "{self.label_full}" has a fixed_relative_profile but no size defined. '
584
+ f'A size is required because flow_rate = size * fixed_relative_profile.'
585
+ )
586
+
587
+ # Size is required when using non-default relative bounds (flow_rate = size * relative_bound)
588
+ if self.size is None and np.any(self.relative_minimum > 0):
589
+ raise PlausibilityError(
590
+ f'Flow "{self.label_full}" has relative_minimum > 0 but no size defined. '
591
+ f'A size is required because the lower bound is size * relative_minimum.'
592
+ )
593
+
594
+ if self.size is None and np.any(self.relative_maximum < 1):
595
+ raise PlausibilityError(
596
+ f'Flow "{self.label_full}" has relative_maximum != 1 but no size defined. '
597
+ f'A size is required because the upper bound is size * relative_maximum.'
456
598
  )
457
599
 
458
- if self.fixed_relative_profile is not None and self.on_off_parameters is not None:
600
+ # Size is required for load factor constraints (total_flow_hours / size)
601
+ if self.size is None and self.load_factor_min is not None:
602
+ raise PlausibilityError(
603
+ f'Flow "{self.label_full}" has load_factor_min but no size defined. '
604
+ f'A size is required because the constraint is total_flow_hours >= size * load_factor_min * hours.'
605
+ )
606
+
607
+ if self.size is None and self.load_factor_max is not None:
608
+ raise PlausibilityError(
609
+ f'Flow "{self.label_full}" has load_factor_max but no size defined. '
610
+ f'A size is required because the constraint is total_flow_hours <= size * load_factor_max * hours.'
611
+ )
612
+
613
+ if self.fixed_relative_profile is not None and self.status_parameters is not None:
459
614
  logger.warning(
460
- f'Flow {self.label_full} has both a fixed_relative_profile and an on_off_parameters.'
461
- f'This will allow the flow to be switched on and off, effectively differing from the fixed_flow_rate.'
615
+ f'Flow {self.label_full} has both a fixed_relative_profile and status_parameters.'
616
+ f'This will allow the flow to be switched active and inactive, effectively differing from the fixed_flow_rate.'
462
617
  )
463
618
 
464
- if np.any(self.relative_minimum > 0) and self.on_off_parameters is None:
619
+ if np.any(self.relative_minimum > 0) and self.status_parameters is None:
465
620
  logger.warning(
466
- f'Flow {self.label_full} has a relative_minimum of {self.relative_minimum} and no on_off_parameters. '
467
- f'This prevents the flow_rate from switching off (flow_rate = 0). '
468
- f'Consider using on_off_parameters to allow the flow to be switched on and off.'
621
+ f'Flow {self.label_full} has a relative_minimum of {self.relative_minimum} and no status_parameters. '
622
+ f'This prevents the Flow from switching inactive (flow_rate = 0). '
623
+ f'Consider using status_parameters to allow the Flow to be switched active and inactive.'
469
624
  )
470
625
 
471
626
  if self.previous_flow_rate is not None:
@@ -489,56 +644,109 @@ class Flow(Element):
489
644
  # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen
490
645
  return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True
491
646
 
647
+ def _format_invest_params(self, params: InvestParameters) -> str:
648
+ """Format InvestParameters for display."""
649
+ return f'size: {params.format_for_repr()}'
650
+
492
651
 
493
652
  class FlowModel(ElementModel):
653
+ """Mathematical model implementation for Flow elements.
654
+
655
+ Creates optimization variables and constraints for flow rate bounds,
656
+ flow-hours tracking, and load factors.
657
+
658
+ Mathematical Formulation:
659
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/Flow/>
660
+ """
661
+
494
662
  element: Flow # Type hint
495
663
 
496
664
  def __init__(self, model: FlowSystemModel, element: Flow):
497
665
  super().__init__(model, element)
498
666
 
499
667
  def _do_modeling(self):
668
+ """Create variables, constraints, and nested submodels"""
500
669
  super()._do_modeling()
670
+
501
671
  # Main flow rate variable
502
672
  self.add_variables(
503
673
  lower=self.absolute_flow_rate_bounds[0],
504
674
  upper=self.absolute_flow_rate_bounds[1],
505
675
  coords=self._model.get_coords(),
506
676
  short_name='flow_rate',
677
+ category=VariableCategory.FLOW_RATE,
507
678
  )
508
679
 
509
680
  self._constraint_flow_rate()
510
681
 
511
- # Total flow hours tracking
682
+ # Total flow hours tracking (per period)
512
683
  ModelingPrimitives.expression_tracking_variable(
513
684
  model=self,
514
685
  name=f'{self.label_full}|total_flow_hours',
515
- tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'),
686
+ tracked_expression=self._model.sum_temporal(self.flow_rate),
516
687
  bounds=(
517
- self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0,
518
- self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None,
688
+ self.element.flow_hours_min if self.element.flow_hours_min is not None else 0,
689
+ self.element.flow_hours_max if self.element.flow_hours_max is not None else None,
519
690
  ),
520
691
  coords=['period', 'scenario'],
521
692
  short_name='total_flow_hours',
693
+ category=VariableCategory.TOTAL,
522
694
  )
523
695
 
696
+ # Weighted sum over all periods constraint
697
+ if self.element.flow_hours_min_over_periods is not None or self.element.flow_hours_max_over_periods is not None:
698
+ # Validate that period dimension exists
699
+ if self._model.flow_system.periods is None:
700
+ raise ValueError(
701
+ f"{self.label_full}: flow_hours_*_over_periods requires FlowSystem to define 'periods', "
702
+ f'but FlowSystem has no period dimension. Please define periods in FlowSystem constructor.'
703
+ )
704
+ # Get period weights from FlowSystem
705
+ weighted_flow_hours_over_periods = (self.total_flow_hours * self._model.flow_system.period_weights).sum(
706
+ 'period'
707
+ )
708
+
709
+ # Create tracking variable for the weighted sum
710
+ ModelingPrimitives.expression_tracking_variable(
711
+ model=self,
712
+ name=f'{self.label_full}|flow_hours_over_periods',
713
+ tracked_expression=weighted_flow_hours_over_periods,
714
+ bounds=(
715
+ self.element.flow_hours_min_over_periods
716
+ if self.element.flow_hours_min_over_periods is not None
717
+ else 0,
718
+ self.element.flow_hours_max_over_periods
719
+ if self.element.flow_hours_max_over_periods is not None
720
+ else None,
721
+ ),
722
+ coords=['scenario'],
723
+ short_name='flow_hours_over_periods',
724
+ category=VariableCategory.TOTAL_OVER_PERIODS,
725
+ )
726
+
524
727
  # Load factor constraints
525
728
  self._create_bounds_for_load_factor()
526
729
 
527
730
  # Effects
528
731
  self._create_shares()
529
732
 
530
- def _create_on_off_model(self):
531
- on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords())
733
+ def _create_status_model(self):
734
+ status = self.add_variables(
735
+ binary=True,
736
+ short_name='status',
737
+ coords=self._model.get_coords(),
738
+ category=VariableCategory.STATUS,
739
+ )
532
740
  self.add_submodels(
533
- OnOffModel(
741
+ StatusModel(
534
742
  model=self._model,
535
743
  label_of_element=self.label_of_element,
536
- parameters=self.element.on_off_parameters,
537
- on_variable=on,
538
- previous_states=self.previous_states,
744
+ parameters=self.element.status_parameters,
745
+ status=status,
746
+ previous_status=self.previous_status,
539
747
  label_of_model=self.label_of_element,
540
748
  ),
541
- short_name='on_off',
749
+ short_name='status',
542
750
  )
543
751
 
544
752
  def _create_investment_model(self):
@@ -548,28 +756,30 @@ class FlowModel(ElementModel):
548
756
  label_of_element=self.label_of_element,
549
757
  parameters=self.element.size,
550
758
  label_of_model=self.label_of_element,
759
+ size_category=VariableCategory.FLOW_SIZE,
551
760
  ),
552
761
  'investment',
553
762
  )
554
763
 
555
764
  def _constraint_flow_rate(self):
556
- if not self.with_investment and not self.with_on_off:
765
+ """Create bounding constraints for flow_rate (models already created in _create_variables)"""
766
+ if not self.with_investment and not self.with_status:
557
767
  # Most basic case. Already covered by direct variable bounds
558
768
  pass
559
769
 
560
- elif self.with_on_off and not self.with_investment:
561
- # OnOff, but no Investment
562
- self._create_on_off_model()
770
+ elif self.with_status and not self.with_investment:
771
+ # Status, but no Investment
772
+ self._create_status_model()
563
773
  bounds = self.relative_flow_rate_bounds
564
774
  BoundingPatterns.bounds_with_state(
565
775
  self,
566
776
  variable=self.flow_rate,
567
777
  bounds=(bounds[0] * self.element.size, bounds[1] * self.element.size),
568
- variable_state=self.on_off.on,
778
+ state=self.status.status,
569
779
  )
570
780
 
571
- elif self.with_investment and not self.with_on_off:
572
- # Investment, but no OnOff
781
+ elif self.with_investment and not self.with_status:
782
+ # Investment, but no Status
573
783
  self._create_investment_model()
574
784
  BoundingPatterns.scaled_bounds(
575
785
  self,
@@ -578,10 +788,10 @@ class FlowModel(ElementModel):
578
788
  relative_bounds=self.relative_flow_rate_bounds,
579
789
  )
580
790
 
581
- elif self.with_investment and self.with_on_off:
582
- # Investment and OnOff
791
+ elif self.with_investment and self.with_status:
792
+ # Investment and Status
583
793
  self._create_investment_model()
584
- self._create_on_off_model()
794
+ self._create_status_model()
585
795
 
586
796
  BoundingPatterns.scaled_bounds_with_state(
587
797
  model=self,
@@ -589,14 +799,14 @@ class FlowModel(ElementModel):
589
799
  scaling_variable=self._investment.size,
590
800
  relative_bounds=self.relative_flow_rate_bounds,
591
801
  scaling_bounds=(self.element.size.minimum_or_fixed_size, self.element.size.maximum_or_fixed_size),
592
- variable_state=self.on_off.on,
802
+ state=self.status.status,
593
803
  )
594
804
  else:
595
805
  raise Exception('Not valid')
596
806
 
597
807
  @property
598
- def with_on_off(self) -> bool:
599
- return self.element.on_off_parameters is not None
808
+ def with_status(self) -> bool:
809
+ return self.element.status_parameters is not None
600
810
 
601
811
  @property
602
812
  def with_investment(self) -> bool:
@@ -622,12 +832,12 @@ class FlowModel(ElementModel):
622
832
  }
623
833
 
624
834
  def _create_shares(self):
625
- # Effects per flow hour
835
+ # Effects per flow hour (use timestep_duration only, cluster_weight is applied when summing to total)
626
836
  if self.element.effects_per_flow_hour:
627
837
  self._model.effects.add_share_to_effects(
628
838
  name=self.label_full,
629
839
  expressions={
630
- effect: self.flow_rate * self._model.hours_per_step * factor
840
+ effect: self.flow_rate * self._model.timestep_duration * factor
631
841
  for effect, factor in self.element.effects_per_flow_hour.items()
632
842
  },
633
843
  target='temporal',
@@ -638,9 +848,12 @@ class FlowModel(ElementModel):
638
848
  # Get the size (either from element or investment)
639
849
  size = self.investment.size if self.with_investment else self.element.size
640
850
 
851
+ # Total hours in the period (sum of temporal weights)
852
+ total_hours = self._model.temporal_weight.sum(self._model.temporal_dims)
853
+
641
854
  # Maximum load factor constraint
642
855
  if self.element.load_factor_max is not None:
643
- flow_hours_per_size_max = self._model.hours_per_step.sum('time') * self.element.load_factor_max
856
+ flow_hours_per_size_max = total_hours * self.element.load_factor_max
644
857
  self.add_constraints(
645
858
  self.total_flow_hours <= size * flow_hours_per_size_max,
646
859
  short_name='load_factor_max',
@@ -648,20 +861,22 @@ class FlowModel(ElementModel):
648
861
 
649
862
  # Minimum load factor constraint
650
863
  if self.element.load_factor_min is not None:
651
- flow_hours_per_size_min = self._model.hours_per_step.sum('time') * self.element.load_factor_min
864
+ flow_hours_per_size_min = total_hours * self.element.load_factor_min
652
865
  self.add_constraints(
653
866
  self.total_flow_hours >= size * flow_hours_per_size_min,
654
867
  short_name='load_factor_min',
655
868
  )
656
869
 
657
- @property
658
- def relative_flow_rate_bounds(self) -> tuple[TemporalData, TemporalData]:
870
+ @functools.cached_property
871
+ def relative_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
659
872
  if self.element.fixed_relative_profile is not None:
660
873
  return self.element.fixed_relative_profile, self.element.fixed_relative_profile
661
- return self.element.relative_minimum, self.element.relative_maximum
874
+ # Ensure both bounds have matching dimensions (broadcast once here,
875
+ # so downstream code doesn't need to handle dimension mismatches)
876
+ return xr.broadcast(self.element.relative_minimum, self.element.relative_maximum)
662
877
 
663
878
  @property
664
- def absolute_flow_rate_bounds(self) -> tuple[TemporalData, TemporalData]:
879
+ def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
665
880
  """
666
881
  Returns the absolute bounds the flow_rate can reach.
667
882
  Further constraining might be needed
@@ -669,27 +884,30 @@ class FlowModel(ElementModel):
669
884
  lb_relative, ub_relative = self.relative_flow_rate_bounds
670
885
 
671
886
  lb = 0
672
- if not self.with_on_off:
887
+ if not self.with_status:
673
888
  if not self.with_investment:
674
- # Basic case without investment and without OnOff
675
- lb = lb_relative * self.element.size
889
+ # Basic case without investment and without Status
890
+ if self.element.size is not None:
891
+ lb = lb_relative * self.element.size
676
892
  elif self.with_investment and self.element.size.mandatory:
677
893
  # With mandatory Investment
678
894
  lb = lb_relative * self.element.size.minimum_or_fixed_size
679
895
 
680
896
  if self.with_investment:
681
897
  ub = ub_relative * self.element.size.maximum_or_fixed_size
682
- else:
898
+ elif self.element.size is not None:
683
899
  ub = ub_relative * self.element.size
900
+ else:
901
+ ub = np.inf # Unbounded when size is None
684
902
 
685
903
  return lb, ub
686
904
 
687
905
  @property
688
- def on_off(self) -> OnOffModel | None:
689
- """OnOff feature"""
690
- if 'on_off' not in self.submodels:
906
+ def status(self) -> StatusModel | None:
907
+ """Status feature"""
908
+ if 'status' not in self.submodels:
691
909
  return None
692
- return self.submodels['on_off']
910
+ return self.submodels['status']
693
911
 
694
912
  @property
695
913
  def _investment(self) -> InvestmentModel | None:
@@ -698,14 +916,14 @@ class FlowModel(ElementModel):
698
916
 
699
917
  @property
700
918
  def investment(self) -> InvestmentModel | None:
701
- """OnOff feature"""
919
+ """Investment feature"""
702
920
  if 'investment' not in self.submodels:
703
921
  return None
704
922
  return self.submodels['investment']
705
923
 
706
924
  @property
707
- def previous_states(self) -> TemporalData | None:
708
- """Previous states of the flow rate"""
925
+ def previous_status(self) -> xr.DataArray | None:
926
+ """Previous status of the flow rate"""
709
927
  # TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well.
710
928
  previous_flow_rate = self.element.previous_flow_rate
711
929
  if previous_flow_rate is None:
@@ -721,14 +939,24 @@ class FlowModel(ElementModel):
721
939
 
722
940
 
723
941
  class BusModel(ElementModel):
942
+ """Mathematical model implementation for Bus elements.
943
+
944
+ Creates optimization variables and constraints for nodal balance equations,
945
+ and optional excess/deficit variables with penalty costs.
946
+
947
+ Mathematical Formulation:
948
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/elements/Bus/>
949
+ """
950
+
724
951
  element: Bus # Type hint
725
952
 
726
953
  def __init__(self, model: FlowSystemModel, element: Bus):
727
- self.excess_input: linopy.Variable | None = None
728
- self.excess_output: linopy.Variable | None = None
954
+ self.virtual_supply: linopy.Variable | None = None
955
+ self.virtual_demand: linopy.Variable | None = None
729
956
  super().__init__(model, element)
730
957
 
731
- def _do_modeling(self) -> None:
958
+ def _do_modeling(self):
959
+ """Create variables, constraints, and nested submodels"""
732
960
  super()._do_modeling()
733
961
  # inputs == outputs
734
962
  for flow in self.element.inputs + self.element.outputs:
@@ -737,28 +965,44 @@ class BusModel(ElementModel):
737
965
  outputs = sum([flow.submodel.flow_rate for flow in self.element.outputs])
738
966
  eq_bus_balance = self.add_constraints(inputs == outputs, short_name='balance')
739
967
 
740
- # Fehlerplus/-minus:
741
- if self.element.with_excess:
742
- excess_penalty = np.multiply(self._model.hours_per_step, self.element.excess_penalty_per_flow_hour)
968
+ # Add virtual supply/demand to balance and penalty if needed
969
+ if self.element.allows_imbalance:
970
+ imbalance_penalty = self.element.imbalance_penalty_per_flow_hour * self._model.timestep_duration
743
971
 
744
- self.excess_input = self.add_variables(lower=0, coords=self._model.get_coords(), short_name='excess_input')
972
+ self.virtual_supply = self.add_variables(
973
+ lower=0,
974
+ coords=self._model.get_coords(),
975
+ short_name='virtual_supply',
976
+ category=VariableCategory.VIRTUAL_FLOW,
977
+ )
745
978
 
746
- self.excess_output = self.add_variables(
747
- lower=0, coords=self._model.get_coords(), short_name='excess_output'
979
+ self.virtual_demand = self.add_variables(
980
+ lower=0,
981
+ coords=self._model.get_coords(),
982
+ short_name='virtual_demand',
983
+ category=VariableCategory.VIRTUAL_FLOW,
748
984
  )
749
985
 
750
- eq_bus_balance.lhs -= -self.excess_input + self.excess_output
986
+ # Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand
987
+ eq_bus_balance.lhs += self.virtual_supply - self.virtual_demand
751
988
 
752
- self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_input * excess_penalty).sum())
753
- self._model.effects.add_share_to_penalty(self.label_of_element, (self.excess_output * excess_penalty).sum())
989
+ # Add penalty shares as temporal effects (time-dependent)
990
+ from .effects import PENALTY_EFFECT_LABEL
991
+
992
+ total_imbalance_penalty = (self.virtual_supply + self.virtual_demand) * imbalance_penalty
993
+ self._model.effects.add_share_to_effects(
994
+ name=self.label_of_element,
995
+ expressions={PENALTY_EFFECT_LABEL: total_imbalance_penalty},
996
+ target='temporal',
997
+ )
754
998
 
755
999
  def results_structure(self):
756
1000
  inputs = [flow.submodel.flow_rate.name for flow in self.element.inputs]
757
1001
  outputs = [flow.submodel.flow_rate.name for flow in self.element.outputs]
758
- if self.excess_input is not None:
759
- inputs.append(self.excess_input.name)
760
- if self.excess_output is not None:
761
- outputs.append(self.excess_output.name)
1002
+ if self.virtual_supply is not None:
1003
+ inputs.append(self.virtual_supply.name)
1004
+ if self.virtual_demand is not None:
1005
+ outputs.append(self.virtual_demand.name)
762
1006
  return {
763
1007
  **super().results_structure(),
764
1008
  'inputs': inputs,
@@ -771,55 +1015,72 @@ class ComponentModel(ElementModel):
771
1015
  element: Component # Type hint
772
1016
 
773
1017
  def __init__(self, model: FlowSystemModel, element: Component):
774
- self.on_off: OnOffModel | None = None
1018
+ self.status: StatusModel | None = None
775
1019
  super().__init__(model, element)
776
1020
 
777
1021
  def _do_modeling(self):
778
- """Initiates all FlowModels"""
1022
+ """Create variables, constraints, and nested submodels"""
779
1023
  super()._do_modeling()
1024
+
780
1025
  all_flows = self.element.inputs + self.element.outputs
781
- if self.element.on_off_parameters:
1026
+
1027
+ # Set status_parameters on flows if needed
1028
+ if self.element.status_parameters:
782
1029
  for flow in all_flows:
783
- if flow.on_off_parameters is None:
784
- flow.on_off_parameters = OnOffParameters()
1030
+ if flow.status_parameters is None:
1031
+ flow.status_parameters = StatusParameters()
1032
+ flow.status_parameters.link_to_flow_system(
1033
+ self._model.flow_system, f'{flow.label_full}|status_parameters'
1034
+ )
785
1035
 
786
1036
  if self.element.prevent_simultaneous_flows:
787
1037
  for flow in self.element.prevent_simultaneous_flows:
788
- if flow.on_off_parameters is None:
789
- flow.on_off_parameters = OnOffParameters()
1038
+ if flow.status_parameters is None:
1039
+ flow.status_parameters = StatusParameters()
1040
+ flow.status_parameters.link_to_flow_system(
1041
+ self._model.flow_system, f'{flow.label_full}|status_parameters'
1042
+ )
790
1043
 
1044
+ # Create FlowModels (which creates their variables and constraints)
791
1045
  for flow in all_flows:
792
1046
  self.add_submodels(flow.create_model(self._model), short_name=flow.label)
793
1047
 
794
- if self.element.on_off_parameters:
795
- on = self.add_variables(binary=True, short_name='on', coords=self._model.get_coords())
1048
+ # Create component status variable and StatusModel if needed
1049
+ if self.element.status_parameters:
1050
+ status = self.add_variables(
1051
+ binary=True,
1052
+ short_name='status',
1053
+ coords=self._model.get_coords(),
1054
+ category=VariableCategory.STATUS,
1055
+ )
796
1056
  if len(all_flows) == 1:
797
- self.add_constraints(on == all_flows[0].submodel.on_off.on, short_name='on')
1057
+ self.add_constraints(status == all_flows[0].submodel.status.status, short_name='status')
798
1058
  else:
799
- flow_ons = [flow.submodel.on_off.on for flow in all_flows]
1059
+ flow_statuses = [flow.submodel.status.status for flow in all_flows]
800
1060
  # TODO: Is the EPSILON even necessary?
801
- self.add_constraints(on <= sum(flow_ons) + CONFIG.Modeling.epsilon, short_name='on|ub')
1061
+ self.add_constraints(status <= sum(flow_statuses) + CONFIG.Modeling.epsilon, short_name='status|ub')
802
1062
  self.add_constraints(
803
- on >= sum(flow_ons) / (len(flow_ons) + CONFIG.Modeling.epsilon), short_name='on|lb'
1063
+ status >= sum(flow_statuses) / (len(flow_statuses) + CONFIG.Modeling.epsilon),
1064
+ short_name='status|lb',
804
1065
  )
805
1066
 
806
- self.on_off = self.add_submodels(
807
- OnOffModel(
1067
+ self.status = self.add_submodels(
1068
+ StatusModel(
808
1069
  model=self._model,
809
1070
  label_of_element=self.label_of_element,
810
- parameters=self.element.on_off_parameters,
811
- on_variable=on,
1071
+ parameters=self.element.status_parameters,
1072
+ status=status,
812
1073
  label_of_model=self.label_of_element,
813
- previous_states=self.previous_states,
1074
+ previous_status=self.previous_status,
814
1075
  ),
815
- short_name='on_off',
1076
+ short_name='status',
816
1077
  )
817
1078
 
818
1079
  if self.element.prevent_simultaneous_flows:
819
1080
  # Simultanious Useage --> Only One FLow is On at a time, but needs a Binary for every flow
820
1081
  ModelingPrimitives.mutual_exclusivity_constraint(
821
1082
  self,
822
- binary_variables=[flow.submodel.on_off.on for flow in self.element.prevent_simultaneous_flows],
1083
+ binary_variables=[flow.submodel.status.status for flow in self.element.prevent_simultaneous_flows],
823
1084
  short_name='prevent_simultaneous_use',
824
1085
  )
825
1086
 
@@ -832,21 +1093,21 @@ class ComponentModel(ElementModel):
832
1093
  }
833
1094
 
834
1095
  @property
835
- def previous_states(self) -> xr.DataArray | None:
836
- """Previous state of the component, derived from its flows"""
837
- if self.element.on_off_parameters is None:
838
- raise ValueError(f'OnOffModel not present in \n{self}\nCant access previous_states')
1096
+ def previous_status(self) -> xr.DataArray | None:
1097
+ """Previous status of the component, derived from its flows"""
1098
+ if self.element.status_parameters is None:
1099
+ raise ValueError(f'StatusModel not present in \n{self}\nCant access previous_status')
839
1100
 
840
- previous_states = [flow.submodel.on_off._previous_states for flow in self.element.inputs + self.element.outputs]
841
- previous_states = [da for da in previous_states if da is not None]
1101
+ previous_status = [flow.submodel.status._previous_status for flow in self.element.inputs + self.element.outputs]
1102
+ previous_status = [da for da in previous_status if da is not None]
842
1103
 
843
- if not previous_states: # Empty list
1104
+ if not previous_status: # Empty list
844
1105
  return None
845
1106
 
846
- max_len = max(da.sizes['time'] for da in previous_states)
1107
+ max_len = max(da.sizes['time'] for da in previous_status)
847
1108
 
848
- padded_previous_states = [
1109
+ padded_previous_status = [
849
1110
  da.assign_coords(time=range(-da.sizes['time'], 0)).reindex(time=range(-max_len, 0), fill_value=0)
850
- for da in previous_states
1111
+ for da in previous_status
851
1112
  ]
852
- return xr.concat(padded_previous_states, dim='flow').any(dim='flow').astype(int)
1113
+ return xr.concat(padded_previous_status, dim='flow').any(dim='flow').astype(int)