Source code for temoa.components.storage

# temoa/components/storage.py
"""
Defines the energy storage-related components of the Temoa model.

This module is responsible for modeling the behavior of storage technologies,
including:
-  Defining the state variables for storage levels (both daily and seasonal).
-  Enforcing the conservation of energy from one time slice to the next.
-  Constraining the storage level to be within the device's energy capacity.
-  Constraining the charge, discharge, and throughput rates to be within the
    device's power capacity.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from pyomo.environ import Constraint, value

from ..types import ExprLike, Period, Region, Season, Technology, TimeOfDay, Vintage
from .utils import Operator, get_variable_efficiency, operator_expression

if TYPE_CHECKING:
    from temoa.core.model import TemoaModel


# ============================================================================
# PYOMO INDEX SET FUNCTIONS
# ============================================================================


def storage_level_variable_indices(
    model: TemoaModel,
) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]] | None:
    return model.storage_level_indices_rpsdtv


def seasonal_storage_level_variable_indices(
    model: TemoaModel,
) -> set[tuple[Region, Period, Season, Technology, Vintage]] | None:
    return model.seasonal_storage_level_indices_rpstv


def seasonal_storage_constraint_indices(
    model: TemoaModel,
) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]:
    if model.seasonal_storage_level_indices_rpstv:
        indices = {
            (r, p, s, d, t, v)
            for r, p, s, t, v in model.seasonal_storage_level_indices_rpstv
            for d in model.time_of_day
        }
        return indices
    return set()


def storage_constraint_indices(
    model: TemoaModel,
) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]] | None:
    return model.storage_level_indices_rpsdtv


# ============================================================================
# PYOMO CONSTRAINT RULES
# ============================================================================

# --- Core Energy Balance Constraints ---


[docs] def storage_energy_constraint( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay, t: Technology, v: Vintage ) -> ExprLike: r""" This constraint enforces the continuity of storage level between time slices. storage level in the next time slice (:math:`s_{next}, d_{next}`) is equal to current storage level plus net charge in the current time slice. .. math:: :label: Storage Energy {SL}_{r,p,s,d,t,v} + \sum\limits_{I,O} \mathbf{FIS}_{r,p,s,d,i,t,v,o} \cdot {EFF}_{r,i,t,v,o} - \sum\limits_{I,O} \mathbf{FO}_{r,p,s,d,i,t,v,o} = {SL}_{r,p,s_{{next}},d_{{next}},t,v} Note that for all seasonal representations except consecutive_days, the last time slice of each season will loop back to the first time slice of the same season, preventing seasonal deltas for non-seasonal storage (see SeasonalStorageEnergyUpperBound). """ # We allow a non-zero daily delta only in the case of seasonal storage if model.is_seasonal_storage[t] and d == model.time_of_day.last(): return Constraint.Skip # handled by SeasonalStorageEnergy_constraint # This is the sum of all input=i sent TO storage tech t of vintage v with # output=o in p,s,d charge = sum( model.v_flow_in[r, p, s, d, S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, d, S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) # This is the sum of all output=o withdrawn FROM storage tech t of vintage v # with input=i in p,s,d discharge = sum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] ) stored_energy = charge - discharge s_next: Season d_next: TimeOfDay s_next, d_next = model.time_next[p, s, d] expr = ( model.v_storage_level[r, p, s, d, t, v] + stored_energy == model.v_storage_level[r, p, s_next, d_next, t, v] ) return expr
[docs] def seasonal_storage_energy_constraint( model: TemoaModel, r: Region, p: Period, s_seq: Season, t: Technology, v: Vintage ) -> ExprLike: r""" This constraint enforces the continuity of state of charge between seasons for seasonal storage. Sequential season storage level increases by the matched season's net charge over that entire day, adjusted for number of days represented by sequential vs non-sequential seasons. Only applies to storage technologies in the :code:`tech_seasonal_storage` set. :math:`s^*` represents the matching non-sequential season for the sequential season :math:`s^{seq}`. .. math:: :label: Storage Energy (Sequential Seasons) \mathbf{SSL}_{r,p,s^{seq},t,v} + DA_{r,p,s^{seq}} \cdot \left(\mathbf{SL}_{r,p,s^*,d_{last},t,v} + \sum_{I,O} \mathbf{FI}_{r,p,s^*,d_{last},i,t,v,o} \cdot EFF_{r,i,t,v,o} - \sum_{I,O} \mathbf{FO}_{r,p,s^*,d_{last},i,t,v,o} \right) = DA_{r,p,s^{seq}_{next}} \cdot \mathbf{SL}_{r,p,s_{next}^*,d_{first},t,v} + \mathbf{SSL}_{r,p,s^{seq}_{next},t,v} \\ \text{where } DA_{r,p,s^{seq}} = \frac{\#days_{s^{seq}}}{SEG_{r,p,s^*} \cdot DPP} .. figure:: images/ldes_chain.* :align: center :width: 100% :figclass: align-center :figwidth: 60% How sequential seasons chain together for seasonal storage. Hatched area is seasonal_storage_level :math:`SSL_{r,p,s^{seq},t,v}`. Vertical lines are StorageLevel :math:`SL_{r,p,s^*,d,t,v}`. Green line is net seasonal storage level :math:`SSL_{r,p,s^{seq},t,v} + SL_{r,p,s^*,d,t,v}`. Background grey lines show how storage levels from non-sequential seasons are combined in sequential seasons. Dashed line is SeasonalStorageEnergyUpperBound. Sequential seasons two and four here are each two days while one and three are each one day. """ s: Season = model.sequential_to_season[p, s_seq] # This is the sum of all input=i sent TO storage tech t of vintage v with # output=o in p,s charge = sum( model.v_flow_in[r, p, s, model.time_of_day.last(), S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, model.time_of_day.last(), S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) # This is the sum of all output=o withdrawn FROM storage tech t of vintage v # with input=i in p,s discharge = sum( model.v_flow_out[r, p, s, model.time_of_day.last(), S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] ) s_seq_next: Season = model.time_next_sequential[p, s_seq] s_next: Season = model.sequential_to_season[p, s_seq_next] # Flows and StorageLevel are normalised to the number of days in the non-sequential season, so must # be adjusted to the number of days in the sequential season days_adjust = value(model.time_season_sequential[p, s_seq, s]) / ( value(model.segment_fraction_per_season[p, s]) * value(model.days_per_period) ) days_adjust_next = value(model.time_season_sequential[p, s_seq_next, s_next]) / ( value(model.segment_fraction_per_season[p, s_next]) * value(model.days_per_period) ) stored_energy = (charge - discharge) * days_adjust start = ( model.v_seasonal_storage_level[r, p, s_seq, t, v] + model.v_storage_level[r, p, s, model.time_of_day.last(), t, v] * days_adjust ) end = ( model.v_seasonal_storage_level[r, p, s_seq_next, t, v] + model.v_storage_level[r, p, s_next, model.time_of_day.first(), t, v] * days_adjust_next ) expr = start + stored_energy == end return expr
# --- Capacity and Rate Limit Constraints ---
[docs] def storage_energy_upper_bound_constraint( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay, t: Technology, v: Vintage ) -> ExprLike: r""" This constraint ensures that the amount of energy stored does not exceed the upper bound set by the energy capacity of the storage device, as calculated on the right-hand side. Because the number and duration of time slices are user-defined, we need to adjust the storage duration, which is specified in hours. First, the hourly duration is divided by the number of hours in a year to obtain the duration as a fraction of the year. Since the :math:`C2A` parameter assumes the conversion of capacity to annual activity, we need to express the storage duration as fraction of a year. Then, :math:`SEG_{s,d}` summed over the time-of-day slices (:math:`d`) multiplied by :math:`DPP` yields the number of days per season. This step is necessary because conventional time sliced models use a single day to represent many days within a given season. Thus, it is necessary to scale the storage duration to account for the number of days in each season. .. math:: :label: StorageEnergyUpperBound \textbf{SL}_{r, p, s, d, t, v} \le \textbf{CAP}_{r,t,v} \cdot C2A_{r,t} \cdot \frac {SD_{r,t}}{24 \cdot DPP} \cdot \sum_{d} SEG_{s,d} \cdot DPP \\ \forall \{r, p, s, d, t, v\} \in \Theta_{\text{StorageEnergyUpperBound}} A season can represent many days. Within each season, flows are multiplied by the number of days each season represents and, so, the upper bound needs to be adjusted to allow day-scale flows (e.g., charge in the morning, discharge in the afternoon). .. figure:: images/daily_storage_representation.* :align: center :width: 100% :figclass: center :figwidth: 40% Representation of a 3-day season for non-seasonal (daily) storage. """ if model.is_seasonal_storage[t]: return Constraint.Skip # redundant on SeasonalStorageEnergyUpperBound energy_capacity = ( model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) * (value(model.storage_duration[r, t]) / (24 * value(model.days_per_period))) * value(model.segment_fraction_per_season[p, s]) * model.days_per_period # adjust for days in season ) expr = model.v_storage_level[r, p, s, d, t, v] <= energy_capacity return expr
[docs] def seasonal_storage_energy_upper_bound_constraint( model: TemoaModel, r: Region, p: Period, s_seq: Season, d: TimeOfDay, t: Technology, v: Vintage ) -> ExprLike: r""" Builds off of StorageEnergyUpperBound_constraint. Enforces the max charge capacity of seasonal storage, summing the real storage level with the superimposed sequential seasonal storage level. :math:`s^*` represents the matching non-sequential season for the sequential season :math:`s^{seq}`. .. math:: :label: Seasonal Storage Energy Capacity \mathbf{SSL}_{r,p,s^{seq},t,v} + \mathbf{SL}_{r,p,s^*,d,t,v} \cdot DA_{r,p,s^{seq}} \leq \mathbf{CAP}_{r,p,t,v} \cdot C2A_{r,t} \cdot \frac{SD_{r,t}}{24 \cdot DPP} \\ \text{where } DA_{r,p,s^{seq}} = \frac{\#days_{s^{seq}}}{SEG_{r,p,s^*} \cdot DPP} Unlike non-seasonal (daily) storage, seasonal storage is allowed to carry energy between seasons. However, through seasons representing multiple days, many days' charge deltas have accumulated, multiplied by the number of days the season represents. If we allowed these stacked deltas to carry between seasons then we would be multiplying the effective energy capacity of the storage. We could just constrain the seasonal delta to the unadjusted energy capacity, but then the final day in the season would sit atop a season's worth of deltas, possibly exceeding our upper or lower bound by a factor of :math:`\frac{N-1}{N}` where :math:`N` is the number of days the sequential season represents. .. figure:: images/ldes_delta_problem.* :align: center :width: 100% :figclass: center :figwidth: 100% The energy upper bound or non-negative lower bound could be violated in a season representing multiple days if we both adjusted the upper bound to the number of days and allowed a seasonal delta. So, we do not adjust the upper energy bound for seasonal storage. This limits the ability of seasonal storage to perform arbitrage within each season, but allows it to carry energy between seasons realistically. .. figure:: images/ldes_delta_representation.* :align: center :width: 100% :figclass: center :figwidth: 40% Unadjusted energy upper bound constraint for seasonal storage. """ s: Season = model.sequential_to_season[p, s_seq] energy_capacity = ( model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) * (value(model.storage_duration[r, t]) / (24 * value(model.days_per_period))) ) # Flows and StorageLevel are normalised to the number of days in the non-sequential season, so must # be adjusted to the number of days in the sequential season days_adjust = value(model.time_season_sequential[p, s_seq, s]) / ( value(model.segment_fraction_per_season[p, s]) * value(model.days_per_period) ) # v_storage_level tracks the running cumulative delta in the non-sequential season, so must be adjusted # to the size of the sequential season running_day_delta = model.v_storage_level[r, p, s, d, t, v] * days_adjust expr = model.v_seasonal_storage_level[r, p, s_seq, t, v] + running_day_delta <= energy_capacity return expr
[docs] def storage_charge_rate_constraint( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay, t: Technology, v: Vintage ) -> ExprLike: r""" This constraint ensures that the charge rate of the storage unit is limited by the power capacity (typically GW) of the storage unit. .. math:: :label: StorageChargeRate \sum_{I, O} \textbf{FIS}_{r, p, s, d, i, t, v, o} \cdot EFF_{r,i,t,v,o} \le \textbf{CAP}_{r,t,v} \cdot C2A_{r,t} \cdot SEG_{s,d} \\ \forall \{r, p, s, d, t, v\} \in \Theta_{\text{StorageChargeRate}} """ # Calculate energy charge in each time slice slice_charge = sum( model.v_flow_in[r, p, s, d, S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, d, S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) # Maximum energy charge in each time slice max_charge = ( model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) * value(model.segment_fraction[p, s, d]) ) # Energy charge cannot exceed the power capacity of the storage unit expr = slice_charge <= max_charge return expr
[docs] def storage_discharge_rate_constraint( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay, t: Technology, v: Vintage ) -> ExprLike: r""" This constraint ensures that the discharge rate of the storage unit is limited by the power capacity (typically GW) of the storage unit. .. math:: :label: StorageDischargeRate \sum_{I, O} \textbf{FO}_{r, p, s, d, i, t, v, o} \le \textbf{CAP}_{r,t,v} \cdot C2A_{r,t} \cdot SEG_{s,d} \\ \forall \{r,p, s, d, t, v\} \in \Theta_{\text{StorageDischargeRate}} """ # Calculate energy discharge in each time slice slice_discharge = sum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] ) # Maximum energy discharge in each time slice max_discharge = ( model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) * value(model.segment_fraction[p, s, d]) ) # Energy discharge cannot exceed the capacity of the storage unit expr = slice_discharge <= max_discharge return expr
[docs] def storage_throughput_constraint( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay, t: Technology, v: Vintage ) -> ExprLike: r""" It is not enough to only limit the charge and discharge rate separately. We also need to ensure that the maximum throughput (charge + discharge) does not exceed the capacity (typically GW) of the storage unit. .. math:: :label: StorageThroughput \sum_{I, O} \textbf{FO}_{r, p, s, d, i, t, v, o} + \sum_{I, O} \textbf{FIS}_{r, p, s, d, i, t, v, o} \cdot EFF_{r,i,t,v,o} \le \textbf{CAP}_{r,t,v} \cdot C2A_{r,t} \cdot SEG_{s,d} \\ \forall \{r, p, s, d, t, v\} \in \Theta_{\text{StorageThroughput}} """ discharge = sum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] ) charge = sum( model.v_flow_in[r, p, s, d, S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, d, S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) throughput = charge + discharge max_throughput = ( model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) * value(model.segment_fraction[p, s, d]) ) expr = throughput <= max_throughput return expr
# A limit but more cohesive here than in limits.py
[docs] def limit_storage_fraction_constraint( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay, t: Technology, v: Vintage, op: str, ) -> ExprLike: r""" This constraint is used if the users wishes to force a specific storage charge level for certain storage technologies and vintages at a certain time slice. In this case, the value of the decision variable :math:`\textbf{SI}_{r,t,v}` is set by this constraint rather than being optimized. User-specified storage charge levels that are sufficiently different from the optimal :math:`\textbf{SI}_{r,t,v}` could impact the cost-effectiveness of storage. For example, if the optimal charge level happens to be 50% of the full energy capacity, forced charge levels (specified by parameter :math:`SIF_{r,t,v}`) equal to 10% or 90% of the full energycapacity could lead to more expensive solutions. .. math:: :label: limit_storage_fraction \textbf{SF}_{r,p,s,d,t,v} \le \ SF_{r,p,s,d,t,v} \cdot \textbf{CAP}_{r,p,t,v} \cdot C2A_{r,t} \cdot \frac {SD_{r,t}}{(24 \cdot DPP hrs/yr} \cdot \sum_{d} SEG_{s,d} \cdot M.days_per_period days/yr \cdot MPL_{r,p,t,v} \\ \forall \{r, p, s, d, t, v\} \in \Theta_{\text{limit_storage_fraction}} """ energy_limit = ( model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) * (value(model.storage_duration[r, t]) / (24 * value(model.days_per_period))) * value(model.limit_storage_fraction[r, p, s, d, t, v, op]) ) if model.is_seasonal_storage[t]: s_seq: Season = s # sequential season s = model.sequential_to_season[p, s_seq] # non-sequential season # adjust the storage level to the individual-day level energy_level = model.v_storage_level[r, p, s, d, t, v] / ( value(model.segment_fraction_per_season[p, s]) * value(model.days_per_period) ) if model.is_seasonal_storage[t]: # seasonal storage upper energy limit is absolute energy_level = model.v_seasonal_storage_level[r, p, s_seq, t, v] + energy_level * value( model.time_season_sequential[p, s_seq, s] ) expr = operator_expression(energy_level, Operator(op), energy_limit) return expr