diff --git a/docs/source/conf.py b/docs/source/conf.py index 065ff137..f7734d00 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -7,6 +7,9 @@ import os import sys +from copy import deepcopy +from builtins import object + import pymgrid @@ -29,6 +32,7 @@ 'sphinx.ext.autosummary', 'sphinx.ext.doctest', 'sphinx.ext.linkcode', + 'sphinx.ext.intersphinx', 'nbsphinx', 'nbsphinx_link', 'IPython.sphinxext.ipython_console_highlighting' @@ -50,8 +54,13 @@ html_static_path = ['_static'] - -skip_members = ['yaml_flow_style'] +# These are attributes that don't have a __doc__ attribute to read ':meta private:' from. +skip_members = ['yaml_flow_style', + 'metadata', + 'render_mode', + 'reward_range', + 'spec' + ] def autodoc_skip_member(app, what, name, obj, skip, options): @@ -68,6 +77,17 @@ def autodoc_skip_member(app, what, name, obj, skip, options): return None +def autodoc_process_signature(app, what, name, obj, options, signature, return_annotation): + """ + If a class signature is being read from cls.__new__, we want to replace it with the signature from cls.__init__. + """ + if what == 'class' and signature[1:] in str(inspect.signature(obj.__new__)): + obj_copy = deepcopy(obj) + obj_copy.__new__ = object.__new__ + signature = str(inspect.signature(obj_copy)) + return signature, return_annotation + + def linkcode_resolve(domain, info): """ Determine the URL corresponding to Python object @@ -116,8 +136,14 @@ def linkcode_resolve(domain, info): fn = os.path.relpath(fn, start=os.path.dirname(pymgrid.__file__)) - return f"https://github.com/Total-RD/pymgrid/tree/v{pymgrid.__version__}/src/pymgrid/{fn}{linespec}" + return f'https://github.com/Total-RD/pymgrid/tree/v{pymgrid.__version__}/src/pymgrid/{fn}{linespec}' + + +intersphinx_mapping = { + 'gym': ('https://www.gymlibrary.dev/', None) +} def setup(app): app.connect('autodoc-skip-member', autodoc_skip_member) + app.connect('autodoc-process-signature', autodoc_process_signature) diff --git a/docs/source/reference/envs/index.rst b/docs/source/reference/envs/index.rst new file mode 100644 index 00000000..47b3fec7 --- /dev/null +++ b/docs/source/reference/envs/index.rst @@ -0,0 +1,30 @@ +.. _api.envs: + +Reinforcement Learning +====================== + +.. currentmodule:: pymgrid.envs + +Environment classes using the `OpenAI Gym API `_ for reinforcement learning. + +Discrete Environment +-------------------- + +Environment with a discrete action space. + + +.. autosummary:: + :toctree: ../api/envs/ + + DiscreteMicrogridEnv + +Continuous Environment +---------------------- + +Environment with a discrete action space. + + +.. autosummary:: + :toctree: ../api/envs/ + + ContinuousMicrogridEnv diff --git a/docs/source/reference/index.rst b/docs/source/reference/index.rst index a69f9b4a..e258a6f6 100644 --- a/docs/source/reference/index.rst +++ b/docs/source/reference/index.rst @@ -8,3 +8,4 @@ This page contains an overview of all public *pymgrid* objects and functions. microgrid modules/index + envs/index diff --git a/local/pymgrid reports.docx b/local/pymgrid reports.docx new file mode 100644 index 00000000..7146e8c9 Binary files /dev/null and b/local/pymgrid reports.docx differ diff --git a/src/pymgrid/envs/base/base.py b/src/pymgrid/envs/base/base.py index f64e8cd7..7582636f 100644 --- a/src/pymgrid/envs/base/base.py +++ b/src/pymgrid/envs/base/base.py @@ -7,19 +7,63 @@ class BaseMicrogridEnv(Microgrid, Env): + """ + Base class for all microgrid environments. + + Implements the `OpenAI Gym API `_ for a microgrid; + inherits from both :class:`.Microgrid` and :class:`gym.Env`. + + Parameters + ---------- + modules : list, Microgrid, NonModularMicrogrid, or int. + The constructor can be called in three ways: + + 1. Passing a list of microgrid modules. This is identical to the :class:`.Microgrid` constructor. + + 2. Passing a :class:`.Microgrid` or :class:`.NonModularMicrogrid` instance. + This will effectively wrap the microgrid instance with the Gym API. + + 3. Passing an integer in [0, 25). + This will be result in loading the corresponding `pymgrid25` benchmark microgrids. + + add_unbalanced_module : bool, default True. + Whether to add an unbalanced energy module to your microgrid. Such a module computes and attributes + costs to any excess supply or demand. + Set to True unless ``modules`` contains an :class:`.UnbalancedEnergyModule`. + + loss_load_cost : float, default 10.0 + Cost per unit of unmet demand. Ignored if ``add_unbalanced_module=False``. + + overgeneration_cost : float, default 2.0 + Cost per unit of excess generation. Ignored if ``add_unbalanced_module=False``. + + flat_spaces : bool, default True + Whether the environment's spaces should be flat. + + If True, all continuous spaces are :class:`gym:gym.spaces.Box`. + + Otherwise, they are nested :class:`gym:gym.spaces.Dict` of :class:`gym:gym.spaces.Tuple` + of :class:`gym:gym.spaces.Box`, corresponding to the structure of the ``control`` arg of :meth:`.Microgrid.run`. + + """ + + action_space = None + 'Space object corresponding to valid actions.' + + observation_space = None + 'Space object corresponding to valid observations.' + def __new__(cls, modules, *args, **kwargs): if isinstance(modules, (NonModularMicrogrid, Microgrid)): instance = cls.from_microgrid(modules) - cls.__init__ = skip_init(cls, cls.__init__) - return instance - elif "scenario" in kwargs or "microgrid_number" in kwargs: - scenario = kwargs.get("scenario", "pymgrid25") - microgrid_number = kwargs.get("microgrid_number", 0) - instance = cls.from_scenario(microgrid_number=microgrid_number) - cls.__init__ = skip_init(cls, cls.__init__) - return instance - return super().__new__(cls) + elif isinstance(modules, int): + instance = cls.from_scenario(modules) + else: + return super().__new__(cls) + + cls.__init__ = skip_init(cls, cls.__init__) + return instance def __init__(self, modules, @@ -59,8 +103,39 @@ def reset(self): obs = super().reset() return flatten(self._nested_observation_space, obs) if self._flat_spaces else obs + @property + def flat_spaces(self): + """ + Whether the environment's spaces are flat. + + If True, all continuous spaces are :class:`gym:gym.spaces.Box`. + + Otherwise, they are nested :class:`gym:gym.spaces.Dict` of :class:`gym:gym.spaces.Tuple` + of :class:`gym:gym.spaces.Box`, corresponding to the structure of the ``control`` arg of :meth:`Microgrid.run`. + + Returns + ------- + flat_spaces : bool + Whether the environment's spaces are flat. + + """ + return self._flat_spaces + @classmethod def from_microgrid(cls, microgrid): + """ + Construct a microgrid from + + Parameters + ---------- + microgrid_number : int, default 0 + Number of the microgrid to return. ``0<=microgrid_number<25``. + + Returns + ------- + scenario : pymgrid.Microgrid + The loaded microgrid. + """ try: return cls(microgrid.module_tuples(), add_unbalanced_module=False) except AttributeError: diff --git a/src/pymgrid/envs/base/skip_init.py b/src/pymgrid/envs/base/skip_init.py index 38ce456f..fc2d901f 100644 --- a/src/pymgrid/envs/base/skip_init.py +++ b/src/pymgrid/envs/base/skip_init.py @@ -1,9 +1,19 @@ def skip_init(cls, init): """ Skip init once on cls, and then revert to original init. - :param cls: Class to skip init on. - :param init: original init. - :return: callable that skips init once. + + Parameters + ---------- + cls : Type + Class to skip init on. + init : callable + Original init. + + Returns + ------- + skip_init : callable + Callable that skips init once. + """ def reset_init(*args, **kwargs): cls.__init__ = init diff --git a/src/pymgrid/envs/continuous/continuous.py b/src/pymgrid/envs/continuous/continuous.py index e45805b1..a3715785 100644 --- a/src/pymgrid/envs/continuous/continuous.py +++ b/src/pymgrid/envs/continuous/continuous.py @@ -5,26 +5,53 @@ class ContinuousMicrogridEnv(BaseMicrogridEnv): - def __init__(self, - modules, - add_unbalanced_module=True, - loss_load_cost=10, - overgeneration_cost=2, - flat_spaces=True - ): + """ + Microgrid environment with a continuous action space. - self._nested_action_space = self._get_nested_action_space() - super().__init__(modules, - add_unbalanced_module=add_unbalanced_module, - loss_load_cost=loss_load_cost, - overgeneration_cost=overgeneration_cost, - flat_spaces=flat_spaces) + Implements the `OpenAI Gym API `_ for a microgrid; + inherits from both :class:`.Microgrid` and :class:`gym.Env`. + + Parameters + ---------- + modules : list, Microgrid, NonModularMicrogrid, or int. + The constructor can be called in three ways: + + 1. Passing a list of microgrid modules. This is identical to the :class:`.Microgrid` constructor. + + 2. Passing a :class:`.Microgrid` or :class:`.NonModularMicrogrid` instance. + This will effectively wrap the microgrid instance with the Gym API. + + 3. Passing an integer in [0, 25). + This will be result in loading the corresponding `pymgrid25` benchmark microgrids. + + add_unbalanced_module : bool, default True. + Whether to add an unbalanced energy module to your microgrid. Such a module computes and attributes + costs to any excess supply or demand. + Set to True unless ``modules`` contains an :class:`.UnbalancedEnergyModule`. + + loss_load_cost : float, default 10.0 + Cost per unit of unmet demand. Ignored if ``add_unbalanced_module=False``. + + overgeneration_cost : float, default 2.0 + Cost per unit of excess generation. Ignored if ``add_unbalanced_module=False``. + + flat_spaces : bool, default True + Whether the environment's spaces should be flat. + + If True, all continuous spaces are :class:`gym:gym.spaces.Box`. + + Otherwise, they are nested :class:`gym:gym.spaces.Dict` of :class:`gym:gym.spaces.Tuple` + of :class:`gym:gym.spaces.Box`, corresponding to the structure of the ``control`` arg of :meth:`.Microgrid.run`. + + """ + _nested_action_space = None def _get_nested_action_space(self): return Dict({name: Tuple([module.action_spaces['normalized'] for module in modules_list]) for name, modules_list in self.fixed.iterdict() if modules_list[0].is_source}) def _get_action_space(self): + self._nested_action_space = self._get_nested_action_space() return flatten_space(self._nested_action_space) if self._flat_spaces else self._nested_action_space def _get_action(self, action): diff --git a/src/pymgrid/envs/discrete/discrete.py b/src/pymgrid/envs/discrete/discrete.py index e4f09972..acd258a7 100644 --- a/src/pymgrid/envs/discrete/discrete.py +++ b/src/pymgrid/envs/discrete/discrete.py @@ -1,8 +1,10 @@ -from itertools import permutations -from warnings import warn import numpy as np +import yaml + +from itertools import permutations from gym.spaces import Discrete from math import isclose +from warnings import warn from pymgrid.envs.base import BaseMicrogridEnv from pymgrid.utils.logger import ModularLogger @@ -24,6 +26,11 @@ class DiscreteMicrogridEnv(BaseMicrogridEnv): Each tuple contains three elements (module_name, total_actions_for_{module_name}, action_num). For example: (('genset', 0), 2, 1) is a tuple defining the first element (of two) for ('genset', 0). """ + + yaml_tag = u"!DiscreteMicrogridEnv" + yaml_loader = yaml.SafeLoader + yaml_dumper = yaml.SafeDumper + def __init__(self, modules, add_unbalanced_module=True, diff --git a/src/pymgrid/microgrid/microgrid.py b/src/pymgrid/microgrid/microgrid.py index c1cef1b7..960ffe21 100644 --- a/src/pymgrid/microgrid/microgrid.py +++ b/src/pymgrid/microgrid/microgrid.py @@ -523,7 +523,7 @@ def dump(self, stream=None): on the value of ``stream``. If ``stream is None``, array-like objects are serialized inline. If ``stream`` is a stream to a file-like object, however, array-like objects will be serialized as `.csv.gz` files in a directory relative to ``stream``, and the relative locations stored inline in the YAML file. For an example of - this behavior, see `data/scenario/pymgrid25/microgrid_0`. + this behavior, see `data/scenario/pymgrid25/microgrid_0`. """ return yaml.safe_dump(self, stream=stream) @@ -630,7 +630,7 @@ def from_scenario(cls, microgrid_number=0): Parameters ---------- microgrid_number : int, default 0 - Number of the microgrid to return. 0<=microgrid_number<25. + Number of the microgrid to return. ``0<=microgrid_number<25``. Returns ------- @@ -639,6 +639,10 @@ def from_scenario(cls, microgrid_number=0): """ from pymgrid import PROJECT_PATH n = microgrid_number + + if n not in np.arange(25): + raise TypeError(f'Invalid microgrid_number {n}, must be an integer in the range [0, 25).') + with open(PROJECT_PATH / f"data/scenario/pymgrid25/microgrid_{n}/microgrid_{n}.yaml", "r") as f: return cls.load(f) diff --git a/src/pymgrid/modules/base/base_module.py b/src/pymgrid/modules/base/base_module.py index b1ffb885..03e08794 100644 --- a/src/pymgrid/modules/base/base_module.py +++ b/src/pymgrid/modules/base/base_module.py @@ -410,7 +410,7 @@ def to_normalized(self, value, act=False, obs=False): def from_normalized(self, value, act=False, obs=False): """ - Un-normalized an action or observation. + Un-normalize an action or observation. Parameters ---------- diff --git a/src/pymgrid/modules/genset_module.py b/src/pymgrid/modules/genset_module.py index d05f4ce0..e3f19ef7 100644 --- a/src/pymgrid/modules/genset_module.py +++ b/src/pymgrid/modules/genset_module.py @@ -48,7 +48,7 @@ class GensetModule(BaseMicrogridModule): If False, actions are clipped to the limit possible. provided_energy_name : str, default "genset_production" - Name of the energy provided by this microgrid used in logging. + Name of the energy provided by this module, to be used in logging. """ module_type = 'genset', 'fixed' diff --git a/src/pymgrid/modules/load_module.py b/src/pymgrid/modules/load_module.py index 438e069d..f1e2ad2a 100644 --- a/src/pymgrid/modules/load_module.py +++ b/src/pymgrid/modules/load_module.py @@ -6,6 +6,51 @@ class LoadModule(BaseTimeSeriesMicrogridModule): + """ + A renewable energy module. + + The classic examples of renewables are photovoltaics (PV) and wind turbines. + + Parameters + ---------- + time_series : array-like, shape (n_steps, ) + Time series of load demand. + + loss_load_cost : float, default 10.0 + Cost per unit of loss load (unmet load). + + forecaster : callable, float, "oracle", or None, default None. + Function that gives a forecast n-steps ahead. + + * If ``callable``, must take as arguments ``(val_c: float, val_{c+n}: float, n: int)``, where + + * ``val_c`` is the current value in the time series: ``self.time_series[self.current_step]`` + + * ``val_{c+n}`` is the value in the time series n steps in the future + + * n is the number of steps in the future at which we are forecasting. + + The output ``forecast = forecaster(val_c, val_{c+n}, n)`` must have the same sign + as the inputs ``val_c`` and ``val_{c+n}``. + + * If ``float``, serves as a standard deviation for a mean-zero gaussian noise function + that is added to the true value. + + * If ``"oracle"``, gives a perfect forecast. + + * If ``None``, no forecast. + + forecast_horizon : int. + Number of steps in the future to forecast. If forecaster is None, ignored and 0 is returned. + + forecaster_increase_uncertainty : bool, default False + Whether to increase uncertainty for farther-out dates if using a GaussianNoiseForecaster. Ignored otherwise.. + + raise_errors : bool, default False + Whether to raise errors if bounds are exceeded in an action. + If False, actions are clipped to the limit possible. + + """ module_type = ('load', 'fixed') yaml_tag = u"!LoadModule" yaml_dumper = yaml.SafeDumper @@ -13,7 +58,7 @@ class LoadModule(BaseTimeSeriesMicrogridModule): def __init__(self, time_series, - loss_load_cost, + loss_load_cost=10.0, forecaster=None, forecast_horizon=DEFAULT_HORIZON, forecaster_increase_uncertainty=False, @@ -53,6 +98,15 @@ def max_consumption(self): @property def current_load(self): + """ + Current load. + + Returns + ------- + load : float + Current load demand. + + """ return self._time_series[self._current_step] @property diff --git a/src/pymgrid/modules/renewable_module.py b/src/pymgrid/modules/renewable_module.py index fd5ea6d7..6125ba75 100644 --- a/src/pymgrid/modules/renewable_module.py +++ b/src/pymgrid/modules/renewable_module.py @@ -6,6 +6,51 @@ class RenewableModule(BaseTimeSeriesMicrogridModule): + """ + A renewable energy module. + + The classic examples of renewables are photovoltaics (PV) and wind turbines. + + Parameters + ---------- + time_series : array-like, shape (n_steps, ) + Time series of renewable production. + + forecaster : callable, float, "oracle", or None, default None. + Function that gives a forecast n-steps ahead. + + * If ``callable``, must take as arguments ``(val_c: float, val_{c+n}: float, n: int)``, where + + * ``val_c`` is the current value in the time series: ``self.time_series[self.current_step]`` + + * ``val_{c+n}`` is the value in the time series n steps in the future + + * n is the number of steps in the future at which we are forecasting. + + The output ``forecast = forecaster(val_c, val_{c+n}, n)`` must have the same sign + as the inputs ``val_c`` and ``val_{c+n}``. + + * If ``float``, serves as a standard deviation for a mean-zero gaussian noise function + that is added to the true value. + + * If ``"oracle"``, gives a perfect forecast. + + * If ``None``, no forecast. + + forecast_horizon : int. + Number of steps in the future to forecast. If forecaster is None, ignored and 0 is returned. + + forecaster_increase_uncertainty : bool, default False + Whether to increase uncertainty for farther-out dates if using a GaussianNoiseForecaster. Ignored otherwise. + + provided_energy_name: str, default "renewable_used" + Name of the energy provided by this module, to be used in logging. + + raise_errors : bool, default False + Whether to raise errors if bounds are exceeded in an action. + If False, actions are clipped to the limit possible. + + """ module_type = ('renewable', 'flex') yaml_tag = u"!RenewableModule" yaml_loader = yaml.SafeLoader @@ -45,6 +90,15 @@ def max_production(self): @property def current_renewable(self): + """ + Current renewable production. + + Returns + ------- + renewable : float + Renewable production. + + """ return self._time_series[self._current_step] @property diff --git a/src/pymgrid/modules/unbalanced_energy_module.py b/src/pymgrid/modules/unbalanced_energy_module.py index 7060f665..0f0c9479 100644 --- a/src/pymgrid/modules/unbalanced_energy_module.py +++ b/src/pymgrid/modules/unbalanced_energy_module.py @@ -33,12 +33,38 @@ def update(self, external_energy_change, as_source=False, as_sink=False): return reward, False, info def get_cost(self, energy_amount, as_source, as_sink): - if as_source: # loss-load + """ + Get the cost of unmet load or excess production. + + Parameters + ---------- + energy_amount : float>=0 + Amount of unmet load or excess production. + + as_source : bool + Whether the energy is unmet load. + + as_sink : bool + Whether the energy is excess production. + + Returns + ------- + cost : float + + Raises + ------ + TypeError + If both as_source and as_sink are True or neither are. + + """ + if as_source and as_sink: + raise TypeError("as_source and as_sink cannot both be True.") + if as_source: # loss load return self.loss_load_cost*energy_amount elif as_sink: return self.overgeneration_cost*energy_amount else: - raise RuntimeError + raise TypeError("One of as_source or as_sink must be True.") @property def state_dict(self): @@ -50,7 +76,6 @@ def state(self): @property def min_obs(self): - # Min charge amount, min soc return np.array([]) @property @@ -80,5 +105,3 @@ def is_source(self): @property def is_sink(self): return True - -