# temoa/components/reserves.py
"""
Defines the reserve margin components of the Temoa model.
This module is responsible for ensuring the energy system maintains a sufficient
planning reserve margin to ensure reliability. It supports both a 'static'
(based on installed capacity) and a 'dynamic' (based on available generation
in a time slice) formulation for calculating available reserves.
"""
from __future__ import annotations
from logging import getLogger
from typing import TYPE_CHECKING
from pyomo.environ import Constraint, value
from .utils import get_capacity_factor, get_variable_efficiency
if TYPE_CHECKING:
from temoa.core.model import TemoaModel
from temoa.types import ExprLike
from temoa.types.core_types import Period, Region, Season, TimeOfDay
logger = getLogger(__name__)
# ============================================================================
# PYOMO INDEX SET FUNCTIONS
# ============================================================================
def reserve_margin_indices(model: TemoaModel) -> set[tuple[Region, Period, Season, TimeOfDay]]:
return {
(r, p, s, d)
for r in model.planning_reserve_margin.sparse_keys()
for p in model.time_optimize
if (r, p) in model.process_reserve_periods
for s in model.time_season
for d in model.time_of_day
}
# ============================================================================
# HELPER FUNCTIONS FOR CONSTRAINT LOGIC
# ============================================================================
[docs]
def reserve_margin_dynamic(
model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay
) -> ExprLike:
r"""
A dynamic alternative to the traditional, static reserve margin constraint. Capacity values
are calculated from availability of generation in each hour—like an operating reserve margin—\
accounting for a capacity derate factor subtracting, for example, forced outage due to icing.
.. math::
:label: reserve_margin_dynamic
&\sum_{t \in T^{res} \setminus T^{x} \setminus T^s,\ V} CFP_{r,s^*,d^*,t,v}\
\cdot RCD_{r,s^*,t,v}\
\cdot \mathbf{CAP}_{r,p,t,v} \cdot SEG_{s^*,d^*}\
\cdot C2A_{r,t} \\
&+ \sum_{t \in T^{res} \cap T^{x} \setminus T^s,\ V} CFP_{r_i - r, s^*, d^*, t, v}\
\cdot RCD_{r_i - r, s^*, t, v}\
\cdot \mathbf{CAP}_{r_i - r,p,t,v} \cdot SEG_{s^*,d^*}\
\cdot C2A_{r_i - r, t} \\
&- \sum_{t \in T^{res} \cap T^{x} \setminus T^s,\ V} CFP_{r - r_i, s^*, d^*, t, v}\
\cdot RCD_{r - r_i, s^*, t, v}\
\cdot \mathbf{CAP}_{r - r_i,p,t,v}\
\cdot SEG_{s^*,d^*} \cdot C2A_{r - r_i, t} \\
&+ \sum_{t \in (T^s \cap T^{res}), V, I, O} \
\left(\
\mathbf{FO}_{r,p,s,d,i,t,v,o} - \mathbf{FI}_{r,p,s,d,i,t,v,o}\
\right)\
\cdot RCD_{r,s,t,v} \\
&\geq\
\left[\
\sum_{t \in T^{res} \setminus T^{x} \setminus T^a, V, I, O}\
\mathbf{FO}_{r, p, s, d, i, t, v, o}\
\right. \\
&+ \sum_{t \in T^{res} \cap T^a, V, I, O}
\begin{cases} DSD_{r,s,d,o} & \text{if } o \in C^d \\
SEG_{s,d} & \text{otherwise} \end{cases}
\cdot \mathbf{FOA}_{r, p, i, t, v, o} \\
&+ \sum_{t \in T^{res} \cap T^{x}, V, I, O} \
\mathbf{FO}_{r_i - r, p, s, d, i, t, v, o} \\
&- \sum_{t \in T^{res} \cap T^{x}, V, I, O} \
\mathbf{FI}_{r - r_i, p, s, d, i, t, v, o} \\
&- \left. \sum_{t \in T^{res} \cap T^{s}, V, I, O} \
\mathbf{FI}_{r, p, s, d, i, t, v, o} \right] \cdot (1 + PRM_r) \\
\\
&\qquad \qquad \forall \{r, p, s, d\} \in \
\Theta_{\text{ReserveMargin}} \text{ and } \forall r_i \in R
"""
if (not model.tech_reserve) or (
(r, p) not in model.process_reserve_periods
): # If reserve set empty or if r,p not in M.processReservePeriod, skip the constraint
return Constraint.Skip
# Everything but storage and exchange techs
# Derated available generation
available = sum(
model.v_capacity[r, p, t, v]
* value(model.reserve_capacity_derate[r, s, t, v])
* get_capacity_factor(model, r, s, d, t, v)
* value(model.capacity_to_activity[r, t])
* value(model.segment_fraction[s, d])
for (t, v) in model.process_reserve_periods[r, p]
if t not in model.tech_uncap and t not in model.tech_storage
)
# Storage
# Derated net output flow
available += sum(
model.v_flow_out[r, p, s, d, i, t, v, o] * value(model.reserve_capacity_derate[r, s, t, v])
for (t, v) in model.process_reserve_periods[r, p]
if t in model.tech_storage
for i in model.process_inputs[r, p, t, v]
for o in model.process_outputs_by_input[r, p, t, v, i]
)
available -= sum(
model.v_flow_in[r, p, s, d, i, t, v, o] * value(model.reserve_capacity_derate[r, s, t, v])
for (t, v) in model.process_reserve_periods[r, p]
if t in model.tech_storage
for i in model.process_inputs[r, p, t, v]
for o in model.process_outputs_by_input[r, p, t, v, i]
)
# The above code does not consider exchange techs, e.g. electricity
# transmission between two distinct regions.
# We take exchange takes into account below.
# Note that a single exchange tech linking regions Ri and Rj is twice
# defined: once for region "Ri-Rj" and once for region "Rj-Ri".
# First, determine the amount of firm capacity each exchange tech
# contributes.
for r1r2 in model.regional_indices:
if '-' not in r1r2:
continue
if (
r1r2,
p,
) not in model.process_reserve_periods: # ensure r1r2 is a valid reserve provider in p
continue
r1, r2 = r1r2.split('-')
# Only consider exchange technologies connecting to this region
if r2 == r:
# Add the firm capacity commitment TO this region
# (this region was guaranteed an import of power)
available += sum(
model.v_capacity[r1r2, p, t, v]
* value(model.reserve_capacity_derate[r1r2, s, t, v])
* get_capacity_factor(model, r1r2, s, d, t, v)
* value(model.capacity_to_activity[r1r2, t])
* value(model.segment_fraction[s, d])
for (t, v) in model.process_reserve_periods[r1r2, p]
)
elif r1 == r:
# Subtract the firm capacity commitment FROM this region
# (this region guaranteed an export of power)
available -= sum(
model.v_capacity[r1r2, p, t, v]
* value(model.reserve_capacity_derate[r1r2, s, t, v])
* get_capacity_factor(model, r1r2, s, d, t, v)
* value(model.capacity_to_activity[r1r2, t])
* value(model.segment_fraction[s, d])
for (t, v) in model.process_reserve_periods[r1r2, p]
)
return available
[docs]
def reserve_margin_static(
model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay
) -> ExprLike:
r"""
During each period :math:`p`, the sum of capacity values of all reserve
technologies :math:`\sum_{t \in T^{res}} \textbf{CAP}_{r,p,t,v}`, which are
defined in the set :math:`\textbf{T}^{res}`, should exceed the peak load by
:math:`PRM`, the regional reserve margin. Note that the reserve
margin is expressed in percentage of the peak load. Generally speaking, in
a database we may not know the peak demand before running the model, therefore,
we write this equation for all the time-slices defined in the database in each region.
Each generator is allowed to contribute its available capacity times a pre-defined
capacity credit, :math:`CC_{r,p,t,v}`.
For exchange technologies (i.e., inter-regional transmission), reserve contributions
are added to the downstream region but *subtracted* from the upstream region. This is
because, since they are not generating any power, their summed contribution across
regions should be zero.
.. math::
:label: reserve_margin_static
&\sum_{t \in T^{res} \setminus T^{x}, V} {CC_{r,p,t,v}
\cdot \textbf{CAP}_{r,p,t,v} \cdot
SEG_{s^*,d^*} \cdot C2A_{r,t} }\\
&+ \sum_{t \in T^{res} \cap T^{x}, V} {CC_{r_i-r,p,t,v}
\cdot \textbf{CAP}_{r_i-r,p,t,v} \cdot
SEG_{s^*,d^*} \cdot C2A_{r_i-r,t} }\\
&- \sum_{t \in T^{res} \cap T^{x}, V} {CC_{r-r_i,p,t,v}
\cdot \textbf{CAP}_{r-r_i,p,t,v} \cdot
SEG_{s^*,d^*} \cdot C2A_{r-r_i,t} }\\
&\geq \left [ \sum_{ t \in T^{res} \setminus T^{x} \setminus T^a,V,I,O }
\textbf{FO}_{r, p, s, d, i, t, v, o}\right.\\
&+ \sum_{ t \in T^{res} \cap T^a,V,I,O }
\begin{cases} DSD_{r,s,d,o} & \text{if } o \in C^d \\
SEG_{s,d} & \text{otherwise} \end{cases}
\cdot \textbf{FOA}_{r, p, i, t, v, o}\\
&+ \sum_{ t \in T^{res} \cap T^{x},V,I,O } \textbf{FO}_{r_i-r, p, s, d, i, t, v, o}\\
&- \sum_{ t \in T^{res} \cap T^{x},V,I,O } \textbf{FI}_{r-r_i, p, s, d, i, t, v, o}\\
&- \left.\sum_{ t \in T^{res} \cap T^{s},V,I,O } \textbf{FI}_{r, p, s, d, i, t, v, o}
\right]
\cdot (1 + PRM_r)\\
\\
&\qquad\qquad\forall \{r, p, s, d\} \in \Theta_{\text{ReserveMargin}} \text{and} \forall
r_i \in R
"""
if (not model.tech_reserve) or (
(r, p) not in model.process_reserve_periods
): # If reserve set empty or if r,p not in M.processReservePeriod, skip the constraint
return Constraint.Skip
available = sum(
value(model.capacity_credit[r, p, t, v])
* model.v_capacity[r, p, t, v]
* value(model.capacity_to_activity[r, t])
* value(model.segment_fraction[s, d])
for (t, v) in model.process_reserve_periods[r, p]
if t not in model.tech_uncap
)
# The above code does not consider exchange techs, e.g. electricity
# transmission between two distinct regions.
# We take exchange takes into account below.
# Note that a single exchange tech linking regions Ri and Rj is twice
# defined: once for region "Ri-Rj" and once for region "Rj-Ri".
# First, determine the amount of firm capacity each exchange tech
# contributes.
for r1r2 in model.regional_indices:
if '-' not in r1r2:
continue
if (
r1r2,
p,
) not in model.process_reserve_periods: # ensure r1r2 is a valid reserve provider in p
continue
r1, r2 = r1r2.split('-')
# Only consider exchange technologies connecting to this region
if r2 == r:
# Add the firm capacity commitment TO this region
# (this region was guaranteed an import of power)
available += sum(
value(model.capacity_credit[r1r2, p, t, v])
* model.v_capacity[r1r2, p, t, v]
* value(model.capacity_to_activity[r1r2, t])
* value(model.segment_fraction[s, d])
for (t, v) in model.process_reserve_periods[r1r2, p]
)
elif r1 == r:
# Subtract the firm capacity commitment FROM this region
# (this region guaranteed an export of power)
available -= sum(
value(model.capacity_credit[r1r2, p, t, v])
* model.v_capacity[r1r2, p, t, v]
* value(model.capacity_to_activity[r1r2, t])
* value(model.segment_fraction[s, d])
for (t, v) in model.process_reserve_periods[r1r2, p]
)
return available
# ============================================================================
# PYOMO CONSTRAINT RULE
# ============================================================================
def reserve_margin_constraint(
model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay
) -> ExprLike:
# Get available generation in this time slice depending on method specified in config file
match model.reserve_margin_method.first():
case 'static':
available = reserve_margin_static(model, r, p, s, d)
case 'dynamic':
available = reserve_margin_dynamic(model, r, p, s, d)
case _:
msg = (
f"Invalid reserve margin parameter '{model.reserve_margin_method.first()}'. "
'Check the config file.'
)
logger.error(msg)
raise ValueError(msg)
# In most Temoa input databases, demand is endogenous, so we use electricity
# generation instead as a proxy for electricity demand.
# Non-annual generation
total_generation = sum(
model.v_flow_out[r, p, s, d, S_i, t, S_v, S_o]
for (t, S_v) in model.process_reserve_periods[r, p]
if t not in model.tech_annual
for S_i in model.process_inputs[r, p, t, S_v]
for S_o in model.process_outputs_by_input[r, p, t, S_v, S_i]
)
# Generators might serve demands directly
# Annual generation
total_generation += sum(
(
value(model.demand_specific_distribution[r, s, d, S_o])
if S_o in model.commodity_demand
else value(model.segment_fraction[s, d])
)
* model.v_flow_out_annual[r, p, S_i, t, S_v, S_o]
for (t, S_v) in model.process_reserve_periods[r, p]
if t in model.tech_annual
for S_i in model.process_inputs[r, p, t, S_v]
for S_o in model.process_outputs_by_input[r, p, t, S_v, S_i]
)
# We must take into account flows into storage technologies.
# Flows into storage technologies need to be subtracted from the
# load calculation.
total_generation -= sum(
model.v_flow_in[r, p, s, d, S_i, t, S_v, S_o]
for (t, S_v) in model.process_reserve_periods[r, p]
if t in model.tech_storage
for S_i in model.process_inputs[r, p, t, S_v]
for S_o in model.process_outputs_by_input[r, p, t, S_v, S_i]
)
# Electricity imports and exports via exchange techs are accounted
# for below:
for r1r2 in model.regional_indices: # ensure the region is of the form r1-r2
if '-' not in r1r2:
continue
if (
r1r2,
p,
) not in model.process_reserve_periods: # ensure r1r2 is a valid reserve provider in p
continue
r1, r2 = r1r2.split('-')
# First, determine the exports, and subtract this value from the
# total generation.
if r1 == r:
total_generation -= sum(
model.v_flow_out[r1r2, p, s, d, S_i, t, S_v, S_o]
/ get_variable_efficiency(model, r1r2, p, s, d, S_i, t, S_v, S_o)
for (t, S_v) in model.process_reserve_periods[r1r2, p]
for S_i in model.process_inputs[r1r2, p, t, S_v]
for S_o in model.process_outputs_by_input[r1r2, p, t, S_v, S_i]
)
# Second, determine the imports, and add this value from the
# total generation.
elif r2 == r:
total_generation += sum(
model.v_flow_out[r1r2, p, s, d, S_i, t, S_v, S_o]
for (t, S_v) in model.process_reserve_periods[r1r2, p]
for S_i in model.process_inputs[r1r2, p, t, S_v]
for S_o in model.process_outputs_by_input[r1r2, p, t, S_v, S_i]
)
requirement = total_generation * (1 + value(model.planning_reserve_margin[r]))
return available >= requirement