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
18 changes: 11 additions & 7 deletions flixopt/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,7 @@ def _add_cluster_cyclic_constraint(self):
"""For 'cyclic' cluster mode: each cluster's start equals its end."""
if self._model.flow_system.clusters is not None and self.element.cluster_mode == 'cyclic':
self.add_constraints(
self.charge_state.isel(time=0) == self.charge_state.isel(time=-2),
self.charge_state.isel(time=0, drop=True) == self.charge_state.isel(time=-2, drop=True),
short_name='cluster_cyclic',
)

Expand Down Expand Up @@ -1018,24 +1018,24 @@ def _add_initial_final_constraints(self):
if self.element.initial_charge_state is not None:
if isinstance(self.element.initial_charge_state, str):
self.add_constraints(
self.charge_state.isel(time=0) == self.charge_state.isel(time=-1),
self.charge_state.isel(time=0, drop=True) == self.charge_state.isel(time=-1, drop=True),
short_name='initial_charge_state',
)
else:
self.add_constraints(
self.charge_state.isel(time=0) == self.element.initial_charge_state,
self.charge_state.isel(time=0, drop=True) == self.element.initial_charge_state,
short_name='initial_charge_state',
)

if self.element.maximal_final_charge_state is not None:
self.add_constraints(
self.charge_state.isel(time=-1) <= self.element.maximal_final_charge_state,
self.charge_state.isel(time=-1, drop=True) <= self.element.maximal_final_charge_state,
short_name='final_charge_max',
)

if self.element.minimal_final_charge_state is not None:
self.add_constraints(
self.charge_state.isel(time=-1) >= self.element.minimal_final_charge_state,
self.charge_state.isel(time=-1, drop=True) >= self.element.minimal_final_charge_state,
short_name='final_charge_min',
)

Expand Down Expand Up @@ -1072,9 +1072,13 @@ def _build_energy_balance_lhs(self):
eff_charge = self.element.eta_charge
eff_discharge = self.element.eta_discharge

# charge_state lives on timesteps_extra; index the balance on the regular timesteps
# (the start of each interval) so it aligns with charge_rate / discharge_rate.
charge_state_t = charge_state.isel(time=slice(None, -1))
charge_state_tp1 = charge_state.isel(time=slice(1, None)).assign_coords(time=charge_state_t.coords['time'])
return (
charge_state.isel(time=slice(1, None))
- charge_state.isel(time=slice(None, -1)) * ((1 - rel_loss) ** timestep_duration)
charge_state_tp1
- charge_state_t * ((1 - rel_loss) ** timestep_duration)
- charge_rate * eff_charge * timestep_duration
+ discharge_rate * timestep_duration / eff_discharge
)
Expand Down
6 changes: 4 additions & 2 deletions flixopt/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ def _create_variables_and_constraints(self):

if self.parameters.linked_periods is not None:
masked_size = self.size.where(self.parameters.linked_periods, drop=True)
lead_period = masked_size.coords['period'].isel(period=slice(1, None))
self.add_constraints(
masked_size.isel(period=slice(None, -1)) == masked_size.isel(period=slice(1, None)),
masked_size.isel(period=slice(None, -1)).assign_coords(period=lead_period)
== masked_size.isel(period=slice(1, None)),
short_name='linked_periods',
)

Expand Down Expand Up @@ -295,7 +297,7 @@ 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),
self.status.isel(time=0, drop=True) == self.status.isel(time=-1, drop=True),
short_name='cluster_cyclic',
)

