Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add data sampling class #32

Open
wants to merge 14 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/source/custom_envs.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Create Custom Environments
===========================
==========================

By inheriting from the :code:`OpfEnv` base class, a wide variety of custom
environments can be created. In the process, some steps have to be considered.
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The repository can be found on
benchmarks
api_base_class
environment_design
sampling
custom_envs
advanced_features
supervised_learning
Expand Down
50 changes: 50 additions & 0 deletions docs/source/sampling.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Data Sampling
=============

The StateSampler Class
-----------------------

In the gymnasium :meth:`reset` method, the environment is set to some random
state. This is done by the :meth:`_sampling` method of the base class, which
calls a :class:`StateSampler` object. In *OPF-Gym*, three standard data samplers
are pre-implemented: The :class:`SimbenchSampler`, the :class:`NormalSampler`,
and the :class:`UniformSampler`. The default in *OPF-Gym* is to use SimBench
data for active and reactive power values, and a uniform distribution for state
variables that are not included in the SimBench data (e.g. prices, slack
voltages, etc.).


SimBench Data
_____________________
.. autoclass:: opfgym.sampling.SimbenchSampler
:members:

Normal Distribution Data
_______________________________
.. autoclass:: opfgym.sampling.NormalSampler
:members:

Uniform Distribution Data
_______________________________
.. autoclass:: opfgym.sampling.UniformSampler
:members:


Data Sampling Wrappers
----------------------

In many cases, we want to combine multiple distributions for different state
variables, for example by sampling generation data from one distribution
and market prices from another. In *OPF-Gym*, this is done with the
:class:`StateSamplerWrapper` class. Two standard wrappers are pre-implemented:
The :class:`SequentialSampler` and the :class:`MixedRandomSampler`.

The SequentialSampler
_______________________________
.. autoclass:: opfgym.sampling.SequentialSampler
:members:

The MixedRandomSampler
_______________________________
.. autoclass:: opfgym.sampling.MixedRandomSampler
:members:
37 changes: 17 additions & 20 deletions opfgym/envs/eco_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class EcoDispatch(opf_env.OpfEnv):

