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/modeling.py
CHANGED
|
@@ -1,18 +1,104 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from typing import Any
|
|
2
3
|
|
|
3
4
|
import linopy
|
|
4
5
|
import numpy as np
|
|
5
6
|
import xarray as xr
|
|
6
7
|
|
|
7
8
|
from .config import CONFIG
|
|
8
|
-
from .
|
|
9
|
-
from .structure import Submodel
|
|
9
|
+
from .structure import Submodel, VariableCategory
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger('flixopt')
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _scalar_safe_isel(data: xr.DataArray | Any, indexers: dict) -> xr.DataArray | Any:
|
|
15
|
+
"""Apply isel if data has the required dimensions, otherwise return data as-is.
|
|
16
|
+
|
|
17
|
+
This allows parameters to remain compact (scalar or lower-dimensional) while still
|
|
18
|
+
being usable in constraint expressions that use .isel() for slicing.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
data: DataArray or scalar value
|
|
22
|
+
indexers: Dictionary of {dim: indexer} for isel
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Sliced DataArray if dims exist, otherwise original data
|
|
26
|
+
"""
|
|
27
|
+
if not isinstance(data, xr.DataArray):
|
|
28
|
+
return data
|
|
29
|
+
# Only apply isel if data has all the required dimensions
|
|
30
|
+
if all(dim in data.dims for dim in indexers):
|
|
31
|
+
return data.isel(indexers)
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scalar_safe_isel_drop(data: xr.DataArray | Any, dim: str, index: int) -> xr.DataArray | Any:
|
|
36
|
+
"""Apply isel with drop=True if data has the dimension, otherwise return data as-is.
|
|
37
|
+
|
|
38
|
+
Useful for cases like selecting the last value of a potentially reduced array:
|
|
39
|
+
- If data has time dimension: returns data.isel(time=-1, drop=True)
|
|
40
|
+
- If data is reduced (no time dimension): returns data unchanged (already represents constant)
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
data: DataArray or scalar value
|
|
44
|
+
dim: Dimension name to select from
|
|
45
|
+
index: Index to select (e.g., -1 for last, 0 for first)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Selected value with dimension dropped if dim exists, otherwise original data
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(data, xr.DataArray):
|
|
51
|
+
return data
|
|
52
|
+
if dim in data.dims:
|
|
53
|
+
return data.isel({dim: index}, drop=True)
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _scalar_safe_reduce(data: xr.DataArray | Any, dim: str, method: str = 'mean') -> xr.DataArray | Any:
|
|
58
|
+
"""Apply reduction (mean/sum/etc) over dimension if it exists, otherwise return data as-is.
|
|
59
|
+
|
|
60
|
+
Useful for aggregating over time dimension when data may be scalar (constant):
|
|
61
|
+
- If data has time dimension: returns getattr(data, method)(dim)
|
|
62
|
+
- If data is reduced (no time dimension): returns data unchanged (already represents constant)
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
data: DataArray or scalar value
|
|
66
|
+
dim: Dimension name to reduce over
|
|
67
|
+
method: Reduction method ('mean', 'sum', 'min', 'max', etc.)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Reduced value if dim exists, otherwise original data
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(data, xr.DataArray):
|
|
73
|
+
return data
|
|
74
|
+
if dim in data.dims:
|
|
75
|
+
return getattr(data, method)(dim)
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _xr_allclose(a: xr.DataArray, b: xr.DataArray, rtol: float = 1e-5, atol: float = 1e-8) -> bool:
|
|
80
|
+
"""Check if two DataArrays are element-wise equal within tolerance.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
a: First DataArray
|
|
84
|
+
b: Second DataArray
|
|
85
|
+
rtol: Relative tolerance (default matches np.allclose)
|
|
86
|
+
atol: Absolute tolerance (default matches np.allclose)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if all elements are close (including matching NaN positions)
|
|
90
|
+
"""
|
|
91
|
+
# Fast path: same dims and shape - use numpy directly
|
|
92
|
+
if a.dims == b.dims and a.shape == b.shape:
|
|
93
|
+
return np.allclose(a.values, b.values, rtol=rtol, atol=atol, equal_nan=True)
|
|
94
|
+
|
|
95
|
+
# Slow path: broadcast to common shape, then use numpy
|
|
96
|
+
a_bc, b_bc = xr.broadcast(a, b)
|
|
97
|
+
return np.allclose(a_bc.values, b_bc.values, rtol=rtol, atol=atol, equal_nan=True)
|
|
98
|
+
|
|
99
|
+
|
|
14
100
|
class ModelingUtilitiesAbstract:
|
|
15
|
-
"""Utility functions for modeling
|
|
101
|
+
"""Utility functions for modeling - leveraging xarray for temporal data"""
|
|
16
102
|
|
|
17
103
|
@staticmethod
|
|
18
104
|
def to_binary(
|
|
@@ -60,16 +146,16 @@ class ModelingUtilitiesAbstract:
|
|
|
60
146
|
"""Count consecutive steps in the final active state of a binary time series.
|
|
61
147
|
|
|
62
148
|
This function counts how many consecutive time steps the series remains "on"
|
|
63
|
-
(non-zero) at the end of the time series. If the final state is "
|
|
149
|
+
(non-zero) at the end of the time series. If the final state is "inactive", returns 0.
|
|
64
150
|
|
|
65
151
|
Args:
|
|
66
|
-
binary_values: Binary DataArray with values close to 0 (
|
|
152
|
+
binary_values: Binary DataArray with values close to 0 (inactive) or 1 (active).
|
|
67
153
|
dim: Dimension along which to count consecutive states.
|
|
68
154
|
epsilon: Tolerance for zero detection. Uses CONFIG.Modeling.epsilon if None.
|
|
69
155
|
|
|
70
156
|
Returns:
|
|
71
|
-
Sum of values in the final consecutive "
|
|
72
|
-
final state is "
|
|
157
|
+
Sum of values in the final consecutive "active" period. Returns 0.0 if the
|
|
158
|
+
final state is "inactive".
|
|
73
159
|
|
|
74
160
|
Examples:
|
|
75
161
|
>>> arr = xr.DataArray([0, 0, 1, 1, 1, 0, 1, 1], dims=['time'])
|
|
@@ -101,11 +187,11 @@ class ModelingUtilitiesAbstract:
|
|
|
101
187
|
if arr.size == 1:
|
|
102
188
|
return float(arr[0]) if not np.isclose(arr[0], 0, atol=epsilon) else 0.0
|
|
103
189
|
|
|
104
|
-
# Return 0 if final state is
|
|
190
|
+
# Return 0 if final state is inactive
|
|
105
191
|
if np.isclose(arr[-1], 0, atol=epsilon):
|
|
106
192
|
return 0.0
|
|
107
193
|
|
|
108
|
-
# Find the last zero position (treat NaNs as
|
|
194
|
+
# Find the last zero position (treat NaNs as inactive)
|
|
109
195
|
arr = np.nan_to_num(arr, nan=0.0)
|
|
110
196
|
is_zero = np.isclose(arr, 0, atol=epsilon)
|
|
111
197
|
zero_indices = np.where(is_zero)[0]
|
|
@@ -119,12 +205,12 @@ class ModelingUtilitiesAbstract:
|
|
|
119
205
|
class ModelingUtilities:
|
|
120
206
|
@staticmethod
|
|
121
207
|
def compute_consecutive_hours_in_state(
|
|
122
|
-
binary_values:
|
|
208
|
+
binary_values: xr.DataArray,
|
|
123
209
|
hours_per_timestep: int | float,
|
|
124
210
|
epsilon: float = None,
|
|
125
211
|
) -> float:
|
|
126
212
|
"""
|
|
127
|
-
Computes the final consecutive duration in state '
|
|
213
|
+
Computes the final consecutive duration in state 'active' (=1) in hours.
|
|
128
214
|
|
|
129
215
|
Args:
|
|
130
216
|
binary_values: Binary DataArray with 'time' dim, or scalar/array
|
|
@@ -132,7 +218,7 @@ class ModelingUtilities:
|
|
|
132
218
|
epsilon: Tolerance for zero detection (uses CONFIG.Modeling.epsilon if None)
|
|
133
219
|
|
|
134
220
|
Returns:
|
|
135
|
-
The duration of the final consecutive '
|
|
221
|
+
The duration of the final consecutive 'active' period in hours
|
|
136
222
|
"""
|
|
137
223
|
if not isinstance(hours_per_timestep, (int, float)):
|
|
138
224
|
raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}')
|
|
@@ -160,14 +246,14 @@ class ModelingUtilities:
|
|
|
160
246
|
previous_values: xr.DataArray, hours_per_step: xr.DataArray | float | int
|
|
161
247
|
) -> float:
|
|
162
248
|
"""
|
|
163
|
-
Compute previous consecutive '
|
|
249
|
+
Compute previous consecutive 'inactive' duration.
|
|
164
250
|
|
|
165
251
|
Args:
|
|
166
252
|
previous_values: DataArray with 'time' dimension
|
|
167
253
|
hours_per_step: Duration of each timestep in hours
|
|
168
254
|
|
|
169
255
|
Returns:
|
|
170
|
-
Previous consecutive
|
|
256
|
+
Previous consecutive inactive duration in hours
|
|
171
257
|
"""
|
|
172
258
|
if previous_values is None or previous_values.size == 0:
|
|
173
259
|
return 0.0
|
|
@@ -200,28 +286,38 @@ class ModelingPrimitives:
|
|
|
200
286
|
@staticmethod
|
|
201
287
|
def expression_tracking_variable(
|
|
202
288
|
model: Submodel,
|
|
203
|
-
tracked_expression,
|
|
289
|
+
tracked_expression: linopy.expressions.LinearExpression | linopy.Variable,
|
|
204
290
|
name: str = None,
|
|
205
291
|
short_name: str = None,
|
|
206
|
-
bounds: tuple[
|
|
292
|
+
bounds: tuple[xr.DataArray, xr.DataArray] = None,
|
|
207
293
|
coords: str | list[str] | None = None,
|
|
294
|
+
category: VariableCategory = None,
|
|
208
295
|
) -> tuple[linopy.Variable, linopy.Constraint]:
|
|
209
|
-
"""
|
|
210
|
-
Creates variable that equals a given expression.
|
|
296
|
+
"""Creates a variable constrained to equal a given expression.
|
|
211
297
|
|
|
212
298
|
Mathematical formulation:
|
|
213
299
|
tracker = expression
|
|
214
|
-
lower ≤ tracker ≤ upper
|
|
300
|
+
lower ≤ tracker ≤ upper (if bounds provided)
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
model: The submodel to add variables and constraints to
|
|
304
|
+
tracked_expression: Expression that the tracker variable must equal
|
|
305
|
+
name: Full name for the variable and constraint
|
|
306
|
+
short_name: Short name for display purposes
|
|
307
|
+
bounds: Optional (lower_bound, upper_bound) tuple for the tracker variable
|
|
308
|
+
coords: Coordinate dimensions for the variable (None uses all model coords)
|
|
309
|
+
category: Category for segment expansion handling. See VariableCategory.
|
|
215
310
|
|
|
216
311
|
Returns:
|
|
217
|
-
|
|
218
|
-
constraints: {'tracking': constraint}
|
|
312
|
+
Tuple of (tracker_variable, tracking_constraint)
|
|
219
313
|
"""
|
|
220
314
|
if not isinstance(model, Submodel):
|
|
221
315
|
raise ValueError('ModelingPrimitives.expression_tracking_variable() can only be used with a Submodel')
|
|
222
316
|
|
|
223
317
|
if not bounds:
|
|
224
|
-
tracker = model.add_variables(
|
|
318
|
+
tracker = model.add_variables(
|
|
319
|
+
name=name, coords=model.get_coords(coords), short_name=short_name, category=category
|
|
320
|
+
)
|
|
225
321
|
else:
|
|
226
322
|
tracker = model.add_variables(
|
|
227
323
|
lower=bounds[0] if bounds[0] is not None else -np.inf,
|
|
@@ -229,6 +325,7 @@ class ModelingPrimitives:
|
|
|
229
325
|
name=name,
|
|
230
326
|
coords=model.get_coords(coords),
|
|
231
327
|
short_name=short_name,
|
|
328
|
+
category=category,
|
|
232
329
|
)
|
|
233
330
|
|
|
234
331
|
# Constraint: tracker = expression
|
|
@@ -239,56 +336,72 @@ class ModelingPrimitives:
|
|
|
239
336
|
@staticmethod
|
|
240
337
|
def consecutive_duration_tracking(
|
|
241
338
|
model: Submodel,
|
|
242
|
-
|
|
339
|
+
state: linopy.Variable,
|
|
243
340
|
name: str = None,
|
|
244
341
|
short_name: str = None,
|
|
245
|
-
minimum_duration:
|
|
246
|
-
maximum_duration:
|
|
342
|
+
minimum_duration: xr.DataArray | None = None,
|
|
343
|
+
maximum_duration: xr.DataArray | None = None,
|
|
247
344
|
duration_dim: str = 'time',
|
|
248
|
-
duration_per_step: int | float |
|
|
249
|
-
previous_duration:
|
|
250
|
-
) -> tuple[linopy.Variable,
|
|
251
|
-
"""
|
|
252
|
-
|
|
345
|
+
duration_per_step: int | float | xr.DataArray = None,
|
|
346
|
+
previous_duration: xr.DataArray | float | int | None = None,
|
|
347
|
+
) -> tuple[dict[str, linopy.Variable], dict[str, linopy.Constraint]]:
|
|
348
|
+
"""Creates consecutive duration tracking for a binary state variable.
|
|
349
|
+
|
|
350
|
+
Tracks how long a binary state has been continuously active (=1).
|
|
351
|
+
Duration resets to 0 when state becomes inactive (=0).
|
|
253
352
|
|
|
254
353
|
Mathematical formulation:
|
|
255
|
-
duration[t] ≤ state[t]
|
|
354
|
+
duration[t] ≤ state[t] · M ∀t
|
|
256
355
|
duration[t+1] ≤ duration[t] + duration_per_step[t] ∀t
|
|
257
|
-
duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1)
|
|
258
|
-
duration[0] = (duration_per_step[0] + previous_duration)
|
|
356
|
+
duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) · M ∀t
|
|
357
|
+
duration[0] = (duration_per_step[0] + previous_duration) · state[0]
|
|
259
358
|
|
|
260
359
|
If minimum_duration provided:
|
|
261
|
-
duration[t] ≥ (state[t-1] - state[t])
|
|
360
|
+
duration[t] ≥ (state[t-1] - state[t]) · minimum_duration[t-1] ∀t > 0
|
|
361
|
+
|
|
362
|
+
Where M is a big-M value (sum of all duration_per_step + previous_duration).
|
|
262
363
|
|
|
263
364
|
Args:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
365
|
+
model: The submodel to add variables and constraints to
|
|
366
|
+
state: Binary state variable (1=active, 0=inactive) to track duration for
|
|
367
|
+
name: Full name for the duration variable
|
|
368
|
+
short_name: Short name for display purposes
|
|
369
|
+
minimum_duration: Optional minimum consecutive duration (enforced at state transitions)
|
|
370
|
+
maximum_duration: Optional maximum consecutive duration (upper bound on duration variable)
|
|
371
|
+
duration_dim: Dimension name to track duration along (default 'time')
|
|
372
|
+
duration_per_step: Time increment per step in duration_dim
|
|
373
|
+
previous_duration: Initial duration value before first timestep. If None (default),
|
|
374
|
+
no initial constraint is added (relaxed initial state).
|
|
269
375
|
|
|
270
376
|
Returns:
|
|
271
|
-
|
|
272
|
-
|
|
377
|
+
Tuple of (variables_dict, constraints_dict).
|
|
378
|
+
variables_dict contains: 'duration'.
|
|
379
|
+
constraints_dict always contains: 'ub', 'forward', 'backward'.
|
|
380
|
+
When previous_duration is not None, also contains: 'initial'.
|
|
381
|
+
When minimum_duration is provided, also contains: 'lb'.
|
|
382
|
+
When minimum_duration is provided and previous_duration is not None and
|
|
383
|
+
0 < previous_duration < minimum_duration[0], also contains: 'initial_lb'.
|
|
273
384
|
"""
|
|
274
385
|
if not isinstance(model, Submodel):
|
|
275
386
|
raise ValueError('ModelingPrimitives.consecutive_duration_tracking() can only be used with a Submodel')
|
|
276
387
|
|
|
277
|
-
|
|
388
|
+
# Big-M value (use 0 for previous_duration if None)
|
|
389
|
+
mega = duration_per_step.sum(duration_dim) + (previous_duration if previous_duration is not None else 0)
|
|
278
390
|
|
|
279
391
|
# Duration variable
|
|
280
392
|
duration = model.add_variables(
|
|
281
393
|
lower=0,
|
|
282
394
|
upper=maximum_duration if maximum_duration is not None else mega,
|
|
283
|
-
coords=
|
|
395
|
+
coords=state.coords,
|
|
284
396
|
name=name,
|
|
285
397
|
short_name=short_name,
|
|
398
|
+
category=VariableCategory.DURATION,
|
|
286
399
|
)
|
|
287
400
|
|
|
288
401
|
constraints = {}
|
|
289
402
|
|
|
290
403
|
# Upper bound: duration[t] ≤ state[t] * M
|
|
291
|
-
constraints['ub'] = model.add_constraints(duration <=
|
|
404
|
+
constraints['ub'] = model.add_constraints(duration <= state * mega, name=f'{duration.name}|ub')
|
|
292
405
|
|
|
293
406
|
# Forward constraint: duration[t+1] ≤ duration[t] + duration_per_step[t]
|
|
294
407
|
constraints['forward'] = model.add_constraints(
|
|
@@ -302,40 +415,40 @@ class ModelingPrimitives:
|
|
|
302
415
|
duration.isel({duration_dim: slice(1, None)})
|
|
303
416
|
>= duration.isel({duration_dim: slice(None, -1)})
|
|
304
417
|
+ duration_per_step.isel({duration_dim: slice(None, -1)})
|
|
305
|
-
+ (
|
|
418
|
+
+ (state.isel({duration_dim: slice(1, None)}) - 1) * mega,
|
|
306
419
|
name=f'{duration.name}|backward',
|
|
307
420
|
)
|
|
308
421
|
|
|
309
422
|
# Initial condition: duration[0] = (duration_per_step[0] + previous_duration) * state[0]
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
423
|
+
# Skipped if previous_duration is None (unconstrained initial state)
|
|
424
|
+
if previous_duration is not None:
|
|
425
|
+
constraints['initial'] = model.add_constraints(
|
|
426
|
+
duration.isel({duration_dim: 0})
|
|
427
|
+
== (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state.isel({duration_dim: 0}),
|
|
428
|
+
name=f'{duration.name}|initial',
|
|
429
|
+
)
|
|
315
430
|
|
|
316
431
|
# Minimum duration constraint if provided
|
|
317
432
|
if minimum_duration is not None:
|
|
318
433
|
constraints['lb'] = model.add_constraints(
|
|
319
434
|
duration
|
|
320
|
-
>= (
|
|
321
|
-
|
|
322
|
-
- state_variable.isel({duration_dim: slice(1, None)})
|
|
323
|
-
)
|
|
324
|
-
* minimum_duration.isel({duration_dim: slice(None, -1)}),
|
|
435
|
+
>= (state.isel({duration_dim: slice(None, -1)}) - state.isel({duration_dim: slice(1, None)}))
|
|
436
|
+
* _scalar_safe_isel(minimum_duration, {duration_dim: slice(None, -1)}),
|
|
325
437
|
name=f'{duration.name}|lb',
|
|
326
438
|
)
|
|
327
439
|
|
|
328
|
-
# Handle initial condition for minimum duration
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
min0 = float(minimum_duration.isel({duration_dim: 0}).max().item())
|
|
335
|
-
if prev > 0 and prev < min0:
|
|
336
|
-
constraints['initial_lb'] = model.add_constraints(
|
|
337
|
-
state_variable.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb'
|
|
440
|
+
# Handle initial condition for minimum duration (skip if previous_duration is None)
|
|
441
|
+
if previous_duration is not None:
|
|
442
|
+
prev = (
|
|
443
|
+
float(previous_duration)
|
|
444
|
+
if not isinstance(previous_duration, xr.DataArray)
|
|
445
|
+
else float(previous_duration.max().item())
|
|
338
446
|
)
|
|
447
|
+
min0 = float(_scalar_safe_isel(minimum_duration, {duration_dim: 0}).max().item())
|
|
448
|
+
if prev > 0 and prev < min0:
|
|
449
|
+
constraints['initial_lb'] = model.add_constraints(
|
|
450
|
+
state.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb'
|
|
451
|
+
)
|
|
339
452
|
|
|
340
453
|
variables = {'duration': duration}
|
|
341
454
|
|
|
@@ -348,23 +461,21 @@ class ModelingPrimitives:
|
|
|
348
461
|
tolerance: float = 1,
|
|
349
462
|
short_name: str = 'mutual_exclusivity',
|
|
350
463
|
) -> linopy.Constraint:
|
|
351
|
-
"""
|
|
352
|
-
Creates mutual exclusivity constraint for binary variables.
|
|
464
|
+
"""Creates mutual exclusivity constraint for binary variables.
|
|
353
465
|
|
|
354
|
-
|
|
355
|
-
Σ(binary_vars[i]) ≤ tolerance ∀t
|
|
466
|
+
Ensures at most one binary variable can be active (=1) at any time.
|
|
356
467
|
|
|
357
|
-
|
|
358
|
-
|
|
468
|
+
Mathematical formulation:
|
|
469
|
+
Σᵢ binary_vars[i] ≤ tolerance ∀t
|
|
359
470
|
|
|
360
471
|
Args:
|
|
472
|
+
model: The submodel to add the constraint to
|
|
361
473
|
binary_variables: List of binary variables that should be mutually exclusive
|
|
362
|
-
tolerance: Upper bound
|
|
363
|
-
short_name: Short name
|
|
474
|
+
tolerance: Upper bound on the sum (default 1, allows slight numerical tolerance)
|
|
475
|
+
short_name: Short name for the constraint
|
|
364
476
|
|
|
365
477
|
Returns:
|
|
366
|
-
|
|
367
|
-
constraints: {'mutual_exclusivity': constraint}
|
|
478
|
+
Mutual exclusivity constraint
|
|
368
479
|
|
|
369
480
|
Raises:
|
|
370
481
|
AssertionError: If fewer than 2 variables provided or variables aren't binary
|
|
@@ -394,22 +505,22 @@ class BoundingPatterns:
|
|
|
394
505
|
def basic_bounds(
|
|
395
506
|
model: Submodel,
|
|
396
507
|
variable: linopy.Variable,
|
|
397
|
-
bounds: tuple[
|
|
508
|
+
bounds: tuple[xr.DataArray, xr.DataArray],
|
|
398
509
|
name: str = None,
|
|
399
510
|
) -> list[linopy.constraints.Constraint]:
|
|
400
|
-
"""
|
|
401
|
-
variable ∈ [lower_bound, upper_bound]
|
|
511
|
+
"""Creates simple lower and upper bounds for a variable.
|
|
402
512
|
|
|
403
|
-
Mathematical
|
|
513
|
+
Mathematical formulation:
|
|
404
514
|
lower_bound ≤ variable ≤ upper_bound
|
|
405
515
|
|
|
406
516
|
Args:
|
|
407
|
-
model: The
|
|
517
|
+
model: The submodel to add constraints to
|
|
408
518
|
variable: Variable to be bounded
|
|
409
519
|
bounds: Tuple of (lower_bound, upper_bound) absolute bounds
|
|
520
|
+
name: Optional name prefix for constraints
|
|
410
521
|
|
|
411
522
|
Returns:
|
|
412
|
-
List
|
|
523
|
+
List of [lower_constraint, upper_constraint]
|
|
413
524
|
"""
|
|
414
525
|
if not isinstance(model, Submodel):
|
|
415
526
|
raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel')
|
|
@@ -426,30 +537,29 @@ class BoundingPatterns:
|
|
|
426
537
|
def bounds_with_state(
|
|
427
538
|
model: Submodel,
|
|
428
539
|
variable: linopy.Variable,
|
|
429
|
-
bounds: tuple[
|
|
430
|
-
|
|
540
|
+
bounds: tuple[xr.DataArray, xr.DataArray],
|
|
541
|
+
state: linopy.Variable,
|
|
431
542
|
name: str = None,
|
|
432
543
|
) -> list[linopy.Constraint]:
|
|
433
|
-
"""
|
|
434
|
-
variable ∈ {0, [max(ε, lower_bound), upper_bound]}
|
|
544
|
+
"""Creates bounds controlled by a binary state variable.
|
|
435
545
|
|
|
436
|
-
|
|
437
|
-
- variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound
|
|
546
|
+
Variable is forced to 0 when state=0, bounded when state=1.
|
|
438
547
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
548
|
+
Mathematical formulation:
|
|
549
|
+
state · max(ε, lower_bound) ≤ variable ≤ state · upper_bound
|
|
550
|
+
|
|
551
|
+
Where ε is a small positive number (CONFIG.Modeling.epsilon) ensuring
|
|
552
|
+
numerical stability when lower_bound is 0.
|
|
442
553
|
|
|
443
554
|
Args:
|
|
444
|
-
model: The
|
|
555
|
+
model: The submodel to add constraints to
|
|
445
556
|
variable: Variable to be bounded
|
|
446
|
-
bounds: Tuple of (lower_bound, upper_bound) absolute bounds
|
|
447
|
-
|
|
557
|
+
bounds: Tuple of (lower_bound, upper_bound) absolute bounds when state=1
|
|
558
|
+
state: Binary variable (0=force variable to 0, 1=allow bounds)
|
|
559
|
+
name: Optional name prefix for constraints
|
|
448
560
|
|
|
449
561
|
Returns:
|
|
450
|
-
|
|
451
|
-
- variables (Dict): Empty dict
|
|
452
|
-
- constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
|
|
562
|
+
List of [lower_constraint, upper_constraint] (or [fix_constraint] if lower=upper)
|
|
453
563
|
"""
|
|
454
564
|
if not isinstance(model, Submodel):
|
|
455
565
|
raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel')
|
|
@@ -457,14 +567,14 @@ class BoundingPatterns:
|
|
|
457
567
|
lower_bound, upper_bound = bounds
|
|
458
568
|
name = name or f'{variable.name}'
|
|
459
569
|
|
|
460
|
-
if
|
|
461
|
-
fix_constraint = model.add_constraints(variable ==
|
|
570
|
+
if _xr_allclose(lower_bound, upper_bound):
|
|
571
|
+
fix_constraint = model.add_constraints(variable == state * upper_bound, name=f'{name}|fix')
|
|
462
572
|
return [fix_constraint]
|
|
463
573
|
|
|
464
574
|
epsilon = np.maximum(CONFIG.Modeling.epsilon, lower_bound)
|
|
465
575
|
|
|
466
|
-
upper_constraint = model.add_constraints(variable <=
|
|
467
|
-
lower_constraint = model.add_constraints(variable >=
|
|
576
|
+
upper_constraint = model.add_constraints(variable <= state * upper_bound, name=f'{name}|ub')
|
|
577
|
+
lower_constraint = model.add_constraints(variable >= state * epsilon, name=f'{name}|lb')
|
|
468
578
|
|
|
469
579
|
return [lower_constraint, upper_constraint]
|
|
470
580
|
|
|
@@ -473,29 +583,25 @@ class BoundingPatterns:
|
|
|
473
583
|
model: Submodel,
|
|
474
584
|
variable: linopy.Variable,
|
|
475
585
|
scaling_variable: linopy.Variable,
|
|
476
|
-
relative_bounds: tuple[
|
|
586
|
+
relative_bounds: tuple[xr.DataArray, xr.DataArray],
|
|
477
587
|
name: str = None,
|
|
478
588
|
) -> list[linopy.Constraint]:
|
|
479
|
-
"""
|
|
480
|
-
variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable]
|
|
589
|
+
"""Creates bounds scaled by another variable.
|
|
481
590
|
|
|
482
|
-
|
|
483
|
-
scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor
|
|
591
|
+
Variable is bounded relative to a scaling variable (e.g., flow rate relative to size).
|
|
484
592
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
- Production levels scaled by plant size
|
|
593
|
+
Mathematical formulation:
|
|
594
|
+
scaling_variable · lower_factor ≤ variable ≤ scaling_variable · upper_factor
|
|
488
595
|
|
|
489
596
|
Args:
|
|
490
|
-
model: The
|
|
597
|
+
model: The submodel to add constraints to
|
|
491
598
|
variable: Variable to be bounded
|
|
492
|
-
scaling_variable: Variable that scales the bound factors
|
|
493
|
-
relative_bounds: Tuple of (lower_factor, upper_factor) relative to
|
|
599
|
+
scaling_variable: Variable that scales the bound factors (e.g., equipment size)
|
|
600
|
+
relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling_variable
|
|
601
|
+
name: Optional name prefix for constraints
|
|
494
602
|
|
|
495
603
|
Returns:
|
|
496
|
-
|
|
497
|
-
- variables (Dict): Empty dict
|
|
498
|
-
- constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
|
|
604
|
+
List of [lower_constraint, upper_constraint] (or [fix_constraint] if lower=upper)
|
|
499
605
|
"""
|
|
500
606
|
if not isinstance(model, Submodel):
|
|
501
607
|
raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel')
|
|
@@ -503,7 +609,7 @@ class BoundingPatterns:
|
|
|
503
609
|
rel_lower, rel_upper = relative_bounds
|
|
504
610
|
name = name or f'{variable.name}'
|
|
505
611
|
|
|
506
|
-
if
|
|
612
|
+
if _xr_allclose(rel_lower, rel_upper):
|
|
507
613
|
return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')]
|
|
508
614
|
|
|
509
615
|
upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub')
|
|
@@ -516,35 +622,35 @@ class BoundingPatterns:
|
|
|
516
622
|
model: Submodel,
|
|
517
623
|
variable: linopy.Variable,
|
|
518
624
|
scaling_variable: linopy.Variable,
|
|
519
|
-
relative_bounds: tuple[
|
|
520
|
-
scaling_bounds: tuple[
|
|
521
|
-
|
|
625
|
+
relative_bounds: tuple[xr.DataArray, xr.DataArray],
|
|
626
|
+
scaling_bounds: tuple[xr.DataArray, xr.DataArray],
|
|
627
|
+
state: linopy.Variable,
|
|
522
628
|
name: str = None,
|
|
523
629
|
) -> list[linopy.Constraint]:
|
|
524
|
-
"""
|
|
630
|
+
"""Creates bounds scaled by a variable and controlled by a binary state.
|
|
525
631
|
|
|
526
|
-
|
|
632
|
+
Variable is forced to 0 when state=0, bounded relative to scaling_variable when state=1.
|
|
527
633
|
|
|
528
|
-
Mathematical
|
|
529
|
-
(
|
|
530
|
-
|
|
634
|
+
Mathematical formulation (Big-M):
|
|
635
|
+
(state - 1) · M_misc + scaling_variable · rel_lower ≤ variable ≤ scaling_variable · rel_upper
|
|
636
|
+
state · big_m_lower ≤ variable ≤ state · big_m_upper
|
|
531
637
|
|
|
532
638
|
Where:
|
|
533
|
-
M_misc = scaling_max
|
|
534
|
-
big_m_upper = scaling_max
|
|
535
|
-
big_m_lower = max(ε, scaling_min
|
|
639
|
+
M_misc = scaling_max · rel_lower
|
|
640
|
+
big_m_upper = scaling_max · rel_upper
|
|
641
|
+
big_m_lower = max(ε, scaling_min · rel_lower)
|
|
536
642
|
|
|
537
643
|
Args:
|
|
538
|
-
model: The
|
|
644
|
+
model: The submodel to add constraints to
|
|
539
645
|
variable: Variable to be bounded
|
|
540
|
-
scaling_variable: Variable that scales the bound factors
|
|
541
|
-
relative_bounds: Tuple of (lower_factor, upper_factor) relative to
|
|
542
|
-
scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the
|
|
543
|
-
|
|
646
|
+
scaling_variable: Variable that scales the bound factors (e.g., equipment size)
|
|
647
|
+
relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling_variable
|
|
648
|
+
scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling_variable
|
|
649
|
+
state: Binary variable (0=force variable to 0, 1=allow scaled bounds)
|
|
544
650
|
name: Optional name prefix for constraints
|
|
545
651
|
|
|
546
652
|
Returns:
|
|
547
|
-
List[
|
|
653
|
+
List of [scaling_lower, scaling_upper, binary_lower, binary_upper] constraints
|
|
548
654
|
"""
|
|
549
655
|
if not isinstance(model, Submodel):
|
|
550
656
|
raise ValueError('BoundingPatterns.scaled_bounds_with_state() can only be used with a Submodel')
|
|
@@ -556,60 +662,74 @@ class BoundingPatterns:
|
|
|
556
662
|
big_m_misc = scaling_max * rel_lower
|
|
557
663
|
|
|
558
664
|
scaling_lower = model.add_constraints(
|
|
559
|
-
variable >= (
|
|
665
|
+
variable >= (state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2'
|
|
560
666
|
)
|
|
561
667
|
scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2')
|
|
562
668
|
|
|
563
669
|
big_m_upper = rel_upper * scaling_max
|
|
564
670
|
big_m_lower = np.maximum(CONFIG.Modeling.epsilon, rel_lower * scaling_min)
|
|
565
671
|
|
|
566
|
-
binary_upper = model.add_constraints(
|
|
567
|
-
binary_lower = model.add_constraints(
|
|
672
|
+
binary_upper = model.add_constraints(state * big_m_upper >= variable, name=f'{name}|ub1')
|
|
673
|
+
binary_lower = model.add_constraints(state * big_m_lower <= variable, name=f'{name}|lb1')
|
|
568
674
|
|
|
569
675
|
return [scaling_lower, scaling_upper, binary_lower, binary_upper]
|
|
570
676
|
|
|
571
677
|
@staticmethod
|
|
572
678
|
def state_transition_bounds(
|
|
573
679
|
model: Submodel,
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
680
|
+
state: linopy.Variable,
|
|
681
|
+
activate: linopy.Variable,
|
|
682
|
+
deactivate: linopy.Variable,
|
|
577
683
|
name: str,
|
|
578
|
-
previous_state=0,
|
|
684
|
+
previous_state: float | xr.DataArray | None = 0,
|
|
579
685
|
coord: str = 'time',
|
|
580
|
-
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]:
|
|
581
|
-
"""
|
|
582
|
-
|
|
686
|
+
) -> tuple[linopy.Constraint, linopy.Constraint | None, linopy.Constraint]:
|
|
687
|
+
"""Creates state transition constraints for binary state variables.
|
|
688
|
+
|
|
689
|
+
Tracks transitions between active (1) and inactive (0) states using
|
|
690
|
+
separate binary variables for activation and deactivation events.
|
|
583
691
|
|
|
584
692
|
Mathematical formulation:
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
693
|
+
activate[t] - deactivate[t] = state[t] - state[t-1] ∀t > 0
|
|
694
|
+
activate[0] - deactivate[0] = state[0] - previous_state
|
|
695
|
+
activate[t] + deactivate[t] ≤ 1 ∀t
|
|
696
|
+
activate[t], deactivate[t] ∈ {0, 1}
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
model: The submodel to add constraints to
|
|
700
|
+
state: Binary state variable (0=inactive, 1=active)
|
|
701
|
+
activate: Binary variable for transitions from inactive to active (0→1)
|
|
702
|
+
deactivate: Binary variable for transitions from active to inactive (1→0)
|
|
703
|
+
name: Base name for constraints
|
|
704
|
+
previous_state: State value before first timestep (default 0). If None,
|
|
705
|
+
no initial constraint is added (relaxed initial state).
|
|
706
|
+
coord: Time dimension name (default 'time')
|
|
589
707
|
|
|
590
708
|
Returns:
|
|
591
|
-
|
|
592
|
-
|
|
709
|
+
Tuple of (transition_constraint, initial_constraint, mutex_constraint).
|
|
710
|
+
initial_constraint is None when previous_state is None.
|
|
593
711
|
"""
|
|
594
712
|
if not isinstance(model, Submodel):
|
|
595
|
-
raise ValueError('
|
|
713
|
+
raise ValueError('BoundingPatterns.state_transition_bounds() can only be used with a Submodel')
|
|
596
714
|
|
|
597
715
|
# State transition constraints for t > 0
|
|
598
716
|
transition = model.add_constraints(
|
|
599
|
-
|
|
600
|
-
==
|
|
717
|
+
activate.isel({coord: slice(1, None)}) - deactivate.isel({coord: slice(1, None)})
|
|
718
|
+
== state.isel({coord: slice(1, None)}) - state.isel({coord: slice(None, -1)}),
|
|
601
719
|
name=f'{name}|transition',
|
|
602
720
|
)
|
|
603
721
|
|
|
604
|
-
# Initial state transition for t = 0
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
722
|
+
# Initial state transition for t = 0 (skipped if previous_state is None for unconstrained)
|
|
723
|
+
if previous_state is not None:
|
|
724
|
+
initial = model.add_constraints(
|
|
725
|
+
activate.isel({coord: 0}) - deactivate.isel({coord: 0}) == state.isel({coord: 0}) - previous_state,
|
|
726
|
+
name=f'{name}|initial',
|
|
727
|
+
)
|
|
728
|
+
else:
|
|
729
|
+
initial = None
|
|
610
730
|
|
|
611
|
-
# At most one
|
|
612
|
-
mutex = model.add_constraints(
|
|
731
|
+
# At most one transition per timestep (mutual exclusivity)
|
|
732
|
+
mutex = model.add_constraints(activate + deactivate <= 1, name=f'{name}|mutex')
|
|
613
733
|
|
|
614
734
|
return transition, initial, mutex
|
|
615
735
|
|
|
@@ -617,63 +737,66 @@ class BoundingPatterns:
|
|
|
617
737
|
def continuous_transition_bounds(
|
|
618
738
|
model: Submodel,
|
|
619
739
|
continuous_variable: linopy.Variable,
|
|
620
|
-
|
|
621
|
-
|
|
740
|
+
activate: linopy.Variable,
|
|
741
|
+
deactivate: linopy.Variable,
|
|
622
742
|
name: str,
|
|
623
743
|
max_change: float | xr.DataArray,
|
|
624
744
|
previous_value: float | xr.DataArray = 0.0,
|
|
625
745
|
coord: str = 'time',
|
|
626
746
|
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
|
|
627
|
-
"""
|
|
628
|
-
|
|
747
|
+
"""Constrains a continuous variable to only change during state transitions.
|
|
748
|
+
|
|
749
|
+
Ensures a continuous variable remains constant unless a transition event occurs.
|
|
750
|
+
Uses Big-M formulation to enforce change bounds.
|
|
629
751
|
|
|
630
752
|
Mathematical formulation:
|
|
631
|
-
-max_change
|
|
632
|
-
-max_change
|
|
633
|
-
|
|
753
|
+
-max_change · (activate[t] + deactivate[t]) ≤ continuous[t] - continuous[t-1] ≤ max_change · (activate[t] + deactivate[t]) ∀t > 0
|
|
754
|
+
-max_change · (activate[0] + deactivate[0]) ≤ continuous[0] - previous_value ≤ max_change · (activate[0] + deactivate[0])
|
|
755
|
+
activate[t], deactivate[t] ∈ {0, 1}
|
|
634
756
|
|
|
635
|
-
|
|
636
|
-
|
|
757
|
+
Behavior:
|
|
758
|
+
- When activate=0 and deactivate=0: variable must stay constant
|
|
759
|
+
- When activate=1 or deactivate=1: variable can change within ±max_change
|
|
637
760
|
|
|
638
761
|
Args:
|
|
639
762
|
model: The submodel to add constraints to
|
|
640
|
-
continuous_variable:
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
name: Base name for
|
|
644
|
-
max_change: Maximum
|
|
645
|
-
previous_value: Initial value
|
|
646
|
-
coord:
|
|
763
|
+
continuous_variable: Continuous variable to constrain
|
|
764
|
+
activate: Binary variable for transitions from inactive to active (0→1)
|
|
765
|
+
deactivate: Binary variable for transitions from active to inactive (1→0)
|
|
766
|
+
name: Base name for constraints
|
|
767
|
+
max_change: Maximum allowed change (Big-M value, should be ≥ actual max change)
|
|
768
|
+
previous_value: Initial value before first timestep (default 0.0)
|
|
769
|
+
coord: Time dimension name (default 'time')
|
|
647
770
|
|
|
648
771
|
Returns:
|
|
649
|
-
Tuple of
|
|
772
|
+
Tuple of (transition_upper, transition_lower, initial_upper, initial_lower) constraints
|
|
650
773
|
"""
|
|
651
774
|
if not isinstance(model, Submodel):
|
|
652
775
|
raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel')
|
|
653
776
|
|
|
654
|
-
# Transition constraints for t > 0: continuous variable can only change when
|
|
777
|
+
# Transition constraints for t > 0: continuous variable can only change when transitions occur
|
|
655
778
|
transition_upper = model.add_constraints(
|
|
656
779
|
continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})
|
|
657
|
-
<= max_change * (
|
|
780
|
+
<= max_change * (activate.isel({coord: slice(1, None)}) + deactivate.isel({coord: slice(1, None)})),
|
|
658
781
|
name=f'{name}|transition_ub',
|
|
659
782
|
)
|
|
660
783
|
|
|
661
784
|
transition_lower = model.add_constraints(
|
|
662
785
|
-(continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)}))
|
|
663
|
-
<= max_change * (
|
|
786
|
+
<= max_change * (activate.isel({coord: slice(1, None)}) + deactivate.isel({coord: slice(1, None)})),
|
|
664
787
|
name=f'{name}|transition_lb',
|
|
665
788
|
)
|
|
666
789
|
|
|
667
790
|
# Initial constraints for t = 0
|
|
668
791
|
initial_upper = model.add_constraints(
|
|
669
792
|
continuous_variable.isel({coord: 0}) - previous_value
|
|
670
|
-
<= max_change * (
|
|
793
|
+
<= max_change * (activate.isel({coord: 0}) + deactivate.isel({coord: 0})),
|
|
671
794
|
name=f'{name}|initial_ub',
|
|
672
795
|
)
|
|
673
796
|
|
|
674
797
|
initial_lower = model.add_constraints(
|
|
675
798
|
-continuous_variable.isel({coord: 0}) + previous_value
|
|
676
|
-
<= max_change * (
|
|
799
|
+
<= max_change * (activate.isel({coord: 0}) + deactivate.isel({coord: 0})),
|
|
677
800
|
name=f'{name}|initial_lb',
|
|
678
801
|
)
|
|
679
802
|
|