Skip to main content

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index] [List Home]
[volttron-dev] Custom Controller Agent Causes Modbus Read Error
  • From: "Thompson, Joe" <jthompson@xxxxxxxx>
  • Date: Thu, 22 Feb 2024 20:25:43 +0000
  • Accept-language: en-US
  • Arc-authentication-results: i=1; mx.microsoft.com 1; spf=pass smtp.mailfrom=epri.com; dmarc=pass action=none header.from=epri.com; dkim=pass header.d=epri.com; arc=none
  • Arc-message-signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; s=arcselector9901; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; bh=pvhqARQNx0+PC602BWXjXxV0j1EFPI5PrMoj23Wnrd4=; b=lgWgYQd8tspDJxeyxECal4RvGI2igsQRDBbdLgP7x6knP3qTDsIkbx7OwNlFPLfWO1a7tQGclmlNeLEcTMlb1fmVPz42g42/ZBZRgqPcem5gZejrRWrrDin3FdjsFw05h+k4p4sq/JJORl3Z97lcsDmlh74RLtoc1Ca2bgNJkzM90EiUvpEzcC5DrcvdL8skJ4UwsdCa6Vqy9BfKJscOsJX4uNNdxdGDGcq28QTmY7RFKiESAkl5SrTNd2B8U1EzKwIGCzDzpgugq2YtAl8nkQQTYJHz9BdtAz+S/aNWcwA0FsTtWiECv/cDKd1d5hBV39U+LbhAwck6LO1c5rDKCg==
  • Arc-seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none; b=br1G6DHjOHCcDymzBKe89tPptMWN5o1It0rsLiW5x5KEAJMj2O0QNcvqwJNVDitILzUHA/g+Ln/1Eituv+OUZ6PvQzJwGSGMiKltF80a8o4RFEu7tDj5dHfrdc72itsV4jZi3orO8Bw6+MlyseM3LH9PE8qDc0QP5wU5KGzxD3cQoTReS6Eu6DhQXvlvafTUlvKtCNDxP+nYGl2qrka9rFTgXxhHGqzKbpt7NMkHyibCwJvVnk5iHdwNuhOznthKBS6MtLlfZimDDJmT+E1uWSBWuhaTuQl98kkrVaOcjuCGRvLyop5hZxECVmUSEE0Uq6xNC8La02TqMBhMGS8SIw==
  • Delivered-to: volttron-dev@xxxxxxxxxxx
  • List-archive: <https://www.eclipse.org/mailman/private/volttron-dev/>
  • List-help: <mailto:volttron-dev-request@eclipse.org?subject=help>
  • List-subscribe: <https://www.eclipse.org/mailman/listinfo/volttron-dev>, <mailto:volttron-dev-request@eclipse.org?subject=subscribe>
  • List-unsubscribe: <https://www.eclipse.org/mailman/options/volttron-dev>, <mailto:volttron-dev-request@eclipse.org?subject=unsubscribe>
  • Thread-index: AQHaZc1PIvu+ISpHc0KaFNOZg90TAg==
  • Thread-topic: Custom Controller Agent Causes Modbus Read Error

Hello Volttron Team,

 

Background:

I am using Volttron 8.1.3 on a Raspberry Pi to coordinate the operation / testing of 2 devices over Modbus:

  1. An energy storage system with a BMS that Volttron’s platform.driver communicates with over modbus
  2. An inverter that also communicates with Volttron’s platform.driver via modbus

 

Using the Listener Agent I can see that the platform.driver is reading all of my modbus points perfectly every 5 seconds (see the attached volttron_Without_Controller.log).

 

I have a simple custom agent, enervenue_ctrlagent (see the “agent.txt” file, changed from “agent.py” for attaching), that is subscribed to the "devices/STAC/EnervenueBMS" topic and runs a simple read from the battery, decide what to do, and write to the inverter loop every 5 seconds when new "devices/STAC/EnervenueBMS" data comes in. The writing is just to a single “Output Power Command” register on the inverter.

 

The Problem:

When enervenue_ctrlagent is installed and started, modbus reads of the inverter begin to fail. The controller’s attempts to write power setpoints are successful, but I start to receive this modbus_tk error (see the attached volttron_With_Controller.log):

 

 

 

