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/features.py CHANGED
@@ -5,33 +5,39 @@ Features extend the functionality of Elements.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- import logging
9
8
  from typing import TYPE_CHECKING
10
9
 
11
10
  import linopy
12
11
  import numpy as np
13
12
 
14
13
  from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilities
15
- from .structure import FlowSystemModel, Submodel
14
+ from .structure import FlowSystemModel, Submodel, VariableCategory
16
15
 
17
16
  if TYPE_CHECKING:
18
- from .core import FlowSystemDimensions, Scalar, TemporalData
19
- from .interface import InvestParameters, OnOffParameters, Piecewise
17
+ from collections.abc import Collection
20
18
 
21
- logger = logging.getLogger('flixopt')
19
+ import xarray as xr
20
+
21
+ from .core import FlowSystemDimensions
22
+ from .interface import InvestParameters, Piecewise, StatusParameters
23
+ from .types import Numeric_PS, Numeric_TPS
22
24
 
23
25
 
24
26
  class InvestmentModel(Submodel):
25
- """
26
- This feature model is used to model the investment of a variable.
27
- It applies the corresponding bounds to the variable and the on/off state of the variable.
27
+ """Mathematical model implementation for investment decisions.
28
+
29
+ Creates optimization variables and constraints for investment sizing decisions,
30
+ supporting both binary and continuous sizing with comprehensive effect modeling.
31
+
32
+ Mathematical Formulation:
33
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/features/InvestParameters/>
28
34
 
29
35
  Args:
30
36
  model: The optimization model instance
31
37
  label_of_element: The label of the parent (Element). Used to construct the full label of the model.
32
38
  parameters: The parameters of the feature model.
33
39
  label_of_model: The label of the model. This is needed to construct the full label of the model.
34
-
40
+ size_category: Category for the size variable (FLOW_SIZE, STORAGE_SIZE, or SIZE for generic).
35
41
  """
36
42
 
37
43
  parameters: InvestParameters
@@ -42,9 +48,11 @@ class InvestmentModel(Submodel):
42
48
  label_of_element: str,
43
49
  parameters: InvestParameters,
44
50
  label_of_model: str | None = None,
51
+ size_category: VariableCategory = VariableCategory.SIZE,
45
52
  ):
46
53
  self.piecewise_effects: PiecewiseEffectsModel | None = None
47
54
  self.parameters = parameters
55
+ self._size_category = size_category
48
56
  super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
49
57
 
50
58
  def _do_modeling(self):
@@ -64,6 +72,7 @@ class InvestmentModel(Submodel):
64
72
  lower=size_min if self.parameters.mandatory else 0,
65
73
  upper=size_max,
66
74
  coords=self._model.get_coords(['period', 'scenario']),
75
+ category=self._size_category,
67
76
  )
68
77
 
69
78
  if not self.parameters.mandatory:
@@ -71,11 +80,12 @@ class InvestmentModel(Submodel):
71
80
  binary=True,
72
81
  coords=self._model.get_coords(['period', 'scenario']),
73
82
  short_name='invested',
83
+ category=VariableCategory.INVESTED,
74
84
  )
75
85
  BoundingPatterns.bounds_with_state(
76
86
  self,
77
87
  variable=self.size,
78
- variable_state=self._variables['invested'],
88
+ state=self._variables['invested'],
79
89
  bounds=(self.parameters.minimum_or_fixed_size, self.parameters.maximum_or_fixed_size),
80
90
  )
81
91
 
@@ -144,124 +154,168 @@ class InvestmentModel(Submodel):
144
154
  return self._variables['invested']
145
155
 
146
156
 
147
- class OnOffModel(Submodel):
148
- """OnOff model using factory patterns"""
157
+ class StatusModel(Submodel):
158
+ """Mathematical model implementation for binary status.
159
+
160
+ Creates optimization variables and constraints for binary status modeling,
161
+ state transitions, duration tracking, and operational effects.
162
+
163
+ Mathematical Formulation:
164
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/features/StatusParameters/>
165
+ """
149
166
 
150
167
  def __init__(
151
168
  self,
152
169
  model: FlowSystemModel,
153
170
  label_of_element: str,
154
- parameters: OnOffParameters,
155
- on_variable: linopy.Variable,
156
- previous_states: TemporalData | None,
171
+ parameters: StatusParameters,
172
+ status: linopy.Variable,
173
+ previous_status: xr.DataArray | None,
157
174
  label_of_model: str | None = None,
158
175
  ):
159
176
  """
