Source code for assetra.contribution
from logging import getLogger
from abc import ABC, abstractmethod
from assetra.units import RESPONSIVE_UNIT_TYPES, NONRESPONSIVE_UNIT_TYPES
from assetra.system import EnergySystem
from assetra.simulation import ProbabilisticSimulation
from assetra.metrics import ResourceAdequacyMetric
import xarray as xr
LOG = getLogger(__name__)
[docs]
class ResourceContributionMetric(ABC):
"""Class responsible for quantifying resource contributions
to an energy system
Args:
energy_system (EnergySystem): Base system to which resources are added
simulation (ProbabilisticSimulation): Instantiated simulation object
whose parameters (i.e. time range and trial size are used
throughout the ELCC calculation)
resource_adequacy_metric: Class derived
from ResourceAdequacyMetric to use throughout the ELCC simulation
(e.g. ExpectedUnservedEnergy, LossOfLoadHours)
"""
def __init__(
self,
energy_system: EnergySystem,
simulation: ProbabilisticSimulation,
resource_adequacy_metric: type[ResourceAdequacyMetric],
):
self._original_energy_system = energy_system
self._simulation = simulation
self._resource_adequacy_metric = resource_adequacy_metric
[docs]
@abstractmethod
def evaluate(self, addition: EnergySystem) -> float:
"""Return resource contribution of addition to the energy system
Args:
addition (EnergySystem): Energy system to add (possibly a single
unit)
Returns:
float: Quantified resource contribution of addition to the energy
system.
"""
[docs]
class EffectiveLoadCarryingCapability(ResourceContributionMetric):
"""Class responsible for quantifying resource contribution to an energy
system using the effective load-carrying capability metric:
"[The] amount by which the system's load can increase when the resource is
added to the system while maintaining the same system adequacy."
https://gridops.epri.com/Adequacy/metrics#Effective_Load_Carrying_Capability_.28ELCC.29
Args:
energy_system (EnergySystem) : Base system to which resources are added
simulation (ProbabilisticSimulation) : Instantiated simulation object
whose parameters (i.e. time range and trial size are used
throughout the ELCC calculation)
resource_adequacy_metric (type[ResourceAdequacyMetric') : Class derived
from ResourceAdequacyMetric to use throughout the ELCC simulation
(e.g. ExpectedUnservedEnergy, LossOfLoadHours)
"""
def __init__(
self,
energy_system: EnergySystem,
simulation: ProbabilisticSimulation,
resource_adequacy_metric: type[ResourceAdequacyMetric],
):
"""The ELCC computes resource adequacy many times iteratively. In many
instances, it is neither necessary nor desirable to re-run the
probabilistic simulation for a set of units more than once. For
so-called non-responsive units (static and stochastic units) which do
not respond to system conditions, a single probabilistic simulation
is sufficient. For responsive units, including storage, changing system
conditions necessitate re-evaluation of probabilistic capacity
contributions.
To account for this classification in the iterative resource adequacy
calculations, the ELCC decomposes energy systems into their responsive
and non-responsive components. In each iteration, combinations of the
component energy systems' probabilistic simulations are "chained"
together depending on which need to be recomputed.
For example, to establish the baseline resource adequacy (as in the
init function), both the original responsive system and the
original non-responsive systems are evaluated. Programmatically, the
non-responsive system is evaluated first. Then the resulting
net hourly capacity matrix is fed into the responsive system.
When a new system is added, it is again decomposed into responsive and
non-responsive subsystems. The original non-responsive system does not
need to be re-evaluated, so its already-simulated probabilistic net
hourly capacity is fed into the new non-responsive system which is fed
into the new and original responsive systems respectively.
In this way, non-responsive units are only evaluated once while
responsive units are able to respond to changes in system conditions.
"""
ResourceContributionMetric.__init__(
self, energy_system, simulation, resource_adequacy_metric
)
# decompose system into responsive and non-responsive components
# non-responsive simulation
self._original_system_non_responsive = (
self._original_energy_system.get_system_by_type(
NONRESPONSIVE_UNIT_TYPES
)
)
self._original_non_responsive_simulation = self._simulation.copy()
self._original_non_responsive_simulation.assign_energy_system(
self._original_system_non_responsive
)
# responsive simulation
self._original_system_responsive = (
self._original_energy_system.get_system_by_type(
RESPONSIVE_UNIT_TYPES
)
)
self._original_responsive_simulation = self._simulation.copy()
self._original_responsive_simulation.assign_energy_system(
self._original_system_responsive
)
# run chained simulation
self._original_non_responsive_simulation.run()
self._original_responsive_simulation.run(
self._original_non_responsive_simulation.net_hourly_capacity_matrix
)
self._resource_adequacy_model = self._resource_adequacy_metric(
self._original_responsive_simulation
)
self._original_resource_adequacy = (
self._resource_adequacy_model.evaluate()
)
LOG.info(
"Original resource adequacy: "
+ str(self._original_resource_adequacy)
)
if self._original_resource_adequacy == 0:
LOG.error("Invalid ELCC calculation for system with no risk")
raise RuntimeWarning()
# save intermediate steps
self._original_net_capacity_matrix = (
self._original_responsive_simulation.net_hourly_capacity_matrix
)
self._intermediate_net_capacity_matrices = []
@property
def original_net_capacity_matrix(self) -> xr.DataArray:
"""Return the net hourly capacity matrix of the base system"""
# TODO test
return self._original_net_capacity_matrix.copy()
@property
def intermediate_net_capacity_matrices(
self,
) -> tuple[tuple[float, xr.DataArray]]:
"""Return intermediate net capacity matrices for each step of the ELCC
calculation. Each element is a tuple composed of the amount of added
constant load and the net hourly capacity matrix corresponding to that
step.
"""
# TODO test
return tuple(m for m in self._intermediate_net_capacity_matrices)
[docs]
def evaluate(
self,
addition: EnergySystem,
additional_demand_resolution_pct: float = 0.01,
) -> float:
"""Return the ELCC of an addition to the energy system.
Args:
addition (EnergySystem): Energy system to add (possibly a
single unit).
additional_demand_resolution_pct (float, optional): Resolution of
added demand to find as percent of added nameplate capacity.
Defaults to 0.01 (1%). E.g. for a 100 MW addition, the ELCC
will be found within 1 MW.
Returns:
float: Amount of added constant load in units of power.
"""
# reset intermediate net capacities
self._intermediate_net_capacity_matrices = []
# decompose system into responsive and non-responsive components
# non-responsive simulation
additional_system_non_responsive = addition.get_system_by_type(
NONRESPONSIVE_UNIT_TYPES
)
additional_non_responsive_simulation = self._simulation.copy()
additional_non_responsive_simulation.assign_energy_system(
additional_system_non_responsive
)
# responsive simulation
additional_system_responsive = addition.get_system_by_type(
RESPONSIVE_UNIT_TYPES
)
additional_responsive_simulation = self._simulation.copy()
additional_responsive_simulation.assign_energy_system(
additional_system_responsive
)
# run non-responsive_simulation
additional_non_responsive_simulation.run()
# get non-responsive net hourly capacity
non_responsive_net_hourly_capacity_matrix = (
self._original_non_responsive_simulation.net_hourly_capacity_matrix
+ additional_non_responsive_simulation.net_hourly_capacity_matrix
)
# add load
additional_demand_upper_bound = addition.system_capacity
additional_demand_lower_bound = 0
additional_demand_resolution = (
additional_demand_upper_bound - additional_demand_lower_bound
)
additional_demand = (
additional_demand_lower_bound + additional_demand_resolution / 2
)
# run chained responsive simulation
additional_responsive_simulation.run(
non_responsive_net_hourly_capacity_matrix - additional_demand
)
self._original_responsive_simulation.run(
additional_responsive_simulation.net_hourly_capacity_matrix
)
self._intermediate_net_capacity_matrices.append(
(
additional_demand,
self._original_responsive_simulation.net_hourly_capacity_matrix,
)
)
# update resource adequacy
new_resource_adequacy = self._resource_adequacy_model.evaluate()
# printout
LOG.info("Additional demand: " + str(round(additional_demand)))
LOG.info("Resource adequacy: " + str(new_resource_adequacy))
# iterate until convergence
iteration = 0
while (
additional_demand_resolution / addition.system_capacity
) > additional_demand_resolution_pct:
# iterate until original resource adequacy level is met
if new_resource_adequacy > self._original_resource_adequacy:
# if over-reliable, add load
additional_demand_upper_bound = additional_demand
else:
# if under-reliable, remove load
additional_demand_lower_bound = additional_demand
# add demand
additional_demand_resolution = (
additional_demand_upper_bound - additional_demand_lower_bound
)
additional_demand = (
additional_demand_lower_bound + additional_demand_resolution / 2
)
# run chained responsive simulation
additional_responsive_simulation.run(
non_responsive_net_hourly_capacity_matrix - additional_demand
)
self._original_responsive_simulation.run(
additional_responsive_simulation.net_hourly_capacity_matrix
)
self._intermediate_net_capacity_matrices.append(
(
additional_demand,
self._original_responsive_simulation.net_hourly_capacity_matrix,
)
)
# update resource adequacy
new_resource_adequacy = self._resource_adequacy_model.evaluate()
LOG.info("Additional demand: " + str(additional_demand))
LOG.info("Resource adequacy: " + str(new_resource_adequacy))
# update iteration count
iteration += 1
return float(additional_demand)