flixopt 2.2.0b0__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flixopt might be problematic. Click here for more details.
- flixopt/__init__.py +35 -1
- flixopt/aggregation.py +60 -81
- flixopt/calculation.py +381 -196
- flixopt/components.py +1022 -359
- flixopt/config.py +553 -191
- flixopt/core.py +475 -1315
- flixopt/effects.py +477 -214
- flixopt/elements.py +591 -344
- flixopt/features.py +403 -957
- flixopt/flow_system.py +781 -293
- flixopt/interface.py +1159 -189
- flixopt/io.py +50 -55
- flixopt/linear_converters.py +384 -92
- flixopt/modeling.py +759 -0
- flixopt/network_app.py +789 -0
- flixopt/plotting.py +273 -135
- flixopt/results.py +639 -383
- flixopt/solvers.py +25 -21
- flixopt/structure.py +928 -442
- flixopt/utils.py +34 -5
- flixopt-3.0.0.dist-info/METADATA +209 -0
- flixopt-3.0.0.dist-info/RECORD +26 -0
- {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
- flixopt-3.0.0.dist-info/top_level.txt +1 -0
- docs/examples/00-Minimal Example.md +0 -5
- docs/examples/01-Basic Example.md +0 -5
- docs/examples/02-Complex Example.md +0 -10
- docs/examples/03-Calculation Modes.md +0 -5
- docs/examples/index.md +0 -5
- docs/faq/contribute.md +0 -49
- docs/faq/index.md +0 -3
- docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
- docs/images/architecture_flixOpt.png +0 -0
- docs/images/flixopt-icon.svg +0 -1
- docs/javascripts/mathjax.js +0 -18
- docs/release-notes/_template.txt +0 -32
- docs/release-notes/index.md +0 -7
- docs/release-notes/v2.0.0.md +0 -93
- docs/release-notes/v2.0.1.md +0 -12
- docs/release-notes/v2.1.0.md +0 -31
- docs/release-notes/v2.2.0.md +0 -55
- docs/user-guide/Mathematical Notation/Bus.md +0 -33
- docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
- docs/user-guide/Mathematical Notation/Flow.md +0 -26
- docs/user-guide/Mathematical Notation/Investment.md +0 -115
- docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
- docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
- docs/user-guide/Mathematical Notation/Storage.md +0 -44
- docs/user-guide/Mathematical Notation/index.md +0 -22
- docs/user-guide/Mathematical Notation/others.md +0 -3
- docs/user-guide/index.md +0 -124
- flixopt/config.yaml +0 -10
- flixopt-2.2.0b0.dist-info/METADATA +0 -146
- flixopt-2.2.0b0.dist-info/RECORD +0 -59
- flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
- pics/architecture_flixOpt-pre2.0.0.png +0 -0
- pics/architecture_flixOpt.png +0 -0
- pics/flixOpt_plotting.jpg +0 -0
- pics/flixopt-icon.svg +0 -1
- pics/pics.pptx +0 -0
- scripts/gen_ref_pages.py +0 -54
- tests/ressources/Zeitreihen2020.csv +0 -35137
- {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/licenses/LICENSE +0 -0
flixopt/modeling.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import linopy
|
|
4
|
+
import numpy as np
|
|
5
|
+
import xarray as xr
|
|
6
|
+
|
|
7
|
+
from .config import CONFIG
|
|
8
|
+
from .core import TemporalData
|
|
9
|
+
from .structure import Submodel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger('flixopt')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelingUtilitiesAbstract:
|
|
15
|
+
"""Utility functions for modeling calculations - leveraging xarray for temporal data"""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def to_binary(
|
|
19
|
+
values: xr.DataArray,
|
|
20
|
+
epsilon: float | None = None,
|
|
21
|
+
dims: str | list[str] | None = None,
|
|
22
|
+
) -> xr.DataArray:
|
|
23
|
+
"""
|
|
24
|
+
Converts a DataArray to binary {0, 1} values.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
values: Input DataArray to convert to binary
|
|
28
|
+
epsilon: Tolerance for zero detection (uses CONFIG.Modeling.epsilon if None)
|
|
29
|
+
dims: Dims to keep. Other dimensions are collapsed using .any() -> If any value is 1, all are 1.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Binary DataArray with same shape (or collapsed if collapse_non_time=True)
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(values, xr.DataArray):
|
|
35
|
+
values = xr.DataArray(values, dims=['time'], coords={'time': range(len(values))})
|
|
36
|
+
|
|
37
|
+
if epsilon is None:
|
|
38
|
+
epsilon = CONFIG.Modeling.epsilon
|
|
39
|
+
|
|
40
|
+
if values.size == 0:
|
|
41
|
+
return xr.DataArray(0) if values.item() < epsilon else xr.DataArray(1)
|
|
42
|
+
|
|
43
|
+
# Convert to binary states
|
|
44
|
+
binary_states = np.abs(values) >= epsilon
|
|
45
|
+
|
|
46
|
+
# Optionally collapse dimensions using .any()
|
|
47
|
+
if dims is not None:
|
|
48
|
+
dims = [dims] if isinstance(dims, str) else dims
|
|
49
|
+
|
|
50
|
+
binary_states = binary_states.any(dim=[d for d in binary_states.dims if d not in dims])
|
|
51
|
+
|
|
52
|
+
return binary_states.astype(int)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def count_consecutive_states(
|
|
56
|
+
binary_values: xr.DataArray | np.ndarray | list[int, float],
|
|
57
|
+
dim: str = 'time',
|
|
58
|
+
epsilon: float | None = None,
|
|
59
|
+
) -> float:
|
|
60
|
+
"""Count consecutive steps in the final active state of a binary time series.
|
|
61
|
+
|
|
62
|
+
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 "off", returns 0.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
binary_values: Binary DataArray with values close to 0 (off) or 1 (on).
|
|
67
|
+
dim: Dimension along which to count consecutive states.
|
|
68
|
+
epsilon: Tolerance for zero detection. Uses CONFIG.Modeling.epsilon if None.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Sum of values in the final consecutive "on" period. Returns 0.0 if the
|
|
72
|
+
final state is "off".
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
>>> arr = xr.DataArray([0, 0, 1, 1, 1, 0, 1, 1], dims=['time'])
|
|
76
|
+
>>> ModelingUtilitiesAbstract.count_consecutive_states(arr)
|
|
77
|
+
2.0
|
|
78
|
+
|
|
79
|
+
>>> arr = [0, 0, 1, 0, 1, 1, 1, 1]
|
|
80
|
+
>>> ModelingUtilitiesAbstract.count_consecutive_states(arr)
|
|
81
|
+
4.0
|
|
82
|
+
"""
|
|
83
|
+
epsilon = epsilon or CONFIG.Modeling.epsilon
|
|
84
|
+
|
|
85
|
+
if isinstance(binary_values, xr.DataArray):
|
|
86
|
+
# xarray path
|
|
87
|
+
other_dims = [d for d in binary_values.dims if d != dim]
|
|
88
|
+
if other_dims:
|
|
89
|
+
binary_values = binary_values.any(dim=other_dims)
|
|
90
|
+
arr = binary_values.values
|
|
91
|
+
else:
|
|
92
|
+
# numpy/array-like path
|
|
93
|
+
arr = np.asarray(binary_values)
|
|
94
|
+
|
|
95
|
+
# Flatten to 1D if needed
|
|
96
|
+
arr = arr.ravel() if arr.ndim > 1 else arr
|
|
97
|
+
|
|
98
|
+
# Handle edge cases
|
|
99
|
+
if arr.size == 0:
|
|
100
|
+
return 0.0
|
|
101
|
+
if arr.size == 1:
|
|
102
|
+
return float(arr[0]) if not np.isclose(arr[0], 0, atol=epsilon) else 0.0
|
|
103
|
+
|
|
104
|
+
# Return 0 if final state is off
|
|
105
|
+
if np.isclose(arr[-1], 0, atol=epsilon):
|
|
106
|
+
return 0.0
|
|
107
|
+
|
|
108
|
+
# Find the last zero position (treat NaNs as off)
|
|
109
|
+
arr = np.nan_to_num(arr, nan=0.0)
|
|
110
|
+
is_zero = np.isclose(arr, 0, atol=epsilon)
|
|
111
|
+
zero_indices = np.where(is_zero)[0]
|
|
112
|
+
|
|
113
|
+
# Calculate sum from last zero to end
|
|
114
|
+
start_idx = zero_indices[-1] + 1 if zero_indices.size > 0 else 0
|
|
115
|
+
|
|
116
|
+
return float(np.sum(arr[start_idx:]))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ModelingUtilities:
|
|
120
|
+
@staticmethod
|
|
121
|
+
def compute_consecutive_hours_in_state(
|
|
122
|
+
binary_values: TemporalData,
|
|
123
|
+
hours_per_timestep: int | float,
|
|
124
|
+
epsilon: float = None,
|
|
125
|
+
) -> float:
|
|
126
|
+
"""
|
|
127
|
+
Computes the final consecutive duration in state 'on' (=1) in hours.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
binary_values: Binary DataArray with 'time' dim, or scalar/array
|
|
131
|
+
hours_per_timestep: Duration of each timestep in hours
|
|
132
|
+
epsilon: Tolerance for zero detection (uses CONFIG.Modeling.epsilon if None)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The duration of the final consecutive 'on' period in hours
|
|
136
|
+
"""
|
|
137
|
+
if not isinstance(hours_per_timestep, (int, float)):
|
|
138
|
+
raise TypeError(f'hours_per_timestep must be a scalar, got {type(hours_per_timestep)}')
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
ModelingUtilitiesAbstract.count_consecutive_states(binary_values=binary_values, epsilon=epsilon)
|
|
142
|
+
* hours_per_timestep
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def compute_previous_states(previous_values: xr.DataArray | None, epsilon: float | None = None) -> xr.DataArray:
|
|
147
|
+
return ModelingUtilitiesAbstract.to_binary(values=previous_values, epsilon=epsilon, dims='time')
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def compute_previous_on_duration(
|
|
151
|
+
previous_values: xr.DataArray, hours_per_step: xr.DataArray | float | int
|
|
152
|
+
) -> float:
|
|
153
|
+
return (
|
|
154
|
+
ModelingUtilitiesAbstract.count_consecutive_states(ModelingUtilitiesAbstract.to_binary(previous_values))
|
|
155
|
+
* hours_per_step
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def compute_previous_off_duration(
|
|
160
|
+
previous_values: xr.DataArray, hours_per_step: xr.DataArray | float | int
|
|
161
|
+
) -> float:
|
|
162
|
+
"""
|
|
163
|
+
Compute previous consecutive 'off' duration.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
previous_values: DataArray with 'time' dimension
|
|
167
|
+
hours_per_step: Duration of each timestep in hours
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Previous consecutive off duration in hours
|
|
171
|
+
"""
|
|
172
|
+
if previous_values is None or previous_values.size == 0:
|
|
173
|
+
return 0.0
|
|
174
|
+
|
|
175
|
+
previous_states = ModelingUtilities.compute_previous_states(previous_values)
|
|
176
|
+
previous_off_states = 1 - previous_states
|
|
177
|
+
return ModelingUtilities.compute_consecutive_hours_in_state(previous_off_states, hours_per_step)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def get_most_recent_state(previous_values: xr.DataArray | None) -> int:
|
|
181
|
+
"""
|
|
182
|
+
Get the most recent binary state from previous values.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
previous_values: DataArray with 'time' dimension
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Most recent binary state (0 or 1)
|
|
189
|
+
"""
|
|
190
|
+
if previous_values is None or previous_values.size == 0:
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
previous_states = ModelingUtilities.compute_previous_states(previous_values)
|
|
194
|
+
return int(previous_states.isel(time=-1).item())
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ModelingPrimitives:
|
|
198
|
+
"""Mathematical modeling primitives returning (variables, constraints) tuples"""
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def expression_tracking_variable(
|
|
202
|
+
model: Submodel,
|
|
203
|
+
tracked_expression,
|
|
204
|
+
name: str = None,
|
|
205
|
+
short_name: str = None,
|
|
206
|
+
bounds: tuple[TemporalData, TemporalData] = None,
|
|
207
|
+
coords: str | list[str] | None = None,
|
|
208
|
+
) -> tuple[linopy.Variable, linopy.Constraint]:
|
|
209
|
+
"""
|
|
210
|
+
Creates variable that equals a given expression.
|
|
211
|
+
|
|
212
|
+
Mathematical formulation:
|
|
213
|
+
tracker = expression
|
|
214
|
+
lower ≤ tracker ≤ upper (if bounds provided)
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
variables: {'tracker': tracker_var}
|
|
218
|
+
constraints: {'tracking': constraint}
|
|
219
|
+
"""
|
|
220
|
+
if not isinstance(model, Submodel):
|
|
221
|
+
raise ValueError('ModelingPrimitives.expression_tracking_variable() can only be used with a Submodel')
|
|
222
|
+
|
|
223
|
+
if not bounds:
|
|
224
|
+
tracker = model.add_variables(name=name, coords=model.get_coords(coords), short_name=short_name)
|
|
225
|
+
else:
|
|
226
|
+
tracker = model.add_variables(
|
|
227
|
+
lower=bounds[0] if bounds[0] is not None else -np.inf,
|
|
228
|
+
upper=bounds[1] if bounds[1] is not None else np.inf,
|
|
229
|
+
name=name,
|
|
230
|
+
coords=model.get_coords(coords),
|
|
231
|
+
short_name=short_name,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Constraint: tracker = expression
|
|
235
|
+
tracking = model.add_constraints(tracker == tracked_expression, name=name, short_name=short_name)
|
|
236
|
+
|
|
237
|
+
return tracker, tracking
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def consecutive_duration_tracking(
|
|
241
|
+
model: Submodel,
|
|
242
|
+
state_variable: linopy.Variable,
|
|
243
|
+
name: str = None,
|
|
244
|
+
short_name: str = None,
|
|
245
|
+
minimum_duration: TemporalData | None = None,
|
|
246
|
+
maximum_duration: TemporalData | None = None,
|
|
247
|
+
duration_dim: str = 'time',
|
|
248
|
+
duration_per_step: int | float | TemporalData = None,
|
|
249
|
+
previous_duration: TemporalData = 0,
|
|
250
|
+
) -> tuple[linopy.Variable, tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]]:
|
|
251
|
+
"""
|
|
252
|
+
Creates consecutive duration tracking for a binary state variable.
|
|
253
|
+
|
|
254
|
+
Mathematical formulation:
|
|
255
|
+
duration[t] ≤ state[t] * M ∀t
|
|
256
|
+
duration[t+1] ≤ duration[t] + duration_per_step[t] ∀t
|
|
257
|
+
duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) * M ∀t
|
|
258
|
+
duration[0] = (duration_per_step[0] + previous_duration) * state[0]
|
|
259
|
+
|
|
260
|
+
If minimum_duration provided:
|
|
261
|
+
duration[t] ≥ (state[t-1] - state[t]) * minimum_duration[t-1] ∀t > 0
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
name: Name of the duration variable
|
|
265
|
+
state_variable: Binary state variable to track duration for
|
|
266
|
+
minimum_duration: Optional minimum consecutive duration
|
|
267
|
+
maximum_duration: Optional maximum consecutive duration
|
|
268
|
+
previous_duration: Duration from before first timestep
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
variables: {'duration': duration_var}
|
|
272
|
+
constraints: {'ub': constraint, 'forward': constraint, 'backward': constraint, ...}
|
|
273
|
+
"""
|
|
274
|
+
if not isinstance(model, Submodel):
|
|
275
|
+
raise ValueError('ModelingPrimitives.consecutive_duration_tracking() can only be used with a Submodel')
|
|
276
|
+
|
|
277
|
+
mega = duration_per_step.sum(duration_dim) + previous_duration # Big-M value
|
|
278
|
+
|
|
279
|
+
# Duration variable
|
|
280
|
+
duration = model.add_variables(
|
|
281
|
+
lower=0,
|
|
282
|
+
upper=maximum_duration if maximum_duration is not None else mega,
|
|
283
|
+
coords=state_variable.coords,
|
|
284
|
+
name=name,
|
|
285
|
+
short_name=short_name,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
constraints = {}
|
|
289
|
+
|
|
290
|
+
# Upper bound: duration[t] ≤ state[t] * M
|
|
291
|
+
constraints['ub'] = model.add_constraints(duration <= state_variable * mega, name=f'{duration.name}|ub')
|
|
292
|
+
|
|
293
|
+
# Forward constraint: duration[t+1] ≤ duration[t] + duration_per_step[t]
|
|
294
|
+
constraints['forward'] = model.add_constraints(
|
|
295
|
+
duration.isel({duration_dim: slice(1, None)})
|
|
296
|
+
<= duration.isel({duration_dim: slice(None, -1)}) + duration_per_step.isel({duration_dim: slice(None, -1)}),
|
|
297
|
+
name=f'{duration.name}|forward',
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Backward constraint: duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) * M
|
|
301
|
+
constraints['backward'] = model.add_constraints(
|
|
302
|
+
duration.isel({duration_dim: slice(1, None)})
|
|
303
|
+
>= duration.isel({duration_dim: slice(None, -1)})
|
|
304
|
+
+ duration_per_step.isel({duration_dim: slice(None, -1)})
|
|
305
|
+
+ (state_variable.isel({duration_dim: slice(1, None)}) - 1) * mega,
|
|
306
|
+
name=f'{duration.name}|backward',
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Initial condition: duration[0] = (duration_per_step[0] + previous_duration) * state[0]
|
|
310
|
+
constraints['initial'] = model.add_constraints(
|
|
311
|
+
duration.isel({duration_dim: 0})
|
|
312
|
+
== (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state_variable.isel({duration_dim: 0}),
|
|
313
|
+
name=f'{duration.name}|initial',
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Minimum duration constraint if provided
|
|
317
|
+
if minimum_duration is not None:
|
|
318
|
+
constraints['lb'] = model.add_constraints(
|
|
319
|
+
duration
|
|
320
|
+
>= (
|
|
321
|
+
state_variable.isel({duration_dim: slice(None, -1)})
|
|
322
|
+
- state_variable.isel({duration_dim: slice(1, None)})
|
|
323
|
+
)
|
|
324
|
+
* minimum_duration.isel({duration_dim: slice(None, -1)}),
|
|
325
|
+
name=f'{duration.name}|lb',
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Handle initial condition for minimum duration
|
|
329
|
+
prev = (
|
|
330
|
+
float(previous_duration)
|
|
331
|
+
if not isinstance(previous_duration, xr.DataArray)
|
|
332
|
+
else float(previous_duration.max().item())
|
|
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'
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
variables = {'duration': duration}
|
|
341
|
+
|
|
342
|
+
return variables, constraints
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def mutual_exclusivity_constraint(
|
|
346
|
+
model: Submodel,
|
|
347
|
+
binary_variables: list[linopy.Variable],
|
|
348
|
+
tolerance: float = 1,
|
|
349
|
+
short_name: str = 'mutual_exclusivity',
|
|
350
|
+
) -> linopy.Constraint:
|
|
351
|
+
"""
|
|
352
|
+
Creates mutual exclusivity constraint for binary variables.
|
|
353
|
+
|
|
354
|
+
Mathematical formulation:
|
|
355
|
+
Σ(binary_vars[i]) ≤ tolerance ∀t
|
|
356
|
+
|
|
357
|
+
Ensures at most one binary variable can be 1 at any time.
|
|
358
|
+
Tolerance > 1.0 accounts for binary variable numerical precision.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
binary_variables: List of binary variables that should be mutually exclusive
|
|
362
|
+
tolerance: Upper bound
|
|
363
|
+
short_name: Short name of the constraint
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
variables: {} (no new variables created)
|
|
367
|
+
constraints: {'mutual_exclusivity': constraint}
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
AssertionError: If fewer than 2 variables provided or variables aren't binary
|
|
371
|
+
"""
|
|
372
|
+
if not isinstance(model, Submodel):
|
|
373
|
+
raise ValueError('ModelingPrimitives.mutual_exclusivity_constraint() can only be used with a Submodel')
|
|
374
|
+
|
|
375
|
+
assert len(binary_variables) >= 2, (
|
|
376
|
+
f'Mutual exclusivity requires at least 2 variables, got {len(binary_variables)}'
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
for var in binary_variables:
|
|
380
|
+
assert var.attrs.get('binary', False), (
|
|
381
|
+
f'Variable {var.name} must be binary for mutual exclusivity constraint'
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Create mutual exclusivity constraint
|
|
385
|
+
mutual_exclusivity = model.add_constraints(sum(binary_variables) <= tolerance, short_name=short_name)
|
|
386
|
+
|
|
387
|
+
return mutual_exclusivity
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class BoundingPatterns:
|
|
391
|
+
"""High-level patterns that compose primitives and return (variables, constraints) tuples"""
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def basic_bounds(
|
|
395
|
+
model: Submodel,
|
|
396
|
+
variable: linopy.Variable,
|
|
397
|
+
bounds: tuple[TemporalData, TemporalData],
|
|
398
|
+
name: str = None,
|
|
399
|
+
):
|
|
400
|
+
"""Create simple bounds.
|
|
401
|
+
variable ∈ [lower_bound, upper_bound]
|
|
402
|
+
|
|
403
|
+
Mathematical Formulation:
|
|
404
|
+
lower_bound ≤ variable ≤ upper_bound
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
model: The optimization model instance
|
|
408
|
+
variable: Variable to be bounded
|
|
409
|
+
bounds: Tuple of (lower_bound, upper_bound) absolute bounds
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Tuple containing:
|
|
413
|
+
- variables (Dict): Empty dict
|
|
414
|
+
- constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
|
|
415
|
+
"""
|
|
416
|
+
if not isinstance(model, Submodel):
|
|
417
|
+
raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel')
|
|
418
|
+
|
|
419
|
+
lower_bound, upper_bound = bounds
|
|
420
|
+
name = name or f'{variable.name}'
|
|
421
|
+
|
|
422
|
+
upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{name}|ub')
|
|
423
|
+
lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{name}|lb')
|
|
424
|
+
|
|
425
|
+
return [lower_constraint, upper_constraint]
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def bounds_with_state(
|
|
429
|
+
model: Submodel,
|
|
430
|
+
variable: linopy.Variable,
|
|
431
|
+
bounds: tuple[TemporalData, TemporalData],
|
|
432
|
+
variable_state: linopy.Variable,
|
|
433
|
+
name: str = None,
|
|
434
|
+
) -> list[linopy.Constraint]:
|
|
435
|
+
"""Constraint a variable to bounds, that can be escaped from to 0 by a binary variable.
|
|
436
|
+
variable ∈ {0, [max(ε, lower_bound), upper_bound]}
|
|
437
|
+
|
|
438
|
+
Mathematical Formulation:
|
|
439
|
+
- variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound
|
|
440
|
+
|
|
441
|
+
Use Cases:
|
|
442
|
+
- Investment decisions
|
|
443
|
+
- Unit commitment (on/off states)
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
model: The optimization model instance
|
|
447
|
+
variable: Variable to be bounded
|
|
448
|
+
bounds: Tuple of (lower_bound, upper_bound) absolute bounds
|
|
449
|
+
variable_state: Binary variable controlling the bounds
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Tuple containing:
|
|
453
|
+
- variables (Dict): Empty dict
|
|
454
|
+
- constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
|
|
455
|
+
"""
|
|
456
|
+
if not isinstance(model, Submodel):
|
|
457
|
+
raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel')
|
|
458
|
+
|
|
459
|
+
lower_bound, upper_bound = bounds
|
|
460
|
+
name = name or f'{variable.name}'
|
|
461
|
+
|
|
462
|
+
if np.allclose(lower_bound, upper_bound, atol=1e-10, equal_nan=True):
|
|
463
|
+
fix_constraint = model.add_constraints(variable == variable_state * upper_bound, name=f'{name}|fix')
|
|
464
|
+
return [fix_constraint]
|
|
465
|
+
|
|
466
|
+
epsilon = np.maximum(CONFIG.Modeling.epsilon, lower_bound)
|
|
467
|
+
|
|
468
|
+
upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{name}|ub')
|
|
469
|
+
lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{name}|lb')
|
|
470
|
+
|
|
471
|
+
return [lower_constraint, upper_constraint]
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def scaled_bounds(
|
|
475
|
+
model: Submodel,
|
|
476
|
+
variable: linopy.Variable,
|
|
477
|
+
scaling_variable: linopy.Variable,
|
|
478
|
+
relative_bounds: tuple[TemporalData, TemporalData],
|
|
479
|
+
name: str = None,
|
|
480
|
+
) -> list[linopy.Constraint]:
|
|
481
|
+
"""Constraint a variable by scaling bounds, dependent on another variable.
|
|
482
|
+
variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable]
|
|
483
|
+
|
|
484
|
+
Mathematical Formulation:
|
|
485
|
+
scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor
|
|
486
|
+
|
|
487
|
+
Use Cases:
|
|
488
|
+
- Flow rates bounded by equipment capacity
|
|
489
|
+
- Production levels scaled by plant size
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
model: The optimization model instance
|
|
493
|
+
variable: Variable to be bounded
|
|
494
|
+
scaling_variable: Variable that scales the bound factors
|
|
495
|
+
relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Tuple containing:
|
|
499
|
+
- variables (Dict): Empty dict
|
|
500
|
+
- constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
|
|
501
|
+
"""
|
|
502
|
+
if not isinstance(model, Submodel):
|
|
503
|
+
raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel')
|
|
504
|
+
|
|
505
|
+
rel_lower, rel_upper = relative_bounds
|
|
506
|
+
name = name or f'{variable.name}'
|
|
507
|
+
|
|
508
|
+
if np.allclose(rel_lower, rel_upper, atol=1e-10, equal_nan=True):
|
|
509
|
+
return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')]
|
|
510
|
+
|
|
511
|
+
upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub')
|
|
512
|
+
lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{name}|lb')
|
|
513
|
+
|
|
514
|
+
return [lower_constraint, upper_constraint]
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def scaled_bounds_with_state(
|
|
518
|
+
model: Submodel,
|
|
519
|
+
variable: linopy.Variable,
|
|
520
|
+
scaling_variable: linopy.Variable,
|
|
521
|
+
relative_bounds: tuple[TemporalData, TemporalData],
|
|
522
|
+
scaling_bounds: tuple[TemporalData, TemporalData],
|
|
523
|
+
variable_state: linopy.Variable,
|
|
524
|
+
name: str = None,
|
|
525
|
+
) -> list[linopy.Constraint]:
|
|
526
|
+
"""Constraint a variable by scaling bounds with binary state control.
|
|
527
|
+
|
|
528
|
+
variable ∈ {0, [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable]}
|
|
529
|
+
|
|
530
|
+
Mathematical Formulation (Big-M):
|
|
531
|
+
(variable_state - 1) * M_misc + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper
|
|
532
|
+
variable_state * big_m_lower ≤ variable ≤ variable_state * big_m_upper
|
|
533
|
+
|
|
534
|
+
Where:
|
|
535
|
+
M_misc = scaling_max * rel_lower
|
|
536
|
+
big_m_upper = scaling_max * rel_upper
|
|
537
|
+
big_m_lower = max(ε, scaling_min * rel_lower)
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
model: The optimization model instance
|
|
541
|
+
variable: Variable to be bounded
|
|
542
|
+
scaling_variable: Variable that scales the bound factors
|
|
543
|
+
relative_bounds: Tuple of (lower_factor, upper_factor) relative to scaling variable
|
|
544
|
+
scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable
|
|
545
|
+
variable_state: Binary variable for on/off control
|
|
546
|
+
name: Optional name prefix for constraints
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
List[linopy.Constraint]: List of constraint objects
|
|
550
|
+
"""
|
|
551
|
+
if not isinstance(model, Submodel):
|
|
552
|
+
raise ValueError('BoundingPatterns.scaled_bounds_with_state() can only be used with a Submodel')
|
|
553
|
+
|
|
554
|
+
rel_lower, rel_upper = relative_bounds
|
|
555
|
+
scaling_min, scaling_max = scaling_bounds
|
|
556
|
+
name = name or f'{variable.name}'
|
|
557
|
+
|
|
558
|
+
big_m_misc = scaling_max * rel_lower
|
|
559
|
+
|
|
560
|
+
scaling_lower = model.add_constraints(
|
|
561
|
+
variable >= (variable_state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2'
|
|
562
|
+
)
|
|
563
|
+
scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2')
|
|
564
|
+
|
|
565
|
+
big_m_upper = rel_upper * scaling_max
|
|
566
|
+
big_m_lower = np.maximum(CONFIG.Modeling.epsilon, rel_lower * scaling_min)
|
|
567
|
+
|
|
568
|
+
binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1')
|
|
569
|
+
binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1')
|
|
570
|
+
|
|
571
|
+
return [scaling_lower, scaling_upper, binary_lower, binary_upper]
|
|
572
|
+
|
|
573
|
+
@staticmethod
|
|
574
|
+
def state_transition_bounds(
|
|
575
|
+
model: Submodel,
|
|
576
|
+
state_variable: linopy.Variable,
|
|
577
|
+
switch_on: linopy.Variable,
|
|
578
|
+
switch_off: linopy.Variable,
|
|
579
|
+
name: str,
|
|
580
|
+
previous_state=0,
|
|
581
|
+
coord: str = 'time',
|
|
582
|
+
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]:
|
|
583
|
+
"""
|
|
584
|
+
Creates switch-on/off variables with state transition logic.
|
|
585
|
+
|
|
586
|
+
Mathematical formulation:
|
|
587
|
+
switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0
|
|
588
|
+
switch_on[0] - switch_off[0] = state[0] - previous_state
|
|
589
|
+
switch_on[t] + switch_off[t] ≤ 1 ∀t
|
|
590
|
+
switch_on[t], switch_off[t] ∈ {0, 1}
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
variables: {'switch_on': binary_var, 'switch_off': binary_var}
|
|
594
|
+
constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint}
|
|
595
|
+
"""
|
|
596
|
+
if not isinstance(model, Submodel):
|
|
597
|
+
raise ValueError('ModelingPrimitives.state_transition_bounds() can only be used with a Submodel')
|
|
598
|
+
|
|
599
|
+
# State transition constraints for t > 0
|
|
600
|
+
transition = model.add_constraints(
|
|
601
|
+
switch_on.isel({coord: slice(1, None)}) - switch_off.isel({coord: slice(1, None)})
|
|
602
|
+
== state_variable.isel({coord: slice(1, None)}) - state_variable.isel({coord: slice(None, -1)}),
|
|
603
|
+
name=f'{name}|transition',
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# Initial state transition for t = 0
|
|
607
|
+
initial = model.add_constraints(
|
|
608
|
+
switch_on.isel({coord: 0}) - switch_off.isel({coord: 0})
|
|
609
|
+
== state_variable.isel({coord: 0}) - previous_state,
|
|
610
|
+
name=f'{name}|initial',
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# At most one switch per timestep
|
|
614
|
+
mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex')
|
|
615
|
+
|
|
616
|
+
return transition, initial, mutex
|
|
617
|
+
|
|
618
|
+
@staticmethod
|
|
619
|
+
def continuous_transition_bounds(
|
|
620
|
+
model: Submodel,
|
|
621
|
+
continuous_variable: linopy.Variable,
|
|
622
|
+
switch_on: linopy.Variable,
|
|
623
|
+
switch_off: linopy.Variable,
|
|
624
|
+
name: str,
|
|
625
|
+
max_change: float | xr.DataArray,
|
|
626
|
+
previous_value: float | xr.DataArray = 0.0,
|
|
627
|
+
coord: str = 'time',
|
|
628
|
+
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
|
|
629
|
+
"""
|
|
630
|
+
Constrains a continuous variable to only change when switch variables are active.
|
|
631
|
+
|
|
632
|
+
Mathematical formulation:
|
|
633
|
+
-max_change * (switch_on[t] + switch_off[t]) <= continuous[t] - continuous[t-1] <= max_change * (switch_on[t] + switch_off[t]) ∀t > 0
|
|
634
|
+
-max_change * (switch_on[0] + switch_off[0]) <= continuous[0] - previous_value <= max_change * (switch_on[0] + switch_off[0])
|
|
635
|
+
switch_on[t], switch_off[t] ∈ {0, 1}
|
|
636
|
+
|
|
637
|
+
This ensures the continuous variable can only change when switch_on or switch_off is 1.
|
|
638
|
+
When both switches are 0, the variable must stay exactly constant.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
model: The submodel to add constraints to
|
|
642
|
+
continuous_variable: The continuous variable to constrain
|
|
643
|
+
switch_on: Binary variable indicating when changes are allowed (typically transitions to active state)
|
|
644
|
+
switch_off: Binary variable indicating when changes are allowed (typically transitions to inactive state)
|
|
645
|
+
name: Base name for the constraints
|
|
646
|
+
max_change: Maximum possible change in the continuous variable (Big-M value)
|
|
647
|
+
previous_value: Initial value of the continuous variable before first period
|
|
648
|
+
coord: Coordinate name for time dimension
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower)
|
|
652
|
+
"""
|
|
653
|
+
if not isinstance(model, Submodel):
|
|
654
|
+
raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel')
|
|
655
|
+
|
|
656
|
+
# Transition constraints for t > 0: continuous variable can only change when switches are active
|
|
657
|
+
transition_upper = model.add_constraints(
|
|
658
|
+
continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})
|
|
659
|
+
<= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
|
|
660
|
+
name=f'{name}|transition_ub',
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
transition_lower = model.add_constraints(
|
|
664
|
+
-(continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)}))
|
|
665
|
+
<= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
|
|
666
|
+
name=f'{name}|transition_lb',
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# Initial constraints for t = 0
|
|
670
|
+
initial_upper = model.add_constraints(
|
|
671
|
+
continuous_variable.isel({coord: 0}) - previous_value
|
|
672
|
+
<= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
|
|
673
|
+
name=f'{name}|initial_ub',
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
initial_lower = model.add_constraints(
|
|
677
|
+
-continuous_variable.isel({coord: 0}) + previous_value
|
|
678
|
+
<= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
|
|
679
|
+
name=f'{name}|initial_lb',
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
return transition_upper, transition_lower, initial_upper, initial_lower
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def link_changes_to_level_with_binaries(
|
|
686
|
+
model: Submodel,
|
|
687
|
+
level_variable: linopy.Variable,
|
|
688
|
+
increase_variable: linopy.Variable,
|
|
689
|
+
decrease_variable: linopy.Variable,
|
|
690
|
+
increase_binary: linopy.Variable,
|
|
691
|
+
decrease_binary: linopy.Variable,
|
|
692
|
+
name: str,
|
|
693
|
+
max_change: float | xr.DataArray,
|
|
694
|
+
initial_level: float | xr.DataArray = 0.0,
|
|
695
|
+
coord: str = 'period',
|
|
696
|
+
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
|
|
697
|
+
"""
|
|
698
|
+
Link changes to level evolution with binary control and mutual exclusivity.
|
|
699
|
+
|
|
700
|
+
Creates the complete constraint system for ALL time periods:
|
|
701
|
+
1. level[0] = initial_level + increase[0] - decrease[0]
|
|
702
|
+
2. level[t] = level[t-1] + increase[t] - decrease[t] ∀t > 0
|
|
703
|
+
3. increase[t] <= max_change * increase_binary[t] ∀t
|
|
704
|
+
4. decrease[t] <= max_change * decrease_binary[t] ∀t
|
|
705
|
+
5. increase_binary[t] + decrease_binary[t] <= 1 ∀t
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
model: The submodel to add constraints to
|
|
709
|
+
increase_variable: Incremental additions for ALL periods (>= 0)
|
|
710
|
+
decrease_variable: Incremental reductions for ALL periods (>= 0)
|
|
711
|
+
increase_binary: Binary indicators for increases for ALL periods
|
|
712
|
+
decrease_binary: Binary indicators for decreases for ALL periods
|
|
713
|
+
level_variable: Level variable for ALL periods
|
|
714
|
+
name: Base name for constraints
|
|
715
|
+
max_change: Maximum change per period
|
|
716
|
+
initial_level: Starting level before first period
|
|
717
|
+
coord: Time coordinate name
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
Tuple of (initial_constraint, transition_constraints, increase_bounds, decrease_bounds, mutual_exclusion)
|
|
721
|
+
"""
|
|
722
|
+
if not isinstance(model, Submodel):
|
|
723
|
+
raise ValueError('BoundingPatterns.link_changes_to_level_with_binaries() can only be used with a Submodel')
|
|
724
|
+
|
|
725
|
+
# 1. Initial period: level[0] - initial_level = increase[0] - decrease[0]
|
|
726
|
+
initial_constraint = model.add_constraints(
|
|
727
|
+
level_variable.isel({coord: 0}) - initial_level
|
|
728
|
+
== increase_variable.isel({coord: 0}) - decrease_variable.isel({coord: 0}),
|
|
729
|
+
name=f'{name}|initial_level',
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# 2. Transition periods: level[t] = level[t-1] + increase[t] - decrease[t] for t > 0
|
|
733
|
+
transition_constraints = model.add_constraints(
|
|
734
|
+
level_variable.isel({coord: slice(1, None)})
|
|
735
|
+
== level_variable.isel({coord: slice(None, -1)})
|
|
736
|
+
+ increase_variable.isel({coord: slice(1, None)})
|
|
737
|
+
- decrease_variable.isel({coord: slice(1, None)}),
|
|
738
|
+
name=f'{name}|transitions',
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# 3. Increase bounds: increase[t] <= max_change * increase_binary[t] for all t
|
|
742
|
+
increase_bounds = model.add_constraints(
|
|
743
|
+
increase_variable <= increase_binary * max_change,
|
|
744
|
+
name=f'{name}|increase_bounds',
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
# 4. Decrease bounds: decrease[t] <= max_change * decrease_binary[t] for all t
|
|
748
|
+
decrease_bounds = model.add_constraints(
|
|
749
|
+
decrease_variable <= decrease_binary * max_change,
|
|
750
|
+
name=f'{name}|decrease_bounds',
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# 5. Mutual exclusivity: increase_binary[t] + decrease_binary[t] <= 1 for all t
|
|
754
|
+
mutual_exclusion = model.add_constraints(
|
|
755
|
+
increase_binary + decrease_binary <= 1,
|
|
756
|
+
name=f'{name}|mutual_exclusion',
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
return initial_constraint, transition_constraints, increase_bounds, decrease_bounds, mutual_exclusion
|