160
- This feature model is used to model the on/off state of flow_rate(s). It does not matter of the flow_rates are
161
- bounded by a size variable or by a hard bound. THe used bound here is the absolute highest/lowest bound!
177
+ This feature model is used to model the status (active/inactive) state of flow_rate(s).
178
+ It does not matter if the flow_rates are bounded by a size variable or by a hard bound.
179
+ The used bound here is the absolute highest/lowest bound!
162
180
 
163
181
  Args:
164
182
  model: The optimization model instance
165
183
  label_of_element: The label of the parent (Element). Used to construct the full label of the model.
166
184
  parameters: The parameters of the feature model.
167
- on_variable: The variable that determines the on state
168
- previous_states: The previous flow_rates
185
+ status: The variable that determines the active state
186
+ previous_status: The previous flow_rates
169
187
  label_of_model: The label of the model. This is needed to construct the full label of the model.
170
188
  """
171
- self.on = on_variable
172
- self._previous_states = previous_states
189
+ self.status = status
190
+ self._previous_status = previous_status
173
191
  self.parameters = parameters
174
192
  super().__init__(model, label_of_element, label_of_model=label_of_model)
175
193
 
176
194
  def _do_modeling(self):
195
+ """Create variables, constraints, and nested submodels"""
177
196
  super()._do_modeling()
178
197
 
179
- if self.parameters.use_off:
180
- off = self.add_variables(binary=True, short_name='off', coords=self._model.get_coords())
181
- self.add_constraints(self.on + off == 1, short_name='complementary')
198
+ # Create a separate binary 'inactive' variable when needed for downtime tracking or explicit use
199
+ # When not needed, the expression (1 - self.status) can be used instead
200
+ if self.parameters.use_downtime_tracking:
201
+ inactive = self.add_variables(
202
+ binary=True,
203
+ short_name='inactive',
204
+ coords=self._model.get_coords(),
205
+ category=VariableCategory.INACTIVE,
206
+ )
207
+ self.add_constraints(self.status + inactive == 1, short_name='complementary')
182
208
 
183
- # 3. Total duration tracking using existing pattern
209
+ # 3. Total duration tracking
210
+ total_hours = self._model.temporal_weight.sum(self._model.temporal_dims)
184
211
  ModelingPrimitives.expression_tracking_variable(
185
212
  self,
186
- tracked_expression=(self.on * self._model.hours_per_step).sum('time'),
213
+ tracked_expression=self._model.sum_temporal(self.status),
187
214
  bounds=(
188
- self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0,
189
- self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf,
190
- ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration())
191
- short_name='on_hours_total',
215
+ self.parameters.active_hours_min if self.parameters.active_hours_min is not None else 0,
216
+ self.parameters.active_hours_max if self.parameters.active_hours_max is not None else total_hours,
217
+ ),
218
+ short_name='active_hours',
192
219
  coords=['period', 'scenario'],
220
+ category=VariableCategory.TOTAL,
193
221
  )
194
222
 
195
223
  # 4. Switch tracking using existing pattern
196
- if self.parameters.use_switch_on:
197
- self.add_variables(binary=True, short_name='switch|on', coords=self.get_coords())
198
- self.add_variables(binary=True, short_name='switch|off', coords=self.get_coords())
224
+ if self.parameters.use_startup_tracking:
225
+ self.add_variables(
226
+ binary=True,
227
+ short_name='startup',
228
+ coords=self.get_coords(),
229
+ category=VariableCategory.STARTUP,
230
+ )
231
+ self.add_variables(
232
+ binary=True,
233
+ short_name='shutdown',
234
+ coords=self.get_coords(),
235
+ category=VariableCategory.SHUTDOWN,
236
+ )
237
+
238
+ # Determine previous_state: None means relaxed (no constraint at t=0)
239
+ previous_state = self._previous_status.isel(time=-1) if self._previous_status is not None else None
199
240
 
200
241
  BoundingPatterns.state_transition_bounds(
201
242
  self,
202
- state_variable=self.on,
203
- switch_on=self.switch_on,
204
- switch_off=self.switch_off,
243
+ state=self.status,
244
+ activate=self.startup,
245
+ deactivate=self.shutdown,
205
246
  name=f'{self.label_of_model}|switch',
206
- previous_state=self._previous_states.isel(time=-1) if self._previous_states is not None else 0,
247
+ previous_state=previous_state,
207
248
  coord='time',
208
249
  )
209
250
 
210
- if self.parameters.switch_on_total_max is not None:
251
+ if self.parameters.startup_limit is not None:
211
252
  count = self.add_variables(
212
253
  lower=0,
213
- upper=self.parameters.switch_on_total_max,
254
+ upper=self.parameters.startup_limit,
214
255
  coords=self._model.get_coords(('period', 'scenario')),
215
- short_name='switch|count',
256
+ short_name='startup_count',
257
+ category=VariableCategory.STARTUP_COUNT,
216
258
  )
217
- self.add_constraints(count == self.switch_on.sum('time'), short_name='switch|count')
259
+ # Sum over all temporal dimensions (time, and cluster if present)
260
+ startup_temporal_dims = [d for d in self.startup.dims if d not in ('period', 'scenario')]
261
+ self.add_constraints(count == self.startup.sum(startup_temporal_dims), short_name='startup_count')
218
262
 
219
- # 5. Consecutive on duration using existing pattern
220
- if self.parameters.use_consecutive_on_hours:
263
+ # 5. Consecutive active duration (uptime) using existing pattern
264
+ if self.parameters.use_uptime_tracking:
221
265
  ModelingPrimitives.consecutive_duration_tracking(
222
266
  self,
223
- state_variable=self.on,
224
- short_name='consecutive_on_hours',
225
- minimum_duration=self.parameters.consecutive_on_hours_min,
226
- maximum_duration=self.parameters.consecutive_on_hours_max,
227
- duration_per_step=self.hours_per_step,
267
+ state=self.status,
268
+ short_name='uptime',
269
+ minimum_duration=self.parameters.min_uptime,
270
+ maximum_duration=self.parameters.max_uptime,
271
+ duration_per_step=self.timestep_duration,
228
272
  duration_dim='time',
229
- previous_duration=self._get_previous_on_duration(),
273
+ previous_duration=self._get_previous_uptime(),
230
274
  )
231
275
 
232
- # 6. Consecutive off duration using existing pattern
233
- if self.parameters.use_consecutive_off_hours:
276
+ # 6. Consecutive inactive duration (downtime) using existing pattern
277
+ if self.parameters.use_downtime_tracking:
234
278
  ModelingPrimitives.consecutive_duration_tracking(
235
279
  self,
236
- state_variable=self.off,
237
- short_name='consecutive_off_hours',
238
- minimum_duration=self.parameters.consecutive_off_hours_min,
239
- maximum_duration=self.parameters.consecutive_off_hours_max,
240
- duration_per_step=self.hours_per_step,
280
+ state=self.inactive,
281
+ short_name='downtime',
282
+ minimum_duration=self.parameters.min_downtime,
283
+ maximum_duration=self.parameters.max_downtime,
284
+ duration_per_step=self.timestep_duration,
241
285
  duration_dim='time',
242
- previous_duration=self._get_previous_off_duration(),
286
+ previous_duration=self._get_previous_downtime(),
243
287
  )
244
- # TODO:
288
+
289
+ # 7. Cyclic constraint for clustered systems
290
+ self._add_cluster_cyclic_constraint()
245
291
 
246
292
  self._add_effects()
247
293
 
294
+ def _add_cluster_cyclic_constraint(self):
295
+ """For 'cyclic' cluster mode: each cluster's start status equals its end status."""
296
+ if self._model.flow_system.clusters is not None and self.parameters.cluster_mode == 'cyclic':
297
+ self.add_constraints(
298
+ self.status.isel(time=0) == self.status.isel(time=-1),
299
+ short_name='cluster_cyclic',
300
+ )
301
+
248
302
  def _add_effects(self):
