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 AgentProfiles that are agnostic to the data files, and provide the common characteristics to the Agents.

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