flixopt 2.2.0rc2__py3-none-any.whl → 3.0.1__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.
Files changed (58) hide show
  1. flixopt/__init__.py +33 -4
  2. flixopt/aggregation.py +60 -80
  3. flixopt/calculation.py +403 -182
  4. flixopt/commons.py +1 -10
  5. flixopt/components.py +939 -448
  6. flixopt/config.py +553 -191
  7. flixopt/core.py +513 -846
  8. flixopt/effects.py +644 -178
  9. flixopt/elements.py +610 -355
  10. flixopt/features.py +394 -966
  11. flixopt/flow_system.py +736 -219
  12. flixopt/interface.py +1104 -302
  13. flixopt/io.py +103 -79
  14. flixopt/linear_converters.py +387 -95
  15. flixopt/modeling.py +757 -0
  16. flixopt/network_app.py +73 -39
  17. flixopt/plotting.py +294 -138
  18. flixopt/results.py +1254 -300
  19. flixopt/solvers.py +25 -21
  20. flixopt/structure.py +938 -396
  21. flixopt/utils.py +36 -12
  22. flixopt-3.0.1.dist-info/METADATA +209 -0
  23. flixopt-3.0.1.dist-info/RECORD +26 -0
  24. flixopt-3.0.1.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 -61
  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/user-guide/Mathematical Notation/Bus.md +0 -33
  37. docs/user-guide/Mathematical Notation/Effects, Penalty & Objective.md +0 -132
  38. docs/user-guide/Mathematical Notation/Flow.md +0 -26
  39. docs/user-guide/Mathematical Notation/LinearConverter.md +0 -21
  40. docs/user-guide/Mathematical Notation/Piecewise.md +0 -49
  41. docs/user-guide/Mathematical Notation/Storage.md +0 -44
  42. docs/user-guide/Mathematical Notation/index.md +0 -22
  43. docs/user-guide/Mathematical Notation/others.md +0 -3
  44. docs/user-guide/index.md +0 -124
  45. flixopt/config.yaml +0 -10
  46. flixopt-2.2.0rc2.dist-info/METADATA +0 -167
  47. flixopt-2.2.0rc2.dist-info/RECORD +0 -54
  48. flixopt-2.2.0rc2.dist-info/top_level.txt +0 -5
  49. pics/architecture_flixOpt-pre2.0.0.png +0 -0
  50. pics/architecture_flixOpt.png +0 -0
  51. pics/flixOpt_plotting.jpg +0 -0
  52. pics/flixopt-icon.svg +0 -1
  53. pics/pics.pptx +0 -0
  54. scripts/extract_release_notes.py +0 -45
  55. scripts/gen_ref_pages.py +0 -54
  56. tests/ressources/Zeitreihen2020.csv +0 -35137
  57. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.1.dist-info}/WHEEL +0 -0
  58. {flixopt-2.2.0rc2.dist-info → flixopt-3.0.1.dist-info}/licenses/LICENSE +0 -0
