Source code for smartgrid.util.available_energy

"""
This module defines classes to generate an amount of available energy.

Each step, the :py:class:`World` "produces" a certain amount of energy, which
is made available to the :py:class:`Agent`\\ s. To adapt to various sizes of
environments(i.e., number of Agents), generators are given access to the
*total need* of all Agents in the World. This allows adapting to any number
of agents, and any :py:class:`AgentProfile`\\ s.
Note that this *total need* can be ignored, leading to a generator which
ignores the number of Agents.

Several generators are implemented in this module, using various methods:

- a random percent based on the agents' needs, for example an amount between
  80% and 120% of their total need.
- a scarcity variation, similar to the 1st one but with a random between
  60% and 80%.
- a generous variation, similar to the 1st one but with a random between
  100% and 120%.
- a realistic variation, using real data.

All these methods lead to different bounds for the amount of available energy.

Knowing these bounds, and especially the upper one (we can assume `0` for
the lower bound), allows us to scale the amount of available energy to `[0,1]`
when computing :py:class:`Observation`\\ s.

Therefore, instead of using a simple function to generate this amount,
we use a class that defines 2 functions, one for generating the amount,
and the other to return the bounds.
"""

from __future__ import annotations

import warnings
from abc import ABC, abstractmethod
from typing import Tuple

import numpy as np


