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

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

Potentially problematic release.


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

Files changed (58) hide show
  1. flixopt/__init__.py +33 -4
  2. flixopt/aggregation.py +60 -80
  3. flixopt/calculation.py +395 -178
  4. flixopt/commons.py +1 -10
  5. flixopt/components.py +939 -448
  6. flixopt/config.py +553 -191
  7. flixopt/core.py +513 -846
  8. flixopt/effects.py +644 -178
  9. flixopt/elements.py +610 -355
  10. flixopt/features.py +394 -966
  11. flixopt/flow_system.py +736 -219
  12. flixopt/interface.py +1104 -302
  13. flixopt/io.py +103 -79
  14. flixopt/linear_converters.py +387 -95
  15. flixopt/modeling.py +759 -0
  16. flixopt/network_app.py +73 -39
  17. flixopt/plotting.py +294 -138
  18. flixopt/results.py +1253 -299
  19. flixopt/solvers.py +25 -21
  20. flixopt/structure.py +938 -396
  21. flixopt/utils.py +38 -12
  22. flixopt-3.0.0.dist-info/METADATA +209 -0
  23. flixopt-3.0.0.dist-info/RECORD +26 -0
  24. flixopt-3.0.0.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -61
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  37. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  38. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  39. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  40. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  41. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  42. docs/user-guide/Mathematical Notation/index.md +0 -22
  43. docs/user-guide/Mathematical Notation/others.md +0 -3
  44. docs/user-guide/index.md +0 -124
  45. flixopt/config.yaml +0 -10
  46. flixopt-2.2.0rc2.dist-info/METADATA +0 -167
  47. flixopt-2.2.0rc2.dist-info/RECORD +0 -54
  48. flixopt-2.2.0rc2.dist-info/top_level.txt +0 -5
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixOpt_plotting.jpg +0 -0
  52. pics/flixopt-icon.svg +0 -1
  53. pics/pics.pptx +0 -0
  54. scripts/extract_release_notes.py +0 -45
  55. scripts/gen_ref_pages.py +0 -54
  56. tests/ressources/Zeitreihen2020.csv +0 -35137
  57. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/WHEEL +0 -0
  58. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/components.py CHANGED
@@ -2,21 +2,25 @@
2
2
  This module contains the basic components of the flixopt framework.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import logging
6
8
  import warnings
7
- from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Set, Tuple, Union
9
+ from typing import TYPE_CHECKING, Literal
8
10
 
9
- import linopy
10
11
  import numpy as np
12
+ import xarray as xr
11
13
 
12
- from . import utils
13
- from .core import NumericData, NumericDataTS, PlausibilityError, Scalar, TimeSeries
14
+ from .core import PeriodicDataUser, PlausibilityError, TemporalData, TemporalDataUser
14
15
  from .elements import Component, ComponentModel, Flow
15
- from .features import InvestmentModel, OnOffModel, PiecewiseModel
16
+ from .features import InvestmentModel, PiecewiseModel
16
17
  from .interface import InvestParameters, OnOffParameters, PiecewiseConversion
17
- from .structure import SystemModel, register_class_for_io
18
+ from .modeling import BoundingPatterns
19
+ from .structure import FlowSystemModel, register_class_for_io
18
20
 
19
21
  if TYPE_CHECKING:
22
+ import linopy
23
+
20
24
  from .flow_system import FlowSystem
21
25
 
22
26
  logger = logging.getLogger('flixopt')
@@ -24,82 +28,114 @@ logger = logging.getLogger('flixopt')
24
28
 
25
29
  @register_class_for_io
26
30
  class LinearConverter(Component):
27
- """Convert input flows into output flows using linear or piecewise linear conversion factors.
31
+ """
32
+ Converts input-Flows into output-Flows via linear conversion factors.
28
33
 
29
- This component models conversion equipment where input flows are transformed
30
- into output flows with fixed or variable conversion ratios, such as:
34
+ LinearConverter models equipment that transforms one or more input flows into one or
35
+ more output flows through linear relationships. This includes heat exchangers,
36
+ electrical converters, chemical reactors, and other equipment where the
37
+ relationship between inputs and outputs can be expressed as linear equations.
31
38
 
32
- - Heat pumps and chillers with variable efficiency
33
- - Power plants with fuel-to-electricity conversion
34
- - Chemical processes with multiple inputs/outputs
35
- - Pumps and compressors
36
- - Combined heat and power (CHP) plants
39
+ The component supports two modeling approaches: simple conversion factors for
40
+ straightforward linear relationships, or piecewise conversion for complex non-linear
41
+ behavior approximated through piecewise linear segments.
42
+
43
+ Mathematical Formulation:
44
+ See the complete mathematical model in the documentation:
45
+ [LinearConverter](../user-guide/mathematical-notation/elements/LinearConverter.md)
37
46
 
38
47
  Args:
39
- label: Unique identifier for the component in the FlowSystem.
40
- inputs: List of input Flow objects that feed into the converter.
41
- outputs: List of output Flow objects produced by the converter.
42
- on_off_parameters: Controls binary on/off behavior of the converter.
43
- When specified, the component can be completely turned on or off, affecting
44
- all connected flows. This creates binary variables in the optimization.
45
- For better performance, consider using OnOffParameters on individual flows instead.
46
- conversion_factors: Linear conversion ratios between flows as time series data.
47
- List of dictionaries mapping flow labels to their conversion factors.
48
- Mutually exclusive with piecewise_conversion.
49
- piecewise_conversion: Piecewise linear conversion relationships between flows.
50
- Enables modeling of variable efficiency or discrete operating modes.
51
- Mutually exclusive with conversion_factors.
52
- meta_data: Additional information stored with the component.
53
- Saved in results but not used internally. Use only Python native types.
54
-
55
- Warning:
56
- When using `piecewise_conversion` without `on_off_parameters`, flow rates cannot
57
- reach zero unless explicitly defined with zero-valued pieces (e.g., `fx.Piece(0, 0)`).
58
- This prevents unintended zero flows and maintains mathematical consistency.
59
-
60
- To allow zero flow rates:
61
-
62
- - Add `on_off_parameters` to enable complete shutdown, or
63
- - Include explicit zero pieces in your `piecewise_conversion` definition
64
-
65
- This behavior was clarified in v2.1.7 to prevent optimization edge cases.
48
+ label: The label of the Element. Used to identify it in the FlowSystem.
49
+ inputs: list of input Flows that feed into the converter.
50
+ outputs: list of output Flows that are produced by the converter.
51
+ on_off_parameters: Information about on and off state of LinearConverter.
52
+ Component is On/Off if all connected Flows are On/Off. This induces an
53
+ On-Variable (binary) in all Flows! If possible, use OnOffParameters in a
54
+ single Flow instead to keep the number of binary variables low.
55
+ conversion_factors: Linear relationships between flows expressed as a list of
56
+ dictionaries. Each dictionary maps flow labels to their coefficients in one
57
+ linear equation. The number of conversion factors must be less than the total
58
+ number of flows to ensure degrees of freedom > 0. Either 'conversion_factors'
59
+ OR 'piecewise_conversion' can be used, but not both.
60
+ For examples also look into the linear_converters.py file.
61
+ piecewise_conversion: Define piecewise linear relationships between flow rates
62
+ of different flows. Enables modeling of non-linear conversion behavior through
63
+ linear approximation. Either 'conversion_factors' or 'piecewise_conversion'
64
+ can be used, but not both.
65
+ meta_data: Used to store additional information about the Element. Not used
66
+ internally, but saved in results. Only use Python native types.
66
67
 
67
68
  Examples:
68
- Simple heat pump with fixed COP:
69
+ Simple 1:1 heat exchanger with 95% efficiency:
70
+
71
+ ```python
72
+ heat_exchanger = LinearConverter(
73
+ label='primary_hx',
74
+ inputs=[hot_water_in],
75
+ outputs=[hot_water_out],
76
+ conversion_factors=[{'hot_water_in': 0.95, 'hot_water_out': 1}],
77
+ )
78
+ ```
79
+
80
+ Multi-input heat pump with COP=3:
69
81
 
70
82
  ```python
71
- heat_pump = fx.LinearConverter(
72
- label='heat_pump',
73
- inputs=[electricity_flow],
74
- outputs=[heat_flow],
83
+ heat_pump = LinearConverter(
84
+ label='air_source_hp',
85
+ inputs=[electricity_in],
86
+ outputs=[heat_output],
87
+ conversion_factors=[{'electricity_in': 3, 'heat_output': 1}],
88
+ )
89
+ ```
90
+
91
+ Combined heat and power (CHP) unit with multiple outputs:
92
+
93
+ ```python
94
+ chp_unit = LinearConverter(
95
+ label='gas_chp',
96
+ inputs=[natural_gas],
97
+ outputs=[electricity_out, heat_out],
75
98
  conversion_factors=[
76
- {
77
- 'electricity_flow': 1.0, # 1 kW electricity input
78
- 'heat_flow': 3.5, # 3.5 kW heat output (COP=3.5)
79
- }
99
+ {'natural_gas': 0.35, 'electricity_out': 1},
100
+ {'natural_gas': 0.45, 'heat_out': 1},
101
+ ],
102
+ )
103
+ ```
104
+
105
+ Electrolyzer with multiple conversion relationships:
106
+
107
+ ```python
108
+ electrolyzer = LinearConverter(
109
+ label='pem_electrolyzer',
110
+ inputs=[electricity_in, water_in],
111
+ outputs=[hydrogen_out, oxygen_out],
112
+ conversion_factors=[
113
+ {'electricity_in': 1, 'hydrogen_out': 50}, # 50 kWh/kg H2
114
+ {'water_in': 1, 'hydrogen_out': 9}, # 9 kg H2O/kg H2
115
+ {'hydrogen_out': 8, 'oxygen_out': 1}, # Mass balance
80
116
  ],
81
117
  )
82
118
  ```
83
119
 
84
- Variable efficiency heat pump:
120
+ Complex converter with piecewise efficiency:
85
121
 