"""

def __init__(self, simbench_network_name='1-HV-urban--0-sw',
def __init__(self, simbench_network_name='1-HV-urban--0-sw',
gen_scaling=1.0, load_scaling=1.5, max_price_eur_gwh=0.5,
min_power=0, *args, **kwargs):

Expand All @@ -41,14 +41,16 @@ def __init__(self, simbench_network_name='1-HV-urban--0-sw',

# Define the RL problem
# See all load power values, non-controlled generators, and generator prices...
obs_keys = [('load', 'p_mw', net.load.index),
('load', 'q_mvar', net.load.index),
('poly_cost', 'cp1_eur_per_mw', net.poly_cost.index),
('pwl_cost', 'cp1_eur_per_mw', net.pwl_cost.index),
# These 3 are not relevant because len=0, if the default is used
('sgen', 'p_mw', net.sgen.index[~net.sgen.controllable]),
('storage', 'p_mw', net.storage.index),
('storage', 'q_mvar', net.storage.index)]
obs_keys = [
('load', 'p_mw', net.load.index),
('load', 'q_mvar', net.load.index),
('poly_cost', 'cp1_eur_per_mw', net.poly_cost.index),
('pwl_cost', 'cp1_eur_per_mw', net.pwl_cost.index),
# These 3 are not relevant because len=0, if the default is used
('sgen', 'p_mw', net.sgen.index[~net.sgen.controllable]),
('storage', 'p_mw', net.storage.index),
('storage', 'q_mvar', net.storage.index)
]

# ... and control all generators' active power values
act_keys = [('sgen', 'p_mw', net.sgen.index[net.sgen.controllable]),
Expand Down Expand Up @@ -89,14 +91,14 @@ def _define_opf(self, simbench_network_name, *args, **kwargs):

# Add price params to the network (as poly cost so that the OPF works)
# Note that the external grids are seen as normal power plants
for idx in net.ext_grid.index:
# Use piece-wise linear costs to prevent negative costs for negative
# power, which would incentivize a constraint violation (see above)
pp.create_pwl_cost(net, idx, 'ext_grid', points=[[0, 10000, 1]])
for idx in net.sgen.index[net.sgen.controllable]:
pp.create_poly_cost(net, idx, 'sgen', cp1_eur_per_mw=0)
for idx in net.gen.index[net.gen.controllable]:
pp.create_poly_cost(net, idx, 'gen', cp1_eur_per_mw=0)
# Use piece-wise linear costs to prevent negative costs for negative
# power, which would incentivize a constraint violation (see above)
for idx in net.ext_grid.index:
pp.create_pwl_cost(net, idx, 'ext_grid', points=[[0, 10000, 1]])

net.poly_cost['min_cp1_eur_per_mw'] = 0
net.poly_cost['max_cp1_eur_per_mw'] = self.max_price_eur_gwh
Expand All @@ -111,13 +113,8 @@ def _define_opf(self, simbench_network_name, *args, **kwargs):
def _sampling(self, *args, **kwargs):
super()._sampling(*args, **kwargs)

# Sample prices uniformly from min/max range for gens/sgens/ext_grids
self._sample_from_range(
'poly_cost', 'cp1_eur_per_mw', self.net.poly_cost.index)
self._sample_from_range(
'pwl_cost', 'cp1_eur_per_mw', self.net.pwl_cost.index)

# Manually update the costs in the pwl 'points' definition
# Manually update the costs in the pwl 'points' definition so that it's
# usable for the pandapower OPF
for idx in self.net.ext_grid.index:
price = self.net.pwl_cost.at[idx, 'cp1_eur_per_mw']
self.net.pwl_cost.at[idx, 'points'] = [[0, 10000, price]]
Expand Down
12 changes: 2 additions & 10 deletions opfgym/envs/load_shedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ def __init__(self, simbench_network_name='1-MV-comm--2-sw',
('load', 'p_mw', net.load.index),
('load', 'q_mvar', net.load.index),
('storage', 'p_mw', net.storage.index[~net.storage.controllable]),
# ('poly_cost', 'cp1_eur_per_mw', net.poly_cost.index), # Separately sampled in _sampling(), see below
# ('pwl_cost', 'cp1_eur_per_mw', net.pwl_cost.index)
('poly_cost', 'cp1_eur_per_mw', net.poly_cost.index),
('pwl_cost', 'cp1_eur_per_mw', net.pwl_cost.index)
]

# Control active power of loads and storages
Expand Down Expand Up @@ -122,14 +122,6 @@ def _define_opf(self, simbench_network_name, *args, **kwargs):
def _sampling(self, *args, **kwargs):
super()._sampling(*args, **kwargs)

# Sample prices for loads and storages
# The idea is that not always the same loads should be shedded. Instead,
# the current situation should be considered, represented by some price.
self._sample_from_range(
'poly_cost', 'cp1_eur_per_mw', self.net.poly_cost.index)
self._sample_from_range(
'pwl_cost', 'cp1_eur_per_mw', self.net.pwl_cost.index)

# Manually update the points of the piece-wise linear costs for storage
for idx in self.net.pwl_cost.index:
price = self.net.pwl_cost.at[idx, 'cp1_eur_per_mw']
Expand Down
7 changes: 0 additions & 7 deletions opfgym/envs/voltage_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,6 @@ def _define_opf(self, simbench_network_name, *args, **kwargs):
def _sampling(self, *args, **kwargs):
super()._sampling(*args, **kwargs)

# Sample reactive power prices uniformly from min/max range
if self.market_based:
for unit_type in ('sgen', 'ext_grid', 'storage'):
self._sample_from_range(
'poly_cost', 'cq2_eur_per_mvar2',
self.net.poly_cost[self.net.poly_cost.et == unit_type].index)

# Active power is not controllable (only relevant for OPF baseline)
# Set active power boundaries to current active power values
for unit_type in ('sgen', 'storage'):
Expand Down
1 change: 1 addition & 0 deletions opfgym/examples/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
from .stochastic_obs import StochasticObs
from .pure_constraint_satisfaction import ConstraintSatisfaction
from .custom_constraint import AddCustomConstraint
from .custom_sampling import CustomSampling
106 changes: 106 additions & 0 deletions opfgym/examples/custom_sampling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

import numpy as np
import pandapower as pp
from scipy.stats import weibull_min

from opfgym import opf_env
import opfgym.sampling as sampling
from opfgym.simbench.build_simbench_net import build_simbench_net
import opfgym.constraints as constraints
from opfgym.simbench.data_split import define_test_train_split


class CustomWindPowerSampler(sampling.StateSampler):
""" Custom sampler that uses the Weibull distribution for wind power
generation"""
def sample_state(self, net, *args, **kwargs):
# Weibull distribution
shape = 2.0
scale = 10.0
wind_speeds = weibull_min.rvs(shape, scale=scale, size=1)[0]

# Assumption: cubic relationship
relative_power = np.clip((wind_speeds / scale) ** 3, 0, 1)

# Assumption: All wind turbines are close to each other -> same wind speed for all of them
idxs = net.sgen["type"].str.contains("wind", case=False, na=False)
max_power = net.sgen.loc[idxs, 'max_max_p_mw'] / net.sgen.loc[idxs, 'scaling']
net.sgen.loc[idxs, "p_mw"] = relative_power * max_power

return net


class CustomSampling(opf_env.OpfEnv):
def __init__(
self,
simbench_network_name='1-LV-urban6--0-sw',
cos_phi=0.95,
*args, **kwargs
):

self.cos_phi = cos_phi
net, profiles = self._define_opf(
simbench_network_name, *args, **kwargs)

obs_keys = [
('load', 'p_mw', net.load.index),
('load', 'q_mvar', net.load.index),
('sgen', 'p_mw', net.sgen.index[~net.sgen.controllable])
]

act_keys = [('sgen', 'q_mvar', net.sgen.index[net.sgen.controllable])]

# Explicitly define the data split into train/validation/test
simbench_data_split = define_test_train_split(**kwargs)
# Define the data sampler: Use SimBench data for everything and
# overwrite with out custom distribution afterwards
train_sampling = sampling.SequentialSampler(samplers=[
sampling.SimbenchSampler(
obs_keys,
profiles=profiles,
available_steps=simbench_data_split[0],
**kwargs
),
# By defining the custom sampler after the SimBench sampler, the
# SimBench values will be overwritten, which is intentional here.
CustomWindPowerSampler()
])

super().__init__(net, act_keys, obs_keys, profiles=profiles,
train_sampling=train_sampling,
simbench_data_split=simbench_data_split,
*args, **kwargs)

def _define_opf(self, simbench_network_name, *args, **kwargs):
net, profiles = build_simbench_net(
simbench_network_name, *args, **kwargs)

# Define first two generators as wind
net.sgen.type[:2] = 'wind'

# Control all non-wind-turbine generators
wind = net.sgen["type"].str.contains("wind", case=False, na=False)
net.sgen['controllable'] = False
net.sgen.loc[net.sgen.index[~wind], 'controllable'] = True
net.sgen['min_p_mw'] = net.sgen['min_min_p_mw']
net.sgen['max_p_mw'] = net.sgen['max_max_p_mw']
net.sgen['min_q_mvar'] = 0.0
net.sgen['max_q_mvar'] = 0.0

# Set everything else to uncontrollable
for unit_type in ('load', 'gen', 'storage'):
net[unit_type]['controllable'] = False

# Objective: Minimize the active power flow from external grid
for idx in net.ext_grid.index:
pp.create_poly_cost(net, idx, 'ext_grid', cp1_eur_per_mw=1)

return net, profiles



if __name__ == '__main__':
env = CustomSampling()
for _ in range(5):
env.reset()
env.step(env.action_space.sample())
4 changes: 0 additions & 4 deletions opfgym/examples/mixed_continuous_discrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,6 @@ def _define_opf(self, simbench_network_name, *args, **kwargs):
def _sampling(self, *args, **kwargs):
super()._sampling(*args, **kwargs)

# Sample slack voltage randomly to make the problem more difficult
# so that trafo tap changing is required for voltage control
self._sample_from_range('ext_grid', 'vm_pu', self.net.ext_grid.index)

# Active power is not controllable (only relevant for OPF baseline)
# Set active power boundaries to current active power values
for unit_type in ('sgen',):
Expand Down
11 changes: 8 additions & 3 deletions opfgym/examples/multi_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@

class MultiStageOpf(MultiStageOpfEnv):
def __init__(self, simbench_network_name='1-LV-urban6--0-sw',
steps_per_episode=4, train_data='simbench',
test_data='simbench',
steps_per_episode=4, train_sampling='simbench',
test_sampling='simbench', validation_sampling='simbench',
*args, **kwargs):

assert steps_per_episode > 1, "At least two steps required for a multi-stage OPF."
assert 'simbench' in train_data and 'simbench' in test_data, "Only simbench networks are supported because time-series data required."
assert 'simbench' in train_sampling, "Only simbench networks are supported because time-series data required."
assert 'simbench' in test_sampling, "Only simbench networks are supported because time-series data required."
assert 'simbench' in validation_sampling, "Only simbench networks are supported because time-series data required."

net, profiles = self._define_opf(
simbench_network_name, *args, **kwargs)
Expand All @@ -41,6 +43,9 @@ def __init__(self, simbench_network_name='1-LV-urban6--0-sw',
super().__init__(net, act_keys, obs_keys, profiles=profiles,
steps_per_episode=steps_per_episode,
optimal_power_flow_solver=False,
train_sampling=train_sampling,
test_sampling=test_sampling,
validation_sampling=validation_sampling,
*args, **kwargs)

def _define_opf(self, simbench_network_name, *args, **kwargs):
Expand Down
14 changes: 10 additions & 4 deletions opfgym/examples/non_simbench_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@


class NonSimbenchNet(opf_env.OpfEnv):
def __init__(self, train_data='normal_around_mean',
test_data='normal_around_mean',
def __init__(self,
train_sampling='normal_around_mean',
test_sampling='normal_around_mean',
validation_sampling='normal_around_mean',
*args, **kwargs):

assert 'simbench' not in train_data and 'simbench' not in test_data, "Only non-simbench networks are supported."
assert 'simbench' not in train_sampling, "Only non-simbench networks are supported."
assert 'simbench' not in test_sampling, "Only non-simbench networks are supported."
assert 'simbench' not in validation_sampling, "Only non-simbench networks are supported."

net = self._define_opf()

Expand All @@ -30,7 +34,9 @@ def __init__(self, train_data='normal_around_mean',
act_keys = [('gen', 'p_mw', net.gen.index)]

super().__init__(net, act_keys, obs_keys,
train_data=train_data, test_data=test_data,
train_sampling=train_sampling,
test_sampling=test_sampling,
validation_sampling=validation_sampling,
*args, **kwargs)

def _define_opf(self):
Expand Down
19 changes: 14 additions & 5 deletions opfgym/multi_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,27 @@ class MultiStageOpfEnv(OpfEnv):
Same as the base class

"""
def __init__(self, *args, steps_per_episode: int=4, **kwargs):
def __init__(self, *args, steps_per_episode: int = 4, **kwargs):
assert steps_per_episode > 1, "At least two steps required for a multi-stage OPF."
if kwargs.get('train_data') and isinstance(kwargs.get('train_data')):
assert 'simbench' in kwargs.get('train_data')
assert 'simbench' in kwargs.get('train_sampling', 'simbench')
assert 'simbench' in kwargs.get('validation_sampling', 'simbench')
assert 'simbench' in kwargs.get('test_sampling', 'simbench')
super().__init__(*args, steps_per_episode=steps_per_episode, **kwargs)


def reset(self, seed=None, options=None):
super().reset(seed=seed, options=options)
if options:
self.test = options.get('test', False)
else:
self.test = False


def step(self, action):
""" Extend step method to sample the next time step of the simbench data. """
obs, reward, terminated, truncated, info = super().step(action)

new_step = self.current_simbench_step + 1
new_step = self.current_time_step + 1

# Enforce train/test-split
if self.test:
Expand All @@ -47,7 +56,7 @@ def step(self, action):
return obs, reward, terminated, truncated, info

# Increment the time-series states
self._sampling(step=new_step)
self._sampling(step=new_step, test=self.test)

# Rerun the power flow calculation for the new state if required
if self.pf_for_obs is True:
Expand Down
Loading