Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions hycon/controllers/battery_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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]

Expand All @@ -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
Expand Down
44 changes: 40 additions & 4 deletions tests/battery_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
}
Expand All @@ -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