86
122
  ```python
87
- heat_pump = fx.LinearConverter(
88
- label='variable_heat_pump',
89
- inputs=[electricity_flow],
90
- outputs=[heat_flow],
91
- piecewise_conversion=fx.PiecewiseConversion(
123
+ variable_efficiency_converter = LinearConverter(
124
+ label='variable_converter',
125
+ inputs=[fuel_in],
126
+ outputs=[power_out],
127
+ piecewise_conversion=PiecewiseConversion(
92
128
  {
93
- 'electricity_flow': fx.Piecewise(
129
+ 'fuel_in': Piecewise(
94
130
  [
95
- fx.Piece(0, 10), # Allow zero to 10 kW input
96
- fx.Piece(10, 25), # Higher load operation
131
+ Piece(0, 10), # Low load operation
132
+ Piece(10, 25), # High load operation
97
133
  ]
98
134
  ),
99
- 'heat_flow': fx.Piecewise(
135
+ 'power_out': Piecewise(
100
136
  [
101
- fx.Piece(0, 35), # COP=3.5 at low loads
102
- fx.Piece(35, 75), # COP=3.0 at high loads
137
+ Piece(0, 3.5), # Lower efficiency at part load
138
+ Piece(3.5, 10), # Higher efficiency at full load
103
139
  ]
104
140
  ),
105
141
  }
@@ -107,56 +143,41 @@ class LinearConverter(Component):
107
143
  )
108
144
  ```
109
145
 
110
- Combined heat and power plant:
146
+ Note:
147
+ Conversion factors define linear relationships where the sum of (coefficient × flow_rate)
148
+ equals zero for each equation: factor1×flow1 + factor2×flow2 + ... = 0
149
+ Conversion factors define linear relationships:
150
+ `{flow1: a1, flow2: a2, ...}` yields `a1×flow_rate1 + a2×flow_rate2 + ... = 0`.
151
+ Note: The input format may be unintuitive. For example,
152
+ `{"electricity": 1, "H2": 50}` implies `1×electricity = 50×H2`,
153
+ i.e., 50 units of electricity produce 1 unit of H2.
111
154
 
112
- ```python
113
- chp_plant = fx.LinearConverter(
114
- label='chp_plant',
115
- inputs=[natural_gas_flow],
116
- outputs=[electricity_flow, heat_flow],
117
- conversion_factors=[
118
- {
119
- 'natural_gas_flow': 1.0, # 1 MW fuel input
120
- 'electricity_flow': 0.4, # 40% electrical efficiency
121
- 'heat_flow': 0.45, # 45% thermal efficiency
122
- }
123
- ],
124
- on_off_parameters=fx.OnOffParameters(
125
- min_on_hours=4, # Minimum 4-hour operation
126
- min_off_hours=2, # Minimum 2-hour downtime
127
- ),
128
- )
129
- ```
155
+ The system must have fewer conversion factors than total flows (degrees of freedom > 0)
156
+ to avoid over-constraining the problem. For n total flows, use at most n-1 conversion factors.
157
+
158
+ When using piecewise_conversion, the converter operates on one piece at a time,
159
+ with binary variables determining which piece is active.
130
160
 
131
- Note:
132
- Either `conversion_factors` or `piecewise_conversion` must be specified, but not both.
133
- The component automatically handles the mathematical relationships between all
134
- connected flows according to the specified conversion ratios.
135
-
136
- See Also:
137
- PiecewiseConversion: For variable efficiency modeling
138
- OnOffParameters: For binary on/off control
139
- Flow: Input and output flow definitions
140
161
  """
141
162
 
142
163
  def __init__(
143
164
  self,
144
165
  label: str,
145
- inputs: List[Flow],
146
- outputs: List[Flow],
147
- on_off_parameters: OnOffParameters = None,
148
- conversion_factors: List[Dict[str, NumericDataTS]] = None,
149
- piecewise_conversion: Optional[PiecewiseConversion] = None,
150
- meta_data: Optional[Dict] = None,
166
+ inputs: list[Flow],
167
+ outputs: list[Flow],
168
+ on_off_parameters: OnOffParameters | None = None,
169
+ conversion_factors: list[dict[str, TemporalDataUser]] | None = None,
170
+ piecewise_conversion: PiecewiseConversion | None = None,
171
+ meta_data: dict | None = None,
151
172
  ):
152
173
  super().__init__(label, inputs, outputs, on_off_parameters, meta_data=meta_data)
153
174
  self.conversion_factors = conversion_factors or []
154
175
  self.piecewise_conversion = piecewise_conversion
155
176
 
156
- def create_model(self, model: SystemModel) -> 'LinearConverterModel':
177
+ def create_model(self, model: FlowSystemModel) -> LinearConverterModel:
157
178
  self._plausibility_checks()
158
- self.model = LinearConverterModel(model, self)
159
- return self.model
179
+ self.submodel = LinearConverterModel(model, self)
180
+ return self.submodel
160
181
 
161
182
  def _plausibility_checks(self) -> None:
162
183
  super()._plausibility_checks()
@@ -182,28 +203,32 @@ class LinearConverter(Component):
182
203
  if self.piecewise_conversion:
183
204
  for flow in self.flows.values():
184
205
  if isinstance(flow.size, InvestParameters) and flow.size.fixed_size is None:
185
- raise PlausibilityError(
186
- f'piecewise_conversion (in {self.label_full}) and variable size '
187
- f'(in flow {flow.label_full}) do not make sense together!'
206
+ logger.warning(
207
+ f'Using a Flow with variable size (InvestParameters without fixed_size) '
208
+ f'and a piecewise_conversion in {self.label_full} is uncommon. Please verify intent '
209
+ f'({flow.label_full}).'
188
210
  )
189
211
 
190
- def transform_data(self, flow_system: 'FlowSystem'):
191
- super().transform_data(flow_system)
212
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
213
+ prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
214
+ super().transform_data(flow_system, prefix)
192
215
  if self.conversion_factors:
193
216
  self.conversion_factors = self._transform_conversion_factors(flow_system)
194
217
  if self.piecewise_conversion:
195
- self.piecewise_conversion.transform_data(flow_system, f'{self.label_full}|PiecewiseConversion')
218
+ self.piecewise_conversion.has_time_dim = True
219
+ self.piecewise_conversion.transform_data(flow_system, f'{prefix}|PiecewiseConversion')
196
220
 
197
- def _transform_conversion_factors(self, flow_system: 'FlowSystem') -> List[Dict[str, TimeSeries]]:
198
- """macht alle Faktoren, die nicht TimeSeries sind, zu TimeSeries"""
221
+ def _transform_conversion_factors(self, flow_system: FlowSystem) -> list[dict[str, xr.DataArray]]:
222
+ """Converts all conversion factors to internal datatypes"""
199
223
  list_of_conversion_factors = []
200
224
  for idx, conversion_factor in enumerate(self.conversion_factors):
201
225
  transformed_dict = {}
202
226
  for flow, values in conversion_factor.items():
203
227
  # TODO: Might be better to use the label of the component instead of the flow
204
- transformed_dict[flow] = flow_system.create_time_series(
205
- f'{self.flows[flow].label_full}|conversion_factor{idx}', values
206
- )
228
+ ts = flow_system.fit_to_model_coords(f'{self.flows[flow].label_full}|conversion_factor{idx}', values)
229
+ if ts is None:
230
+ raise PlausibilityError(f'{self.label_full}: conversion factor for flow "{flow}" must not be None')
231
+ transformed_dict[flow] = ts
207
232
  list_of_conversion_factors.append(transformed_dict)
208
233
  return list_of_conversion_factors
209
234
 
@@ -215,7 +240,140 @@ class LinearConverter(Component):
215
240
  @register_class_for_io
216
241
  class Storage(Component):
217
242
  """