Expected Behavior:

I was expecting that my controller agent should be able to run in tandem with the platform agent with no conflicts between reading and writing, but something funny is happening that I don’t understand.

 

Any and all help is greatly appreciated!

 

Joe Thompson

Engineer / Scientist

Electric Power Research Institute

Energy Storage and Distributed Generation

(912) 663-3407

 

*** This email message is for the sole use of the intended recipient(s) and may contain information that is confidential, privileged or exempt from disclosure under applicable law. Unless otherwise expressed in this message by the sender or except as may be allowed by separate written agreement between EPRI and recipient or recipient’s employer, any review, use, distribution or disclosure by others of this message is prohibited and this message is not intended to be an electronic signature, instrument or anything that may form a legally binding agreement with EPRI. If you are not the intended recipient, please contact the sender by reply email and permanently delete all copies of this message. Please be advised that the message and its contents may be disclosed, accessed and reviewed by the sender's email system administrator and/or provider. ***

Attachment: volttron_Without_Controller.log
Description: volttron_Without_Controller.log

"""
Agent documentation goes here.
"""

__docformat__ = 'reStructuredText'

import logging
import sys
from volttron.platform.agent import utils
from volttron.platform.vip.agent import Agent, Core, RPC
from volttron.platform.agent.utils import format_timestamp, get_aware_utc_now

import pandas as pd

_log = logging.getLogger(__name__)
utils.setup_logging()
__version__ = "0.1"


def enervenue_ctrl(config_path, **kwargs):
    """
    Parses the Agent configuration and returns an instance of
    the agent created using that configuration.

    :param config_path: Path to a configuration file.
    :type config_path: str
    :returns: EnervenueCtrl
    :rtype: EnervenueCtrl
    """
    try:
        config = utils.load_config(config_path)
    except Exception:
        config = {}

    if not config:
        _log.info("Using Agent defaults for starting configuration.")

    setting1 = int(config.get('setting1', 1))
    setting2 = config.get('setting2', "some/random/topic")
    sched_path = config['control_schedule_path']

    return EnervenueCtrl(sched_path, setting1, setting2, **kwargs)


