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.

Files changed (63) hide show
  1. flixopt/__init__.py +35 -1
  2. flixopt/aggregation.py +60 -81
  3. flixopt/calculation.py +381 -196
  4. flixopt/components.py +1022 -359
  5. flixopt/config.py +553 -191
  6. flixopt/core.py +475 -1315
  7. flixopt/effects.py +477 -214
  8. flixopt/elements.py +591 -344
  9. flixopt/features.py +403 -957
  10. flixopt/flow_system.py +781 -293
  11. flixopt/interface.py +1159 -189
  12. flixopt/io.py +50 -55
  13. flixopt/linear_converters.py +384 -92
  14. flixopt/modeling.py +759 -0
  15. flixopt/network_app.py +789 -0
  16. flixopt/plotting.py +273 -135
  17. flixopt/results.py +639 -383
  18. flixopt/solvers.py +25 -21
  19. flixopt/structure.py +928 -442
  20. flixopt/utils.py +34 -5
  21. flixopt-3.0.0.dist-info/METADATA +209 -0
  22. flixopt-3.0.0.dist-info/RECORD +26 -0
  23. {flixopt-2.2.0b0.dist-info → flixopt-3.0.0.dist-info}/WHEEL +1 -1
  24. flixopt-3.0.0.dist-info/top_level.txt +1 -0
  25. docs/examples/00-Minimal Example.md +0 -5
  26. docs/examples/01-Basic Example.md +0 -5
  27. docs/examples/02-Complex Example.md +0 -10
  28. docs/examples/03-Calculation Modes.md +0 -5
  29. docs/examples/index.md +0 -5
  30. docs/faq/contribute.md +0 -49
  31. docs/faq/index.md +0 -3
  32. docs/images/architecture_flixOpt-pre2.0.0.png +0 -0
  33. docs/images/architecture_flixOpt.png +0 -0
  34. docs/images/flixopt-icon.svg +0 -1
  35. docs/javascripts/mathjax.js +0 -18
  36. docs/release-notes/_template.txt +0 -32
  37. docs/release-notes/index.md +0 -7
  38. docs/release-notes/v2.0.0.md +0 -93
  39. docs/release-notes/v2.0.1.md +0 -12
  40. docs/release-notes/v2.1.0.md +0 -31
  41. docs/release-notes/v2.2.0.md +0 -55
  42. docs/user-guide/Mathematical Notation/Bus.md +0 -33
  43. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  44. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  45. docs/user-guide/Mathematical Notation/Investment.md +0 -115
  46. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  47. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  48. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  49. docs/user-guide/Mathematical Notation/index.md +0 -22
  50. docs/user-guide/Mathematical Notation/others.md +0 -3
  51. docs/user-guide/index.md +0 -124
  52. flixopt/config.yaml +0 -10
  53. flixopt-2.2.0b0.dist-info/METADATA +0 -146
  54. flixopt-2.2.0b0.dist-info/RECORD +0 -59
  55. flixopt-2.2.0b0.dist-info/top_level.txt +0 -5
  56. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  57. pics/architecture_flixOpt.png +0 -0
  58. pics/flixOpt_plotting.jpg +0 -0
  59. pics/flixopt-icon.svg +0 -1
  60. pics/pics.pptx +0 -0
  61. scripts/gen_ref_pages.py +0 -54
  62. tests/ressources/Zeitreihen2020.csv +0 -35137
  63. {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