218
- Used to model the storage of energy or material.
243
+ A Storage models the temporary storage and release of energy or material.
244
+
245
+ Storages have one incoming and one outgoing Flow, each with configurable efficiency
246
+ factors. They maintain a charge state variable that represents the stored amount,
247
+ bounded by capacity limits and evolving over time based on charging, discharging,
248
+ and self-discharge losses.
249
+
250
+ The storage model handles complex temporal dynamics including initial conditions,
251
+ final state constraints, and time-varying parameters. It supports both fixed-size
252
+ and investment-optimized storage systems with comprehensive techno-economic modeling.
253
+
254
+ Mathematical Formulation:
255
+ See the complete mathematical model in the documentation:
256
+ [Storage](../user-guide/mathematical-notation/elements/Storage.md)
257
+
258
+ - Equation (1): Charge state bounds
259
+ - Equation (3): Storage balance (charge state evolution)
260
+
261
+ Variable Mapping:
262
+ - ``capacity_in_flow_hours`` → C (storage capacity)
263
+ - ``charge_state`` → c(t_i) (state of charge at time t_i)
264
+ - ``relative_loss_per_hour`` → ċ_rel,loss (self-discharge rate)
265
+ - ``eta_charge`` → η_in (charging efficiency)
266
+ - ``eta_discharge`` → η_out (discharging efficiency)
267
+
268
+ Args:
269
+ label: Element identifier used in the FlowSystem.
270
+ charging: Incoming flow for loading the storage.
271
+ discharging: Outgoing flow for unloading the storage.
272
+ capacity_in_flow_hours: Storage capacity in flow-hours (kWh, m³, kg).
273
+ Scalar for fixed size or InvestParameters for optimization.
274
+ relative_minimum_charge_state: Minimum charge state (0-1). Default: 0.
275
+ relative_maximum_charge_state: Maximum charge state (0-1). Default: 1.
276
+ initial_charge_state: Charge at start. Numeric or 'lastValueOfSim'. Default: 0.
277
+ minimal_final_charge_state: Minimum absolute charge required at end (optional).
278
+ maximal_final_charge_state: Maximum absolute charge allowed at end (optional).
279
+ relative_minimum_final_charge_state: Minimum relative charge at end.
280
+ Defaults to last value of relative_minimum_charge_state.
281
+ relative_maximum_final_charge_state: Maximum relative charge at end.
282
+ Defaults to last value of relative_maximum_charge_state.
283
+ eta_charge: Charging efficiency (0-1). Default: 1.
284
+ eta_discharge: Discharging efficiency (0-1). Default: 1.
285
+ relative_loss_per_hour: Self-discharge per hour (0-0.1). Default: 0.
286
+ prevent_simultaneous_charge_and_discharge: Prevent charging and discharging
287
+ simultaneously. Adds binary variables. Default: True.
288
+ meta_data: Additional information stored in results. Python native types only.
289
+
290
+ Examples:
291
+ Battery energy storage system:
292
+
293
+ ```python
294
+ battery = Storage(
295
+ label='lithium_battery',
296
+ charging=battery_charge_flow,
297
+ discharging=battery_discharge_flow,
298
+ capacity_in_flow_hours=100, # 100 kWh capacity
299
+ eta_charge=0.95, # 95% charging efficiency
300
+ eta_discharge=0.95, # 95% discharging efficiency
301
+ relative_loss_per_hour=0.001, # 0.1% loss per hour
302
+ relative_minimum_charge_state=0.1, # Never below 10% SOC
303
+ relative_maximum_charge_state=0.9, # Never above 90% SOC
304
+ )
305
+ ```
306
+
307
+ Thermal storage with cycling constraints:
308
+
309
+ ```python
310
+ thermal_storage = Storage(
311
+ label='hot_water_tank',
312
+ charging=heat_input,
313
+ discharging=heat_output,
314
+ capacity_in_flow_hours=500, # 500 kWh thermal capacity
315
+ initial_charge_state=250, # Start half full
316
+ # Impact of temperature on energy capacity
317
+ relative_maximum_charge_state=water_temperature_spread / rated_temeprature_spread,
318
+ eta_charge=0.90, # Heat exchanger losses
319
+ eta_discharge=0.85, # Distribution losses
320
+ relative_loss_per_hour=0.02, # 2% thermal loss per hour
321
+ prevent_simultaneous_charge_and_discharge=True,
322
+ )
323
+ ```
324
+
325
+ Pumped hydro storage with investment optimization:
326
+
327
+ ```python
328
+ pumped_hydro = Storage(
329
+ label='pumped_hydro',
330
+ charging=pump_flow,
331
+ discharging=turbine_flow,
332
+ capacity_in_flow_hours=InvestParameters(
333
+ minimum_size=1000, # Minimum economic scale
334
+ maximum_size=10000, # Site constraints
335
+ specific_effects={'cost': 150}, # €150/MWh capacity
336
+ fix_effects={'cost': 50_000_000}, # €50M fixed costs
337
+ ),
338
+ eta_charge=0.85, # Pumping efficiency
339
+ eta_discharge=0.90, # Turbine efficiency
340
+ initial_charge_state='lastValueOfSim', # Ensuring no deficit compared to start
341
+ relative_loss_per_hour=0.0001, # Minimal evaporation
342
+ )
343
+ ```
344
+
345
+ Material storage with inventory management:
346
+
347
+ ```python
348
+ fuel_storage = Storage(
349
+ label='natural_gas_storage',
350
+ charging=gas_injection,
351
+ discharging=gas_withdrawal,
352
+ capacity_in_flow_hours=10000, # 10,000 m³ storage volume
353
+ initial_charge_state=3000, # Start with 3,000 m³
354
+ minimal_final_charge_state=1000, # Strategic reserve
355
+ maximal_final_charge_state=9000, # Prevent overflow
356
+ eta_charge=0.98, # Compression losses
357
+ eta_discharge=0.95, # Pressure reduction losses
358
+ relative_loss_per_hour=0.0005, # 0.05% leakage per hour
359
+ prevent_simultaneous_charge_and_discharge=False, # Allow flow-through
360
+ )
361
+ ```
362
+
363
+ Note:
364
+ **Mathematical formulation**: See [Storage](../user-guide/mathematical-notation/elements/Storage.md)
365
+ for charge state evolution equations and balance constraints.
366
+
367
+ **Efficiency parameters** (eta_charge, eta_discharge) are dimensionless (0-1 range).
368
+ The relative_loss_per_hour represents exponential decay per hour.
369
+
370
+ **Binary variables**: When prevent_simultaneous_charge_and_discharge is True, binary
371
+ variables enforce mutual exclusivity, increasing solution time but preventing unrealistic
372
+ simultaneous charging and discharging.
373
+
374
+ **Units**: Flow rates and charge states are related by the concept of 'flow hours' (=flow_rate * time).
375
+ With flow rates in kW, the charge state is therefore (usually) kWh.
376
+ With flow rates in m3/h, the charge state is therefore in m3.
219
377
  """
220
378
 
221
379
  def __init__(
@@ -223,43 +381,21 @@ class Storage(Component):
223
381
  label: str,
224
382
  charging: Flow,
225
383
  discharging: Flow,
226
- capacity_in_flow_hours: Union[Scalar, InvestParameters],
227
- relative_minimum_charge_state: NumericData = 0,
228
- relative_maximum_charge_state: NumericData = 1,
229
- initial_charge_state: Union[Scalar, Literal['lastValueOfSim']] = 0,
230
- minimal_final_charge_state: Optional[Scalar] = None,
231
- maximal_final_charge_state: Optional[Scalar] = None,
232
- eta_charge: NumericData = 1,
233
- eta_discharge: NumericData = 1,
234
- relative_loss_per_hour: NumericData = 0,
384
+ capacity_in_flow_hours: PeriodicDataUser | InvestParameters,
385
+ relative_minimum_charge_state: TemporalDataUser = 0,
386
+ relative_maximum_charge_state: TemporalDataUser = 1,
387
+ initial_charge_state: PeriodicDataUser | Literal['lastValueOfSim'] = 0,
388
+ minimal_final_charge_state: PeriodicDataUser | None = None,
389
+ maximal_final_charge_state: PeriodicDataUser | None = None,
390
+ relative_minimum_final_charge_state: PeriodicDataUser | None = None,
391
+ relative_maximum_final_charge_state: PeriodicDataUser | None = None,
392
+ eta_charge: TemporalDataUser = 1,
393
+ eta_discharge: TemporalDataUser = 1,
394
+ relative_loss_per_hour: TemporalDataUser = 0,
235
395
  prevent_simultaneous_charge_and_discharge: bool = True,
236
- meta_data: Optional[Dict] = None,
396
+ balanced: bool = False,
397
+ meta_data: dict | None = None,
237
398
  ):
238
- """
239
- Storages have one incoming and one outgoing Flow each with an efficiency.
240
- Further, storages have a `size` and a `charge_state`.
241
- Similarly to the flow-rate of a Flow, the `size` combined with a relative upper and lower bound
242
- limits the `charge_state` of the storage.
243
-
244
- For mathematical details take a look at our online documentation
245
-
246
- Args:
247
- label: The label of the Element. Used to identify it in the FlowSystem
248
- charging: ingoing flow.
249
- discharging: outgoing flow.
250
- capacity_in_flow_hours: nominal capacity/size of the storage
251
- relative_minimum_charge_state: minimum relative charge state. The default is 0.
252
- relative_maximum_charge_state: maximum relative charge state. The default is 1.
253
- initial_charge_state: storage charge_state at the beginning. The default is 0.
254
- minimal_final_charge_state: minimal value of chargeState at the end of timeseries.
255
- maximal_final_charge_state: maximal value of chargeState at the end of timeseries.
256
- eta_charge: efficiency factor of charging/loading. The default is 1.
257
- eta_discharge: efficiency factor of uncharging/unloading. The default is 1.
258
- relative_loss_per_hour: loss per chargeState-Unit per hour. The default is 0.
259
- prevent_simultaneous_charge_and_discharge: If True, loading and unloading at the same time is not possible.
260
- Increases the number of binary variables, but is recommended for easier evaluation. The default is True.
261
- meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
262
- """
263
399
  # TODO: fixed_relative_chargeState implementieren
264
400
  super().__init__(
265
401
  label,
@@ -272,117 +408,252 @@ class Storage(Component):
272
408
  self.charging = charging
273
409
  self.discharging = discharging
274
410
  self.capacity_in_flow_hours = capacity_in_flow_hours
275
- self.relative_minimum_charge_state: NumericDataTS = relative_minimum_charge_state
276
- self.relative_maximum_charge_state: NumericDataTS = relative_maximum_charge_state
411
+ self.relative_minimum_charge_state: TemporalDataUser = relative_minimum_charge_state
412
+ self.relative_maximum_charge_state: TemporalDataUser = relative_maximum_charge_state
413
+
414
+ self.relative_minimum_final_charge_state = relative_minimum_final_charge_state
415
+ self.relative_maximum_final_charge_state = relative_maximum_final_charge_state
277
416
 
278
417
  self.initial_charge_state = initial_charge_state
279
418
  self.minimal_final_charge_state = minimal_final_charge_state
280
419
  self.maximal_final_charge_state = maximal_final_charge_state
281
420
 
282
- self.eta_charge: NumericDataTS = eta_charge
283
- self.eta_discharge: NumericDataTS = eta_discharge
284
- self.relative_loss_per_hour: NumericDataTS = relative_loss_per_hour
421
+ self.eta_charge: TemporalDataUser = eta_charge
422
+ self.eta_discharge: TemporalDataUser = eta_discharge
423
+ self.relative_loss_per_hour: TemporalDataUser = relative_loss_per_hour
285
424
  self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge
425
+ self.balanced = balanced
286
426
 
287
- def create_model(self, model: SystemModel) -> 'StorageModel':
427
+ def create_model(self, model: FlowSystemModel) -> StorageModel:
288
428
  self._plausibility_checks()
289
- self.model = StorageModel(model, self)
290
- return self.model
291
-
292
- def transform_data(self, flow_system: 'FlowSystem') -> None:
293
- super().transform_data(flow_system)
294
- self.relative_minimum_charge_state = flow_system.create_time_series(
295
- f'{self.label_full}|relative_minimum_charge_state',
429
+ self.submodel = StorageModel(model, self)
430
+ return self.submodel
431
+
432
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
433
+ prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
434
+ super().transform_data(flow_system, prefix)
435
+ self.relative_minimum_charge_state = flow_system.fit_to_model_coords(
436
+ f'{prefix}|relative_minimum_charge_state',
296
437
  self.relative_minimum_charge_state,
297
- needs_extra_timestep=True,
298
438
  )
299
- self.relative_maximum_charge_state = flow_system.create_time_series(
300
- f'{self.label_full}|relative_maximum_charge_state',
439
+ self.relative_maximum_charge_state = flow_system.fit_to_model_coords(
440
+ f'{prefix}|relative_maximum_charge_state',
301
441
  self.relative_maximum_charge_state,
302
- needs_extra_timestep=True,
303
442
  )
304
- self.eta_charge = flow_system.create_time_series(f'{self.label_full}|eta_charge', self.eta_charge)
305
- self.eta_discharge = flow_system.create_time_series(f'{self.label_full}|eta_discharge', self.eta_discharge)
306
- self.relative_loss_per_hour = flow_system.create_time_series(
307
- f'{self.label_full}|relative_loss_per_hour', self.relative_loss_per_hour
443
+ self.eta_charge = flow_system.fit_to_model_coords(f'{prefix}|eta_charge', self.eta_charge)
444
+ self.eta_discharge = flow_system.fit_to_model_coords(f'{prefix}|eta_discharge', self.eta_discharge)
445
+ self.relative_loss_per_hour = flow_system.fit_to_model_coords(
446
+ f'{prefix}|relative_loss_per_hour', self.relative_loss_per_hour
447
+ )
448
+ if not isinstance(self.initial_charge_state, str):
449
+ self.initial_charge_state = flow_system.fit_to_model_coords(
450
+ f'{prefix}|initial_charge_state', self.initial_charge_state, dims=['period', 'scenario']
451
+ )
452
+ self.minimal_final_charge_state = flow_system.fit_to_model_coords(
453
+ f'{prefix}|minimal_final_charge_state', self.minimal_final_charge_state, dims=['period', 'scenario']
454
+ )
455
+ self.maximal_final_charge_state = flow_system.fit_to_model_coords(
456
+ f'{prefix}|maximal_final_charge_state', self.maximal_final_charge_state, dims=['period', 'scenario']
457
+ )
458
+ self.relative_minimum_final_charge_state = flow_system.fit_to_model_coords(
459
+ f'{prefix}|relative_minimum_final_charge_state',
460
+ self.relative_minimum_final_charge_state,
461
+ dims=['period', 'scenario'],
462
+ )
463
+ self.relative_maximum_final_charge_state = flow_system.fit_to_model_coords(
464
+ f'{prefix}|relative_maximum_final_charge_state',
465
+ self.relative_maximum_final_charge_state,
466
+ dims=['period', 'scenario'],
308
467
  )
309
468
  if isinstance(self.capacity_in_flow_hours, InvestParameters):
310
- self.capacity_in_flow_hours.transform_data(flow_system)
469
+ self.capacity_in_flow_hours.transform_data(flow_system, f'{prefix}|InvestParameters')
470
+ else:
471
+ self.capacity_in_flow_hours = flow_system.fit_to_model_coords(
472
+ f'{prefix}|capacity_in_flow_hours', self.capacity_in_flow_hours, dims=['period', 'scenario']
473
+ )
311
474
 
312
475
  def _plausibility_checks(self) -> None:
313
476
  """
