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