flixopt/modeling.py ADDED
@@ -0,0 +1,757 @@
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
+ ) -> list[linopy.constraints.Constraint]:
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
+ List containing lower_bound and upper_bound constraints
413
+ """
414
+ if not isinstance(model, Submodel):
415
+ raise ValueError('BoundingPatterns.basic_bounds() can only be used with a Submodel')
416
+
417
+ lower_bound, upper_bound = bounds
418
+ name = name or f'{variable.name}'
419
+
420
+ upper_constraint = model.add_constraints(variable <= upper_bound, name=f'{name}|ub')
421
+ lower_constraint = model.add_constraints(variable >= lower_bound, name=f'{name}|lb')
422
+
423
+ return [lower_constraint, upper_constraint]
424
+
425
+ @staticmethod
426
+ def bounds_with_state(
427
+ model: Submodel,
428
+ variable: linopy.Variable,
429
+ bounds: tuple[TemporalData, TemporalData],
430
+ variable_state: linopy.Variable,
431
+ name: str = None,
432
+ ) -> list[linopy.Constraint]:
433
+ """Constraint a variable to bounds, that can be escaped from to 0 by a binary variable.
434
+ variable ∈ {0, [max(ε, lower_bound), upper_bound]}
435
+
436
+ Mathematical Formulation:
437
+ - variable_state * max(ε, lower_bound) ≤ variable ≤ variable_state * upper_bound
438
+
439
+ Use Cases:
440
+ - Investment decisions
441
+ - Unit commitment (on/off states)
442
+
443
+ Args:
444
+ model: The optimization model instance
445
+ variable: Variable to be bounded
446
+ bounds: Tuple of (lower_bound, upper_bound) absolute bounds
447
+ variable_state: Binary variable controlling the bounds
448
+
449
+ Returns:
450
+ Tuple containing:
451
+ - variables (Dict): Empty dict
452
+ - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
453
+ """
454
+ if not isinstance(model, Submodel):
455
+ raise ValueError('BoundingPatterns.bounds_with_state() can only be used with a Submodel')
456
+
457
+ lower_bound, upper_bound = bounds
458
+ name = name or f'{variable.name}'
459
+
460
+ if np.allclose(lower_bound, upper_bound, atol=1e-10, equal_nan=True):
461
+ fix_constraint = model.add_constraints(variable == variable_state * upper_bound, name=f'{name}|fix')
462
+ return [fix_constraint]
463
+
464
+ epsilon = np.maximum(CONFIG.Modeling.epsilon, lower_bound)
465
+
466
+ upper_constraint = model.add_constraints(variable <= variable_state * upper_bound, name=f'{name}|ub')
467
+ lower_constraint = model.add_constraints(variable >= variable_state * epsilon, name=f'{name}|lb')
468
+
469
+ return [lower_constraint, upper_constraint]
470
+
471
+ @staticmethod
472
+ def scaled_bounds(
473
+ model: Submodel,
474
+ variable: linopy.Variable,
475
+ scaling_variable: linopy.Variable,
476
+ relative_bounds: tuple[TemporalData, TemporalData],
477
+ name: str = None,
478
+ ) -> list[linopy.Constraint]:
479
+ """Constraint a variable by scaling bounds, dependent on another variable.
480
+ variable ∈ [lower_bound * scaling_variable, upper_bound * scaling_variable]
481
+
482
+ Mathematical Formulation:
483
+ scaling_variable * lower_factor ≤ variable ≤ scaling_variable * upper_factor
484
+
485
+ Use Cases:
486
+ - Flow rates bounded by equipment capacity
487
+ - Production levels scaled by plant size
488
+
489
+ Args:
490
+ model: The optimization model instance
491
+ 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 scaling variable
494
+
495
+ Returns:
496
+ Tuple containing:
497
+ - variables (Dict): Empty dict
498
+ - constraints (Dict[str, linopy.Constraint]): 'ub', 'lb'
499
+ """
500
+ if not isinstance(model, Submodel):
501
+ raise ValueError('BoundingPatterns.scaled_bounds() can only be used with a Submodel')
502
+
503
+ rel_lower, rel_upper = relative_bounds
504
+ name = name or f'{variable.name}'
505
+
506
+ if np.allclose(rel_lower, rel_upper, atol=1e-10, equal_nan=True):
507
+ return [model.add_constraints(variable == scaling_variable * rel_lower, name=f'{name}|fixed')]
508
+
509
+ upper_constraint = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub')
510
+ lower_constraint = model.add_constraints(variable >= scaling_variable * rel_lower, name=f'{name}|lb')
511
+
512
+ return [lower_constraint, upper_constraint]
513
+
514
+ @staticmethod
515
+ def scaled_bounds_with_state(
516
+ model: Submodel,
517
+ variable: linopy.Variable,
518
+ scaling_variable: linopy.Variable,
519
+ relative_bounds: tuple[TemporalData, TemporalData],
520
+ scaling_bounds: tuple[TemporalData, TemporalData],
521
+ variable_state: linopy.Variable,
522
+ name: str = None,
523
+ ) -> list[linopy.Constraint]:
524
+ """Constraint a variable by scaling bounds with binary state control.
525
+
526
+ variable ∈ {0, [max(ε, lower_relative_bound) * scaling_variable, upper_relative_bound * scaling_variable]}
527
+
528
+ Mathematical Formulation (Big-M):
529
+ (variable_state - 1) * M_misc + scaling_variable * rel_lower ≤ variable ≤ scaling_variable * rel_upper
530
+ variable_state * big_m_lower ≤ variable ≤ variable_state * big_m_upper
531
+
532
+ Where:
533
+ M_misc = scaling_max * rel_lower
534
+ big_m_upper = scaling_max * rel_upper
535
+ big_m_lower = max(ε, scaling_min * rel_lower)
536
+
537
+ Args:
538
+ model: The optimization model instance
539
+ 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 scaling variable
542
+ scaling_bounds: Tuple of (scaling_min, scaling_max) bounds of the scaling variable
543
+ variable_state: Binary variable for on/off control
544
+ name: Optional name prefix for constraints
545
+
546
+ Returns:
547
+ List[linopy.Constraint]: List of constraint objects
548
+ """
549
+ if not isinstance(model, Submodel):
550
+ raise ValueError('BoundingPatterns.scaled_bounds_with_state() can only be used with a Submodel')
551
+
552
+ rel_lower, rel_upper = relative_bounds
553
+ scaling_min, scaling_max = scaling_bounds
554
+ name = name or f'{variable.name}'
555
+
556
+ big_m_misc = scaling_max * rel_lower
557
+
558
+ scaling_lower = model.add_constraints(
559
+ variable >= (variable_state - 1) * big_m_misc + scaling_variable * rel_lower, name=f'{name}|lb2'
560
+ )
561
+ scaling_upper = model.add_constraints(variable <= scaling_variable * rel_upper, name=f'{name}|ub2')
562
+
563
+ big_m_upper = rel_upper * scaling_max
564
+ big_m_lower = np.maximum(CONFIG.Modeling.epsilon, rel_lower * scaling_min)
565
+
566
+ binary_upper = model.add_constraints(variable_state * big_m_upper >= variable, name=f'{name}|ub1')
567
+ binary_lower = model.add_constraints(variable_state * big_m_lower <= variable, name=f'{name}|lb1')
568
+
569
+ return [scaling_lower, scaling_upper, binary_lower, binary_upper]
570
+
571
+ @staticmethod
572
+ def state_transition_bounds(
573
+ model: Submodel,
574
+ state_variable: linopy.Variable,
575
+ switch_on: linopy.Variable,
576
+ switch_off: linopy.Variable,
577
+ name: str,
578
+ previous_state=0,
579
+ coord: str = 'time',
580
+ ) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]:
581
+ """
582
+ Creates switch-on/off variables with state transition logic.
583
+
584
+ Mathematical formulation:
585
+ switch_on[t] - switch_off[t] = state[t] - state[t-1] ∀t > 0
586
+ switch_on[0] - switch_off[0] = state[0] - previous_state
587
+ switch_on[t] + switch_off[t] ≤ 1 ∀t
588
+ switch_on[t], switch_off[t] ∈ {0, 1}
589
+
590
+ Returns:
591
+ variables: {'switch_on': binary_var, 'switch_off': binary_var}
592
+ constraints: {'transition': constraint, 'initial': constraint, 'mutex': constraint}
593
+ """
594
+ if not isinstance(model, Submodel):
595
+ raise ValueError('ModelingPrimitives.state_transition_bounds() can only be used with a Submodel')
596
+
597
+ # State transition constraints for t > 0
598
+ transition = model.add_constraints(
599
+ switch_on.isel({coord: slice(1, None)}) - switch_off.isel({coord: slice(1, None)})
600
+ == state_variable.isel({coord: slice(1, None)}) - state_variable.isel({coord: slice(None, -1)}),
601
+ name=f'{name}|transition',
602
+ )
603
+
604
+ # Initial state transition for t = 0
605
+ initial = model.add_constraints(
606
+ switch_on.isel({coord: 0}) - switch_off.isel({coord: 0})
607
+ == state_variable.isel({coord: 0}) - previous_state,
608
+ name=f'{name}|initial',
609
+ )
610
+
611
+ # At most one switch per timestep
612
+ mutex = model.add_constraints(switch_on + switch_off <= 1, name=f'{name}|mutex')
613
+
614
+ return transition, initial, mutex
615
+
616
+ @staticmethod
617
+ def continuous_transition_bounds(
618
+ model: Submodel,
619
+ continuous_variable: linopy.Variable,
620
+ switch_on: linopy.Variable,
621
+ switch_off: linopy.Variable,
622
+ name: str,
623
+ max_change: float | xr.DataArray,
624
+ previous_value: float | xr.DataArray = 0.0,
625
+ coord: str = 'time',
626
+ ) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
627
+ """
628
+ Constrains a continuous variable to only change when switch variables are active.
629
+
630
+ Mathematical formulation:
631
+ -max_change * (switch_on[t] + switch_off[t]) <= continuous[t] - continuous[t-1] <= max_change * (switch_on[t] + switch_off[t]) ∀t > 0
632
+ -max_change * (switch_on[0] + switch_off[0]) <= continuous[0] - previous_value <= max_change * (switch_on[0] + switch_off[0])
633
+ switch_on[t], switch_off[t] ∈ {0, 1}
634
+
635
+ This ensures the continuous variable can only change when switch_on or switch_off is 1.
636
+ When both switches are 0, the variable must stay exactly constant.
637
+
638
+ Args:
639
+ model: The submodel to add constraints to
640
+ continuous_variable: The continuous variable to constrain
641
+ switch_on: Binary variable indicating when changes are allowed (typically transitions to active state)
642
+ switch_off: Binary variable indicating when changes are allowed (typically transitions to inactive state)
643
+ name: Base name for the constraints
644
+ max_change: Maximum possible change in the continuous variable (Big-M value)
645
+ previous_value: Initial value of the continuous variable before first period
646
+ coord: Coordinate name for time dimension
647
+
648
+ Returns:
649
+ Tuple of constraints: (transition_upper, transition_lower, initial_upper, initial_lower)
650
+ """
651
+ if not isinstance(model, Submodel):
652
+ raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel')
653
+
654
+ # Transition constraints for t > 0: continuous variable can only change when switches are active
655
+ transition_upper = model.add_constraints(
656
+ continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})
657
+ <= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
658
+ name=f'{name}|transition_ub',
659
+ )
660
+
661
+ transition_lower = model.add_constraints(
662
+ -(continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)}))
663
+ <= max_change * (switch_on.isel({coord: slice(1, None)}) + switch_off.isel({coord: slice(1, None)})),
664
+ name=f'{name}|transition_lb',
665
+ )
666
+
667
+ # Initial constraints for t = 0
668
+ initial_upper = model.add_constraints(
669
+ continuous_variable.isel({coord: 0}) - previous_value
670
+ <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
671
+ name=f'{name}|initial_ub',
672
+ )
673
+
674
+ initial_lower = model.add_constraints(
675
+ -continuous_variable.isel({coord: 0}) + previous_value
676
+ <= max_change * (switch_on.isel({coord: 0}) + switch_off.isel({coord: 0})),
677
+ name=f'{name}|initial_lb',
678
+ )
679
+
680
+ return transition_upper, transition_lower, initial_upper, initial_lower
681
+
682
+ @staticmethod
683
+ def link_changes_to_level_with_binaries(
684
+ model: Submodel,
685
+ level_variable: linopy.Variable,
686
+ increase_variable: linopy.Variable,
687
+ decrease_variable: linopy.Variable,
688
+ increase_binary: linopy.Variable,
689
+ decrease_binary: linopy.Variable,
690
+ name: str,
691
+ max_change: float | xr.DataArray,
692
+ initial_level: float | xr.DataArray = 0.0,
693
+ coord: str = 'period',
694
+ ) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint, linopy.Constraint]:
695
+ """
696
+ Link changes to level evolution with binary control and mutual exclusivity.
697
+
698
+ Creates the complete constraint system for ALL time periods:
699
+ 1. level[0] = initial_level + increase[0] - decrease[0]
700
+ 2. level[t] = level[t-1] + increase[t] - decrease[t] ∀t > 0
701
+ 3. increase[t] <= max_change * increase_binary[t] ∀t
702
+ 4. decrease[t] <= max_change * decrease_binary[t] ∀t
703
+ 5. increase_binary[t] + decrease_binary[t] <= 1 ∀t
704
+
705
+ Args:
706
+ model: The submodel to add constraints to
707
+ increase_variable: Incremental additions for ALL periods (>= 0)
708
+ decrease_variable: Incremental reductions for ALL periods (>= 0)
709
+ increase_binary: Binary indicators for increases for ALL periods
710
+ decrease_binary: Binary indicators for decreases for ALL periods
711
+ level_variable: Level variable for ALL periods
712
+ name: Base name for constraints
713
+ max_change: Maximum change per period
714
+ initial_level: Starting level before first period
715
+ coord: Time coordinate name
716
+
717
+ Returns:
718
+ Tuple of (initial_constraint, transition_constraints, increase_bounds, decrease_bounds, mutual_exclusion)
719
+ """
720
+ if not isinstance(model, Submodel):
721
+ raise ValueError('BoundingPatterns.link_changes_to_level_with_binaries() can only be used with a Submodel')
722
+
723
+ # 1. Initial period: level[0] - initial_level = increase[0] - decrease[0]
724
+ initial_constraint = model.add_constraints(
725
+ level_variable.isel({coord: 0}) - initial_level
726
+ == increase_variable.isel({coord: 0}) - decrease_variable.isel({coord: 0}),
727
+ name=f'{name}|initial_level',
728
+ )
729
+
730
+ # 2. Transition periods: level[t] = level[t-1] + increase[t] - decrease[t] for t > 0
731
+ transition_constraints = model.add_constraints(
732
+ level_variable.isel({coord: slice(1, None)})
733
+ == level_variable.isel({coord: slice(None, -1)})
734
+ + increase_variable.isel({coord: slice(1, None)})
735
+ - decrease_variable.isel({coord: slice(1, None)}),
736
+ name=f'{name}|transitions',
737
+ )
738
+
739
+ # 3. Increase bounds: increase[t] <= max_change * increase_binary[t] for all t
740
+ increase_bounds = model.add_constraints(
741
+ increase_variable <= increase_binary * max_change,
742
+ name=f'{name}|increase_bounds',
743
+ )
744
+
745
+ # 4. Decrease bounds: decrease[t] <= max_change * decrease_binary[t] for all t
746
+ decrease_bounds = model.add_constraints(
747
+ decrease_variable <= decrease_binary * max_change,
748
+ name=f'{name}|decrease_bounds',
749
+ )
750
+
751
+ # 5. Mutual exclusivity: increase_binary[t] + decrease_binary[t] <= 1 for all t
752
+ mutual_exclusion = model.add_constraints(
753
+ increase_binary + decrease_binary <= 1,
754
+ name=f'{name}|mutual_exclusion',
755
+ )
756
+
757
+ return initial_constraint, transition_constraints, increase_bounds, decrease_bounds, mutual_exclusion