314
477
  Check for infeasible or uncommon combinations of parameters
315
478
  """
316
479
  super()._plausibility_checks()
317
- if utils.is_number(self.initial_charge_state):
318
- if isinstance(self.capacity_in_flow_hours, InvestParameters):
319
- if self.capacity_in_flow_hours.fixed_size is None:
320
- maximum_capacity = self.capacity_in_flow_hours.maximum_size
321
- minimum_capacity = self.capacity_in_flow_hours.minimum_size
322
- else:
323
- maximum_capacity = self.capacity_in_flow_hours.fixed_size
324
- minimum_capacity = self.capacity_in_flow_hours.fixed_size
480
+
481
+ # Validate string values and set flag
482
+ initial_is_last = False
483
+ if isinstance(self.initial_charge_state, str):
484
+ if self.initial_charge_state == 'lastValueOfSim':
485
+ initial_is_last = True
325
486
  else:
326
- maximum_capacity = self.capacity_in_flow_hours
327
- minimum_capacity = self.capacity_in_flow_hours
487
+ raise PlausibilityError(f'initial_charge_state has undefined value: {self.initial_charge_state}')
328
488
 
329
- # initial capacity >= allowed min for maximum_size:
330
- minimum_inital_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=1)
331
- # initial capacity <= allowed max for minimum_size:
332
- maximum_inital_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=1)
489
+ # Use new InvestParameters methods to get capacity bounds
490
+ if isinstance(self.capacity_in_flow_hours, InvestParameters):
491
+ minimum_capacity = self.capacity_in_flow_hours.minimum_or_fixed_size
492
+ maximum_capacity = self.capacity_in_flow_hours.maximum_or_fixed_size
493
+ else:
494
+ maximum_capacity = self.capacity_in_flow_hours
495
+ minimum_capacity = self.capacity_in_flow_hours
333
496
 
334
- if self.initial_charge_state > maximum_inital_capacity:
335
- raise ValueError(
497
+ # Initial capacity should not constraint investment decision
498
+ minimum_initial_capacity = maximum_capacity * self.relative_minimum_charge_state.isel(time=0)
499
+ maximum_initial_capacity = minimum_capacity * self.relative_maximum_charge_state.isel(time=0)
500
+
501
+ # Only perform numeric comparisons if not using 'lastValueOfSim'
502
+ if not initial_is_last:
503
+ if (self.initial_charge_state > maximum_initial_capacity).any():
504
+ raise PlausibilityError(
336
505
  f'{self.label_full}: {self.initial_charge_state=} '
337
- f'is above allowed maximum charge_state {maximum_inital_capacity}'
506
+ f'is constraining the investment decision. Chosse a value above {maximum_initial_capacity}'
338
507
  )
339
- if self.initial_charge_state < minimum_inital_capacity:
340
- raise ValueError(
508
+ if (self.initial_charge_state < minimum_initial_capacity).any():
509
+ raise PlausibilityError(
341
510
  f'{self.label_full}: {self.initial_charge_state=} '
342
- f'is below allowed minimum charge_state {minimum_inital_capacity}'
511
+ f'is constraining the investment decision. Chosse a value below {minimum_initial_capacity}'
512
+ )
513
+
514
+ if self.balanced:
515
+ if not isinstance(self.charging.size, InvestParameters) or not isinstance(
516
+ self.discharging.size, InvestParameters
517
+ ):
518
+ raise PlausibilityError(
519
+ f'Balancing charging and discharging Flows in {self.label_full} is only possible with Investments.'
520
+ )
521
+
522
+ if (self.charging.size.minimum_size > self.discharging.size.maximum_size).any() or (
523
+ self.charging.size.maximum_size < self.discharging.size.minimum_size
524
+ ).any():
525
+ raise PlausibilityError(
526
+ f'Balancing charging and discharging Flows in {self.label_full} need compatible minimum and maximum sizes.'
527
+ f'Got: {self.charging.size.minimum_size=}, {self.charging.size.maximum_size=} and '
528
+ f'{self.discharging.size.minimum_size=}, {self.discharging.size.maximum_size=}.'
343
529
  )
344
- elif self.initial_charge_state != 'lastValueOfSim':
345
- raise ValueError(f'{self.label_full}: {self.initial_charge_state=} has an invalid value')
346
530
 
347
531
 
348
532
  @register_class_for_io
349
533
  class Transmission(Component):
350
- # TODO: automatic on-Value in Flows if loss_abs
351
- # TODO: loss_abs must be: investment_size * loss_abs_rel!!!
352
- # TODO: investmentsize only on 1 flow
353
- # TODO: automatic investArgs for both in-flows (or alternatively both out-flows!)
354
- # TODO: optional: capacities should be recognised for losses
534
+ """
535
+ Models transmission infrastructure that transports flows between two locations with losses.
536
+
537
+ Transmission components represent physical infrastructure like pipes, cables,
538
+ transmission lines, or conveyor systems that transport energy or materials between
539
+ two points. They can model both unidirectional and bidirectional flow with
540
+ configurable loss mechanisms and operational constraints.
541
+
542
+ The component supports complex transmission scenarios including relative losses
543
+ (proportional to flow), absolute losses (fixed when active), and bidirectional
544
+ operation with flow direction constraints.
545
+
546
+ Args:
547
+ label: The label of the Element. Used to identify it in the FlowSystem.
548
+ in1: The primary inflow (side A). Pass InvestParameters here for capacity optimization.
549
+ out1: The primary outflow (side B).
550
+ in2: Optional secondary inflow (side B) for bidirectional operation.
551
+ If in1 has InvestParameters, in2 will automatically have matching capacity.
552
+ out2: Optional secondary outflow (side A) for bidirectional operation.
553
+ relative_losses: Proportional losses as fraction of throughput (e.g., 0.02 for 2% loss).
554
+ Applied as: output = input × (1 - relative_losses)
555
+ absolute_losses: Fixed losses that occur when transmission is active.
556
+ Automatically creates binary variables for on/off states.
557
+ on_off_parameters: Parameters defining binary operation constraints and costs.
558
+ prevent_simultaneous_flows_in_both_directions: If True, prevents simultaneous
559
+ flow in both directions. Increases binary variables but reflects physical
560
+ reality for most transmission systems. Default is True.
561
+ balanced: Whether to equate the size of the in1 and in2 Flow. Needs InvestParameters in both Flows.
562
+ meta_data: Used to store additional information. Not used internally but saved
563
+ in results. Only use Python native types.
564
+
565
+ Examples:
566
+ Simple electrical transmission line:
567
+
568
+ ```python
569
+ power_line = Transmission(
570
+ label='110kv_line',
571
+ in1=substation_a_out,
572
+ out1=substation_b_in,
573
+ relative_losses=0.03, # 3% line losses
574
+ )
575
+ ```
576
+
577
+ Bidirectional natural gas pipeline:
578
+
579
+ ```python
580
+ gas_pipeline = Transmission(
581
+ label='interstate_pipeline',
582
+ in1=compressor_station_a,
583
+ out1=distribution_hub_b,
584
+ in2=compressor_station_b,
585
+ out2=distribution_hub_a,
586
+ relative_losses=0.005, # 0.5% friction losses
587
+ absolute_losses=50, # 50 kW compressor power when active
588
+ prevent_simultaneous_flows_in_both_directions=True,
589
+ )
590
+ ```
591
+
592
+ District heating network with investment optimization:
593
+
594
+ ```python
595
+ heating_network = Transmission(
596
+ label='dh_main_line',
597
+ in1=Flow(
598
+ label='heat_supply',
599
+ bus=central_plant_bus,
600
+ size=InvestParameters(
601
+ minimum_size=1000, # Minimum 1 MW capacity
602
+ maximum_size=10000, # Maximum 10 MW capacity
603
+ specific_effects={'cost': 200}, # €200/kW capacity
604
+ fix_effects={'cost': 500000}, # €500k fixed installation
605
+ ),
606
+ ),
607
+ out1=district_heat_demand,
608
+ relative_losses=0.15, # 15% thermal losses in distribution
609
+ )
610
+ ```
611
+
612
+ Material conveyor with on/off operation:
613
+
614
+ ```python
615
+ conveyor_belt = Transmission(
616
+ label='material_transport',
617
+ in1=loading_station,
618
+ out1=unloading_station,
619
+ absolute_losses=25, # 25 kW motor power when running
620
+ on_off_parameters=OnOffParameters(
621
+ effects_per_switch_on={'maintenance': 0.1},
622
+ consecutive_on_hours_min=2, # Minimum 2-hour operation
623
+ switch_on_total_max=10, # Maximum 10 starts per day
624
+ ),
625
+ )
626
+ ```
627
+
628
+ Note:
629
+ The transmission equation balances flows with losses:
630
+ output_flow = input_flow × (1 - relative_losses) - absolute_losses
631
+
632
+ For bidirectional transmission, each direction has independent loss calculations.
633
+
634
+ When using InvestParameters on in1, the capacity automatically applies to in2
635
+ to maintain consistent bidirectional capacity without additional investment variables.
636
+
637
+ Absolute losses force the creation of binary on/off variables, which increases
638
+ computational complexity but enables realistic modeling of equipment with
639
+ standby power consumption.
640
+
641
+ """
355
642
 
356
643
  def __init__(
357
644
  self,
358
645
  label: str,
359
646
  in1: Flow,
360
647
  out1: Flow,
361
- in2: Optional[Flow] = None,
362
- out2: Optional[Flow] = None,
363
- relative_losses: Optional[NumericDataTS] = None,
364
- absolute_losses: Optional[NumericDataTS] = None,
648
+ in2: Flow | None = None,
649
+ out2: Flow | None = None,
650
+ relative_losses: TemporalDataUser | None = None,
651
+ absolute_losses: TemporalDataUser | None = None,
365
652
  on_off_parameters: OnOffParameters = None,
366
653
  prevent_simultaneous_flows_in_both_directions: bool = True,
367
- meta_data: Optional[Dict] = None,
654
+ balanced: bool = False,
655
+ meta_data: dict | None = None,
368
656
  ):
369
- """
370
- Initializes a Transmission component (Pipe, cable, ...) that models the flows between two sides
371
- with potential losses.
372
-
373
- Args:
374
- label: The label of the Element. Used to identify it in the FlowSystem
375
- in1: The inflow at side A. Pass InvestmentParameters here.
376
- out1: The outflow at side B.
377
- in2: The optional inflow at side B.
378
- If in1 got InvestParameters, the size of this Flow will be equal to in1 (with no extra effects!)
379
- out2: The optional outflow at side A.
380
- relative_losses: The relative loss between inflow and outflow, e.g., 0.02 for 2% loss.
381
- absolute_losses: The absolute loss, occur only when the Flow is on. Induces the creation of the ON-Variable
382
- on_off_parameters: Parameters defining the on/off behavior of the component.
383
- prevent_simultaneous_flows_in_both_directions: If True, inflow and outflow are not allowed to be both non-zero at same timestep.
384
- meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
385
- """
386
657
  super().__init__(
387
658
  label,
388
659
  inputs=[flow for flow in (in1, in2) if flow is not None],
@@ -400,6 +671,7 @@ class Transmission(Component):
400
671
 
401
672
  self.relative_losses = relative_losses
402
673
  self.absolute_losses = absolute_losses
674
+ self.balanced = balanced
403
675
 
404
676
  def _plausibility_checks(self):
405
677
  super()._plausibility_checks()
@@ -412,51 +684,47 @@ class Transmission(Component):
412
684
  assert self.out2.bus == self.in1.bus, (
413
685
  f'Input 1 and Output 2 do not start/end at the same Bus: {self.in1.bus=}, {self.out2.bus=}'
414
686
  )
415
- # Check Investments
416
- for flow in [self.out1, self.in2, self.out2]:
417
- if flow is not None and isinstance(flow.size, InvestParameters):
687
+
688
+ if self.balanced:
689
+ if self.in2 is None:
690
+ raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows')
691
+ if not isinstance(self.in1.size, InvestParameters) or not isinstance(self.in2.size, InvestParameters):
692
+ raise ValueError('Balanced Transmission needs InvestParameters in both in-Flows')
693
+ if (self.in1.size.minimum_or_fixed_size > self.in2.size.maximum_or_fixed_size).any() or (
694
+ self.in1.size.maximum_or_fixed_size < self.in2.size.minimum_or_fixed_size
695
+ ).any():
418
696
  raise ValueError(
419
- 'Transmission currently does not support separate InvestParameters for Flows. '
420
- 'Please use Flow in1. The size of in2 is equal to in1. THis is handled internally'
697
+ f'Balanced Transmission needs compatible minimum and maximum sizes.'
698
+ f'Got: {self.in1.size.minimum_size=}, {self.in1.size.maximum_size=}, {self.in1.size.fixed_size=} and '
699
+ f'{self.in2.size.minimum_size=}, {self.in2.size.maximum_size=}, {self.in2.size.fixed_size=}.'
421
700
  )
422
701
 
423
- def create_model(self, model) -> 'TransmissionModel':
702
+ def create_model(self, model) -> TransmissionModel:
424
703
  self._plausibility_checks()
425
- self.model = TransmissionModel(model, self)
426
- return self.model
704
+ self.submodel = TransmissionModel(model, self)
705
+ return self.submodel
427
706
 
428
- def transform_data(self, flow_system: 'FlowSystem') -> None:
429
- super().transform_data(flow_system)
430
- self.relative_losses = flow_system.create_time_series(
431
- f'{self.label_full}|relative_losses', self.relative_losses
432
- )
433
- self.absolute_losses = flow_system.create_time_series(
434
- f'{self.label_full}|absolute_losses', self.absolute_losses
435
- )
707
+ def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None:
708
+ prefix = '|'.join(filter(None, [name_prefix, self.label_full]))
709
+ super().transform_data(flow_system, prefix)
710
+ self.relative_losses = flow_system.fit_to_model_coords(f'{prefix}|relative_losses', self.relative_losses)
711
+ self.absolute_losses = flow_system.fit_to_model_coords(f'{prefix}|absolute_losses', self.absolute_losses)
436
712
 
437
713
 
438
714
  class TransmissionModel(ComponentModel):
439
- def __init__(self, model: SystemModel, element: Transmission):
440
- super().__init__(model, element)
441
- self.element: Transmission = element
442
- self.on_off: Optional[OnOffModel] = None
715
+ element: Transmission
443
716
 
444
- def do_modeling(self):
445
- """Initiates all FlowModels"""
446
- # Force On Variable if absolute losses are present
447
- if (self.element.absolute_losses is not None) and np.any(self.element.absolute_losses.active_data != 0):
448
- for flow in self.element.inputs + self.element.outputs:
717
+ def __init__(self, model: FlowSystemModel, element: Transmission):
718
+ if (element.absolute_losses is not None) and np.any(element.absolute_losses != 0):
719
+ for flow in element.inputs + element.outputs:
449
720
  if flow.on_off_parameters is None:
450
721
  flow.on_off_parameters = OnOffParameters()
451
722
 
452
- # Make sure either None or both in Flows have InvestParameters
453
- if self.element.in2 is not None:
454
- if isinstance(self.element.in1.size, InvestParameters) and not isinstance(
455
- self.element.in2.size, InvestParameters
456
- ):
457
- self.element.in2.size = InvestParameters(maximum_size=self.element.in1.size.maximum_size)
723
+ super().__init__(model, element)
458
724
 
459
- super().do_modeling()
725
+ def _do_modeling(self):
726
+ """Initiates all FlowModels"""
727
+ super()._do_modeling()
460
728
 
461
729
  # first direction
462
730
  self.create_transmission_equation('dir1', self.element.in1, self.element.out1)
@@ -466,43 +734,37 @@ class TransmissionModel(ComponentModel):
466
734
  self.create_transmission_equation('dir2', self.element.in2, self.element.out2)
467
735
 
468
736
  # equate size of both directions
469
- if isinstance(self.element.in1.size, InvestParameters) and self.element.in2 is not None:
737
+ if self.element.balanced:
470
738
  # eq: in1.size = in2.size
471
- self.add(
472
- self._model.add_constraints(
473
- self.element.in1.model._investment.size == self.element.in2.model._investment.size,
474
- name=f'{self.label_full}|same_size',
475
- ),
476
- 'same_size',
739
+ self.add_constraints(
740
+ self.element.in1.submodel._investment.size == self.element.in2.submodel._investment.size,
741
+ short_name='same_size',
477
742
  )
478
743
 
479
744
  def create_transmission_equation(self, name: str, in_flow: Flow, out_flow: Flow) -> linopy.Constraint:
480
745
  """Creates an Equation for the Transmission efficiency and adds it to the model"""
481
746
  # eq: out(t) + on(t)*loss_abs(t) = in(t)*(1 - loss_rel(t))
482
- con_transmission = self.add(
483
- self._model.add_constraints(
484
- out_flow.model.flow_rate == -in_flow.model.flow_rate * (self.element.relative_losses.active_data - 1),
485
- name=f'{self.label_full}|{name}',
486
- ),
487
- name,
747
+ rel_losses = 0 if self.element.relative_losses is None else self.element.relative_losses
748
+ con_transmission = self.add_constraints(
749
+ out_flow.submodel.flow_rate == in_flow.submodel.flow_rate * (1 - rel_losses),
750
+ short_name=name,
488
751
  )
489
752
 
490
753
  if self.element.absolute_losses is not None:
491
- con_transmission.lhs += in_flow.model.on_off.on * self.element.absolute_losses.active_data
754
+ con_transmission.lhs += in_flow.submodel.on_off.on * self.element.absolute_losses
492
755
 
493
756
  return con_transmission
494
757
 
495
758
 
496
759
  class LinearConverterModel(ComponentModel):
497
- def __init__(self, model: SystemModel, element: LinearConverter):
498
- super().__init__(model, element)
499
- self.element: LinearConverter = element
500
- self.on_off: Optional[OnOffModel] = None
501
- self.piecewise_conversion: Optional[PiecewiseConversion] = None
760
+ element: LinearConverter
502
761
 
503
- def do_modeling(self):
504
- super().do_modeling()
762
+ def __init__(self, model: FlowSystemModel, element: LinearConverter):
763
+ self.piecewise_conversion: PiecewiseConversion | None = None
764
+ super().__init__(model, element)
505
765
 
766
+ def _do_modeling(self):
767
+ super()._do_modeling()
506
768
  # conversion_factors:
507
769
  if self.element.conversion_factors:
508
770
  all_input_flows = set(self.element.inputs)
@@ -511,149 +773,135 @@ class LinearConverterModel(ComponentModel):
511
773
  # für alle linearen Gleichungen:
512
774
  for i, conv_factors in enumerate(self.element.conversion_factors):
513
775
  used_flows = set([self.element.flows[flow_label] for flow_label in conv_factors])
514
- used_inputs: Set = all_input_flows & used_flows
515
- used_outputs: Set = all_output_flows & used_flows
516
-
517
- self.add(
518
- self._model.add_constraints(
519
- sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_inputs])
520
- == sum([flow.model.flow_rate * conv_factors[flow.label].active_data for flow in used_outputs]),
521
- name=f'{self.label_full}|conversion_{i}',
522
- )
776
+ used_inputs: set[Flow] = all_input_flows & used_flows
777
+ used_outputs: set[Flow] = all_output_flows & used_flows
778
+
779
+ self.add_constraints(
780
+ sum([flow.submodel.flow_rate * conv_factors[flow.label] for flow in used_inputs])
781
+ == sum([flow.submodel.flow_rate * conv_factors[flow.label] for flow in used_outputs]),
782
+ short_name=f'conversion_{i}',
523
783
  )
524
784
 
525
785
  else:
526
786
  # TODO: Improve Inclusion of OnOffParameters. Instead of creating a Binary in every flow, the binary could only be part of the Piece itself
527
787
  piecewise_conversion = {
528
- self.element.flows[flow].model.flow_rate.name: piecewise
788
+ self.element.flows[flow].submodel.flow_rate.name: piecewise
529
789
  for flow, piecewise in self.element.piecewise_conversion.items()
530
790
  }
531
791
 
532
- self.piecewise_conversion = self.add(
792
+ self.piecewise_conversion = self.add_submodels(
533
793
  PiecewiseModel(
534
794
  model=self._model,
535
795
  label_of_element=self.label_of_element,
796
+ label_of_model=f'{self.label_of_element}',
536
797
  piecewise_variables=piecewise_conversion,
537
798
  zero_point=self.on_off.on if self.on_off is not None else False,
538
- as_time_series=True,
539
- )
799
+ dims=('time', 'period', 'scenario'),
800
+ ),
801
+ short_name='PiecewiseConversion',
540
802
  )
541
- self.piecewise_conversion.do_modeling()
542
803
 
543
804
 
544
805
  class StorageModel(ComponentModel):
545
- """Model of Storage"""
806
+ """Submodel of Storage"""
807
+
808
+ element: Storage
546
809
 
547
- def __init__(self, model: SystemModel, element: Storage):
810
+ def __init__(self, model: FlowSystemModel, element: Storage):
548
811
  super().__init__(model, element)
549
- self.element: Storage = element
550
- self.charge_state: Optional[linopy.Variable] = None
551
- self.netto_discharge: Optional[linopy.Variable] = None
552
- self._investment: Optional[InvestmentModel] = None
553
-
554
- def do_modeling(self):
555
- super().do_modeling()
556
-
557
- lb, ub = self.absolute_charge_state_bounds
558
- self.charge_state = self.add(
559
- self._model.add_variables(
560
- lower=lb, upper=ub, coords=self._model.coords_extra, name=f'{self.label_full}|charge_state'
561
- ),
562
- 'charge_state',
563
- )
564
- self.netto_discharge = self.add(
565
- self._model.add_variables(coords=self._model.coords, name=f'{self.label_full}|netto_discharge'),
566
- 'netto_discharge',
812
+
813
+ def _do_modeling(self):
814
+ super()._do_modeling()
815
+
816
+ lb, ub = self._absolute_charge_state_bounds
817
+ self.add_variables(
818
+ lower=lb,
819
+ upper=ub,
820
+ coords=self._model.get_coords(extra_timestep=True),
821
+ short_name='charge_state',
567
822
  )
823
+
824
+ self.add_variables(coords=self._model.get_coords(), short_name='netto_discharge')
825
+
568
826
  # netto_discharge:
569
827
  # eq: nettoFlow(t) - discharging(t) + charging(t) = 0
570
- self.add(
571
- self._model.add_constraints(
572
- self.netto_discharge
573
- == self.element.discharging.model.flow_rate - self.element.charging.model.flow_rate,
574
- name=f'{self.label_full}|netto_discharge',
575
- ),
576
- 'netto_discharge',
828
+ self.add_constraints(
829
+ self.netto_discharge
830
+ == self.element.discharging.submodel.flow_rate - self.element.charging.submodel.flow_rate,
831
+ short_name='netto_discharge',
577
832
  )
578
833
 
579
834
  charge_state = self.charge_state
580
- rel_loss = self.element.relative_loss_per_hour.active_data
835
+ rel_loss = self.element.relative_loss_per_hour
581
836
  hours_per_step = self._model.hours_per_step
582
- charge_rate = self.element.charging.model.flow_rate
583
- discharge_rate = self.element.discharging.model.flow_rate
584
- eff_charge = self.element.eta_charge.active_data
585
- eff_discharge = self.element.eta_discharge.active_data
586
-
587
- self.add(
588
- self._model.add_constraints(
589
- charge_state.isel(time=slice(1, None))
590
- == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step)
591
- + charge_rate * eff_charge * hours_per_step
592
- - discharge_rate * eff_discharge * hours_per_step,
593
- name=f'{self.label_full}|charge_state',
594
- ),
595
- 'charge_state',
837
+ charge_rate = self.element.charging.submodel.flow_rate
838
+ discharge_rate = self.element.discharging.submodel.flow_rate
839
+ eff_charge = self.element.eta_charge
840
+ eff_discharge = self.element.eta_discharge
841
+
842
+ self.add_constraints(
843
+ charge_state.isel(time=slice(1, None))
844
+ == charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** hours_per_step)
845
+ + charge_rate * eff_charge * hours_per_step
846
+ - discharge_rate * hours_per_step / eff_discharge,
847
+ short_name='charge_state',
596
848
  )
597
849
 
598
850
  if isinstance(self.element.capacity_in_flow_hours, InvestParameters):
599
- self._investment = InvestmentModel(
600
- model=self._model,
601
- label_of_element=self.label_of_element,
602
- parameters=self.element.capacity_in_flow_hours,
603
- defining_variable=self.charge_state,
604
- relative_bounds_of_defining_variable=self.relative_charge_state_bounds,
851
+ self.add_submodels(
852
+ InvestmentModel(
853
+ model=self._model,
854
+ label_of_element=self.label_of_element,
855
+ label_of_model=self.label_of_element,
856
+ parameters=self.element.capacity_in_flow_hours,
857
+ ),
858
+ short_name='investment',
859
+ )
860
+
861
+ BoundingPatterns.scaled_bounds(
862
+ self,
863
+ variable=self.charge_state,
864
+ scaling_variable=self.investment.size,
865
+ relative_bounds=self._relative_charge_state_bounds,
605
866
  )
606
- self.sub_models.append(self._investment)
607
- self._investment.do_modeling()
608
867
 
609
868
  # Initial charge state
610
869
  self._initial_and_final_charge_state()
611
870
 
871
+ if self.element.balanced:
872
+ self.add_constraints(
873
+ self.element.charging.submodel._investment.size * 1
874
+ == self.element.discharging.submodel._investment.size * 1,
875
+ short_name='balanced_sizes',
876
+ )
877
+
612
878
  def _initial_and_final_charge_state(self):
613
879
  if self.element.initial_charge_state is not None:
614
- name_short = 'initial_charge_state'
615
- name = f'{self.label_full}|{name_short}'
616
-
617
- if utils.is_number(self.element.initial_charge_state):
618
- self.add(
619
- self._model.add_constraints(
620
- self.charge_state.isel(time=0) == self.element.initial_charge_state, name=name
621
- ),
622
- name_short,
880
+ if isinstance(self.element.initial_charge_state, str):
881
+ self.add_constraints(
882
+ self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), short_name='initial_charge_state'
623
883
  )
624
- elif self.element.initial_charge_state == 'lastValueOfSim':
625
- self.add(
626
- self._model.add_constraints(
627
- self.charge_state.isel(time=0) == self.charge_state.isel(time=-1), name=name
628
- ),
629
- name_short,
630
- )
631
- else: # TODO: Validation in Storage Class, not in Model
632
- raise PlausibilityError(
633
- f'initial_charge_state has undefined value: {self.element.initial_charge_state}'
884
+ else:
885
+ self.add_constraints(
886
+ self.charge_state.isel(time=0) == self.element.initial_charge_state,
887
+ short_name='initial_charge_state',
634
888
  )
635
889
 
636
890
  if self.element.maximal_final_charge_state is not None:
637
- self.add(
638
- self._model.add_constraints(
639
- self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state,
640
- name=f'{self.label_full}|final_charge_max',
641
- ),
642
- 'final_charge_max',
891
+ self.add_constraints(
892
+ self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state,
893
+ short_name='final_charge_max',
643
894
  )
644
895
 
645
896
  if self.element.minimal_final_charge_state is not None:
646
- self.add(
647
- self._model.add_constraints(
648
- self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state,
649
- name=f'{self.label_full}|final_charge_min',
650
- ),
651
- 'final_charge_min',
897
+ self.add_constraints(
898
+ self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state,
899
+ short_name='final_charge_min',
652
900
  )
653
901
 
654
902
  @property
655
- def absolute_charge_state_bounds(self) -> Tuple[NumericData, NumericData]:
656
- relative_lower_bound, relative_upper_bound = self.relative_charge_state_bounds
903
+ def _absolute_charge_state_bounds(self) -> tuple[TemporalData, TemporalData]:
904
+ relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds
657
905
  if not isinstance(self.element.capacity_in_flow_hours, InvestParameters):
658
906
  return (
659
907
  relative_lower_bound * self.element.capacity_in_flow_hours,
@@ -666,69 +914,171 @@ class StorageModel(ComponentModel):
666
914
  )
667
915
 
668
916
  @property
669
- def relative_charge_state_bounds(self) -> Tuple[NumericData, NumericData]:
670
- return (
671
- self.element.relative_minimum_charge_state.active_data,
672
- self.element.relative_maximum_charge_state.active_data,
673
- )
917
+ def _relative_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]:
918
+ """
919
+ Get relative charge state bounds with final timestep values.
920
+
921
+ Returns:
922
+ Tuple of (minimum_bounds, maximum_bounds) DataArrays extending to final timestep
923
+ """
924
+ final_coords = {'time': [self._model.flow_system.timesteps_extra[-1]]}
925
+
926
+ # Get final minimum charge state
927
+ if self.element.relative_minimum_final_charge_state is None:
928
+ min_final = self.element.relative_minimum_charge_state.isel(time=-1, drop=True)
929
+ else:
930
+ min_final = self.element.relative_minimum_final_charge_state
931
+ min_final = min_final.expand_dims('time').assign_coords(time=final_coords['time'])
932
+
933
+ # Get final maximum charge state
934
+ if self.element.relative_maximum_final_charge_state is None:
935
+ max_final = self.element.relative_maximum_charge_state.isel(time=-1, drop=True)
936
+ else:
937
+ max_final = self.element.relative_maximum_final_charge_state
938
+ max_final = max_final.expand_dims('time').assign_coords(time=final_coords['time'])
939
+ # Concatenate with original bounds
940
+ min_bounds = xr.concat([self.element.relative_minimum_charge_state, min_final], dim='time')
941
+ max_bounds = xr.concat([self.element.relative_maximum_charge_state, max_final], dim='time')
942
+
943
+ return min_bounds, max_bounds
944
+
945
+ @property
946
+ def _investment(self) -> InvestmentModel | None:
947
+ """Deprecated alias for investment"""
948
+ return self.investment
949
+
950
+ @property
951
+ def investment(self) -> InvestmentModel | None:
952
+ """OnOff feature"""
953
+ if 'investment' not in self.submodels:
954
+ return None
955
+ return self.submodels['investment']
956
+
957
+ @property
958
+ def charge_state(self) -> linopy.Variable:
959
+ """Charge state variable"""
960
+ return self['charge_state']
961
+
962
+ @property
963
+ def netto_discharge(self) -> linopy.Variable:
964
+ """Netto discharge variable"""
965
+ return self['netto_discharge']
674
966
 
675
967
 
676
968
  @register_class_for_io
677
969
  class SourceAndSink(Component):
678
970
  """