249
- """Add operational effects"""
250
- if self.parameters.effects_per_running_hour:
303
+ """Add operational effects (use timestep_duration only, cluster_weight is applied when summing to total)"""
304
+ if self.parameters.effects_per_active_hour:
251
305
  self._model.effects.add_share_to_effects(
252
306
  name=self.label_of_element,
253
307
  expressions={
254
- effect: self.on * factor * self._model.hours_per_step
255
- for effect, factor in self.parameters.effects_per_running_hour.items()
308
+ effect: self.status * factor * self._model.timestep_duration
309
+ for effect, factor in self.parameters.effects_per_active_hour.items()
256
310
  },
257
311
  target='temporal',
258
312
  )
259
313
 
260
- if self.parameters.effects_per_switch_on:
314
+ if self.parameters.effects_per_startup:
261
315
  self._model.effects.add_share_to_effects(
262
316
  name=self.label_of_element,
263
317
  expressions={
264
- effect: self.switch_on * factor for effect, factor in self.parameters.effects_per_switch_on.items()
318
+ effect: self.startup * factor for effect, factor in self.parameters.effects_per_startup.items()
265
319
  },
266
320
  target='temporal',
267
321
  )
@@ -269,55 +323,64 @@ class OnOffModel(Submodel):
269
323
  # Properties access variables from Submodel's tracking system
