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.
- flixopt/__init__.py +57 -49
- flixopt/carrier.py +159 -0
- flixopt/clustering/__init__.py +51 -0
- flixopt/clustering/base.py +1746 -0
- flixopt/clustering/intercluster_helpers.py +201 -0
- flixopt/color_processing.py +372 -0
- flixopt/comparison.py +819 -0
- flixopt/components.py +848 -270
- flixopt/config.py +853 -496
- flixopt/core.py +111 -98
- flixopt/effects.py +294 -284
- flixopt/elements.py +484 -223
- flixopt/features.py +220 -118
- flixopt/flow_system.py +2026 -389
- flixopt/interface.py +504 -286
- flixopt/io.py +1718 -55
- flixopt/linear_converters.py +291 -230
- flixopt/modeling.py +304 -181
- flixopt/network_app.py +2 -1
- flixopt/optimization.py +788 -0
- flixopt/optimize_accessor.py +373 -0
- flixopt/plot_result.py +143 -0
- flixopt/plotting.py +1177 -1034
- flixopt/results.py +1331 -372
- flixopt/solvers.py +12 -4
- flixopt/statistics_accessor.py +2412 -0
- flixopt/stats_accessor.py +75 -0
- flixopt/structure.py +954 -120
- flixopt/topology_accessor.py +676 -0
- flixopt/transform_accessor.py +2277 -0
- flixopt/types.py +120 -0
- flixopt-6.0.0rc7.dist-info/METADATA +290 -0
- flixopt-6.0.0rc7.dist-info/RECORD +36 -0
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/WHEEL +1 -1
- flixopt/aggregation.py +0 -382
- flixopt/calculation.py +0 -672
- flixopt/commons.py +0 -51
- flixopt/utils.py +0 -86
- flixopt-3.0.1.dist-info/METADATA +0 -209
- flixopt-3.0.1.dist-info/RECORD +0 -26
- {flixopt-3.0.1.dist-info → flixopt-6.0.0rc7.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
16
|
-
from .features import InvestmentModel,
|
|
17
|
-
from .interface import InvestParameters,
|
|
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
|
|
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 .
|
|
25
|
-
|
|
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
|
-
|
|
50
|
-
component has discrete
|
|
51
|
-
connected Flows. For better performance, prefer defining
|
|
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 "
|
|
62
|
-
- Component is "
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
101
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
222
|
+
Using predefined carrier names:
|
|
143
223
|
|
|
144
224
|
```python
|
|
145
|
-
electricity_bus = Bus(
|
|
146
|
-
|
|
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
|
-
|
|
229
|
+
Registering custom carriers on FlowSystem:
|
|
152
230
|
|
|
153
231
|
```python
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
239
|
+
Heat network with penalty for imbalances:
|
|
161
240
|
|
|
162
241
|
```python
|
|
163
|
-
|
|
164
|
-
label='
|
|
165
|
-
|
|
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) +
|
|
250
|
+
The bus balance equation enforced is: Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand
|
|
171
251
|
|
|
172
|
-
When
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
205
|
-
zero_penalty = np.all(np.equal(self.
|
|
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
|
|
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
|
|
213
|
-
return
|
|
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/
|
|
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
|
-
- **
|
|
352
|
+
- **StatusParameters**: Used for `status_parameters` when flow has discrete states
|
|
248
353
|
|
|
249
354
|
Mathematical Formulation:
|
|
250
|
-
See
|
|
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 (
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
|
413
|
+
relative_minimum=0.3, # Minimum 15 kW output when active
|
|
306
414
|
effects_per_flow_hour={'electricity_cost': 25, 'maintenance': 2},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
-
|
|
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:
|
|
366
|
-
fixed_relative_profile:
|
|
367
|
-
relative_minimum:
|
|
368
|
-
relative_maximum:
|
|
369
|
-
effects_per_flow_hour:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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 =
|
|
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.
|
|
389
|
-
self.
|
|
390
|
-
self.
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
f'
|
|
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
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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.
|
|
422
|
-
prefix, self.
|
|
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.
|
|
425
|
-
f'{prefix}|
|
|
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.
|
|
428
|
-
f'{prefix}|
|
|
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 =
|
|
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 =
|
|
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.
|
|
438
|
-
self.
|
|
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(
|
|
441
|
-
|
|
442
|
-
self.size =
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
f'
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
|
461
|
-
f'This will allow the flow to be switched
|
|
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.
|
|
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
|
|
467
|
-
f'This prevents the
|
|
468
|
-
f'Consider using
|
|
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=
|
|
686
|
+
tracked_expression=self._model.sum_temporal(self.flow_rate),
|
|
516
687
|
bounds=(
|
|
517
|
-
self.element.
|
|
518
|
-
self.element.
|
|
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
|
|
531
|
-
|
|
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
|
-
|
|
741
|
+
StatusModel(
|
|
534
742
|
model=self._model,
|
|
535
743
|
label_of_element=self.label_of_element,
|
|
536
|
-
parameters=self.element.
|
|
537
|
-
|
|
538
|
-
|
|
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='
|
|
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
|
-
|
|
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.
|
|
561
|
-
#
|
|
562
|
-
self.
|
|
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
|
-
|
|
778
|
+
state=self.status.status,
|
|
569
779
|
)
|
|
570
780
|
|
|
571
|
-
elif self.with_investment and not self.
|
|
572
|
-
# Investment, but no
|
|
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.
|
|
582
|
-
# Investment and
|
|
791
|
+
elif self.with_investment and self.with_status:
|
|
792
|
+
# Investment and Status
|
|
583
793
|
self._create_investment_model()
|
|
584
|
-
self.
|
|
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
|
-
|
|
802
|
+
state=self.status.status,
|
|
593
803
|
)
|
|
594
804
|
else:
|
|
595
805
|
raise Exception('Not valid')
|
|
596
806
|
|
|
597
807
|
@property
|
|
598
|
-
def
|
|
599
|
-
return self.element.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
-
@
|
|
658
|
-
def relative_flow_rate_bounds(self) -> tuple[
|
|
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
|
-
|
|
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[
|
|
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.
|
|
887
|
+
if not self.with_status:
|
|
673
888
|
if not self.with_investment:
|
|
674
|
-
# Basic case without investment and without
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
689
|
-
"""
|
|
690
|
-
if '
|
|
906
|
+
def status(self) -> StatusModel | None:
|
|
907
|
+
"""Status feature"""
|
|
908
|
+
if 'status' not in self.submodels:
|
|
691
909
|
return None
|
|
692
|
-
return self.submodels['
|
|
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
|
-
"""
|
|
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
|
|
708
|
-
"""Previous
|
|
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.
|
|
728
|
-
self.
|
|
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)
|
|
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
|
-
#
|
|
741
|
-
if self.element.
|
|
742
|
-
|
|
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.
|
|
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.
|
|
747
|
-
lower=0,
|
|
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
|
-
|
|
986
|
+
# Σ(inflows) + virtual_supply = Σ(outflows) + virtual_demand
|
|
987
|
+
eq_bus_balance.lhs += self.virtual_supply - self.virtual_demand
|
|
751
988
|
|
|
752
|
-
|
|
753
|
-
|
|
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.
|
|
759
|
-
inputs.append(self.
|
|
760
|
-
if self.
|
|
761
|
-
outputs.append(self.
|
|
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.
|
|
1018
|
+
self.status: StatusModel | None = None
|
|
775
1019
|
super().__init__(model, element)
|
|
776
1020
|
|
|
777
1021
|
def _do_modeling(self):
|
|
778
|
-
"""
|
|
1022
|
+
"""Create variables, constraints, and nested submodels"""
|
|
779
1023
|
super()._do_modeling()
|
|
1024
|
+
|
|
780
1025
|
all_flows = self.element.inputs + self.element.outputs
|
|
781
|
-
|
|
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.
|
|
784
|
-
flow.
|
|
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.
|
|
789
|
-
flow.
|
|
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
|
|
795
|
-
|
|
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(
|
|
1057
|
+
self.add_constraints(status == all_flows[0].submodel.status.status, short_name='status')
|
|
798
1058
|
else:
|
|
799
|
-
|
|
1059
|
+
flow_statuses = [flow.submodel.status.status for flow in all_flows]
|
|
800
1060
|
# TODO: Is the EPSILON even necessary?
|
|
801
|
-
self.add_constraints(
|
|
1061
|
+
self.add_constraints(status <= sum(flow_statuses) + CONFIG.Modeling.epsilon, short_name='status|ub')
|
|
802
1062
|
self.add_constraints(
|
|
803
|
-
|
|
1063
|
+
status >= sum(flow_statuses) / (len(flow_statuses) + CONFIG.Modeling.epsilon),
|
|
1064
|
+
short_name='status|lb',
|
|
804
1065
|
)
|
|
805
1066
|
|
|
806
|
-
self.
|
|
807
|
-
|
|
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.
|
|
811
|
-
|
|
1071
|
+
parameters=self.element.status_parameters,
|
|
1072
|
+
status=status,
|
|
812
1073
|
label_of_model=self.label_of_element,
|
|
813
|
-
|
|
1074
|
+
previous_status=self.previous_status,
|
|
814
1075
|
),
|
|
815
|
-
short_name='
|
|
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.
|
|
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
|
|
836
|
-
"""Previous
|
|
837
|
-
if self.element.
|
|
838
|
-
raise ValueError(f'
|
|
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
|
-
|
|
841
|
-
|
|
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
|
|
1104
|
+
if not previous_status: # Empty list
|
|
844
1105
|
return None
|
|
845
1106
|
|
|
846
|
-
max_len = max(da.sizes['time'] for da in
|
|
1107
|
+
max_len = max(da.sizes['time'] for da in previous_status)
|
|
847
1108
|
|
|
848
|
-
|
|
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
|
|
1111
|
+
for da in previous_status
|
|
851
1112
|
]
|
|
852
|
-
return xr.concat(
|
|
1113
|
+
return xr.concat(padded_previous_status, dim='flow').any(dim='flow').astype(int)
|