679
- class for source (output-flow) and sink (input-flow) in one commponent
971
+ A SourceAndSink combines both supply and demand capabilities in a single component.
972
+
973
+ SourceAndSink components can both consume AND provide energy or material flows
974
+ from and to the system, making them ideal for modeling markets, (simple) storage facilities,
975
+ or bidirectional grid connections where buying and selling occur at the same location.
976
+
977
+ Args:
978
+ label: The label of the Element. Used to identify it in the FlowSystem.
979
+ inputs: Input-flows into the SourceAndSink representing consumption/demand side.
980
+ outputs: Output-flows from the SourceAndSink representing supply/generation side.
981
+ prevent_simultaneous_flow_rates: If True, prevents simultaneous input and output
982
+ flows. This enforces that the component operates either as a source OR sink
983
+ at any given time, but not both simultaneously. Default is True.
984
+ meta_data: Used to store additional information about the Element. Not used
985
+ internally but saved in results. Only use Python native types.
986
+
987
+ Examples:
988
+ Electricity market connection (buy/sell to grid):
989
+
990
+ ```python
991
+ electricity_market = SourceAndSink(
992
+ label='grid_connection',
993
+ inputs=[electricity_purchase], # Buy from grid
994
+ outputs=[electricity_sale], # Sell to grid
995
+ prevent_simultaneous_flow_rates=True, # Can't buy and sell simultaneously
996
+ )
997
+ ```
998
+
999
+ Natural gas storage facility:
1000
+
1001
+ ```python
1002
+ gas_storage_facility = SourceAndSink(
1003
+ label='underground_gas_storage',
1004
+ inputs=[gas_injection_flow], # Inject gas into storage
1005
+ outputs=[gas_withdrawal_flow], # Withdraw gas from storage
1006
+ prevent_simultaneous_flow_rates=True, # Injection or withdrawal, not both
1007
+ )
1008
+ ```
1009
+
1010
+ District heating network connection:
1011
+
1012
+ ```python
1013
+ dh_connection = SourceAndSink(
1014
+ label='district_heating_tie',
1015
+ inputs=[heat_purchase_flow], # Purchase heat from network
1016
+ outputs=[heat_sale_flow], # Sell excess heat to network
1017
+ prevent_simultaneous_flow_rates=False, # May allow simultaneous flows
1018
+ )
1019
+ ```
1020
+
1021
+ Industrial waste heat exchange:
1022
+
1023
+ ```python
1024
+ waste_heat_exchange = SourceAndSink(
1025
+ label='industrial_heat_hub',
1026
+ inputs=[
1027
+ waste_heat_input_a, # Receive waste heat from process A
1028
+ waste_heat_input_b, # Receive waste heat from process B
1029
+ ],
1030
+ outputs=[
1031
+ useful_heat_supply_c, # Supply heat to process C
1032
+ useful_heat_supply_d, # Supply heat to process D
1033
+ ],
1034
+ prevent_simultaneous_flow_rates=False, # Multiple simultaneous flows allowed
1035
+ )
1036
+ ```
1037
+
1038
+ Note:
1039
+ When prevent_simultaneous_flow_rates is True, binary variables are created to
1040
+ ensure mutually exclusive operation between input and output flows, which
1041
+ increases computational complexity but reflects realistic market or storage
1042
+ operation constraints.
1043
+
1044
+ SourceAndSink is particularly useful for modeling:
1045
+ - Energy markets with bidirectional trading
1046
+ - Storage facilities with injection/withdrawal operations
1047
+ - Grid tie points with import/export capabilities
1048
+ - Waste exchange networks with multiple participants
1049
+
1050
+ Deprecated:
1051
+ The deprecated `sink` and `source` kwargs are accepted for compatibility but will be removed in future releases.
680
1052
  """
681
1053
 
682
1054
  def __init__(
683
1055
  self,
684
1056
  label: str,
685
- inputs: List[Flow] = None,
686
- outputs: List[Flow] = None,
1057
+ inputs: list[Flow] | None = None,
1058
+ outputs: list[Flow] | None = None,
687
1059
  prevent_simultaneous_flow_rates: bool = True,
688
- meta_data: Optional[Dict] = None,
1060
+ meta_data: dict | None = None,
689
1061
  **kwargs,
690
1062
  ):
691
- """
692
- Args:
693
- label: The label of the Element. Used to identify it in the FlowSystem
694
- outputs: output-flows of this component
695
- inputs: input-flows of this component
696
- prevent_simultaneous_flow_rates: If True, inflow and outflow can not be active simultaniously.
697
- meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
698
- """
699
- source = kwargs.pop('source', None)
700
- sink = kwargs.pop('sink', None)
701
- prevent_simultaneous_sink_and_source = kwargs.pop('prevent_simultaneous_sink_and_source', None)
702
- if source is not None:
703
- warnings.deprecated(
704
- 'The use of the source argument is deprecated. Use the outputs argument instead.',
705
- stacklevel=2,
706
- )
707
- if outputs is not None:
708
- raise ValueError('Either source or outputs can be specified, but not both.')
709
- outputs = [source]
710
-
711
- if sink is not None:
712
- warnings.deprecated(
713
- 'The use of the sink argument is deprecated. Use the outputs argument instead.',
714
- stacklevel=2,
715
- )
716
- if inputs is not None:
717
- raise ValueError('Either sink or outputs can be specified, but not both.')
718
- inputs = [sink]
719
-
720
- if prevent_simultaneous_sink_and_source is not None:
721
- warnings.deprecated(
722
- 'The use of the prevent_simultaneous_sink_and_source argument is deprecated. Use the prevent_simultaneous_flow_rates argument instead.',
723
- stacklevel=2,
724
- )
725
- prevent_simultaneous_flow_rates = prevent_simultaneous_sink_and_source
1063
+ # Handle deprecated parameters using centralized helper
1064
+ outputs = self._handle_deprecated_kwarg(kwargs, 'source', 'outputs', outputs, transform=lambda x: [x])
1065
+ inputs = self._handle_deprecated_kwarg(kwargs, 'sink', 'inputs', inputs, transform=lambda x: [x])
1066
+ prevent_simultaneous_flow_rates = self._handle_deprecated_kwarg(
1067
+ kwargs,
1068
+ 'prevent_simultaneous_sink_and_source',
1069
+ 'prevent_simultaneous_flow_rates',
1070
+ prevent_simultaneous_flow_rates,
1071
+ check_conflict=False,
1072
+ )
1073
+
1074
+ # Validate any remaining unexpected kwargs
1075
+ self._validate_kwargs(kwargs)
726
1076
 
727
1077
  super().__init__(
728
1078
  label,
729
1079
  inputs=inputs,
730
1080
  outputs=outputs,
731
- prevent_simultaneous_flows=inputs + outputs if prevent_simultaneous_flow_rates is True else None,
1081
+ prevent_simultaneous_flows=(inputs or []) + (outputs or []) if prevent_simultaneous_flow_rates else None,
732
1082
  meta_data=meta_data,
733
1083
  )
734
1084
  self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
@@ -745,7 +1095,7 @@ class SourceAndSink(Component):
745
1095
  @property
746
1096
  def sink(self) -> Flow:
747
1097
  warnings.warn(
748
- 'The sink property is deprecated. Use the outputs property instead.',
1098
+ 'The sink property is deprecated. Use the inputs property instead.',
749
1099
  DeprecationWarning,
750
1100
  stacklevel=2,
751
1101
  )
@@ -763,30 +1113,93 @@ class SourceAndSink(Component):
763
1113
 
764
1114
  @register_class_for_io
765
1115
  class Source(Component):
1116
+ """
1117
+ A Source generates or provides energy or material flows into the system.
1118
+
1119
+ Sources represent supply points like power plants, fuel suppliers, renewable
1120
+ energy sources, or any system boundary where flows originate. They provide
1121
+ unlimited supply capability subject to flow constraints, demand patterns and effects.
1122
+
1123
+ Args:
1124
+ label: The label of the Element. Used to identify it in the FlowSystem.
1125
+ outputs: Output-flows from the source. Can be single flow or list of flows
1126
+ for sources providing multiple commodities or services.
1127
+ meta_data: Used to store additional information about the Element. Not used
1128
+ internally but saved in results. Only use Python native types.
1129
+ prevent_simultaneous_flow_rates: If True, only one output flow can be active
1130
+ at a time. Useful for modeling mutually exclusive supply options. Default is False.
1131
+
1132
+ Examples:
1133
+ Simple electricity grid connection:
1134
+
1135
+ ```python
1136
+ grid_source = Source(label='electrical_grid', outputs=[grid_electricity_flow])
1137
+ ```
1138
+
1139
+ Natural gas supply with cost and capacity constraints:
1140
+
1141
+ ```python
1142
+ gas_supply = Source(
1143
+ label='gas_network',
1144
+ outputs=[
1145
+ Flow(
1146
+ label='natural_gas_flow',
1147
+ bus=gas_bus,
1148
+ size=1000, # Maximum 1000 kW supply capacity
1149
+ effects_per_flow_hour={'cost': 0.04}, # €0.04/kWh gas cost
1150
+ )
1151
+ ],
1152
+ )
1153
+ ```
1154
+
1155
+ Multi-fuel power plant with switching constraints:
1156
+
1157
+ ```python
1158
+ multi_fuel_plant = Source(
1159
+ label='flexible_generator',
1160
+ outputs=[coal_electricity, gas_electricity, biomass_electricity],
1161
+ prevent_simultaneous_flow_rates=True, # Can only use one fuel at a time
1162
+ )
1163
+ ```
1164
+
1165
+ Renewable energy source with investment optimization:
1166
+
1167
+ ```python
1168
+ solar_farm = Source(
1169
+ label='solar_pv',
1170
+ outputs=[
1171
+ Flow(
1172
+ label='solar_power',
1173
+ bus=electricity_bus,
1174
+ size=InvestParameters(
1175
+ minimum_size=0,
1176
+ maximum_size=50000, # Up to 50 MW
1177
+ specific_effects={'cost': 800}, # €800/kW installed
1178
+ fix_effects={'cost': 100000}, # €100k development costs
1179
+ ),
1180
+ fixed_relative_profile=solar_profile, # Hourly generation profile
1181
+ )
1182
+ ],
1183
+ )
1184
+ ```
1185
+
1186
+ Deprecated:
1187
+ The deprecated `source` kwarg is accepted for compatibility but will be removed in future releases.
1188
+ """
1189
+
766
1190
  def __init__(
767
1191
  self,
768
1192
  label: str,
769
- outputs: List[Flow] = None,
770
- meta_data: Optional[Dict] = None,
1193
+ outputs: list[Flow] | None = None,
1194
+ meta_data: dict | None = None,
771
1195
  prevent_simultaneous_flow_rates: bool = False,
772
1196
  **kwargs,
773
1197
  ):
774
- """
775
- Args:
776
- label: The label of the Element. Used to identify it in the FlowSystem
777
- outputs: output-flows of source
778
- meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
779
- """
780
- source = kwargs.pop('source', None)
781
- if source is not None:
782
- warnings.warn(
783
- 'The use of the source argument is deprecated. Use the outputs argument instead.',
784
- DeprecationWarning,
785
- stacklevel=2,
786
- )
787
- if outputs is not None:
788
- raise ValueError('Either source or outputs can be specified, but not both.')
789
- outputs = [source]
1198
+ # Handle deprecated parameter using centralized helper
1199
+ outputs = self._handle_deprecated_kwarg(kwargs, 'source', 'outputs', outputs, transform=lambda x: [x])
1200
+
1201
+ # Validate any remaining unexpected kwargs
1202
+ self._validate_kwargs(kwargs)
790
1203
 
791
1204
  self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
792
1205
  super().__init__(
@@ -808,30 +1221,108 @@ class Source(Component):
808
1221
 
809
1222
  @register_class_for_io
810
1223
  class Sink(Component):
1224
+ """
1225
+ A Sink consumes energy or material flows from the system.
1226
+
1227
+ Sinks represent demand points like electrical loads, heat demands, material
1228
+ consumption, or any system boundary where flows terminate. They provide
1229
+ unlimited consumption capability subject to flow constraints, demand patterns and effects.
1230
+
1231
+ Args:
1232
+ label: The label of the Element. Used to identify it in the FlowSystem.
1233
+ inputs: Input-flows into the sink. Can be single flow or list of flows
1234
+ for sinks consuming multiple commodities or services.
1235
+ meta_data: Used to store additional information about the Element. Not used
1236
+ internally but saved in results. Only use Python native types.
1237
+ prevent_simultaneous_flow_rates: If True, only one input flow can be active
1238
+ at a time. Useful for modeling mutually exclusive consumption options. Default is False.
1239
+
1240
+ Examples:
1241
+ Simple electrical demand:
1242
+
1243
+ ```python
1244
+ electrical_load = Sink(label='building_load', inputs=[electricity_demand_flow])
1245
+ ```
1246
+
1247
+ Heat demand with time-varying profile:
1248
+
1249
+ ```python
1250
+ heat_demand = Sink(
1251
+ label='district_heating_load',
1252
+ inputs=[
1253
+ Flow(
1254
+ label='heat_consumption',
1255
+ bus=heat_bus,
1256
+ fixed_relative_profile=hourly_heat_profile, # Demand profile
1257
+ size=2000, # Peak demand of 2000 kW
1258
+ )
1259
+ ],
1260
+ )
1261
+ ```
1262
+
1263
+ Multi-energy building with switching capabilities:
1264
+
1265
+ ```python
1266
+ flexible_building = Sink(
1267
+ label='smart_building',
1268
+ inputs=[electricity_heating, gas_heating, heat_pump_heating],
1269
+ prevent_simultaneous_flow_rates=True, # Can only use one heating mode
1270
+ )
1271
+ ```
1272
+
1273
+ Industrial process with variable demand:
1274
+
1275
+ ```python
1276
+ factory_load = Sink(
1277
+ label='manufacturing_plant',
1278
+ inputs=[
1279
+ Flow(
1280
+ label='electricity_process',
1281
+ bus=electricity_bus,
1282
+ size=5000, # Base electrical load
1283
+ effects_per_flow_hour={'cost': -0.1}, # Value of service (negative cost)
1284
+ ),
1285
+ Flow(
1286
+ label='steam_process',
1287
+ bus=steam_bus,
1288
+ size=3000, # Process steam demand
1289
+ fixed_relative_profile=production_schedule,
1290
+ ),
1291
+ ],
1292
+ )
1293
+ ```
1294
+
1295
+ Deprecated:
1296
+ The deprecated `sink` kwarg is accepted for compatibility but will be removed in future releases.
1297
+ """
1298
+
811
1299
  def __init__(
812
1300
  self,
813
1301
  label: str,
814
- inputs: List[Flow] = None,
815
- meta_data: Optional[Dict] = None,
1302
+ inputs: list[Flow] | None = None,
1303
+ meta_data: dict | None = None,
816
1304
  prevent_simultaneous_flow_rates: bool = False,
817
1305
  **kwargs,
818
1306
  ):
819
1307
  """
