flixopt 3.0.1__py3-none-any.whl → 6.0.0rc7__py3-none-any.whl

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