Building a Controller

The following documents the development of a new controller. In this case we are going to implement an arbitrary controllable storage unit. This may be a battery, an electrically powered car or some sort of reservoir storage.

Parent-Class

First we start by creating a new file control/storage_control.py, containing our new class.

import control.basic_controller

class Storage(control.basic_controller.Controller):
    """
        Example class of a Storage-Controller. Models an abstract energy storage.
    """

    def __init__(self):
        # init object here
        pass

    def time_step(self, time):
        """
        Note: This method is ONLY being called during time-series simulation!

        It is the first call in each time step, thus suited for things like
        reading profiles or prepare the controller for the next control step.
        """
        pass

    def write_to_net(self):
        """
        This method will write any values the controller is in charge of to the
        data structure. It will be called at the beginning of each simulated
        loadflow, in order to ensure consistency between controller and
        data structure.

        You will probably want to write the final state of the controller to the
        data structure at the end of the control_step using this method.
        """
        pass

    def initialize_control(self):
        """
        Some controller require extended initialization in respect to the
        current state of the net (or their view of it). This method is being
        called after an initial loadflow but BEFORE any control strategies are
        being applied.

        This method may be interesting if you are aiming for a global
        controller or if it has to be aware of its initial state.
        """
        pass

    def is_converged(self):
        """
        This method calculated whether or not the controller converged. This is
        where any target values are being calculated and compared to the actual
        measurements. Returns convergence of the controller.
        """
        return True

    def control_step(self):
        """
        If the is_converged method returns false, the control_step will be
        called. In other words: if the controller did not converge yet, this
        method should implement actions that promote convergence e.g. adapting
        actuating variables and writing them back to the data structure.

        Note: You might want to store the mismatch calculated in is_converged so
        you don't have to do it again. Also, you might want to write the
        reaction back to the data structure (use write_to_net).
        """
        pass

    def finalize_step(self):
        """
        Note: This method is ONLY being called during time-series simulation!

        After each time step, this method is being called to clean things up or
        similar. The OutputWriter is a class specifically designed to store
        results of the loadflow. If the ControlHandler.output_writer got an
        instance of this class, it will be called before the finalize step.
        """
        pass

Note

Import and inherent from the parent class Controller and override methods you would like to use. Also remember that is_converged() returns the boolean value of convergence.

Next we write the actual code for the methods. We choose to represent the storage-unit as a static generator in pandapower. To do so we overwrite __init__ and initiate all the attributes of our class with the values of the corresponding generator using its ID.

def __init__(self, net, gid, soc, capacity, sizing):

    # read generator attributes from net
    self.gid = gid
    self.bus = net.sgen.at[gid, "bus"]
    self.p_kw = net.sgen.at[gid, "p_kw"]
    self.q_kvar = net.sgen.at[gid, "q_kvar"]
    self.sn_kva = net.sgen.at[gid, "sn_kva"]
    self.name = net.sgen.at[gid, "name"]
    self.gen_type = net.sgen.at[gid, "type"]
    self.in_service = net.sgen.at[gid, "in_service"]

    #specific attributes
    self.capacity = capacity
    self.soc = soc
    self.sizing = sizing

Methods that should be shared amongst all storage classes have to be implemented here as well.

def get_stored_ernergy(self):
    # do some "complex" calculations
    return self.capacity * self.soc

After doing so, our parent class is finished. But now that we have a parent class, lets actually use it by implementing a subclass of it. In this example it will be a simple battery.

Child-Class

Again create a new file control/storage/electric_car.py for our new ECar class. Note: It is a good idea to keep your project files organized by creating subfolders for closely related classes or scripts.

import control.controller.storage_control

class Battery(control.controller.storage_control.Storage):
    """
    Models a battery plus inverter.
    """

    def __init__(self):
        # init object here
        pass

    def time_step(self, time):
        # change state according to profile
        pass

    def write_to_net(self):
        # write current P and Q values to the data structure
        pass

    def is_converged(self):
        # calculate convergence criteria
        pass

    def control_step(self):
        # apply control strategy
        return True

Except the import and its inherence, this class looks quite the same. We want to make some adjustments though:

def __init__(self, net, gid, soc, capacity, sizing, p_profile=None, data_source=None):
    super(Battery, self).__init__(net, gid, soc, capacity, sizing)

    # profile attributes
    self.data_source = data_source
    self.p_profile = p_profile
    self.last_time_step = None

Lets have a closer look at this code. We can call the constructor of the parent class letting it handle all the parameters and set attributes by using the super mechanism: super(CHILD-CLASS, self).__init__(). Additionally we want read values from a profile.

Note

If you strictly follow the order of parameters the parents constructor expects, you can refrain from writing net=net and go with super(Battery, self).__init__(net, gid, soc, capacity, sizing) instead.

As a first step we want our controller to be able to write its P and Q values back to the data structure.

def write_to_net(self):
    # write p, q to bus within the net
    self.net.sgen.at[self.gid, "p_kw"] = self.p_kw
    self.net.sgen.at[self.gid, "q_kvar"] = self.q_kvar
def is_converged(self):
    # calculate if controller is converged
    is_converged = "some boolean logic"

    return bool(is_converged)

In case the controller is not yet converged, the control step is executed. In the example it simply adopts a new value according to the previously calculated target and writes back to the net.

def control_step(self):
    # some control mechanism

    # write p, q to bus within the net
    self.write_to_net()

In a time-series simulation the battery should read new power values from a profile and keep track of its state of charge as depicted below.

def time_step(self, time):
    # keep track of the soc (assuming time is given in seconds)
    if self.last_time_step is not None:
        self.soc += self.capacity / (self.p_kw * (self.current_time_step-self.last_time_step) / 3600)
    self.last_time_step = time

    # read new values from a profile
    if self.data_source:
        if self.p_profile:
            self.p_kw = self.data_source.get_time_step_value(time_step=time,
                                                            profile_name=self.p_profile)

We are now ready to create objects of our newly implemented class and simulate with it!

Note

Decent commentary is best practice. It is very handy for people reviewing your code or in case you want to look into the code a few months after implementation.