Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/notebooks/08c2-clustering-storage-modes.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
"\n",
" # Create a copy and set the storage mode\n",
" fs_copy = flow_system.copy()\n",
" fs_copy.components['SeasonalStorage'].cluster_storage_mode = mode\n",
" fs_copy.storages['SeasonalStorage'].cluster_mode = mode\n",
"\n",
" start = timeit.default_timer()\n",
" fs_clustered = fs_copy.transform.cluster(\n",
Expand Down
34 changes: 23 additions & 11 deletions flixopt/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,16 @@ def _do_modeling(self):
self.add_variables(binary=True, short_name='startup', coords=self.get_coords())
self.add_variables(binary=True, short_name='shutdown', coords=self.get_coords())

# Determine previous_state: None means relaxed (no constraint at t=0)
previous_state = self._previous_status.isel(time=-1) if self._previous_status is not None else None

BoundingPatterns.state_transition_bounds(
self,
state=self.status,
activate=self.startup,
deactivate=self.shutdown,
name=f'{self.label_of_model}|switch',
previous_state=self._previous_status.isel(time=-1) if self._previous_status is not None else 0,
previous_state=previous_state,
coord='time',
)

Expand Down Expand Up @@ -269,8 +272,19 @@ def _do_modeling(self):
previous_duration=self._get_previous_downtime(),
)

# 7. Cyclic constraint for clustered systems
self._add_cluster_cyclic_constraint()

self._add_effects()

def _add_cluster_cyclic_constraint(self):
"""For 'cyclic' cluster mode: each cluster's start status equals its end status."""
if self._model.flow_system.clusters is not None and self.parameters.cluster_mode == 'cyclic':
self.add_constraints(
self.status.isel(time=0) == self.status.isel(time=-1),
short_name='cluster_cyclic',
)

def _add_effects(self):
"""Add operational effects (use timestep_duration only, cluster_weight is applied when summing to total)"""
if self.parameters.effects_per_active_hour:
Expand Down Expand Up @@ -337,24 +351,22 @@ def downtime(self) -> linopy.Variable | None:
def _get_previous_uptime(self):
"""Get previous uptime (consecutive active hours).

Returns 0 if no previous status is provided (assumes previously inactive).
Returns None if no previous status is provided (relaxed mode - no constraint at t=0).
"""
hours_per_step = self._model.timestep_duration.isel(time=0).min().item()
if self._previous_status is None:
return 0
else:
return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_status, hours_per_step)
return None # Relaxed mode
hours_per_step = self._model.timestep_duration.isel(time=0).min().item()
return ModelingUtilities.compute_consecutive_hours_in_state(self._previous_status, hours_per_step)

def _get_previous_downtime(self):
"""Get previous downtime (consecutive inactive hours).

Returns one timestep duration if no previous status is provided (assumes previously inactive).
Returns None if no previous status is provided (relaxed mode - no constraint at t=0).
"""
hours_per_step = self._model.timestep_duration.isel(time=0).min().item()
if self._previous_status is None:
return hours_per_step
else:
return ModelingUtilities.compute_consecutive_hours_in_state(1 - self._previous_status, hours_per_step)
return None # Relaxed mode
hours_per_step = self._model.timestep_duration.isel(time=0).min().item()
return ModelingUtilities.compute_consecutive_hours_in_state(1 - self._previous_status, hours_per_step)


class PieceModel(Submodel):
Expand Down
12 changes: 11 additions & 1 deletion flixopt/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -1337,6 +1337,14 @@ class StatusParameters(Interface):
force_startup_tracking: When True, creates startup variables even without explicit
startup_limit constraint. Useful for tracking or reporting startup
events without enforcing limits.
cluster_mode: How inter-timestep constraints are handled at cluster boundaries.
Only relevant when using ``transform.cluster()``. Options:

