diff --git a/battery_local_control.py b/battery_local_control.py new file mode 100644 index 0000000..23be00a --- /dev/null +++ b/battery_local_control.py @@ -0,0 +1,96 @@ +from util import pos, clamp, soc_scaler +import parameters +from time import time, sleep +import zmq +import logging +import syslab +logging.basicConfig(level=logging.INFO) + +# Controller Parameters +Kp = ... # P factor for our controller. +Ki = ... # I factor for our controller. + +### Variables +# Target that we are trying to reach at the grid connection. +pcc_target = 0.0 + +# Controller variables +x_battery = 1 # Default splitting factor + +# Info on battery state +battery_setpoint = 0.0 # Default setpoint +battery_soc = 0.5 # Default SOC (= state of charge) + +### Communication +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket we can use to publish our soc on +soc_out_socket = context.socket(zmq.PUB) +soc_out_socket.bind(f"tcp://*:{parameters.BATTERY_SOC_PORT}") + +# Set up a socket to subscribe to our splitting factor +splitting_in_socket = context.socket(zmq.SUB) +splitting_in_socket.connect(f"tcp://{parameters.SUPERVISOR_IP}:{parameters.SUPERVISOR_PORT}") + +# Ensure we only see message on the battery's splitting factor +splitting_in_socket.subscribe(parameters.TOPIC_BATTERY_SPLITTING) + +### Unit connections +# TODO step 1.2: Set up connection to control the battery and reconstruct the pcc (remember that vswitchboard is still not working) + +### Import your controller class +# TODO step 1.2: Import the controller class from "simlab_controller_d5_batt.py" or copy/paste it here and pick reasonable controller parameters +# Note: The controller is identical to Day 5 with the exception of incorporating the splitting factor + +pid = PIDController(Kp=Kp, Ki=Ki, Kd=0.0, + u_min=parameters.MIN_BATTERY_P, + u_max=parameters.MAX_BATTERY_P, + Iterm=0.0) + + +# Put everything in a "try" block so clean-up is easy +try: + while True: + try: + # Try to connect to the supervisor. + # If we have a connection to the supervisor, get our requested splitting factor. + # If none have come in, continue with previous splitting factor. + # We put this in a while-loop to ensure we empty the queue each time, + # so we always have the latest value. + while True: + # Receive the latest splitting factor + incoming_str = splitting_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "batt_split;0.781", + # so we split it up and take the last bit forward. + x_battery = float(incoming_str.split(" ")[-1]) + logging.info(f"New splitting factor: {x_battery}") + except zmq.Again as e: + # No (more) new messages, move along + pass + + # Poll the grid connection to get the current grid exchange. + pcc_p = 10.0 # TODO step 1.2: Reconstruct the pcc from unit measurements + # Check our own state of charge + battery_soc = 0.68 # TODO step 1.2: Read the true battery SOC + + # Calculate new requests using PID controller + battery_setpoint = pid.update(pcc_p.value, x_batt=x_battery) + + # Ensure we don't exceed our bounds for the battery + battery_setpoint = clamp(parameters.MIN_BATTERY_P, battery_setpoint, parameters.MAX_BATTERY_P) + + # Send the new setpoint to the battery + # TODO step 1.2: Send the new setpoint to the battery + logging.info(f"Sent setpoint: {battery_setpoint}") + + # Publish our current state of charge for the supervisory controller to see + soc_out_socket.send_string(f"{parameters.TOPIC_BATTERY_SOC} {battery_soc:.06f}") + + # Loop once more in a second + sleep(1) +finally: + # Clean up by closing our sockets. + # TODO step 1.2: Set the setpoint of the battery to zero after use + splitting_in_socket.close() + soc_out_socket.close() diff --git a/d7_controller_splitting.pdf b/d7_controller_splitting.pdf new file mode 100644 index 0000000..8161178 Binary files /dev/null and b/d7_controller_splitting.pdf differ diff --git a/d7_controller_transfer.pdf b/d7_controller_transfer.pdf new file mode 100644 index 0000000..bf8d1c2 Binary files /dev/null and b/d7_controller_transfer.pdf differ diff --git a/mobileload_local_control.py b/mobileload_local_control.py new file mode 100644 index 0000000..b06801b --- /dev/null +++ b/mobileload_local_control.py @@ -0,0 +1,98 @@ +from util import pos, clamp +import parameters +from time import time, sleep +import zmq +import syslab +import logging + +# Ensure that we see "info" statements below +logging.basicConfig(level=logging.INFO) + +# # Parameters +Kp = ... # P factor for our controller. +Ki = ... # I factor for our controller. + +# # Variables +# Target that we are trying to reach at the grid connection. +pcc_target = 0.0 + +# Controller variables +x_load = 1 # Default splitting factor + +# Info on battery state +mobileload_setpoint = 0.0 # Default setpoint +battery_soc = 0.5 # Default SOC (= state of charge) + +### Communication +# Handle the sockets we need +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket to subscribe to our splitting factor +splitting_in_socket = context.socket(zmq.SUB) +splitting_in_socket.connect(f"tcp://{parameters.SUPERVISOR_IP}:{parameters.SUPERVISOR_PORT}") + +# Ensure we only see message on the load's splitting factor +splitting_in_socket.subscribe(parameters.TOPIC_LOAD_SPLITTING) + +### Unit connections +# TODO step 1.2: Set up connection to control the battery and reconstruct the pcc (remember that vswitchboard is still not working) + +### Import your controller class +# TODO step 1.2: Import the controller class from "simlab_controller_d5_load.py" or copy/paste it here and pick reasonable controller parameters +# Note: The controller is identical to Day 5 with the exception of incorporating the splitting factor + +pid = PIDController(Kp=Kp, Ki=Ki, Kd=0.0, + u_min=parameters.MIN_LOAD_P, + u_max=parameters.MAX_LOAD_P, + Iterm=0.0) + + +# # Unit connections +# TODO step 2.2: Set up connection to control the battery/mobile load and reconstruct the pcc (remember that vswitchboard is still not working) + + +# Ensure the mobile load is on before we start (The sleeps wait for the load to respond.) +while not mobload1.isLoadOn().value: + print("Starting load") + mobload1.startLoad() + sleep(2) + + +try: + while True: + try: + # Try to connect the the supervisor. + # If we have a connection to the supervisor, get our requested splitting factor. + # If none have come in, continue with previous splitting factor. + # We put this in a while-loop to ensure we empty the queue each time. + while True: + # Receive the latest splitting factor + incoming_str = splitting_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "load_split;0.781", + # so we split it up and take the last part. + x_load = float(incoming_str.split(" ")[-1]) + logging.info(f"New splitting factor: {x_load}") + except zmq.Again as e: + # No (more) new messages, move along + pass + + # Poll the grid connection to get the current grid exchange. + pcc_p = 10.0 # TODO step 1.2: Reconstruct the pcc from unit measurements + + # Calculate new requests using PID controller + mobileload_setpoint = pid.update(pcc_p.value, x_load=x_load) + + # Ensure we don't exceed our bounds for the load + mobileload_setpoint = clamp(parameters.MIN_LOAD_P, mobileload_setpoint, parameters.MAX_LOAD_P) + + # Send the new setpoint to the load + # TODO step 1.2: Send the new setpoint to the load + logging.info(f"Sent setpoint: {mobileload_setpoint}") + + # Loop once more in a second + sleep(1) +finally: + # Clean up by closing our socket. + # TODO step 1.2: Set the setpoint of the mobile load to zero and shut it down after use + splitting_in_socket.close() diff --git a/parameters.py b/parameters.py new file mode 100644 index 0000000..d041bfe --- /dev/null +++ b/parameters.py @@ -0,0 +1,21 @@ + +# Parameters + +# Location of supervisor +SUPERVISOR_IP = 'localhost' +SUPERVISOR_PORT = 6001 + +# Location of battery +BATTERY_SOC_IP = 'localhost' +BATTERY_SOC_PORT = 6002 + +# Unit parameters +MAX_BATTERY_P = 15 +MIN_BATTERY_P = -15 +MAX_LOAD_P = 15.0 +MIN_LOAD_P = 0 + +# Topics for pub/sub +TOPIC_BATTERY_SPLITTING = "batt_split" +TOPIC_BATTERY_SOC = "batt_soc" +TOPIC_LOAD_SPLITTING = "load_split" diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..96de8e0 --- /dev/null +++ b/readme.md @@ -0,0 +1,37 @@ +# Power-sharing controller + +This is an example of a power sharing controller for the Microgrid case. + +The case consists of the following units: +- A battery +- A mobile load, representing controllable consumption that gives us money, e.g. an electrolyzer which produces hydrogen. +- A wind turbine +- A solar panel +- A dump load, which is controlled externally from the controller, representing load that must be served, for instance the local neighbourhood. + +The controller uses the battery and mobile loads to attempt to keep the active power at 0 on the grid connection (PCC). +Further, we try to keep the battery's state of charge somewhat away from being full or empty, to give the most flexibility if we are called on to deliver services. +The service delivery is not implemented here, and so we just try to keep the battery in a certain range of state of charge. + +If we need more power in the system, i.e. we are importing from the grid, the battery will be asked to provide the missing amount, up to its maximum. + +If we have too much power, both the battery and mobile load will act together to consume the excess. +How much will be consumed by each unit is determined by a *splitting factor*: +- If the battery's state of charge is less than an `soc_lower`, the battery will consume all excess power. +- If the battery's state of charge is above an `soc_upper`, the mobile load will consume all excess power. +- If the battery's state of charge is *in between* these two values, the excess will be split between the two in a manner which linearly depends on the battery's state of charge; the higher the state of charge, the more power is given to the mobile load. + +See the included "d7_controller_splitting.pdf" for a graph of the splitting factor. +Essentially, we are trying to keep the battery charged to above `soc_lower` plus some buffer, and then use the rest of the power for production. + +In addition to changing the splitting factor, the boundaries `soc_lower` and `soc_upper` change depending on the currently available renewable energy: +- If there is a lot of renewable energy available, the range from `soc_lower` to `soc_upper` gets wider. We are less worried about getting the battery charged, so we use more power for the dump load. +- If there is only a small amount of renewable energy available, the range from `soc_lower` to `soc_upper` gets narrower. We need to ensure the battery gets charged up so we are ready to handle a high load or a request for energy from elsewhere. + +## Included scripts + +- `battery_local_control.py`: Connects to the battery and acts on the splitting factor delivered by the supervisor. Makes the battery state of charge available to the supervisor. +- `mobileload_local_control.py`: Connects to the battery and acts on the splitting factor delivered by the supervisor. +- `supervisory_controller.py`: Is in charge of calculating the splitting factor depending on the available renewable production. + +Each script can be started independently, and will use defaults or re-use last known values if the other scripts are down. diff --git a/simlab_controller_d5_batt.py b/simlab_controller_d5_batt.py new file mode 100644 index 0000000..f62726f --- /dev/null +++ b/simlab_controller_d5_batt.py @@ -0,0 +1,178 @@ +import syslab +from time import sleep, time +from dataclasses import dataclass +from util import clamp, cast_to_cm +import sys +import zmq +import logging + +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket to subscribe to the tester +reference_in_socket = context.socket(zmq.SUB) +reference_in_socket.connect(f"tcp://localhost:5000") + +# Ensure we only see message on the setpoint reference +reference_in_socket.subscribe("setpoint_reference") + +# Used to log the incoming reference setpoints +logging.basicConfig(level=logging.INFO) + +@dataclass +class PIDController: + Kp: float = 0.0 # P factor + Ki: float = 0.0 # I factor + Kd: float = 0.0 # D factor + r: float = 0.0 # Reference which we want the measured y to be + Pterm: float = 0.0 # P part + Iterm: float = 0.0 # Integral part + Dterm: float = 0.0 # Differential part + previous_error: float = 0.0 # used to calculate differential part + previous_Iterm: float = 0.0 # used to calculate integral part + current_time: float = None # used to calculate differential and integral part + previous_time: float = None # used to calculate differential and integral part + u_max: float = None # used to calculate controller input saturation + u_min: float = None # used to calculate controller input saturation + u_p: float = 0.0 + u: float = 0.0 + + def __post_init__(self): + self.previous_time = self.previous_time if self.previous_time is not None else time() + self.current_time = self.current_time if self.current_time is not None else time() + + def set_reference(self, new_reference): + self.r = new_reference + + def update(self, new_y_meas, x_batt, current_time=None): + + # Ensure we have the time elapsed since our last value; we'll use that for the integral and differential parts + self.current_time = current_time if current_time is not None else time() + delta_time = self.current_time - self.previous_time + + # Filter the incoming value + y_2 = self._filter_input(new_y_meas) + + # Calculate the error to the setpoint + error = self._calculate_error(self.r, y_2) + + # Apply splitting factor # TODO step 1.2: Familiarize with the usage of the splitting factor + error = error - (1-x_batt)*max(error, 0) + + # Calculate the PID terms + Pterm = self._calc_Pterm(error) + Iterm = self._calc_Iterm(error, delta_time) + Dterm = self._calc_Dterm(error, delta_time) + + # Calculate our raw response + self.u_p = Pterm + Iterm + Dterm + + # Filter our response + self.u = self._filter_output(self.u_p) + u_new = clamp(self.u_min, self.u, self.u_max) + + # Remember values for next update + if self.u == u_new: + self.previous_Iterm = Iterm + else: # in saturation - don't sum up Iterm + self.u = u_new + + self.previous_time = self.current_time + self.previous_error = error + + # Return the filtered response + return self.u + + def _filter_input(self, new_y_hat): + # optional code to filter the measurement signal + return new_y_hat + + def _filter_output(self, u_p): + # optional code to filter the input / actuation signal + return u_p + + def _calculate_error(self, y_hat_2): + # TODO step 1.2: calculate and return the error between reference and measurement + return 0.0 + + def _calc_Pterm(self, error): + # TODO step 1.2: calculate the proportional term based on the error and self.Kp + return 0.0 + + def _calc_Iterm(self, error, delta_time): + # TODO step 1.2: calculate the integral term based on error, last error and deltaT, and self.Ki + return 0.0 + + def _calc_Dterm(self, error, delta_time): + # calculate the proportional term based on error, last error and deltaT + return self.Kd * 0.0 + +if __name__ == "__main__": + # "main" method if this class is run as a script + BATT_MIN_P, BATT_MAX_P, = -15, 15 + #LOAD_MIN_P, LOAD_MAX_P = 0, 20 + pid = PIDController(Kp=0.4, Ki=0.8, Kd=0.0, # 0.5 , 0.2 / 0.4 , 0.1, Kp 0.7 + u_min=BATT_MIN_P, + u_max=BATT_MAX_P) + + # Establish connection to the switchboard to get the pcc power measurement (that's the usual practice, however, vswitchboard currently not working) + # sb = syslab.SwitchBoard('vswitchboard') + + # Instead, establish connection to all units to replace switchboard measurements + gaia = syslab.WindTurbine("vgaia1") + dumpload = syslab.Dumpload("vload1") + mobload1 = syslab.Dumpload("vmobload1") + pv319 = syslab.Photovoltaics("vpv319") + batt = syslab.Battery('vbatt1') + + # Initiate reference signal + r_old = 0.0 + r = 0.0 + + try: + while True: + + # We loop over the broadcast queue to empty it and only keep the latest value + try: + while True: + # Receive reference setpoint + incoming_str = reference_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "setpoint_reference;0.547", + # so we split it up and take the last bit forward. + r = float(incoming_str.split(" ")[-1]) + #logging.info(f"New reference setpoint: {r}") + + # An error will indicate that the queue is empty so we move along. + except zmq.Again as e: + pass + + # Overwrite setpoint reference r if different from previous value + pid.set_reference(r) if r != r_old else None + + # Store reference for comparison in next loop + r_old = r + + # To ensure accurate + start_time = time() + # Get measurements of the current power exchange at the grid connection (=Point of Common Coupling (PCC)) + #pcc_p = sb.getActivePower(0) + pcc_p = cast_to_cm(-(gaia.getActivePower().value - dumpload.getActivePower().value - mobload1.getActivePower().value + batt.getActivePower().value + pv319.getACActivePower().value)) + + # Calculate new requests using PID controller + p_request_total = pid.update(pcc_p.value) + + # Ensure requests do not exceed limits of battery (extra safety, should be ensured by controller) + batt_p = clamp(BATT_MIN_P, p_request_total, BATT_MAX_P) + + # # Send new setpoints + batt.setActivePower(batt_p) + + #print(f"Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + print(f"Setpoint:{r:.02f} Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + + # Run the loop again once at most a second has passed. + sleep(1) + finally: + # When closing, set all setpoints to 0. + batt.setActivePower(0.0) + reference_in_socket.close() diff --git a/simlab_controller_d5_load.py b/simlab_controller_d5_load.py new file mode 100644 index 0000000..860d529 --- /dev/null +++ b/simlab_controller_d5_load.py @@ -0,0 +1,181 @@ +import syslab +from time import sleep, time +from dataclasses import dataclass +from util import clamp, cast_to_cm +import sys +import zmq +import logging + +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket to subscribe to the tester +reference_in_socket = context.socket(zmq.SUB) +reference_in_socket.connect(f"tcp://localhost:5000") + +# Ensure we only see message on the setpoint reference +reference_in_socket.subscribe("setpoint_reference") + +# Used to log the incoming reference setpoints +logging.basicConfig(level=logging.INFO) + +@dataclass +class PIDController: + Kp: float = 0.0 # P factor + Ki: float = 0.0 # I factor + Kd: float = 0.0 # D factor + r: float = 0.0 # Reference which we want the measured y to be + Pterm: float = 0.0 # P part + Iterm: float = 0.0 # Integral part + Dterm: float = 0.0 # Differential part + previous_error: float = 0.0 # used to calculate differential part + previous_Iterm: float = 0.0 # used to calculate integral part + current_time: float = None # used to calculate differential and integral part + previous_time: float = None # used to calculate differential and integral part + u_max: float = None # used to calculate controller input saturation + u_min: float = None # used to calculate controller input saturation + u_p: float = 0.0 + u: float = 0.0 + + def __post_init__(self): + self.previous_time = self.previous_time if self.previous_time is not None else time() + self.current_time = self.current_time if self.current_time is not None else time() + + def set_reference(self, new_reference): + self.r = new_reference + + def update(self, new_y_meas, x_load, current_time=None): + + # Ensure we have the time elapsed since our last value; we'll use that for the integral and differential parts + self.current_time = current_time if current_time is not None else time() + delta_time = self.current_time - self.previous_time + + # Filter the incoming value + y_2 = self._filter_input(new_y_meas) + + # Calculate the error to the setpoint + error = self._calculate_error(self.r, y_2) + + # Apply splitting factor # TODO step 1.2: Familiarize with the usage of the splitting factor + error = x_load*max(error, 0) + + # Calculate the PID terms + Pterm = self._calc_Pterm(error) + Iterm = self._calc_Iterm(error, delta_time) + Dterm = self._calc_Dterm(error, delta_time) + + # Calculate our raw response + self.u_p = Pterm + Iterm + Dterm + + # Filter our response + self.u = self._filter_output(self.u_p) + u_new = clamp(self.u_min, self.u, self.u_max) + + # Remember values for next update + if self.u == u_new: + self.previous_Iterm = Iterm + else: # in saturation - don't sum up Iterm + self.u = u_new + + self.previous_time = self.current_time + self.previous_error = error + + # Return the filtered response + return self.u + + def _filter_input(self, new_y_hat): + # optional code to filter the measurement signal + return new_y_hat + + def _filter_output(self, u_p): + # optional code to filter the input / actuation signal + return u_p + + def _calculate_error(self, r, y_hat_2): + # calculate the error between reference and measurement + return r - y_hat_2 + + def _calculate_error(self, y_hat_2): + # TODO step 1.2: calculate and return the error between reference and measurement + return 0.0 + + def _calc_Pterm(self, error): + # TODO step 1.2: calculate the proportional term based on the error and self.Kp + return 0.0 + + def _calc_Iterm(self, error, delta_time): + # TODO step 1.2: calculate the integral term based on error, last error and deltaT, and self.Ki + return 0.0 + def _calc_Dterm(self, error, delta_time): + # calculate the proportional term based on error, last error and deltaT + return self.Kd * 0.0 + +if __name__ == "__main__": + # "main" method if this class is run as a script + BATT_MIN_P, BATT_MAX_P, = -15, 15 + #LOAD_MIN_P, LOAD_MAX_P = 0, 20 + pid = PIDController(Kp=0.4, Ki=0.8, Kd=0.0, # 0.5 , 0.2 / 0.4 , 0.1, Kp 0.7 + u_min=BATT_MIN_P, + u_max=BATT_MAX_P) + + # Establish connection to the switchboard to get the pcc power measurement (that's the usual practice, however, vswitchboard currently not working) + # sb = syslab.SwitchBoard('vswitchboard') + + # Instead, establish connection to all units to replace switchboard measurements + gaia = syslab.WindTurbine("vgaia1") + dumpload = syslab.Dumpload("vload1") + mobload1 = syslab.Dumpload("vmobload1") + pv319 = syslab.Photovoltaics("vpv319") + batt = syslab.Battery('vbatt1') + + # Initiate reference signal + r_old = 0.0 + r = 0.0 + + try: + while True: + + # We loop over the broadcast queue to empty it and only keep the latest value + try: + while True: + # Receive reference setpoint + incoming_str = reference_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "setpoint_reference;0.547", + # so we split it up and take the last bit forward. + r = float(incoming_str.split(" ")[-1]) + #logging.info(f"New reference setpoint: {r}") + + # An error will indicate that the queue is empty so we move along. + except zmq.Again as e: + pass + + # Overwrite setpoint reference r if different from previous value + pid.set_reference(r) if r != r_old else None + + # Store reference for comparison in next loop + r_old = r + + # To ensure accurate + start_time = time() + # Get measurements of the current power exchange at the grid connection (=Point of Common Coupling (PCC)) + #pcc_p = sb.getActivePower(0) + pcc_p = cast_to_cm(-(gaia.getActivePower().value - dumpload.getActivePower().value - mobload1.getActivePower().value + batt.getActivePower().value + pv319.getACActivePower().value)) + + # Calculate new requests using PID controller + p_request_total = pid.update(pcc_p.value) + + # Ensure requests do not exceed limits of battery (extra safety, should be ensured by controller) + batt_p = clamp(BATT_MIN_P, p_request_total, BATT_MAX_P) + + # # Send new setpoints + batt.setActivePower(batt_p) + + #print(f"Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + print(f"Setpoint:{r:.02f} Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + + # Run the loop again once at most a second has passed. + sleep(1) + finally: + # When closing, set all setpoints to 0. + batt.setActivePower(0.0) + reference_in_socket.close() diff --git a/supervisory_controller.py b/supervisory_controller.py new file mode 100644 index 0000000..e495356 --- /dev/null +++ b/supervisory_controller.py @@ -0,0 +1,93 @@ +from util import pos, clamp +import parameters +from time import time, sleep +import zmq +import syslab +from syslab.comm.LogUtils import setup_event_logger +logging = setup_event_logger() + + +### Parameters +# If soc is below this limit, divert all power to the battery. +base_soc_lower_limit = 0.2 +# If soc is above this limit, divert all power to the mobile load. +base_soc_upper_limit = 0.8 +# This much renewable production shifts our soc_lower_limit down by 0.1 and soc_upper_limit up by 0.1. +# I.e. if there is a lot of renewable production, the range over which we split between battery and +# load widens. +base_renewable_shift = 10.0 + + +# # Variables +# Controller variables +x_battery = 0.5 # Default splitting factor +x_load = 0.5 # Default splitting factor +# Info on battery state +battery_soc = 0.5 # Default SOC (= state of charge) + +soc_lower_limit = 0.2 +soc_upper_limit = 0.8 + +# # Communication +# Handle the sockets we need +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket we can use to broadcast splitting factors on +splitting_out_socket = context.socket(zmq.PUB) +splitting_out_socket.bind(f"tcp://*:{parameters.SUPERVISOR_PORT}") + +# Set up a socket to subscribe to the battery's soc +soc_in_socket = context.socket(zmq.SUB) +soc_in_socket.connect(f"tcp://{parameters.BATTERY_SOC_IP}:{parameters.BATTERY_SOC_PORT}") + +# Ensure we only see message on the battery's soc +soc_in_socket.subscribe(parameters.TOPIC_BATTERY_SOC) + +### Unit connections +# TODO step 1.3: Set up connection to the units + +try: + while True: + try: + # Try to connect the the battery. + # If we have a connection to the battery, get its current soc. + # If none have come in, continue with previous soc. + # We put this in a while-loop to ensure we empty the queue each time, + # so we always have the latest value. + while True: + # Receive the latest splitting factor + incoming_str = soc_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "batt_split;0.781", + # so we split it up and take the last bit forward. + battery_soc = float(incoming_str.split(" ")[-1]) + logging.info(f"New battery soc: {battery_soc}") + except zmq.Again as e: + # No (more) new messages, move along + pass + + # Poll the grid connection to get the current production of renewables. + wind_p = 4.0 # TODO Task 1.3: Change into syslab connection. Remember that the measurements need to be collected directly from the units since vswitchboard is not working + solar_p = 7.0 + logging.info(f"Current renewable production: {wind_p + solar_p} kW.") + + soc_lower_limit = base_soc_lower_limit - (wind_p + solar_p) / base_renewable_shift / 10.0 + soc_upper_limit = base_soc_upper_limit + (wind_p + solar_p) / base_renewable_shift / 10.0 + + # Calculate a new splitting factor for the battery. + x_battery = clamp(0, (soc_upper_limit - battery_soc)/(soc_upper_limit - soc_lower_limit), 1) + + # If anything is not taken by the battery, put it in the load. + x_load = 1 - x_battery + + # Publish the new splitting factors. + splitting_out_socket.send_string(f"{parameters.TOPIC_BATTERY_SPLITTING} {x_battery:.06f}") + splitting_out_socket.send_string(f"{parameters.TOPIC_LOAD_SPLITTING} {x_load:.06f}") + logging.info(f"Battery split: {x_battery:.03f}; Load split: {x_load:.03f}") + + # Loop once more in a second + sleep(1) +finally: + # Clean up by closing our sockets. + soc_in_socket.close() + splitting_out_socket.close() diff --git a/util.py b/util.py index 321e0e9..51cde42 100644 --- a/util.py +++ b/util.py @@ -1,5 +1,42 @@ +import numpy as np +from syslab.core.datatypes import CompositeMeasurement +from typing import Union +from time import time + + +def pos(x): + """ + :param x: input + :return: x if x > 0 else 0 + """ + return max(x, 0) + + def clamp(a, x, b): """ Restrict x to lie in the range [a, b] """ return max(a, min(x, b)) + +def cast_to_cm(m: Union[CompositeMeasurement, float]): + if type(m) == float: + request = CompositeMeasurement(m, timestampMicros=time()*1e6, timePrecision=1000) + elif type(m) == CompositeMeasurement: + request = m + else: + raise TypeError(f"Unknown request type: {type(m)}") + return request + +def soc_scaler(soc, min_soc=0.0, max_soc=1.0): + """ + Scale an soc to emulate having a smaller battery. + If the actual battery has a capacity of 14 kWh, the rescaled battery will have a capacity + of 14*(max_soc - min_soc) kWh. + Does not check for negative soc. + :param soc: current actual soc + :param min_soc: actual soc that should correspond to an soc of 0.0 + :param max_soc: actual soc that should correspond to an soc of 1.0 + :return: rescaled soc + """ + + return (soc - min_soc)/(max_soc - min_soc)