class EnervenueCtrl(Agent):
    """
    Document agent constructor here.
    """

    def __init__(self, sched_path, setting1=1, setting2="some/random/topic", **kwargs):
        super(EnervenueCtrl, self).__init__(**kwargs)
        _log.debug("vip_identity: " + self.core.identity)

        self.agent_id = "enervenue_sched_cntl"  #enervenue_ctrl

        self.sched_path = sched_path
        self.setting1 = setting1
        self.setting2 = setting2
        self.bms_write_device = 'STAC/EnervenueBMS'
        self.inverter_write_device = 'STAC/DynapowerInverter'
        self.inverter_rated_kw = 124.7

        # Configure the actual modbus writes and format the write request 
        self.dynapower_control = {
            'Fault Reset': 0,                       # 0: No Action 1: Reset All Latched Alarms/Faults
            'Operation Mode Select': 3,             # 1: Idle State 3: Grid-Tied Mode
            'Output Power Command': 0,          # Percentage of rated power (124.7kW), negative = charge
            'PCS Set Operation': 0,                 # 0: No Action 1: Start PCS Operation 2: Stop PCS Operation
            }


        self.default_config = {
            "control_schedule_path": sched_path,
            "setting1": setting1,
            "setting2": setting2
            }

        # Set a default configuration to ensure that self.configure is called immediately to setup
        # the agent.
        self.vip.config.set_default("config", self.default_config)
        # Hook self.configure up to changes to the configuration file "config".
        self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config")

    def configure(self, config_name, action, contents):
        """
        Called after the Agent has connected to the message bus. If a configuration exists at startup
        this will be called before onstart.

        Is called every time the configuration in the store changes.
        """
        config = self.default_config.copy()
        config.update(contents)

        _log.debug("Configuring Agent")

        try:
            sched_path = config["control_schedule_path"]
            setting1 = int(config["setting1"])
            setting2 = str(config["setting2"])
        except ValueError as e:
            _log.error("ERROR PROCESSING CONFIGURATION: {}".format(e))
            return

        self.setting1 = setting1
        self.setting2 = setting2
        self.sched_path = sched_path
        self.system_topic = "devices/STAC/EnervenueBMS"
        self.sched = pd.read_excel(self.sched_path)
        
        self.sched_step_ind = 0
        self.parse_control_step()
        self.timed_step_end_utc = None if pd.isna(self.duration) else pd.Timestamp.utcnow() + pd.Timedelta(seconds=self.duration) 

        self._create_subscriptions(self.system_topic)

        # self._create_subscriptions(self.setting2)
        ### SUBSCRIBE TO BESS AND INVERTER POINTS. GO THROUGH CONTROL LOOP AT EACH READ

    def _create_subscriptions(self, topic):
        """
        Unsubscribe from all pub/sub topics and create a subscription to a topic in the configuration which triggers
        the _handle_publish callback
        """
        self.vip.pubsub.unsubscribe("pubsub", None, None)

        # subscribe to all read data from the BMS
        self.vip.pubsub.subscribe(peer='pubsub',
                                  prefix=topic,
                                  callback=self._handle_publish)


    def parse_control_step(self):
        # get details for the current control step
        self.ctrl_step = self.sched.loc[self.sched_step_ind]
        self.step_name = self.ctrl_step['Step']
        self.target_soc = self.ctrl_step['Target SoC (%)']
        self.power_cmd = self.ctrl_step['Power (kW)\n+ = discharge']
        self.duration = self.ctrl_step['Duration (s)']
        self.action = 'discharge' if self.power_cmd > 0 else 'charge' if self.power_cmd < 0 else 'idle'


    def _handle_publish(self, peer, sender, bus, topic, headers, message):
        """
        Callback triggered by the subscription setup using the topic from the agent's config file

        For now we can write out all the control logic here
        """
 
        current_soc = float(message[0]['EnerStation SOC'])
        connected_strings = sum([message[0]['String %d Relay status' % s] for s in range(1, 4)])
        
        # adjust the power request given the number of strings online
        try:
            power_command = (self.power_cmd / 3) * connected_strings
        except ZeroDivisionError as e:
            do_not_proceed = True
            _log.info("No Strings are connected. Stopping the inverter.")
            self.write_to_device(
                device=self.inverter_write_device,
                write_values={
                    'Output Power Command': 0,          # Percentage of rated power (124.7kW), negative = charge
                    'PCS Set Operation': 2,             # 0: No Action 1: Start PCS Operation 2: Stop PCS Operation
                }
            )

        # convert power command into percent of rated power
        power_command_pct = (power_command / self.inverter_rated_kw) * 100

        # if the current step name is not an integer then send an idle command
        try:
            int(self.ctrl_step.name)
            do_not_proceed = False
        except ValueError as e:
            do_not_proceed = True
            _log.info("Schedule was completed. Stopping the inverter.")
            self.write_to_device(
                device=self.inverter_write_device,
                write_values={
                    'Output Power Command': 0,          # Percentage of rated power (124.7kW), negative = charge
                    'PCS Set Operation': 2,             # 0: No Action 1: Start PCS Operation 2: Stop PCS Operation
                }
            )
            
        # check whether we are in a timed / duration step
        if self.timed_step_end_utc is not None:        # we are within a timed step
            if pd.Timestamp.utcnow() < self.timed_step_end_utc:
                # send write command that corresponds to this step
                self.write_to_device(
                    device=self.inverter_write_device,
                    write_values={
                        'Output Power Command': power_command_pct,          # Percentage of rated power (124.7kW), negative = charge
                    }
                )
                
                # do not proceed with the rest of control logic
                # wait for next read from battery
                
                return

            
            else:                   # enough time has passed move on to the next step
                self.timed_step_end_utc = None
                self.sched_step_ind += 1
                self.parse_control_step()


        if pd.isna(self.duration):  # then this is a power / SoC command
            if (self.action == 'charge') & (current_soc < self.target_soc):
                _log.info("Sending charge command of %0.1fkW" % power_command)
                self.write_to_device(
                    device=self.inverter_write_device,
                    write_values={
                        'Output Power Command': -20 #power_command_pct,          # Percentage of rated power (124.7kW), negative = charge
                    }
                )

            elif (self.action == 'discharge') & (current_soc > self.target_soc):
                _log.info("Sending discharge command of %0.1fkW" % power_command)
                self.write_to_device(
                    device=self.inverter_write_device,
                    write_values={
                        'Output Power Command': power_command_pct,          # Percentage of rated power (124.7kW), negative = charge
                    }
                )

            else:                   # move on to the next step and execute that step
                self.sched_step_ind += 1
                self.parse_control_step()
                self.timed_step_end_utc = None if pd.isna(self.duration) else pd.Timestamp.utcnow() + pd.Timedelta(seconds=self.duration) 
                _log.info("Moving on to next step with power of %0.1fkW" % power_command)
                self.write_to_device(
                    device=self.inverter_write_device,
                    write_values={
                        'Output Power Command': power_command_pct,          # Percentage of rated power (124.7kW), negative = charge
                    }
                )

        else:                       # this must be a duration command
            self.timed_step_end_utc = None if pd.isna(self.duration) else pd.Timestamp.utcnow() + pd.Timedelta(seconds=self.duration) 
            self.write_to_device(
                    device=self.inverter_write_device,
                    write_values={
                        'Output Power Command': power_command_pct,          # Percentage of rated power (124.7kW), negative = charge
                    }
                )


    def write_to_device(self, device, write_values):
        """
        Write some points to a device
        """

        # Create a start and end timestep to serve as the times we reserve to communicate with the device
        _now = get_aware_utc_now()
        str_now = format_timestamp(_now)
        _end = _now + pd.Timedelta(seconds=3600)
        str_end = format_timestamp(_end)
        
        # Wrap the timestamps and device topic (used by the Actuator to identify the device) into an actuator request
        schedule_request = [[device, str_now, str_end]]
        # Use a remote procedure call to ask the actuator to schedule us some time on the device
        _log.info("schedule_request {}".format(schedule_request))
        result = self.vip.rpc.call(
            'platform.actuator', 'request_new_schedule', self.core.identity, 'my_test', 'HIGH', schedule_request).get(
            timeout=4)

        # start by creating our topic_values
        topic_values = []
        for point, value in write_values.items():
            # create a (topic, value) tuple and add it to our topic values
            topic_values.append((device + '/' + point, value))

        # Now use another RPC call to ask the actuator to set the point during the scheduled time
        _log.info("Modbus points to write: {}".format(topic_values))
        result = self.vip.rpc.call(
            'platform.actuator', 'set_multiple_points', self.core.identity, topic_values).get(
            timeout=4)
        _log.info("Modbus write response: {}".format(result))


    @Core.receiver("onstart")
    def onstart(self, sender, **kwargs):
        """
        This is method is called once the Agent has successfully connected to the platform.
        This is a good place to setup subscriptions if they are not dynamic or
        do any other startup activities that require a connection to the message bus.
        Called after any configurations methods that are called at startup.

        Usually not needed if using the configuration store.
        """
        # Example publish to pubsub
        # self.vip.pubsub.publish('pubsub', "some/random/topic", message="HI!")
        _log.info("Schedule Controller started")

        # Example RPC call
        # self.vip.rpc.call("some_agent", "some_method", arg1, arg2)
        pass

    @Core.receiver("onstop")
    def onstop(self, sender, **kwargs):
        """
        This method is called when the Agent is about to shutdown, but before it disconnects from
        the message bus.
        """

        self.write_step(power=0)

        pass

    @RPC.export
    def rpc_method(self, arg1, arg2, kwarg1=None, kwarg2=None):
        """
        RPC method

        May be called from another agent via self.core.rpc.call
        """
        return self.setting1 + arg1 - arg2


def main():
    """Main method called to start the agent."""
    utils.vip_main(enervenue_ctrl, 
                   version=__version__)


if __name__ == '__main__':
    # Entry point for script
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        pass

Attachment: volttron_With_Controller.log
Description: volttron_With_Controller.log


Back to the top