diff --git a/src/batterymodel/__main__.py b/src/batterymodel/__main__.py new file mode 100644 index 0000000..9a25066 --- /dev/null +++ b/src/batterymodel/__main__.py @@ -0,0 +1,47 @@ +import argparse +import datetime +import sys +from copy import copy +from pathlib import Path + +from batterymodel import BatteryModel +from batterymodel.models.market import MarketState, MarketDataIncrement +from batterymodel.models.battery import BatteryState + + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--market1", type=Path, help="Market state file") + parser.add_argument("--market2", type=Path, help="Market state file") + + args = parser.parse_args() + market1 = MarketState().load_market_data_from_csv(args.market1) + market2 = MarketState().load_market_data_from_csv(args.market2) + + + + increment = datetime.timedelta(minutes=10) + market_1_start = market1.get_start_time() + market2_start = market2.get_start_time() + + start_time = min(market_1_start, market2_start) + print(f"Start Time: {start_time}") + end_time = max(market1.get_end_time(), market2.get_end_time()) + print(f"End Time: {end_time}") + + battery = BatteryModel(start_time) + + current_time = copy(start_time) + while True: + this_market_info_1 = market1.get_market_rate_for_timestamp(current_time) + this_market_info_2 = market2.get_market_rate_for_timestamp(current_time) + if current_time > end_time: + sys.exit() + + market_increment = MarketDataIncrement(current_time, [this_market_info_1, this_market_info_2]) + battery.update(increment, market_increment) + current_time = current_time + increment + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/batterymodel/models/__main__.py b/src/batterymodel/models/__main__.py deleted file mode 100644 index 395222e..0000000 --- a/src/batterymodel/models/__main__.py +++ /dev/null @@ -1,32 +0,0 @@ -import argparse -import datetime -from pathlib import Path - -from .market import MarketState -from .battery import BatteryState - - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--market1", type=Path, help="Market state file") - parser.add_argument("--market2", type=Path, help="Market state file") - - args = parser.parse_args() - market1 = MarketState().load_market_data_from_csv(args.market1) - market2 = MarketState().load_market_data_from_csv(args.market2) - - battery = BatteryState() - - increment = datetime.timedelta(minutes=10) - market_1_start = market1.get_start_time() - market2_start = market2.get_start_time() - start_time = min(market_1_start, market2_start) - - for item in market_1_start: - print(item) - battery.update(increment, None, market1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/batterymodel/models/battery.py b/src/batterymodel/models/battery.py index a7ddfbd..f5a810c 100644 --- a/src/batterymodel/models/battery.py +++ b/src/batterymodel/models/battery.py @@ -1,7 +1,7 @@ import datetime from enum import Enum -from batterymodel.models.market import MarketDataIncrement +from ..models.market import MarketDataIncrement class BatteryState(Enum): @@ -10,7 +10,7 @@ class BatteryState(Enum): DISCHARGING = 2 class BatteryModel: - def __init__(self): + def __init__(self, start_time: datetime.datetime): self._max_charge_rate = 2000000 # Watts self._max_discharge_rate = 2000000 # Watts self._max_storage_volume = 4000000 # Wh @@ -22,29 +22,61 @@ class BatteryModel: self._fixed_operational_costs = 50000 # £/year self._runtime = datetime.timedelta(0) self._charge_discharge_cycles = 0 - self._decision_log_file_handle = open("battery_log.txt", "w+") + self._decision_log_file = "battery.log" + self._decision_log_handle = open(self._decision_log_file, "w+") self._battery_state = BatteryState.IDLE self._current_charge_rate = 0.0 self._current_discharge_rate = 0.0 - self._current_market_rate = 0.0 + self._current_market_rate = () self._current_charge_value = 0.0 self._current_charge = 0.0 self._total_profit = 0.0 - def update(self, increment: datetime.timedelta, previous_state: "BatteryModel", market_data_increment: MarketDataIncrement): - self.market_decision(increment, previous_state, market_data_increment) + self.start_time = start_time + self.current_time = start_time + + def update(self, increment: datetime.timedelta, market_data_increment: MarketDataIncrement): + self.current_time += increment + self.market_decision(increment, market_data_increment) + if not self._battery_state == BatteryState.IDLE: + self._runtime += increment pass - def market_decision(self, increment: datetime.timedelta, previous_state: "BatteryModel", market_data_increment: MarketDataIncrement): + def market_decision(self, increment: datetime.timedelta, market_data_increment: MarketDataIncrement): + self.log_state_change("market_decision", None, None) if self._battery_state == BatteryState.IDLE and self._current_charge > 0: # Sell, I guess, to whoever offers more - for market_data_increment in market_data_increment.markets: - self._battery_state = BatteryState.DISCHARGING - self._current_charge_rate = self._max_discharge_rate - self._current_market_rate = market_data_increment.get_max_market_rate() - self._current_charge_value = self._current_charge_rate * self._current_charge - return + self._battery_state = BatteryState.DISCHARGING + self._current_charge_rate = self._max_discharge_rate + self._current_market_rate = market_data_increment.get_max_market_rate() + self._current_charge_value = self._current_charge_rate * self._current_charge + self.log_state_change("market_decision_battery", BatteryState.IDLE, self._battery_state) + elif self._battery_state == BatteryState.DISCHARGING and self._current_market_rate[0] < self.current_time: + # We've committed to discharging, so do figure out what we've earned this period, do the discharge calculation + # and continue + discharge_time = (increment.total_seconds() / 60.0) + self._total_profit = self._current_market_rate[1] * discharge_time + elif self._battery_state == BatteryState.DISCHARGING and self._current_market_rate[0] > self.current_time: + # We've finished discharging for this time period, calculate profit assuming we only discharged for this increment + # and remember to chop any time we wouldn't have been charging if our increments have overlapped. + discharge_time = (increment.total_seconds()/60.0) - ((self.current_time-self._current_market_rate[0])/60.0) + + # Could we actually discharge for this time + remaining_capacity = self._current_charge - (self._max_discharge_rate * discharge_time) + if remaining_capacity > 0: + self._total_profit += (self._current_market_rate[1] * discharge_time) + else: + discharge_time = remaining_capacity / self._max_discharge_rate + self._total_profit += (self._current_market_rate[1] * discharge_time) + self._battery_state = BatteryState.IDLE + self._current_charge = self._max_discharge_rate * discharge_time + elif self._battery_state == BatteryState.IDLE and self._current_charge == 0.0: + # No charge at all, normally I'd assume some kind of settable boundary here so we don't buy power too high + # but I'll just assume we buy to make life easy + self._battery_state = BatteryState.CHARGING + selected_market = market_data_increment.get_min_market_rate() + self._current_market_rate = selected_market def handle_charge_or_discharge(self, increment: datetime.timedelta, previous_state: "BatteryModel"): @@ -53,9 +85,9 @@ class BatteryModel: self._current_charge = self._current_charge + total_charge_this_increment # We filled the battery this increment - if self._current_charge > self._battery_capacity: - self._current_charge = self._battery_capacity - total_charge_this_increment = self._battery_capacity - previous_state._current_charge + if self._current_charge > self._max_storage_volume: + self._current_charge = self._max_storage_volume + total_charge_this_increment = self._max_storage_volume - previous_state._current_charge self._battery_state = BatteryState.IDLE time_of_charge = datetime.timedelta(seconds=total_charge_this_increment / self._current_charge_rate) @@ -78,7 +110,7 @@ class BatteryModel: self._current_charge_value) def log_state_change(self, fn, original_state, new_state): - self._decision_log_file_handle.write(f"{self._runtime},{fn},{original_state},{new_state}\n") + self._decision_log_handle.write(f"{self.current_time},{self._runtime},{fn},{original_state},{new_state}\n") def handle_storage_capacity_check(self, previous_state: "BatteryModel"): if self._charge_discharge_cycles > previous_state._charge_discharge_cycles and self._charge_discharge_cycles / int(self._charge_discharge_cycles) == 1: diff --git a/src/batterymodel/models/market.py b/src/batterymodel/models/market.py index 7ccb7ce..53f73ac 100644 --- a/src/batterymodel/models/market.py +++ b/src/batterymodel/models/market.py @@ -8,29 +8,41 @@ from xmlrpc.client import DateTime class MarketState: def __init__(self): self._market_data = OrderedDict() + self._last_key = None def load_market_data_from_csv(self, csv_file): with open(csv_file, 'r') as file: + reader = csv.reader(file) + next(reader, None) # skip the headers for row in reader: - self._market_data[datetime.datetime.strptime(row[0], '%d-%m-%Y %H:%M')] = float(row[1]) + self._market_data[datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M')] = float(row[1]) + self._last_key = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M') + return self def get_start_time(self): - return next(iter(self._market_data)) + return next(iter(self._market_data.items()))[0] + + def get_end_time(self): + end = None + for i in iter(self._market_data.items()): + end = i[0] + return end def get_market_rate_for_timestamp(self, timestamp: datetime.datetime): last_timestamp = None + if timestamp >= self._last_key: + return None for key in self._market_data: if last_timestamp is not None: delta = timestamp - last_timestamp key_delta = key - last_timestamp if key == timestamp: - return self._market_data[key] + return timestamp, self._market_data[key] elif last_timestamp is not None and last_timestamp < key and key_delta > delta: - - return self._market_data[last_timestamp] + return last_timestamp, self._market_data[last_timestamp] else: - last_timestamp = key + last_timestamp = key return None @@ -46,4 +58,13 @@ class MarketDataIncrement: if i[1] > rate: rate = i[1] highest = i - return highest \ No newline at end of file + return highest + + def get_min_market_rate(self): + rate = 99999999999999 + lowest = () + for i in self.markets: + if i[1] < rate: + rate = i[1] + lowest = i + return lowest \ No newline at end of file diff --git a/tests/test_battery.py b/tests/test_battery.py index 9dbf143..9f49cd0 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -1,5 +1,8 @@ +import datetime +import copy from batterymodel import BatteryModel from batterymodel.models.battery import BatteryState +from batterymodel.models.market import MarketDataIncrement def test_battery_stats(): @@ -20,3 +23,24 @@ def test_battery_update(): def test_market_decision_idle(): battery = BatteryModel() + battery._current_charge=100000 + time_increment = datetime.timedelta(minutes=30) + market_increment = MarketDataIncrement( + datetime.datetime.strptime('01-01-2022 00:00', '%d-%m-%Y %H:%M'), + [ + (datetime.datetime.strptime('01-01-2022 00:00', '%d-%m-%Y %H:%M'),43.0), + (datetime.datetime.strptime('01-01-2022 00:00', '%d-%m-%Y %H:%M'),14.0) + ] + ) + + market_increment_current = MarketDataIncrement( + datetime.datetime.strptime('01-01-2022 00:30', '%d-%m-%Y %H:%M'), + [ + (datetime.datetime.strptime('01-01-2022 00:00', '%d-%m-%Y %H:%M'),43.0), + (datetime.datetime.strptime('01-01-2022 00:30', '%d-%m-%Y %H:%M'),13.0) + ] + ) + current_battery = copy.deepcopy(battery) + battery.market_decision(time_increment, current_battery, market_increment_current) + assert current_battery._battery_state == BatteryState.IDLE + assert battery._battery_state == BatteryState.DISCHARGING \ No newline at end of file diff --git a/tests/test_market.py b/tests/test_market.py index a66760f..0d5c614 100644 --- a/tests/test_market.py +++ b/tests/test_market.py @@ -10,7 +10,7 @@ def test_market(): this_market._market_data[datetime.datetime.strptime('01-01-2022 00:02', '%d-%m-%Y %H:%M')] = 13 this_market._market_data[datetime.datetime.strptime('01-01-2022 00:05', '%d-%m-%Y %H:%M')] = 14 - assert this_market.get_market_rate_for_timestamp(datetime.datetime.strptime('01-01-2022 00:02', '%d-%m-%Y %H:%M')) == 13 - assert this_market.get_market_rate_for_timestamp(datetime.datetime.strptime('01-01-2022 00:03', '%d-%m-%Y %H:%M')) == 13 + assert this_market.get_market_rate_for_timestamp(datetime.datetime.strptime('01-01-2022 00:02', '%d-%m-%Y %H:%M')) == (datetime.datetime.strptime('01-01-2022 00:02', '%d-%m-%Y %H:%M'),13) + assert this_market.get_market_rate_for_timestamp(datetime.datetime.strptime('01-01-2022 00:03', '%d-%m-%Y %H:%M')) == (datetime.datetime.strptime('01-01-2022 00:02', '%d-%m-%Y %H:%M'),13)