820
- Args:
821
- label: The label of the Element. Used to identify it in the FlowSystem
822
- inputs: output-flows of source
823
- meta_data: used to store more information about the Element. Is not used internally, but saved in the results. Only use python native types.
1308
+ Initialize a Sink (consumes flow from the system).
1309
+
1310
+ Supports legacy `sink=` keyword for backward compatibility (deprecated): if `sink` is provided it is used as the single input flow and a DeprecationWarning is issued; specifying both `inputs` and `sink` raises ValueError.
1311
+
1312
+ Parameters:
1313
+ label (str): Unique element label.
1314
+ inputs (list[Flow], optional): Input flows for the sink.
1315
+ meta_data (dict, optional): Arbitrary metadata attached to the element.
1316
+ prevent_simultaneous_flow_rates (bool, optional): If True, prevents simultaneous nonzero flow rates across the element's inputs by wiring that restriction into the base Component setup.
1317
+
1318
+ Note:
1319
+ The deprecated `sink` kwarg is accepted for compatibility but will be removed in future releases.
824
1320
  """
825
- sink = kwargs.pop('sink', None)
826
- if sink is not None:
827
- warnings.warn(
828
- 'The use of the sink argument is deprecated. Use the outputs argument instead.',
829
- DeprecationWarning,
830
- stacklevel=2,
831
- )
832
- if inputs is not None:
833
- raise ValueError('Either sink or outputs can be specified, but not both.')
834
- inputs = [sink]
1321
+ # Handle deprecated parameter using centralized helper
1322
+ inputs = self._handle_deprecated_kwarg(kwargs, 'sink', 'inputs', inputs, transform=lambda x: [x])
1323
+
1324
+ # Validate any remaining unexpected kwargs
1325
+ self._validate_kwargs(kwargs)
835
1326
 
836
1327
  self.prevent_simultaneous_flow_rates = prevent_simultaneous_flow_rates
837
1328
  super().__init__(
@@ -844,7 +1335,7 @@ class Sink(Component):
844
1335
  @property
845
1336
  def sink(self) -> Flow:
846
1337
  warnings.warn(
847
- 'The sink property is deprecated. Use the outputs property instead.',
1338
+ 'The sink property is deprecated. Use the inputs property instead.',
848
1339
  DeprecationWarning,
849
1340
  stacklevel=2,
850
1341
  )