diff --git a/README.md b/README.md index 05fb9bc..627b148 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ pip install solar-and-storage Import the packages ```python import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots from solar_and_storage.solar_and_storage import SolarAndStorage @@ -52,14 +50,15 @@ result_df = solar_and_storage.get_results() Now plot the data ```python -fig = solar_and_storage.get_fig() +fig = solar_and_storage.get_figure() fig.show(rendered="browser") ``` -![Example1](https://raw.githubusercontent.com/openclimatefix/solar-and-storage/main/examples/solar_and_storage.png) -The first plot shows the solar profile, the second shows the prices that day. The third shows the battery profile. +![Example1](examples/images/battery_solar.png) + +The first plot shows the solar profile, the second shows the prices that day. The third shows the battery profile. Finally the fourth shows profit. You can see that the battery charged from the solar site at the end of the solar maximum @@ -94,4 +93,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! \ No newline at end of file +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..cb288ae --- /dev/null +++ b/TODO.md @@ -0,0 +1,5 @@ +- add tests +- refactor to use net_transfer_to_grid +- incorporate consumption + - example with 0 solar + 0 battery + - build from here diff --git a/examples/battery_only.py b/examples/battery_only.py index c7d5479..0e7ac1f 100644 --- a/examples/battery_only.py +++ b/examples/battery_only.py @@ -3,35 +3,29 @@ import numpy as np from solar_and_storage.solar_and_storage import SolarAndStorage +from solar_and_storage.example import prices, no_solar -HTML_OUTPUT = "examples/battery_only.html" # Change this to your desired path or leave as an empty string +HTML_OUTPUT = "" # set path or empty will skip writing HTML +PNG_OUTPUT = "examples/images/battery_only.png" # empty will skip writing PNG -hours_per_day = 24 - - -# make prices -prices = np.zeros(hours_per_day) + 30 -prices[6:19] = 40 -prices[9] = 50 -prices[12:14] = 30 -prices[16:18] = 50 -prices[17] = 60 - -# make zero solar profile -solar = np.zeros(hours_per_day) - -solar_and_storage = SolarAndStorage(prices=prices, solar_generation=list(solar)) -solar_and_storage.run_optimization() +# use example prices and no generation profile +solar_and_storage = SolarAndStorage(prices=prices, solar_generation=list(no_solar)) result_df = solar_and_storage.get_results() # data is available for direct access power = result_df["power"] e_soc = result_df["e_soc"] solar_power_to_grid = result_df["solar_power_to_grid"] +profit = result_df["profit"] # plot -fig = solar_and_storage.get_fig() +fig = solar_and_storage.get_figure() fig.show(rendered="browser") if HTML_OUTPUT: fig.write_html(HTML_OUTPUT) +if PNG_OUTPUT: + fig.write_image(PNG_OUTPUT, format="png") +print(result_df.attrs["message"]) +total_profit = solar_and_storage.get_total_profit() +print(f'total profit: {total_profit}') diff --git a/examples/battery_solar.py b/examples/battery_solar.py index 596acad..c8ef69e 100644 --- a/examples/battery_solar.py +++ b/examples/battery_solar.py @@ -4,37 +4,29 @@ import os from solar_and_storage.solar_and_storage import SolarAndStorage +from solar_and_storage.example import prices, with_solar -HTML_OUTPUT = "examples/battery_solar.html" # Change this to your desired path or leave as an empty string +HTML_OUTPUT = "" # set path or empty will skip writing HTML +PNG_OUTPUT = "examples/images/battery_solar.png" # empty will skip writing PNG -hours_per_day = 24 - - -# make prices -prices = np.zeros(hours_per_day) + 30 -prices[6:19] = 40 -prices[9] = 50 -prices[12:14] = 30 -prices[16:18] = 50 -prices[17] = 60 - -# make solar profile -solar = np.zeros(hours_per_day) -solar[8:16] = 2.0 -solar[10:14] = 4.0 - -solar_and_storage = SolarAndStorage(prices=prices, solar_generation=list(solar)) -solar_and_storage.run_optimization() +# use example prices and solar generation profile +solar_and_storage = SolarAndStorage(prices=prices, solar_generation=list(with_solar)) result_df = solar_and_storage.get_results() # data is available for direct access power = result_df["power"] e_soc = result_df["e_soc"] solar_power_to_grid = result_df["solar_power_to_grid"] +profit = result_df["profit"] # plot -fig = solar_and_storage.get_fig() +fig = solar_and_storage.get_figure() fig.show(rendered="browser") if HTML_OUTPUT: fig.write_html(HTML_OUTPUT) +if PNG_OUTPUT: + fig.write_image(PNG_OUTPUT, format="png") +print(result_df.attrs["message"]) +total_profit = solar_and_storage.get_total_profit() +print(f'total profit: {total_profit}') diff --git a/examples/images/battery_only.png b/examples/images/battery_only.png new file mode 100644 index 0000000..def4058 Binary files /dev/null and b/examples/images/battery_only.png differ diff --git a/examples/images/battery_solar.png b/examples/images/battery_solar.png new file mode 100644 index 0000000..bd2dbd8 Binary files /dev/null and b/examples/images/battery_solar.png differ diff --git a/examples/solar_and_storage.png b/examples/solar_and_storage.png deleted file mode 100644 index cd05572..0000000 Binary files a/examples/solar_and_storage.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index 80f1de7..5e5b5d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ scipy==1.8.1 cvxopt cvxpy==1.2.2 plotly +kaleido diff --git a/solar_and_storage/example.py b/solar_and_storage/example.py new file mode 100644 index 0000000..d8e2b6a --- /dev/null +++ b/solar_and_storage/example.py @@ -0,0 +1,25 @@ +""" These are used in the /examples directory as well as (planned) tests """ + +import numpy as np + +# examples use 24 hour day for analysis period +# any granularity should be supported but only 24 hour day has been tested +hours_per_day = 24 + +prices = np.zeros(hours_per_day) + 30 +prices[6:19] = 40 +prices[9] = 50 +prices[12:14] = 30 +prices[16:18] = 50 +prices[17] = 60 + +no_solar = np.zeros(hours_per_day) + +with_solar = np.zeros(hours_per_day) +with_solar[8:16] = 2.0 +with_solar[10:14] = 4.0 + +solar_generation = { + "no_solar": no_solar, + "with_solar": with_solar, +} diff --git a/solar_and_storage/solar_and_storage.py b/solar_and_storage/solar_and_storage.py index a767456..1cc37a9 100644 --- a/solar_and_storage/solar_and_storage.py +++ b/solar_and_storage/solar_and_storage.py @@ -40,6 +40,7 @@ def __init__( should be between 0 and 1. :param grid_connection_capacity: the amount of power that can be delivered to the grid """ + self.prob = None self.battery_soc_min = battery_soc_min self.battery_soc_max = battery_soc_max self.battery_capacity = battery_capacity @@ -132,6 +133,13 @@ def __init__( self.constraints = constraints self.objective_function = objective_function + def get_status(self) -> str: + """Runs optimization if not already run, and returns status""" + + if self.prob is None: + self.run_optimization() + return self.prob.status + def run_optimization(self): """ Run optimization problem @@ -145,6 +153,16 @@ def run_optimization(self): def get_results(self) -> pd.DataFrame: """Get optimization results (after running)""" + + status = self.get_status() + + if status != "optimal": + # Return an empty DataFrame with metadata for non-optimal cases + result_df = pd.DataFrame() + result_df.attrs["status"] = status + result_df.attrs["message"] = message + return result_df + # run plot resutls power = np.round( self.battery_power_charge_cp_variable.value - self.power_discharge_cp_variable.value, 2 @@ -157,26 +175,69 @@ def get_results(self) -> pd.DataFrame: data = np.array([power, e_soc[:HOURS_PER_DAY], solar_power_to_grid, profit]).transpose() - return pd.DataFrame( + result_df = pd.DataFrame( data=data, columns=["power", "e_soc", "solar_power_to_grid", "profit"], ) + result_df.attrs["status"] = status + result_df.attrs["message"] = "Optimization successful" + + return result_df + + def get_total_profit(self) -> float: + results = self.get_results() + if results.attrs["status"] != "optimal": + raise ValueError(f"Cannot calculate total profit: {results.attrs['message']}") + return sum(results["profit"]) + + def get_figure(self) -> go.Figure: + """Generate figure on successful optimization""" + + status = self.get_status() + + if status != "optimal": + fig = go.Figure() + fig.update_layout( + title=f"Optimization Failed: {status.capitalize()}", + title_x=0.5, + ) + return fig - def get_fig(self) -> go.Figure: result_df = self.get_results() + total_profit = self.get_total_profit() # run plot resutls power = result_df["power"] e_soc = result_df["e_soc"] solar_power_to_grid = result_df["solar_power_to_grid"] + profit = result_df["profit"] # plot - fig = make_subplots(rows=3, cols=1, subplot_titles=["Solar profile", "Price", "SOC"]) + fig = make_subplots(rows=4, cols=1, subplot_titles=["Solar profile", "Price", "SOC", "Profit"]) fig.add_trace(go.Scatter(y=e_soc[:24], name="SOC"), row=3, col=1) fig.add_trace(go.Scatter(y=self.solar_generation, name="solar", line_shape="hv"), row=1, col=1) fig.add_trace( go.Scatter(y=solar_power_to_grid, name="solar to gird", line_shape="hv"), row=1, col=1 ) fig.add_trace(go.Scatter(y=self.prices, name="price", line_shape="hv"), row=2, col=1) + fig.add_trace(go.Scatter(y=profit, name="profit", line_shape="hv"), row=4, col=1) + + # Add title + fig.update_layout( + title="Solar and Storage Optimization Results", + title_x=0.5, + ) + + # Add total profit as an annotation below the chart + fig.update_layout( + annotations=[ + dict( + text=f"Total Profit: {total_profit:.2f}", + yref="paper", + y=-0.2, # Position below the chart + font=dict(size=14) + ) + ] + ) return fig