diff --git a/hycon/controllers/battery_controller.py b/hycon/controllers/battery_controller.py index 882ecfef..8314faee 100644 --- a/hycon/controllers/battery_controller.py +++ b/hycon/controllers/battery_controller.py @@ -145,6 +145,31 @@ def compute_controls(self, measurements_dict): class BatteryPriceSOCController(ControllerBase): """ Controller considers price and SOC to determine power setpoint. + + This controller implements a price-arbitrage strategy that uses day-ahead (DA) + locational marginal prices (LMPs) and real-time (RT) LMPs to decide when to + charge or discharge the battery. The algorithm identifies the top and bottom + price hours of the day based on battery duration (e.g., for a 4-hour battery, + it targets the "top_d" = 4 highest and "bottom_d" = 4 lowest priced hours). + + The decision logic is as follows: + 1. If RT price exceeds the highest DA price: discharge at full rate + (unconditionally). + 2. Else if RT price is in the top-d highest DA prices AND SOC > low_soc: + discharge at full rate. + 3. Else if RT price is below the lowest DA price: charge at full rate + (unconditionally). + 4. Else if RT price is in the bottom-d lowest DA prices AND SOC < high_soc: + charge at full rate. + 5. Otherwise: hold (power setpoint = 0). + + The SOC thresholds (high_soc, low_soc) prevent over-charging or over-discharging + during moderate price signals, while still allowing full charge/discharge when + prices move outside the expected DA range. + + Note: + Charging power is represented as negative values, matching the convention + used at the Hercules/hybrid_plant level. """ def __init__(self, interface, input_dict, controller_parameters={}, verbose=True): @@ -165,6 +190,27 @@ def __init__(self, interface, input_dict, controller_parameters={}, verbose=True self.rated_power_charging = input_dict["battery"]["charge_rate"] self.rated_power_discharging = input_dict["battery"]["discharge_rate"] + # Save the duration rounded to nearest hour + self.duration = round( + interface.plant_parameters["battery"]["energy_capacity"] + / interface.plant_parameters["battery"]["power_capacity"] + ) + + # Raise if duration makes this controller implausible + if self.duration >= 12: + raise ValueError( + f"Battery duration is {self.duration} hours, which is not " + "supported by BatteryPriceSOCController." + " This controller is only intended for durations shorter than 12 hours." + ) + + if self.duration < 1: + raise ValueError( + f"Battery duration is {self.duration} hours, which is not " + "supported by BatteryPriceSOCController." + " This controller is only intended for durations of at least 1 hour." + ) + def set_controller_parameters( self, high_soc=0.8, @@ -193,8 +239,8 @@ def compute_controls(self, measurements_dict): real_time_lmp = measurements_dict["RT_LMP"] # Extract limits - bottom_4 = sorted_day_ahead_lmps[3] - top_4 = sorted_day_ahead_lmps[-4] + bottom_d = sorted_day_ahead_lmps[self.duration - 1] + top_d = sorted_day_ahead_lmps[-self.duration] bottom_1 = sorted_day_ahead_lmps[0] top_1 = sorted_day_ahead_lmps[-1] @@ -206,11 +252,11 @@ def compute_controls(self, measurements_dict): # will be inverted before passing into the battery modules if real_time_lmp > top_1: power_setpoint = self.rated_power_discharging - elif (real_time_lmp > top_4) & (soc < self.high_soc): + elif (real_time_lmp > top_d) & (soc > self.low_soc): power_setpoint = self.rated_power_discharging elif real_time_lmp < bottom_1: power_setpoint = -self.rated_power_charging - elif (real_time_lmp < bottom_4) & (soc > self.low_soc): + elif (real_time_lmp < bottom_d) & (soc < self.high_soc): power_setpoint = -self.rated_power_charging else: power_setpoint = 0.0 diff --git a/tests/battery_test.py b/tests/battery_test.py index b874193d..511cf385 100644 --- a/tests/battery_test.py +++ b/tests/battery_test.py @@ -18,6 +18,8 @@ def test_BatteryPriceSOCController_init(test_hercules_dict): def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): + # This test originally written assuming 4-hour battery + test_interface = HerculesInterface(test_hercules_dict) # Initialize controller @@ -30,10 +32,10 @@ def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 # Test the high soc condition when RT_LMP is below the charge price - # but above the low_soc_price + # but above the low_soc_price. SOC is too high to justify charging. measurement_dict = { "battery": {"state_of_charge": 0.9}, - "RT_LMP": 3, + "RT_LMP": 2.5, "DA_LMP_24hours": DA_LMP_test, } controls_dict = test_controller.compute_controls(measurement_dict) @@ -44,9 +46,9 @@ def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): controls_dict = test_controller.compute_controls(measurement_dict) assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging - # Test the high SOC conditions + # Test the high price / low soc condition measurement_dict = { - "battery": {"state_of_charge": 0.9}, + "battery": {"state_of_charge": 0.1}, "RT_LMP": 22, "DA_LMP_24hours": DA_LMP_test, } @@ -73,3 +75,37 @@ def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): measurement_dict["RT_LMP"] = 10 controls_dict = test_controller.compute_controls(measurement_dict) assert controls_dict["power_setpoint"] == 0.0 + + +def test_BatteryPriceSOCController_compute_controls_2_hour_duration(test_hercules_dict): + # Set the duration to 2 hours + test_hercules_dict["battery"]["energy_capacity"] = 20.0e3 + test_interface = HerculesInterface(test_hercules_dict) + + # Initialize controller + test_controller = BatteryPriceSOCController(test_interface, test_hercules_dict) + + # For testing, overwrite the high_soc and low_soc + test_controller.high_soc = 0.8 + test_controller.low_soc = 0.2 + + DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 + + # Test the in-between bottom 1 and bottom d prices + measurement_dict = { + "battery": {"state_of_charge": 0.5}, + "RT_LMP": 0.5, + "DA_LMP_24hours": DA_LMP_test, + } + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging + + # Now raise the state of charge to 0.85 + measurement_dict["battery"]["state_of_charge"] = 0.85 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["power_setpoint"] == 0.0 + + # Now drop the RT_LMP to -.5 (Going below bottom 1 price) + measurement_dict["RT_LMP"] = -0.5 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging