Agents profiles¶
The AgentProfile
contains a
few common characteristics of Agents. It represents somewhat a “type” or “kind”
of Agents, such as Household, Office, or School.
Creating new Agent Profiles thus allows creating new kinds of Agents. In particular, the following characteristics are part of Profiles:
The need function, which determines the energy needed by an agent at each time step.
The production function, which determines the energy locally produced by an agent at each time step.
The comfort function, which determines the agent’s comfort, based on its energy need and energy consumed during a given time step.
The max storage, which is the capacity of the agent’s personal battery.
In this simulator, the AgentProfiles are created by the
DataConversion
.
The data conversion classes are responsible for loading agent profiles from
raw data files (i.e., datasets).
These data may be hard-coded or come from realistic datasets, using various
formats. Thus, to allow any type of data file to be used, the data conversion
classes represent some kind of bridging interface: they are specialized to a
given type of data files (e.g, the data/openai
files) and create
AgentProfile
s
that are agnostic to the data files, and provide the common characteristics to
the Agent
s.
Data conversion¶
New data conversion classes can be created to support new datasets, using different file formats.
To illustrate this, assume that we want to load the needs and productions from
CSV files. The principal method of a
DataConversion
is
load()
.
Assuming a CSV file in the following form:
Step,Need,Production
0,100,50
1,200,30
2,150,70
The following code loads such a CSV into a usable agent profile:
from smartgrid.agents import DataConversion
from smartgrid.agents.profile import comfort, AgentProfile, NeedProfile, ProductionProfile
import numpy as np
import csv
class CSVDataConversion(DataConversion):
def load(self, name, data_path):
needs = [] # Amount of need each step
productions = [] # Amount of production each step
with open(data_path, 'r') as file:
reader = csv.DictReader(file, fieldnames=['Step', 'Need', 'Production'])
for row in reader:
needs.append(int(row['Need']))
productions.append(int(row['Production']))
# The default `NeedProfile` simply returns the need corresponding
# to the current step (cycles over the array of given needs).
need_profile = NeedProfile(np.asarray(needs))
# The default `ProductionProfile` works similarly.
production_profile = ProductionProfile(np.asarray(productions))
# The minimum value allowed for action parameters (e.g., consuming).
low = 0
# The maximum value allowed for action parameters. Recommended to
# have more than the maximum need so that agents are allowed to
# consume as much as they need (even if it might not be desirable
# at a given step, due to ethical considerations).
high = max(needs) + 100
# The agents' personal storage capacity.
max_storage = 120
# The agents' comfort function.
comfort_fn = comfort.neutral_comfort_profile
# Create the Agent Profile.
profile = AgentProfile(
name=name,
action_space_low=low,
action_space_high=high,
max_storage=max_storage,
need_profile=need_profile,
production_profile=production_profile,
action_dim=len(Action._fields),
comfort_fn=comfort_fn
)
# The profile must be registered in the `profiles` dict to be
# reused later.
self.profiles[name] = profile
return profile
This CSVDataConversion
can then be used as follows:
from smartgrid.agents import Agent
converter = CSVDataConversion()
converter.load('MyCustomProfile', '/path/to/the/data.csv')
my_custom_profile = converter.profiles['MyCustomProfile']
agent = Agent('MyAgent1', my_custom_profile)
Note that, in this example, as the CSV file contains only data for the needs
and productions, other values (e.g., max_storage
) are
hard-coded. The load()
method also accepts any
additional keyworded parameter to specify these values externally instead, for
example:
class CSVDataConversion(DataConversion):
def load(self, name, data_path, max_storage=None):
# Same code as above, except for `max_storage = 120` (...).
if max_storage is None:
max_storage = 120
# Create the Agent Profile.
profile = AgentProfile(
name=name,
action_space_low=low,
action_space_high=high,
max_storage=max_storage,
need_profile=need_profile,
production_profile=production_profile,
action_dim=len(Action._fields),
comfort_fn=comfort_fn
)
# The profile must be registered in the `profiles` dict to be
# reused later.
self.profiles[name] = profile
return profile
To further customize the resulting agent profile, new classes can also be
created for the NeedProfile
and ProductionProfile
.
New comfort functions
can also
be implemented.
Need profile¶
The need function is encapsulated in the NeedProfile
class. This class contains an array of values (the needs), and returns for
each time step the corresponding value in the array (cycling over the array if
necessary). It is thus best suited for using realistic needs coming from datasets.
Let us assume that we want a similar profile, but adding a +/- 5% random noise on the needs at each time step, to create more diversity and variety among agents:
from smartgrid.agents.profile import NeedProfile
import numpy as np
class NoisedNeedProfile(NeedProfile):
def __init__(self, need_per_hour, noise=0.05):
super().__init__(self, need_per_hour)
self.noise = noise
def compute(self, step=0):
# The "basic" need (based on the array of data).
step %= len(self.need_per_hour)
need = self.need_per_hour[step]
# Compute the bounds (+/- noise%).
min_need = int(need - self.noise * need)
max_need = int(need + self.noise * need)
# Return a random amount within the bounds.
return np.random.randint(min_need, max_need)
# Let us test the need profile now.
# Assume here that `data` is your dataset.
# You may load it from a CSV, or a binary file, e.g., NPZ.
data = [100, 200, 300]
noise = 0.05
need_profile = NoisedNeedProfile(data, noise)
# Assert that the need at the first step is indeed in [95, 105]
assert 100 * 0.95 <= need.compute(step=0) <= 100 * 1.05
# More generally, for any step t:
for t in range(100):
min_bound = data[t % len(data)] * (1.0 - noise)
max_bound = data[t % len(data)] * (1.0 + noise)
assert min_bound <= need.compute(t) <= max_bound
For a more complex example, for example using a stochastic function instead of
relying purely on a dataset, you may ignore the need_per_hour
array, but an extra attention must be paid to the
max_energy_needed
attribute,
which can no longer be computed automatically by the base class. See the
following block code for an example:
import numpy as np
from smartgrid.agents import NeedProfile
class RandomNeedProfile(NeedProfile):
def __init__(self, lower, upper):
# Note that you should not use `super().__init__()` here
# because we will not use the parameters in NeedProfile.
self.lower = lower
self.upper = upper
# Setting the `max_energy_needed` is very important!
self.max_energy_needed = upper
def compute(self, step=0):
# Return a random amount between `lower` and `upper`
return np.random.randint(self.lower, self.upper)
need = RandomNeedProfile(100, 1000)
# Test for some steps that the bounds are indeed respected
for step in range(10):
assert 100 <= need.compute(step) <= 1000
Setting max_energy_needed
is crucial for the EnergyGenerator
in particular.
These classes can then be used in your custom DataConversion
when
instantiating an agent profile.
Production profile¶
The production function is encapsulated in the
ProductionProfile
class. This
class contains an array of values (the productions), and returns for each time
step the corresponding value in the array. It is thus best suited for using
realistic productions coming from datasets.
The default ProductionProfile
behaves very similarly to the need profile.
Again, let us assume we want to add a small random noise:
from smartgrid.agents.profile import ProductionProfile
import random
class NoisedProductionProfile(ProductionProfile):
def __init__(self, production_per_hour, noise=0.05):
super().__init__(self, production_per_hour)
self.noise = noise
def compute(self, step=0):
step %= len(self.production_per_hour)
production = self.production_per_hour[step]
min_production = int(production - self.noise * production)
max_production = int(production + self.noise * production)
return random.randint(min_production, max_production)
Contrary to need profiles, the production profile does not have additional
attributes to set. The only requirement is to return a value from the
compute()
method. More complex use-cases, such as using a stochastic function instead
of relying purely on a dataset, are thus simplified. See the following block
code for an example:
from smartgrid.agents.profile import ProductionProfile
import random
class RandomProductionProfile(ProductionProfile):
def __init__(self, lower, upper):
# Again, we do not use `super().__init__()` because we do not set
# the same attributes as the base class.
self.lower = lower
self.upper = upper
# Note that there is no additional (base-required) attribute to set.
def compute(self, step=0):
# Return a random amount between `lower` and `upper`
return np.random.randint(self.lower, self.upper)
Comfort functions¶
The comfort function is any Python callable which takes a consumption (float) and need (float) as inputs, and returns the comfort (float). It is used to determine the degree of satisfaction (comfort) of an agent at each time step. Agents that accept to consume less when necessary may use a comfort function that returns “high” comforts even when the consumption is less than the need; on the contrary, agents that cannot accept to do so, e.g., an hospital because its consumption is too important, may instead use a comfort function that returns “low” comforts when the consumption is less than the need.
The already implemented comfort functions in this simulator leverage the
Richard’s curve (or generalized logistic function); you may use it for your own
functions. Please see its documentation for more details:
smartgrid.agents.profile.comfort.richard_curve()
.
Alternatively, you may provide your custom function. We give an example of a
(very) simple linear comfort, which simply considers the ratio of consumption
over the need as the comfort. This means that, if the agent consumes 80% of
their need, it will have a comfort of 80% (or 0.8
), if it consumes 40%,
the comfort will be 0.4
, and so on. A special attention is paid to the
output range: to avoid undesired side effects (especially when computing equity),
it is recommended that the comfort lies within [0, 1]
.
import numpy as np
def linear_comfort_profile(consumption, need):
# Simply return the ratio of consumption / need.
# Thus, the comfort increases linearly with the consumption.
comfort = consumption / need
# Important! It is better to clip in [0,1]!
# Not doing so would have undesired effects
# (especially when computing equity).
comfort = np.clip(comfort, 0, 1)
return comfort