270
324
 
271
325
  @property
272
- def on_hours_total(self) -> linopy.Variable:
273
- """Total on hours variable"""
274
- return self['on_hours_total']
326
+ def active_hours(self) -> linopy.Variable:
327
+ """Total active hours variable"""
328
+ return self['active_hours']
275
329
 
276
330
  @property
277
- def off(self) -> linopy.Variable | None:
278
- """Binary off state variable"""
279
- return self.get('off')
331
+ def inactive(self) -> linopy.Variable | None:
332
+ """Binary inactive state variable.
333
+
334
+ Note:
335
+ Only created when downtime tracking is enabled (min_downtime or max_downtime set).
336
+ For general use, prefer the expression `1 - status` instead of this variable.
337
+ """
338
+ return self.get('inactive')
280
339
 
281
340
  @property
282
- def switch_on(self) -> linopy.Variable | None:
283
- """Switch on variable"""
284
- return self.get('switch|on')
341
+ def startup(self) -> linopy.Variable | None:
342
+ """Startup variable"""
343
+ return self.get('startup')
285
344
 
286
345
  @property
287
- def switch_off(self) -> linopy.Variable | None:
288
- """Switch off variable"""
289
- return self.get('switch|off')
346
+ def shutdown(self) -> linopy.Variable | None:
347
+ """Shutdown variable"""
348
+ return self.get('shutdown')
290
349
 
291
350
  @property
292
- def switch_on_nr(self) -> linopy.Variable | None:
293
- """Number of switch-ons variable"""
294
- return self.get('switch|count')
351
+ def startup_count(self) -> linopy.Variable | None:
352
+ """Number of startups variable"""
353
+ return self.get('startup_count')
295
354
 
296
355
  @property
297
- def consecutive_on_hours(self) -> linopy.Variable | None:
298
- """Consecutive on hours variable"""
299
- return self.get('consecutive_on_hours')
356
+ def uptime(self) -> linopy.Variable | None:
357
+ """Consecutive active hours (uptime) variable"""
358
+ return self.get('uptime')
300
359
 
301
360
  @property