[docs] class EnergyGenerator(ABC): """ An *EnergyGenerator* is responsible for the production of energy each step. """ _random_generator: np.random.Generator """ The pseudo-random number generator (PRNG). EnergyGenerators that are purely deterministic can safely ignore this attribute; those that rely on random generation *must* use exclusively this attribute, to ensure reproducibility. To set this attribute (and thus, to set the random seed), use the :py:meth:`.set_random_generator` method. It is initialized by default, so the EnergyGenerator can be used as-is without needing to configure. """
[docs] def __init__(self): self._random_generator = np.random.default_rng()
def set_random_generator(self, random_generator: np.random.Generator): self._random_generator = random_generator def __str__(self): return type(self).__name__
[docs] @abstractmethod def generate_available_energy(self, current_need: int, current_step: int, min_need: int, max_need: int) -> int: """ Generate an amount of available energy during a single time step. EnergyGenerators can use any method to compute this amount, e.g., returning a fixed value, drawing from a distribution, using an array of realistic data for each time step, etc. :param current_need: The total energy needed by all agents at the current step. This value can be used to effectively scale the generator to the current agent population. This value can also be ignored by the generator. The ``current_need`` should be comprised between ``min_need`` and ``max_need``. :param current_step: The current time step. Mostly used by "realistic" or data-based generators that need to know the current date/hour. :param min_need: The minimum energy needed by all agents, for all time steps. It does not need to be exact, and only serve as a lower bound, e.g., ``0`` is a perfectly sane value. However, the more accurate this value is, the more accurate the scaling of the :py:class:`.Observation` space will be. :param max_need: The maximum energy needed by all agents, for all time steps. It does not need to be exact, and only serves as an upper bound, e.g., any sufficiently high value can be used. However, the more accurate this value is, the more accurate the scaling of the :py:class:`.Observation` space will be. :return: The amount of available energy, a value in :math:`\\mathbb{R}`. For example, returning ``40_000`` means that 40,000Wh are available for the current time step. """ pass
[docs] @abstractmethod def available_energy_bounds(self, current_need: int, current_step: int, min_need: int, max_need: int) -> Tuple[int, int]: """ Determine the possible min and max bounds for the energy generation. This method is used to provide a range (a domain), which is important for specifying the :py:class:`.Observation` space of :py:class:`.Agent`\\ s. This also allows scaling the generated amount to ``[0,1]``. For example, assuming the bounds are ``[0, 10_000]``, a generated amount of ``8_000`` can be scaled to ``0.8``, which is easier to use by learning algorithms. :param current_need: The total energy needed by all agents at the current step. :param current_step: The current time step. :param min_need: The minimum energy needed by all agents, for all time steps. This value is used for the same objective as ``max_need``, but it does not need to be accurate, e.g., ``0`` can be used as a safe default. :param max_need: The maximum energy needed by all agents, for all time steps. This value can be used to accurately determine the bounds for all time steps, instead of a single time step. The ``need_at_step`` should always be lower or equal to the ``max_need``. :return: The min and max bounds of the energy generator, i.e., the minimum and maximum possible values that :py:meth:`.generate_available_energy` may return. It is important that these bounds are coherent with the method, otherwise scaling may not work properly, and Agents may receive incorrect observations. .. note: To avoid changing the Observation space, the available energy bounds *should not* shift from a time step to another. In other words, this method *should* return the same bounds for any value of ``current_need`` and ``current_step``. However, the code structure intentionally allows not respecting this, to avoid restricting potential experiments. It can be considered as a "not recommended" setup. """ pass
[docs] class RandomEnergyGenerator(EnergyGenerator): """ Generate a random amount, with respect to the agents' current energy needed. Assuming that the total maximum energy needed is ``M``, that we want at least a lower bound of L=80% (i.e., L=0.8), and an upper bound of U=120% (i.e., U=1.2), this class returns amounts in the interval ``[L*M, U*M]``. Knowing the minimum sum of energy needed by all agents ``minM``, we derive that the lowest amount of energy that can be produced by this generator is ``L*minM``, for any time step. Similarly, assuming the maximum sum is ``maxM``, the highest amount that can be produced is ``U*maxM``. Thus, this generator's possible bounds are ``[L*minM, U*maxM]``. Lower and upper bounds are configurable. """ lower: float """Lower bound for generating energy, in proportion of the total need.""" upper: float """Upper bound for generating energy, in proportion of the total need."""
[docs] def __init__(self, lower_proportion=0.8, upper_proportion=1.2, ): super().__init__() self.lower = lower_proportion self.upper = upper_proportion
[docs] def generate_available_energy(self, current_need: int, current_step: int, min_need: int, max_need: int): if not min_need <= current_need <= max_need: warnings.warn('Incoherent current need and min/max needs; ' f'found min={min_need}, current_need={current_need}, ' f'max_need={max_need}. Continuing, but the result ' 'may be incoherent with the possible bounds.') lower_bound = int(self.lower * current_need) upper_bound = int(self.upper * current_need) return self._random_generator.integers(lower_bound, upper_bound + 1)
[docs] def available_energy_bounds(self, current_need: int, current_step: int, min_need: int, max_need: int): lower_bound = int(self.lower * min_need) upper_bound = int(self.upper * max_need) return lower_bound, upper_bound
[docs] class ScarceEnergyGenerator(RandomEnergyGenerator): """ Similar to the :py:class:`.RandomEnergyGenerator`, but simulating scarcity. In practice, the bounds are set to [60%, 80%]. Note that, as the upper bound is set to less 100% of the max, we force conflicts between agents by not giving them enough. """ lower: float upper: float
[docs] def __init__(self): super(ScarceEnergyGenerator, self).__init__( lower_proportion=0.6, upper_proportion=0.8, )
[docs] class GenerousEnergyGenerator(RandomEnergyGenerator): """ Similar to the :py:class:`.RandomEnergyGenerator`, but simulating a generous env. In practice, the bounds are set to [100%, 120%]. Note that, as the lower bound is set to 100% of the max, we always have enough energy available for all agents. """ lower: float upper: float
[docs] def __init__(self): super(GenerousEnergyGenerator, self).__init__( lower_proportion=1.0, upper_proportion=1.2, )
[docs] class RealisticEnergyGenerator(EnergyGenerator): """ A realistic generator that generates energy based on real-world data. The ``data`` parameter should be a NumPy ndarray giving the ratio of energy for each step, with respect to the maximum amount of energy needed by the agents. For example, ``[0.3, 0.8, 0.7]`` means that at the 1st step, we should make 30% of the agents' maximum need available ; 80% at the 2nd step, and 70% at the 3rd step. """ data: np.ndarray """ Data representing how much of the maximum need should be available each step. """
[docs] def __init__(self, data): super().__init__() data = np.asarray(data) assert len(data.shape) == 1 self._data = data
[docs] def generate_available_energy(self, current_need: int, current_step: int, min_need: int, max_need: int): step = current_step % len(self._data) ratio = self._data[step] return int(ratio * max_need)
[docs] def available_energy_bounds(self, current_need: int, current_step: int, min_need: int, max_need: int): min_ratio = min(self._data) max_ratio = max(self._data) lower_bound = int(min_ratio * max_need) upper_bound = int(max_ratio * max_need) return lower_bound, upper_bound