# -*- coding: utf-8 -*-
"""reV scheduled losses module.
"""
import logging
import warnings
import json
import numpy as np
from reV.losses.utils import (convert_to_full_month_names,
filter_unknown_month_names,
hourly_indices_for_months)
from reV.utilities.exceptions import reVLossesValueError, reVLossesWarning
logger = logging.getLogger(__name__)
[docs]class Outage:
"""A specific type of outage.
This class stores and validates information about a single type of
outage. In particular, the number of outages, duration, percentage
of farm down, and the allowed months for scheduling the outage
must all be provided. These inputs are then validated so that they
can be used in instances of scheduling objects.
"""
REQUIRED_KEYS = {'count',
'duration',
'percentage_of_capacity_lost',
'allowed_months'}
"""Required keys in the input specification dictionary."""
def __init__(self, specs):
"""
Parameters
----------
specs : dict
A dictionary containing specifications for this outage. This
dictionary must contain the following keys:
- `count`
An integer value representing the total number of
times this outage should be scheduled. This number
should be larger than 0.
- `duration`
An integer value representing the total number of
consecutive hours that this outage should take. This
value must be larger than 0 and less than the number
of hours in the allowed months.
- `percentage_of_capacity_lost`
An integer or float value representing the total
percentage of the total capacity that will be lost
for the duration of the outage. This value must be
in the range (0, 100].
- `allowed_months`
A list of month names corresponding to the allowed
months for the scheduled outages. Month names can be
unformatted and can be specified using 3-letter
month abbreviations.
The input dictionary can also provide the following optional
keys:
- `allow_outage_overlap` - by default, ``True``
A bool flag indicating whether or not this outage is
allowed to overlap with other outages, including
itself. It is recommended to set this value to
``True`` whenever possible, as it allows for more
flexible scheduling.
- `name` - by default, string containing init parameters
A unique name for the outage, used for more
descriptive error messages.
"""
self._specs = specs
self._full_month_names = None
self._total_available_hours = None
self._name = None
self._validate()
def _validate(self):
"""Validate the input specs."""
self._validate_required_keys_exist()
self._validate_count()
self._validate_and_convert_to_full_name_months()
self._validate_duration()
self._validate_percentage()
def _validate_required_keys_exist(self):
"""Raise an error if any required keys are missing."""
missing_keys = [n for n in self.REQUIRED_KEYS if n not in self._specs]
if any(missing_keys):
msg = ("The following required keys are missing from the Outage "
"specification: {}".format(sorted(missing_keys)))
logger.error(msg)
raise reVLossesValueError(msg)
def _validate_count(self):
"""Validate that the total number of outages is an integer. """
if not isinstance(self.count, int):
msg = ("Number of outages must be an integer, but got {} for {}"
.format(self.count, self.name))
logger.error(msg)
raise reVLossesValueError(msg)
if self.count < 1:
msg = ("Number of outages must be greater than 0, but got "
"{} for {}".format(self.count, self.name))
logger.error(msg)
raise reVLossesValueError(msg)
def _validate_and_convert_to_full_name_months(self):
"""Validate month input and convert to full month names. """
months = convert_to_full_month_names(self._specs['allowed_months'])
known_months, unknown_months = filter_unknown_month_names(months)
if unknown_months:
msg = ("The following month names were not understood: {}. Please "
"use either the full month name or the standard 3-letter "
"month abbreviation. For more info, see the month name "
"documentation for the python standard package `calendar`."
.format(unknown_months))
logger.warning(msg)
warnings.warn(msg, reVLossesWarning)
if not known_months:
msg = ("No known month names were provided! Please use either the "
"full month name or the standard 3-letter month "
"abbreviation. For more info, see the month name "
"documentation for the python standard package `calendar`. "
"Received input: {!r}"
.format(self._specs['allowed_months']))
logger.error(msg)
raise reVLossesValueError(msg)
self._full_month_names = list(set(known_months))
def _validate_duration(self):
"""Validate that the duration is between 0 and the max total. """
if not isinstance(self.duration, int):
msg = ("Duration must be an integer number of hours, "
"but got {} for {}".format(self.duration, self.name))
logger.error(msg)
raise reVLossesValueError(msg)
if not 1 <= self.duration <= self.total_available_hours:
msg = ("Duration of outage must be between 1 and the total "
"available hours based on allowed month input ({} for "
"a total hour count of {}), but got {} for {}"
.format(self.allowed_months, self.total_available_hours,
self.percentage_of_capacity_lost, self.name))
logger.error(msg)
raise reVLossesValueError(msg)
def _validate_percentage(self):
"""Validate that the percentage is in the range (0, 100]. """
if not 0 < self.percentage_of_capacity_lost <= 100:
msg = ("Percentage of farm down during outage must be in the "
"range (0, 100], but got {} for {}"
.format(self.percentage_of_capacity_lost, self.name))
logger.error(msg)
raise reVLossesValueError(msg)
def __repr__(self):
return "Outage({!r})".format(self._specs)
def __str__(self):
if self._name is None:
self._name = self._specs.get('name') or self._default_name()
return self._name
def _default_name(self):
"""Generate a default name for the outage."""
specs = self._specs.copy()
specs.update({'allowed_months': self.allowed_months,
'allow_outage_overlap': self.allow_outage_overlap})
specs_as_str = ", ".join(["{}={}".format(k, v)
for k, v in specs.items()])
return "Outage({})".format(specs_as_str)
@property
def count(self):
"""int: Total number of times outage should be scheduled."""
return self._specs['count']
@property
def duration(self):
"""int: Total number of consecutive hours per outage."""
return self._specs['duration']
@property
def percentage_of_capacity_lost(self):
"""int | float: Percent of capacity taken down per outage."""
return self._specs['percentage_of_capacity_lost']
@property
def allowed_months(self):
"""list: Months during which outage can be scheduled."""
return self._full_month_names
@property
def allow_outage_overlap(self):
"""bool: Indicator for overlap with other outages."""
return self._specs.get('allow_outage_overlap', True)
@property
def name(self):
"""str: Name of the outage."""
return self._specs.get('name', str(self))
@property
def total_available_hours(self):
"""int: Total number of hours available based on allowed months."""
if self._total_available_hours is None:
self._total_available_hours = len(
hourly_indices_for_months(self.allowed_months))
return self._total_available_hours
[docs]class OutageScheduler:
"""A scheduler for multiple input outages.
Given a list of information about different types of desired
outages, this class leverages the stochastic scheduling routines of
:class:`SingleOutageScheduler` to calculate the total losses due to
the input outages on an hourly basis.
Attributes
----------
outages : :obj:`list` of :obj:`Outages <Outage>`
The user-provided list of :obj:`Outages <Outage>` containing
info about all types of outages to be scheduled.
seed : :obj:`int`
The seed value used to seed the random generator in order
to produce random but reproducible losses. This is useful
for ensuring that stochastically scheduled losses vary
between different sites (i.e. that randomly scheduled
outages in two different location do not match perfectly on
an hourly basis).
total_losses : :obj:`np.array`
An array (of length 8760) containing the per-hour total loss
percentage resulting from the stochastically scheduled outages.
This array contains only zero values before the
:meth:`~OutageScheduler.calculate` method is run.
can_schedule_more : :obj:`np.array`
A boolean array (of length 8760) indicating wether or not more
losses can be scheduled for a given hour. This array keeps track
of all the scheduling conflicts between input outages.
Warnings
--------
It is possible that not all outages input by the user will be
scheduled. This can happen when there is not enough time allowed
for all of the input outages. To avoid this issue, always be sure to
allow a large enough month range for long outages that take up a big
portion of the farm and try to allow outage overlap whenever
possible.
See Also
--------
:class:`SingleOutageScheduler` : Single outage scheduler.
:class:`Outage` : Specifications for a single outage.
"""
def __init__(self, outages, seed=0):
"""
Parameters
----------
outages : list of :obj:`Outages <Outage>`
A list of :obj:`Outages <Outage>`, where each :obj:`Outage`
contains info about a single type of outage. See the
documentation of :class:`Outage` for a description of the
required keys of each outage dictionary.
seed : int, optional
An integer value used to seed the random generator in order
to produce random but reproducible losses. This is useful
for ensuring that stochastically scheduled losses vary
between different sites (i.e. that randomly scheduled
outages in two different location do not match perfectly on
an hourly basis). By default, the seed is set to 0.
"""
self.outages = outages
self.seed = seed
self.total_losses = np.zeros(8760)
self.can_schedule_more = np.full(8760, True)
[docs] def calculate(self):
"""Calculate total losses from stochastically scheduled outages.
This function calls :meth:`SingleOutageScheduler.calculate`
on every outage input (sorted by largest duration and then
largest number of outages) and returns the aggregate the losses
from the result.
Returns
-------
:obj:`np.array`
An array (of length 8760) containing the per-hour total loss
percentage resulting from the stochastically scheduled
outages.
"""
sorted_outages = sorted(self.outages,
key=lambda o: (o.duration,
o.count,
o.percentage_of_capacity_lost,
sum(sum(map(ord, name))
for name
in o.allowed_months),
o.allow_outage_overlap))
for outage in sorted_outages[::-1]:
self.seed += 1
SingleOutageScheduler(outage, self).calculate()
return self.total_losses
[docs]class SingleOutageScheduler:
"""A scheduler for a single outage.
Given information about a single type of outage, this class
facilitates the (randomized) scheduling of all requested instances
of the outage. See :meth:`SingleOutageScheduler.calculate` for
specific details about the scheduling process.
Attributes
----------
outage : :obj:`Outage`
The user-provided :obj:`Outage` containing info about the outage
to be scheduled.
scheduler : :obj:`OutageScheduler`
A scheduler object that keeps track of the total hourly losses
from the input outage as well as any other outages it has
already scheduled.
can_schedule_more : :obj:`np.array`
A boolean array (of length 8760) indicating wether or not more
losses can be scheduled for a given hour. This is specific
to the input outage only.
Warnings
--------
It is possible that not all outages input by the user can be
scheduled. This can happen when there is not enough time allowed
for all of the input outages. To avoid this issue, always be sure to
allow a large enough month range for long outages that take up a big
portion of the farm and try to allow outage overlap whenever
possible.
See Also
--------
:class:`OutageScheduler` : Scheduler for multiple outages.
:class:`Outage` : Specifications for a single outage.
"""
MAX_ITER = 10_000
"""Max number of extra attempts to schedule outages."""
def __init__(self, outage, scheduler):
"""
Parameters
----------
outage : Outage
An outage object containing info about the outage to be
scheduled.
scheduler : OutageScheduler
A scheduler object that keeps track of the total hourly
losses from the input outage as well as any other outages
it has already scheduled.
"""
self.outage = outage
self.scheduler = scheduler
self.can_schedule_more = np.full(8760, False)
self._scheduled_outage_inds = []
[docs] def calculate(self):
"""Calculate losses from stochastically scheduled outages.
This function attempts to schedule outages according to the
specification provided in the :obj:`Outage` input. Specifically,
it checks the available hours based on the main
:obj:`Scheduler <OutageScheduler>` (which may have other outages
already scheduled) and attempts to randomly add new outages with
the specified duration and percent of losses. The function
terminates when the desired number of outages (specified by
:attr:`Outage.count`) have been successfully scheduled, or when
the number of attempts exceeds
:attr:`~SingleOutageScheduler.MAX_ITER` + :attr:`Outage.count`.
Warns
-----
reVLossesWarning
If the number of requested outages could not be scheduled.
"""
self.update_when_can_schedule_from_months()
for iter_ind in range(self.outage.count + self.MAX_ITER):
self.update_when_can_schedule()
if not self.can_schedule_more.any():
break
seed = self.scheduler.seed + iter_ind
outage_slice = self.find_random_outage_slice(seed=seed)
if self.can_schedule_more[outage_slice].all():
self.schedule_losses(outage_slice)
if len(self._scheduled_outage_inds) == self.outage.count:
break
if len(self._scheduled_outage_inds) < self.outage.count:
if len(self._scheduled_outage_inds) == 0:
msg_start = "Could not schedule any requested outages"
else:
msg_start = ("Could only schedule {} out of {} requested "
"outages"
.format(len(self._scheduled_outage_inds),
self.outage.count))
msg = ("{} after a max of {:,} iterations. You are likely "
"attempting to schedule a lot of long outages or a lot "
"of short outages with a large percentage of the farm at "
"a time. Please adjust the outage specifications and try "
"again"
.format(msg_start, self.outage.count + self.MAX_ITER))
logger.warning(msg)
warnings.warn(msg, reVLossesWarning)
[docs] def update_when_can_schedule_from_months(self):
"""
Update :attr:`can_schedule_more` using :attr:`Outage.allowed_months`.
This function sets the :attr:`can_schedule_more` bool array to
`True` for all of the months in :attr:`Outage.allowed_months`.
"""
inds = hourly_indices_for_months(self.outage.allowed_months)
self.can_schedule_more[inds] = True
[docs] def update_when_can_schedule(self):
"""Update :attr:`can_schedule_more` using :obj:`OutageScheduler`.
This function sets the :attr:`can_schedule_more` bool array to
`True` wherever :attr:`OutageScheduler.can_schedule_more` is
also `True` and wherever the losses from this outage would not
cause the :attr:`OutageScheduler.total_losses` to exceed 100%.
"""
self.can_schedule_more &= self.scheduler.can_schedule_more
if self.outage.allow_outage_overlap:
total_new_losses = (self.scheduler.total_losses
+ self.outage.percentage_of_capacity_lost)
losses_will_not_exceed_100 = total_new_losses <= 100
self.can_schedule_more &= losses_will_not_exceed_100
else:
self.can_schedule_more &= self.scheduler.total_losses == 0
[docs] def find_random_outage_slice(self, seed=None):
"""Find a random slot of time for this type of outage.
This function randomly selects a starting time for this outage
given the allowed times in :attr:`can_schedule_more`. It does
**not** verify that the outage can be scheduled for the entire
requested duration.
Parameters
----------
seed : int, optional
Integer used to seed the :func:`np.random.choice` call.
If :obj:`None`, seed is not used.
Returns
-------
:obj:`slice`
A slice corresponding to the random slot of time for this
type of outage.
"""
if seed is not None:
np.random.seed(seed)
outage_ind = np.random.choice(np.where(self.can_schedule_more)[0])
return slice(outage_ind, outage_ind + self.outage.duration)
[docs] def schedule_losses(self, outage_slice):
"""Schedule the input outage during the given slice of time.
Given a slice in the hourly loss array, add the losses from this
outage (which is equivalent to scheduling them).
Parameters
----------
outage_slice : slice
A slice corresponding to the slot of time to schedule this
outage.
"""
self._scheduled_outage_inds.append(outage_slice.start)
self.scheduler.total_losses[outage_slice] += (
self.outage.percentage_of_capacity_lost)
if not self.outage.allow_outage_overlap:
self.scheduler.can_schedule_more[outage_slice] = False
[docs]class ScheduledLossesMixin:
"""Mixin class for :class:`reV.SAM.generation.AbstractSamGeneration`.
Warning
-------
Using this class for anything except as a mixin for
:class:`~reV.SAM.generation.AbstractSamGeneration` may result in
unexpected results and/or errors.
"""
OUTAGE_CONFIG_KEY = 'reV_outages'
"""Specify outage information in the config file using this key."""
OUTAGE_SEED_CONFIG_KEY = 'reV_outages_seed'
"""Specify a randomizer seed in the config file using this key."""
[docs] def add_scheduled_losses(self, resource=None):
"""Add stochastically scheduled losses to SAM config file.
This function reads the information in the ``reV_outages`` key
of the ``sam_sys_inputs`` dictionary and computes stochastically
scheduled losses from that input. If the value for
``reV_outages`` is a string, it must have been generated by
calling :func:`json.dumps` on the list of dictionaries
containing outage specifications. Otherwise, the outage
information is expected to be a list of dictionaries containing
outage specifications. See :class:`Outage` for a description of
the specifications allowed for each outage. The scheduled losses
are passed to SAM via the ``hourly`` key to signify which hourly
capacity factors should be adjusted with outage losses. If no
outage info is specified in ``sam_sys_inputs``, no scheduled
losses are added.
Parameters
----------
resource : pd.DataFrame, optional
Time series resource data for a single location with a
pandas DatetimeIndex. The ``year`` value of the index will
be used to seed the stochastically scheduled losses. If
`None`, no yearly seed will be used.
See Also
--------
:class:`Outage` : Single outage specification.
Notes
-----
The scheduled losses are passed to SAM via the ``hourly`` key to
signify which hourly capacity factors should be adjusted with
outage losses. If the user specifies other hourly adjustment
factors via the ``hourly`` key, the effect is combined. For
example, if the user inputs a 33% hourly adjustment factor and
reV schedules an outage for 70% of the farm down for the same
hour, then the resulting adjustment factor is
.. math: 1 - [(1 - 70/100) * (1 - 33/100)] = 0.799
This means the generation will be reduced by ~80%, because the
user requested 33% losses for the 30% the farm that remained
operational during the scheduled outage (i.e. 20% remaining of
the original generation).
"""
outages = self._user_outage_input()
if not outages:
return
self._set_base_seed(resource)
logger.debug("Adding the following stochastically scheduled outages: "
"{}".format(outages))
logger.debug("Scheduled outages seed: {}".format(self.outage_seed))
scheduler = OutageScheduler(outages, seed=self.outage_seed)
hourly_outages = scheduler.calculate()
self._add_outages_to_sam_inputs(hourly_outages)
logger.debug("Hourly adjustment factors after scheduled outages: {}"
.format(list(self.sam_sys_inputs['hourly'])))
def _user_outage_input(self):
"""Get outage and seed info from config. """
outage_specs = self.sam_sys_inputs.pop(self.OUTAGE_CONFIG_KEY, None)
if outage_specs is None:
return
# site-specific info is input as str
if isinstance(outage_specs, str):
outage_specs = json.loads(outage_specs)
outages = [Outage(spec) for spec in outage_specs]
return outages
def _set_base_seed(self, resource):
"""Set the base seed base don user input. """
self.__base_seed = 0
if resource is not None:
self.__base_seed += int(resource.index.year.values[0])
self.__base_seed += self.sam_sys_inputs.pop(
self.OUTAGE_SEED_CONFIG_KEY, 0)
def _add_outages_to_sam_inputs(self, outages):
"""Add the hourly adjustment factors to config, checking user input."""
hourly_mult = 1 - outages / 100
user_hourly_input = self.sam_sys_inputs.pop('hourly', [0] * 8760)
user_hourly_mult = 1 - np.array(user_hourly_input) / 100
final_hourly_mult = hourly_mult * user_hourly_mult
self.sam_sys_inputs['hourly'] = (1 - final_hourly_mult) * 100
@property
def outage_seed(self):
"""int: A value to use as the seed for the outage losses. """
# numpy seeds must be between 0 and 2**32 - 1
return self._seed_from_inputs() % 2**32
def _seed_from_inputs(self):
"""Get seed value from inputs. """
try:
return int(self.meta.name) + self.__base_seed
except (AttributeError, TypeError, ValueError):
pass
try:
return hash(tuple(self.meta)) + self.__base_seed
except (AttributeError, TypeError):
pass
return self.__base_seed