302
- def consecutive_off_hours(self) -> linopy.Variable | None:
303
- """Consecutive off hours variable"""
304
- return self.get('consecutive_off_hours')
305
-
306
- def _get_previous_on_duration(self):
307
- """Get previous on duration. Previously OFF by default, for one timestep"""
308
- hours_per_step = self._model.hours_per_step.isel(time=0).min().item()
309
- if self._previous_states is None:
310
- return 0
311
- else:
312
- return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states, hours_per_step)
361
+ def downtime(self) -> linopy.Variable | None:
362
+ """Consecutive inactive hours (downtime) variable"""
363
+ return self.get('downtime')
313
364
 
314
- def _get_previous_off_duration(self):
315
- """Get previous off duration. Previously OFF by default, for one timestep"""
316
- hours_per_step = self._model.hours_per_step.isel(time=0).min().item()
317
- if self._previous_states is None:
318
- return hours_per_step
319
- else:
320
- return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_states * -1 + 1, hours_per_step)
365
+ def _get_previous_uptime(self):
366
+ """Get previous uptime (consecutive active hours).
367
+
368
+ Returns None if no previous status is provided (relaxed mode - no constraint at t=0).
369
+ """
370
+ if self._previous_status is None:
371
+ return None # Relaxed mode
372
+ hours_per_step = self._model.timestep_duration.isel(time=0).min().item()
373
+ return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_status, hours_per_step)
374
+
375
+ def _get_previous_downtime(self):
376
+ """Get previous downtime (consecutive inactive hours).
377
+
378
+ Returns None if no previous status is provided (relaxed mode - no constraint at t=0).
379
+ """
380
+ if self._previous_status is None:
381
+ return None # Relaxed mode
382
+ hours_per_step = self._model.timestep_duration.isel(time=0).min().item()
383
+ return ModelingUtilities.compute_consecutive_hours_in_state(1 - self._previous_status, hours_per_step)
321
384
 
322
385
 
323
386
  class PieceModel(Submodel):
@@ -328,7 +391,7 @@ class PieceModel(Submodel):
328
391
  model: FlowSystemModel,
329
392
  label_of_element: str,
330
393
  label_of_model: str,
331
- dims: FlowSystemDimensions | None,
394
+ dims: Collection[FlowSystemDimensions] | None,
332
395
  ):
333
396
  self.inside_piece: linopy.Variable | None = None
334
397
  self.lambda0: linopy.Variable | None = None
@@ -338,17 +401,22 @@ class PieceModel(Submodel):
338
401
  super().__init__(model, label_of_element, label_of_model)
339
402
 
340
403
  def _do_modeling(self):
404
+ """Create variables, constraints, and nested submodels"""
341
405
  super()._do_modeling()
406
+
407
+ # Create variables
342
408
  self.inside_piece = self.add_variables(
343
409
  binary=True,
344
410
  short_name='inside_piece',
345
411
  coords=self._model.get_coords(dims=self.dims),
412
+ category=VariableCategory.INSIDE_PIECE,
346
413
  )
347
414
  self.lambda0 = self.add_variables(
348
415
  lower=0,
349
416
  upper=1,
350
417
  short_name='lambda0',
351
418
  coords=self._model.get_coords(dims=self.dims),
419
+ category=VariableCategory.LAMBDA0,
352
420
  )
353
421
 
354
422
  self.lambda1 = self.add_variables(
@@ -356,13 +424,24 @@ class PieceModel(Submodel):
356
424
  upper=1,
357
425
  short_name='lambda1',
358
426
  coords=self._model.get_coords(dims=self.dims),
427
+ category=VariableCategory.LAMBDA1,
359
428
  )
360
429
 
430
+ # Create constraints
361
431
  # eq: lambda0(t) + lambda1(t) = inside_piece(t)
362
432
  self.add_constraints(self.inside_piece == self.lambda0 + self.lambda1, short_name='inside_piece')
363
433
 
364
434
 
365
435
  class PiecewiseModel(Submodel):