Expand Down
98 changes: 54 additions & 44 deletions flixopt/modeling.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,37 +403,45 @@ def consecutive_duration_tracking(
# Upper bound: duration[t] ≤ state[t] * M
constraints['ub'] = model.add_constraints(duration <= state * mega, name=f'{duration.name}|ub')

# Adjacent-step constraints are indexed by lag (start-of-interval) labels; lead operands
# are relabeled onto the lag axis so positional alignment is explicit (linopy v1 requires
# matching labels on shared dims). The lag convention also preserves the lb semantics —
# duration[t] there must reference the on-state moment, not the off-transition that follows.
lag = {duration_dim: slice(None, -1)}
lead = {duration_dim: slice(1, None)}
lag_coord = {duration_dim: duration.coords[duration_dim].isel(lag)}

# Forward constraint: duration[t+1] ≤ duration[t] + duration_per_step[t]
constraints['forward'] = model.add_constraints(
duration.isel({duration_dim: slice(1, None)})
<= duration.isel({duration_dim: slice(None, -1)}) + duration_per_step.isel({duration_dim: slice(None, -1)}),
duration.isel(lead).assign_coords(lag_coord) <= duration.isel(lag) + duration_per_step.isel(lag),
name=f'{duration.name}|forward',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

# Backward constraint: duration[t+1] ≥ duration[t] + duration_per_step[t] + (state[t+1] - 1) * M
constraints['backward'] = model.add_constraints(
duration.isel({duration_dim: slice(1, None)})
>= duration.isel({duration_dim: slice(None, -1)})
+ duration_per_step.isel({duration_dim: slice(None, -1)})
+ (state.isel({duration_dim: slice(1, None)}) - 1) * mega,
duration.isel(lead).assign_coords(lag_coord)
>= duration.isel(lag)
+ duration_per_step.isel(lag)
+ (state.isel(lead).assign_coords(lag_coord) - 1) * mega,
name=f'{duration.name}|backward',
)

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

# Minimum duration constraint if provided
if minimum_duration is not None:
constraints['lb'] = model.add_constraints(
duration
>= (state.isel({duration_dim: slice(None, -1)}) - state.isel({duration_dim: slice(1, None)}))
* _scalar_safe_isel(minimum_duration, {duration_dim: slice(None, -1)}),
duration.isel(lag)
>= (state.isel(lag) - state.isel(lead).assign_coords(lag_coord))
* _scalar_safe_isel(minimum_duration, lag),
name=f'{duration.name}|lb',
)

Expand All @@ -447,7 +455,7 @@ def consecutive_duration_tracking(
min0 = float(_scalar_safe_isel(minimum_duration, {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'
state.isel({duration_dim: 0}, drop=True) == 1, name=f'{duration.name}|initial_lb'
)

variables = {'duration': duration}
Expand Down Expand Up @@ -712,17 +720,21 @@ def state_transition_bounds(
if not isinstance(model, Submodel):
raise ValueError('BoundingPatterns.state_transition_bounds() can only be used with a Submodel')

# State transition constraints for t > 0
# State transition constraints for t > 0; relabel the lag slice onto the lead axis so
# positional alignment is explicit (linopy v1 requires matching labels on shared dims).
lead = {coord: slice(1, None)}
lag = {coord: slice(None, -1)}
lead_coord = {coord: state.coords[coord].isel(lead)}
transition = model.add_constraints(
activate.isel({coord: slice(1, None)}) - deactivate.isel({coord: slice(1, None)})
== state.isel({coord: slice(1, None)}) - state.isel({coord: slice(None, -1)}),
activate.isel(lead) - deactivate.isel(lead) == state.isel(lead) - state.isel(lag).assign_coords(lead_coord),
name=f'{name}|transition',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

# Initial state transition for t = 0 (skipped if previous_state is None for unconstrained)
if previous_state is not None:
initial = model.add_constraints(
activate.isel({coord: 0}) - deactivate.isel({coord: 0}) == state.isel({coord: 0}) - previous_state,
activate.isel({coord: 0}, drop=True) - deactivate.isel({coord: 0}, drop=True)
== state.isel({coord: 0}, drop=True) - previous_state,
name=f'{name}|initial',
)
else:
Expand Down Expand Up @@ -774,31 +786,24 @@ def continuous_transition_bounds(
if not isinstance(model, Submodel):
raise ValueError('ModelingPrimitives.continuous_transition_bounds() can only be used with a Submodel')

# Transition constraints for t > 0: continuous variable can only change when transitions occur
transition_upper = model.add_constraints(
continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)})
<= max_change * (activate.isel({coord: slice(1, None)}) + deactivate.isel({coord: slice(1, None)})),
name=f'{name}|transition_ub',
)
# Transition constraints for t > 0: continuous variable can only change when transitions occur.
# Lag slices are relabeled onto the lead axis so positional alignment is explicit
# (linopy v1 requires matching labels on shared dims).
lead = {coord: slice(1, None)}
lag = {coord: slice(None, -1)}
lead_coord = {coord: continuous_variable.coords[coord].isel(lead)}
change = continuous_variable.isel(lead) - continuous_variable.isel(lag).assign_coords(lead_coord)
transition_bound = max_change * (activate.isel(lead) + deactivate.isel(lead))

transition_lower = model.add_constraints(
-(continuous_variable.isel({coord: slice(1, None)}) - continuous_variable.isel({coord: slice(None, -1)}))
<= max_change * (activate.isel({coord: slice(1, None)}) + deactivate.isel({coord: slice(1, None)})),
name=f'{name}|transition_lb',
)
transition_upper = model.add_constraints(change <= transition_bound, name=f'{name}|transition_ub')
transition_lower = model.add_constraints(-change <= transition_bound, name=f'{name}|transition_lb')

# Initial constraints for t = 0
initial_upper = model.add_constraints(
continuous_variable.isel({coord: 0}) - previous_value
<= max_change * (activate.isel({coord: 0}) + deactivate.isel({coord: 0})),
name=f'{name}|initial_ub',
)
initial_bound = max_change * (activate.isel({coord: 0}, drop=True) + deactivate.isel({coord: 0}, drop=True))
initial_change = continuous_variable.isel({coord: 0}, drop=True) - previous_value

initial_lower = model.add_constraints(
-continuous_variable.isel({coord: 0}) + previous_value
<= max_change * (activate.isel({coord: 0}) + deactivate.isel({coord: 0})),
name=f'{name}|initial_lb',
)
initial_upper = model.add_constraints(initial_change <= initial_bound, name=f'{name}|initial_ub')
initial_lower = model.add_constraints(-initial_change <= initial_bound, name=f'{name}|initial_lb')

return transition_upper, transition_lower, initial_upper, initial_lower

Expand Down Expand Up @@ -845,17 +850,22 @@ def link_changes_to_level_with_binaries(

# 1. Initial period: level[0] - initial_level = increase[0] - decrease[0]
initial_constraint = model.add_constraints(
level_variable.isel({coord: 0}) - initial_level
== increase_variable.isel({coord: 0}) - decrease_variable.isel({coord: 0}),
level_variable.isel({coord: 0}, drop=True) - initial_level
== increase_variable.isel({coord: 0}, drop=True) - decrease_variable.isel({coord: 0}, drop=True),
name=f'{name}|initial_level',
)

# 2. Transition periods: level[t] = level[t-1] + increase[t] - decrease[t] for t > 0
# 2. Transition periods: level[t] = level[t-1] + increase[t] - decrease[t] for t > 0.
# Lag slice of level_variable is relabeled onto the lead axis so positional alignment is
# explicit (linopy v1 requires matching labels on shared dims).
lead = {coord: slice(1, None)}
lag = {coord: slice(None, -1)}
lead_coord = {coord: level_variable.coords[coord].isel(lead)}
transition_constraints = model.add_constraints(
level_variable.isel({coord: slice(1, None)})
== level_variable.isel({coord: slice(None, -1)})
+ increase_variable.isel({coord: slice(1, None)})
- decrease_variable.isel({coord: slice(1, None)}),
level_variable.isel(lead)
== level_variable.isel(lag).assign_coords(lead_coord)
+ increase_variable.isel(lead)
- decrease_variable.isel(lead),
name=f'{name}|transitions',
)

Expand Down
Loading