- ``'relaxed'``: No constraint at cluster boundaries. Startups at the first
timestep of each cluster are not forced - the optimizer is free to choose.
This prevents clustering from inducing "phantom" startups. (default)
- ``'cyclic'``: Each cluster's final status equals its initial status.
Ensures consistent behavior within each representative period.

Note:
**Time Series Boundary Handling**: The final time period constraints for
Expand Down Expand Up @@ -1472,6 +1480,7 @@ def __init__(
max_downtime: Numeric_TPS | None = None,
startup_limit: Numeric_PS | None = None,
force_startup_tracking: bool = False,
cluster_mode: Literal['relaxed', 'cyclic'] = 'relaxed',
):
self.effects_per_startup = effects_per_startup if effects_per_startup is not None else {}
self.effects_per_active_hour = effects_per_active_hour if effects_per_active_hour is not None else {}
Expand All @@ -1483,6 +1492,7 @@ def __init__(
self.max_downtime = max_downtime
self.startup_limit = startup_limit
self.force_startup_tracking: bool = force_startup_tracking
self.cluster_mode = cluster_mode

def transform_data(self) -> None:
self.effects_per_startup = self._fit_effect_coords(
Expand Down
73 changes: 42 additions & 31 deletions flixopt/modeling.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def consecutive_duration_tracking(
maximum_duration: xr.DataArray | None = None,
duration_dim: str = 'time',
duration_per_step: int | float | xr.DataArray = None,
previous_duration: xr.DataArray = 0,
previous_duration: xr.DataArray | None = None,
) -> tuple[dict[str, linopy.Variable], dict[str, linopy.Constraint]]:
"""Creates consecutive duration tracking for a binary state variable.

Expand All @@ -262,7 +262,7 @@ def consecutive_duration_tracking(
duration[t] ≤ state[t] · M ∀t
duration[t+1] ≤ duration[t] + duration_per_step[t] ∀t
duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) · M ∀t
duration[0] = (duration_per_step[0] + previous_duration) · state[0]
duration[0] = (duration_per_step[0] + previous_duration) · state[0] (if previous_duration is not None)

If minimum_duration provided:
duration[t] ≥ (state[t-1] - state[t]) · minimum_duration[t-1] ∀t > 0
Expand All @@ -278,16 +278,19 @@ def consecutive_duration_tracking(
maximum_duration: Optional maximum consecutive duration (upper bound on duration variable)
duration_dim: Dimension name to track duration along (default 'time')
duration_per_step: Time increment per step in duration_dim
previous_duration: Initial duration value before first timestep (default 0)
previous_duration: Initial duration value before first timestep. If None (default),
the initial constraint is skipped ("relaxed" mode) - duration at t=0 is unconstrained.

Returns:
Tuple of (duration_variable, constraints_dict)
where constraints_dict contains: 'ub', 'forward', 'backward', 'initial', and optionally 'lb', 'initial_lb'
where constraints_dict contains: 'ub', 'forward', 'backward', and optionally 'initial', 'lb', 'initial_lb'
"""
if not isinstance(model, Submodel):
raise ValueError('ModelingPrimitives.consecutive_duration_tracking() can only be used with a Submodel')

mega = duration_per_step.sum(duration_dim) + previous_duration # Big-M value
# Big-M value (use 0 for previous_duration if None/relaxed mode)
prev_for_mega = previous_duration if previous_duration is not None else 0
mega = duration_per_step.sum(duration_dim) + prev_for_mega

# Duration variable
duration = model.add_variables(
Expand Down Expand Up @@ -319,12 +322,14 @@ def consecutive_duration_tracking(
name=f'{duration.name}|backward',
)

# Initial condition: duration[0] = (duration_per_step[0] + previous_duration) * state[0]
constraints['initial'] = model.add_constraints(
duration.isel({duration_dim: 0})
== (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state.isel({duration_dim: 0}),
name=f'{duration.name}|initial',
)
# Initial condition (skip if previous_duration is None = relaxed mode)
if previous_duration is not None:
# duration[0] = (duration_per_step[0] + previous_duration) * state[0]
constraints['initial'] = model.add_constraints(
duration.isel({duration_dim: 0})
== (duration_per_step.isel({duration_dim: 0}) + previous_duration) * state.isel({duration_dim: 0}),
name=f'{duration.name}|initial',
)

# Minimum duration constraint if provided
if minimum_duration is not None:
Expand All @@ -335,17 +340,18 @@ def consecutive_duration_tracking(
name=f'{duration.name}|lb',
)

# Handle initial condition for minimum duration
prev = (
float(previous_duration)
if not isinstance(previous_duration, xr.DataArray)
else float(previous_duration.max().item())
)
min0 = float(minimum_duration.isel({duration_dim: 0}).max().item())
if prev > 0 and prev < min0:
constraints['initial_lb'] = model.add_constraints(
state.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb'
# Handle initial condition for minimum duration (only if not relaxed mode)
if previous_duration is not None:
prev = (
float(previous_duration)
if not isinstance(previous_duration, xr.DataArray)
else float(previous_duration.max().item())
)
min0 = float(minimum_duration.isel({duration_dim: 0}).max().item())
if prev > 0 and prev < min0:
constraints['initial_lb'] = model.add_constraints(
state.isel({duration_dim: 0}) == 1, name=f'{duration.name}|initial_lb'
)

variables = {'duration': duration}

Expand Down Expand Up @@ -578,17 +584,17 @@ def state_transition_bounds(
activate: linopy.Variable,
deactivate: linopy.Variable,
name: str,
previous_state: float | xr.DataArray = 0,
previous_state: float | xr.DataArray | None = None,
coord: str = 'time',
) -> tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]:
) -> tuple[linopy.Constraint, linopy.Constraint | None, linopy.Constraint]:
"""Creates state transition constraints for binary state variables.

Tracks transitions between active (1) and inactive (0) states using
separate binary variables for activation and deactivation events.

Mathematical formulation:
activate[t] - deactivate[t] = state[t] - state[t-1] ∀t > 0
activate[0] - deactivate[0] = state[0] - previous_state
activate[0] - deactivate[0] = state[0] - previous_state (if previous_state is not None)
activate[t] + deactivate[t] ≤ 1 ∀t
activate[t], deactivate[t] ∈ {0, 1}

Expand All @@ -598,11 +604,13 @@ def state_transition_bounds(
activate: Binary variable for transitions from inactive to active (0→1)
deactivate: Binary variable for transitions from active to inactive (1→0)
name: Base name for constraints
previous_state: State value before first timestep (default 0)
previous_state: State value before first timestep. If None (default), the initial
constraint is skipped ("relaxed" mode) - startup/shutdown at t=0 is not forced.
coord: Time dimension name (default 'time')

Returns:
Tuple of (transition_constraint, initial_constraint, mutex_constraint)
Tuple of (transition_constraint, initial_constraint, mutex_constraint).
initial_constraint is None if previous_state is None (relaxed mode).
"""
if not isinstance(model, Submodel):
raise ValueError('BoundingPatterns.state_transition_bounds() can only be used with a Submodel')
Expand All @@ -614,11 +622,14 @@ def state_transition_bounds(
name=f'{name}|transition',
)

# Initial state transition for t = 0
initial = model.add_constraints(
activate.isel({coord: 0}) - deactivate.isel({coord: 0}) == state.isel({coord: 0}) - previous_state,
name=f'{name}|initial',
)
# Initial state transition for t = 0 (skip if previous_state is None = relaxed mode)
if previous_state is not None:
initial = model.add_constraints(
activate.isel({coord: 0}) - deactivate.isel({coord: 0}) == state.isel({coord: 0}) - previous_state,
name=f'{name}|initial',
)
else:
initial = None # Relaxed mode: no constraint at t=0, startup/shutdown is "free"

# At most one transition per timestep (mutual exclusivity)
mutex = model.add_constraints(activate + deactivate <= 1, name=f'{name}|mutex')
Expand Down
Loading