436
+ """Mathematical model implementation for piecewise linear approximations.
437
+
438
+ Creates optimization variables and constraints for piecewise linear relationships,
439
+ including lambda variables, piece activation binaries, and coupling constraints.
440
+
441
+ Mathematical Formulation:
442
+ See <https://flixopt.github.io/flixopt/latest/user-guide/mathematical-notation/features/Piecewise/>
443
+ """
444
+
366
445
  def __init__(
367
446
  self,
368
447
  model: FlowSystemModel,
@@ -370,7 +449,7 @@ class PiecewiseModel(Submodel):
370
449
  label_of_model: str,
371
450
  piecewise_variables: dict[str, Piecewise],
372
451
  zero_point: bool | linopy.Variable | None,
373
- dims: FlowSystemDimensions | None,
452
+ dims: Collection[FlowSystemDimensions] | None,
374
453
  ):
375
454
  """
376
455
  Modeling a Piecewise relation between miultiple variables.
@@ -394,12 +473,15 @@ class PiecewiseModel(Submodel):
394
473
  super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
395
474
 
396
475
  def _do_modeling(self):
476
+ """Create variables, constraints, and nested submodels"""
397
477
  super()._do_modeling()
478
+
398
479
  # Validate all piecewise variables have the same number of segments
399
480
  segment_counts = [len(pw) for pw in self._piecewise_variables.values()]
400
481
  if not all(count == segment_counts[0] for count in segment_counts):
401
482
  raise ValueError(f'All piecewises must have the same number of pieces, got {segment_counts}')
402
483
 
484
+ # Create PieceModel submodels (which creates their variables and constraints)
403
485
  for i in range(len(list(self._piecewise_variables.values())[0])):
404
486
  new_piece = self.add_submodels(
405
487
  PieceModel(
@@ -438,11 +520,16 @@ class PiecewiseModel(Submodel):
438
520
  coords=self._model.get_coords(self.dims),
439
521
  binary=True,
440
522
  short_name='zero_point',
523
+ category=VariableCategory.ZERO_POINT,
441
524
  )
442
525
  rhs = self.zero_point
443
526
  else:
444
527
  rhs = 1
445
528
 
529
+ # This constraint ensures at most one segment is active at a time.
530
+ # When zero_point is a binary variable, it acts as a gate:
531
+ # - zero_point=1: at most one segment can be active (normal piecewise operation)
532
+ # - zero_point=0: all segments must be inactive (effectively disables the piecewise)
446
533
  self.add_constraints(
447
534
  sum([piece.inside_piece for piece in self.pieces]) <= rhs,
448
535
  name=f'{self.label_full}|{variable.name}|single_segment',
@@ -477,6 +564,10 @@ class PiecewiseEffectsModel(Submodel):
477
564
  super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
478
565
 
479
566
  def _do_modeling(self):
567
+ """Create variables, constraints, and nested submodels"""
568
+ super()._do_modeling()
569
+
570
+ # Create variables
480
571
  self.shares = {
481
572
  effect: self.add_variables(coords=self._model.get_coords(['period', 'scenario']), short_name=effect)
482
573
  for effect in self._piecewise_shares
@@ -490,6 +581,7 @@ class PiecewiseEffectsModel(Submodel):
490
581
  },
491
582
  }
492
583
 
584
+ # Create piecewise model (which creates its variables and constraints)
493
585
  self.piecewise_model = self.add_submodels(
494
586
  PiecewiseModel(
495
587
  model=self._model,
@@ -502,7 +594,7 @@ class PiecewiseEffectsModel(Submodel):
502
594
  short_name='PiecewiseEffects',
503
595
  )
504
596
 
505
- # Shares
597
+ # Add shares to effects
506
598
  self._model.effects.add_share_to_effects(
507
599
  name=self.label_of_element,
508
600
  expressions={effect: variable * 1 for effect, variable in self.shares.items()},
@@ -517,13 +609,13 @@ class ShareAllocationModel(Submodel):
517
609
  dims: list[FlowSystemDimensions],
518
610
  label_of_element: str | None = None,
519
611
  label_of_model: str | None = None,
520
- total_max: Scalar | None = None,
521
- total_min: Scalar | None = None,
522
- max_per_hour: TemporalData | None = None,
523
- min_per_hour: TemporalData | None = None,
612
+ total_max: Numeric_PS | None = None,
613
+ total_min: Numeric_PS | None = None,
614
+ max_per_hour: Numeric_TPS | None = None,
615
+ min_per_hour: Numeric_TPS | None = None,
524
616
  ):
525
617
  if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None):
526
- raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False')
618
+ raise ValueError("max_per_hour and min_per_hour require 'time' dimension in dims")
527
619
 
528
620
  self._dims = dims
529
621
  self.total_per_timestep: linopy.Variable | None = None
@@ -535,37 +627,44 @@ class ShareAllocationModel(Submodel):
535
627
  self._eq_total: linopy.Constraint | None = None
536
628
 
537
629
  # Parameters
538
- self._total_max = total_max if total_max is not None else np.inf
539
- self._total_min = total_min if total_min is not None else -np.inf
540
- self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf
541
- self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf
630
+ self._total_max = total_max
631
+ self._total_min = total_min
632
+ self._max_per_hour = max_per_hour
633
+ self._min_per_hour = min_per_hour
542
634
 
543
635
  super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model)
544
636
 
545
637
  def _do_modeling(self):
638
+ """Create variables, constraints, and nested submodels"""
546
639
  super()._do_modeling()
640
+
641
+ # Create variables
547
642
  self.total = self.add_variables(
548
- lower=self._total_min,
549
- upper=self._total_max,
643
+ lower=self._total_min if self._total_min is not None else -np.inf,
644
+ upper=self._total_max if self._total_max is not None else np.inf,
550
645
  coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']),
551
646
  name=self.label_full,
552
647
  short_name='total',
648
+ category=VariableCategory.TOTAL,
553
649
  )
554
650
  # eq: sum = sum(share_i) # skalar
555
651
  self._eq_total = self.add_constraints(self.total == 0, name=self.label_full)
556
652
 
557
653
  if 'time' in self._dims:
558
654
  self.total_per_timestep = self.add_variables(
559
- lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.hours_per_step,
560
- upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.hours_per_step,
655
+ lower=-np.inf if (self._min_per_hour is None) else self._min_per_hour * self._model.timestep_duration,
656
+ upper=np.inf if (self._max_per_hour is None) else self._max_per_hour * self._model.timestep_duration,
561
657
  coords=self._model.get_coords(self._dims),
562
658
  short_name='per_timestep',
659
+ category=VariableCategory.PER_TIMESTEP,
563
660
  )
564
661
 
565
662
  self._eq_total_per_timestep = self.add_constraints(self.total_per_timestep == 0, short_name='per_timestep')
566
663
 
567
- # Add it to the total
568
- self._eq_total.lhs -= self.total_per_timestep.sum(dim='time')
664
+ # Add it to the total (cluster_weight handles cluster representation, defaults to 1.0)
665
+ # Sum over all temporal dimensions (time, and cluster if present)
666
+ weighted_per_timestep = self.total_per_timestep * self._model.weights.get('cluster', 1.0)
667
+ self._eq_total.lhs -= weighted_per_timestep.sum(dim=self._model.temporal_dims)
569
668
 
570
669
  def add_share(
571
670
  self,
@@ -597,10 +696,13 @@ class ShareAllocationModel(Submodel):
597
696
  if name in self.shares:
598
697
  self.share_constraints[name].lhs -= expression
599
698
  else:
699
+ # Temporal shares (with 'time' dim) are segment totals that need division
700
+ category = VariableCategory.SHARE if 'time' in dims else None
600
701
  self.shares[name] = self.add_variables(
601
702
  coords=self._model.get_coords(dims),
602
703
  name=f'{name}->{self.label_full}',
603
704
  short_name=name,
705
+ category=category,
604
706
  )
605
707
 
606
708
  self.share_constraints[name] = self.add_constraints(