diff --git a/flixopt/components.py b/flixopt/components.py index 06313d7f6..00ba52fc3 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -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', ) @@ -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', ) @@ -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 ) diff --git a/flixopt/features.py b/flixopt/features.py index e85636435..4f0ca92e2 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -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', ) @@ -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', ) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index ff84c808f..1c9cc8699 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -403,19 +403,26 @@ 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', ) # 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', ) @@ -423,17 +430,18 @@ def consecutive_duration_tracking( # 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', ) @@ -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} @@ -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', ) # 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: @@ -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 @@ -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', )