1
+ import streamlit as st
2
+ import http .client
3
+ import pandas as pd
4
+ import plotly .express as px
5
+ from datetime import datetime , timezone , timedelta
6
+ import sys
7
+ import os
8
+ import logging
9
+ import numpy as np
10
+ import xarray as xr
11
+ from dotenv import load_dotenv
12
+ import base64
13
+ import json
14
+ import requests
15
+ from urllib .parse import urlencode
16
+ from PIL import Image
17
+
18
+ # Load environment variables
19
+ load_dotenv ()
20
+
21
+ # Set up logging
22
+ logging .basicConfig (level = logging .INFO )
23
+ logger = logging .getLogger (__name__ )
24
+
25
+ # Add the parent directory to the Python path
26
+ sys .path .append (os .path .dirname (os .path .dirname (os .path .abspath (__file__ ))))
27
+
28
+ from quartz_solar_forecast .pydantic_models import PVSite
29
+ from quartz_solar_forecast .forecasts import forecast_v1_tilt_orientation , TryolabsSolarPowerPredictor
30
+ from quartz_solar_forecast .data import get_nwp , process_pv_data
31
+ from quartz_solar_forecast .inverters .enphase import process_enphase_data
32
+
33
+ # Get the directory of the current script
34
+ script_dir = os .path .dirname (os .path .abspath (__file__ ))
35
+
36
+ # Construct the path to logo.png
37
+ logo_path = os .path .join (script_dir , "logo.png" )
38
+ im = Image .open (logo_path )
39
+
40
+ st .set_page_config (page_title = "Open Source Quartz Solar Forecast | Open Climate Fix" , layout = "wide" , page_icon = im )
41
+
42
+ st .title ("☀️ Open Source Quartz Solar Forecast" )
43
+
44
+ def get_enphase_auth_url ():
45
+ client_id = os .getenv ('ENPHASE_CLIENT_ID' )
46
+ redirect_uri = "https://api.enphaseenergy.com/oauth/redirect_uri"
47
+ params = {
48
+ "response_type" : "code" ,
49
+ "client_id" : client_id ,
50
+ "redirect_uri" : redirect_uri ,
51
+ }
52
+ auth_url = f"https://api.enphaseenergy.com/oauth/authorize?{ urlencode (params )} "
53
+ return auth_url
54
+
55
+ def get_enphase_access_token (auth_code ):
56
+ client_id = os .getenv ('ENPHASE_CLIENT_ID' )
57
+ client_secret = os .getenv ('ENPHASE_CLIENT_SECRET' )
58
+
59
+ credentials = f"{ client_id } :{ client_secret } "
60
+ credentials_bytes = credentials .encode ("utf-8" )
61
+ encoded_credentials = base64 .b64encode (credentials_bytes ).decode ("utf-8" )
62
+ conn = http .client .HTTPSConnection ("api.enphaseenergy.com" )
63
+ payload = ""
64
+ headers = {
65
+ "Authorization" : f"Basic { encoded_credentials } "
66
+ }
67
+ conn .request (
68
+ "POST" ,
69
+ f"/oauth/token?grant_type=authorization_code&redirect_uri=https://api.enphaseenergy.com/oauth/redirect_uri&code={ auth_code } " ,
70
+ payload ,
71
+ headers ,
72
+ )
73
+ res = conn .getresponse ()
74
+ data = res .read ()
75
+
76
+ # Decode the data read from the response
77
+ decoded_data = data .decode ("utf-8" )
78
+
79
+ # Convert the decoded data into JSON format
80
+ data_json = json .loads (decoded_data )
81
+ access_token = data_json ["access_token" ]
82
+
83
+ return access_token
84
+
85
+ def get_enphase_data (enphase_system_id : str , access_token : str ) -> pd .DataFrame :
86
+ api_key = os .getenv ('ENPHASE_API_KEY' )
87
+ start_at = int ((datetime .now () - timedelta (weeks = 1 )).timestamp ())
88
+ granularity = "week"
89
+
90
+ conn = http .client .HTTPSConnection ("api.enphaseenergy.com" )
91
+ headers = {
92
+ "Authorization" : f"Bearer { str (access_token )} " ,
93
+ "key" : str (api_key )
94
+ }
95
+
96
+ url = f"/api/v4/systems/{ enphase_system_id } /telemetry/production_micro?start_at={ start_at } &granularity={ granularity } "
97
+ conn .request ("GET" , url , headers = headers )
98
+ res = conn .getresponse ()
99
+ data = res .read ()
100
+ decoded_data = data .decode ("utf-8" )
101
+ data_json = json .loads (decoded_data )
102
+
103
+ return process_enphase_data (data_json , start_at )
104
+
105
+ def enphase_authorization ():
106
+ auth_url = get_enphase_auth_url ()
107
+ st .write ("Please visit the following URL to authorize the application:" )
108
+ st .markdown (f"[Enphase Authorization URL]({ auth_url } )" )
109
+ st .write ("After authorization, you will be redirected to a URL. Please copy the entire URL and paste it below:" )
110
+
111
+ redirect_url = st .text_input ("Enter the redirect URL:" )
112
+
113
+ if redirect_url :
114
+ auth_code = redirect_url .split ("?code=" )[1 ] if "?code=" in redirect_url else None
115
+
116
+ if auth_code :
117
+ access_token = get_enphase_access_token (auth_code )
118
+ return access_token , os .getenv ('ENPHASE_SYSTEM_ID' )
119
+
120
+ return None , None
121
+
122
+ def make_pv_data (site : PVSite , ts : pd .Timestamp , access_token : str = None , enphase_system_id : str = None ) -> xr .Dataset :
123
+ live_generation_kw = None
124
+
125
+ if site .inverter_type == 'enphase' and access_token and enphase_system_id :
126
+ live_generation_kw = get_enphase_data (enphase_system_id , access_token )
127
+
128
+ da = process_pv_data (live_generation_kw , ts , site )
129
+ return da
130
+
131
+ def predict_ocf (site : PVSite , model = None , ts : datetime | str = None , nwp_source : str = "icon" , access_token : str = None , enphase_system_id : str = None ):
132
+ if ts is None :
133
+ ts = pd .Timestamp .now ().round ("15min" )
134
+ if isinstance (ts , str ):
135
+ ts = datetime .fromisoformat (ts )
136
+
137
+ nwp_xr = get_nwp (site = site , ts = ts , nwp_source = nwp_source )
138
+ pv_xr = make_pv_data (site = site , ts = ts , access_token = access_token , enphase_system_id = enphase_system_id )
139
+
140
+ pred_df = forecast_v1_tilt_orientation (nwp_source , nwp_xr , pv_xr , ts , model = model )
141
+ return pred_df
142
+
143
+ def predict_tryolabs (site : PVSite , ts : datetime | str = None ):
144
+ solar_power_predictor = TryolabsSolarPowerPredictor ()
145
+
146
+ if ts is None :
147
+ start_date = pd .Timestamp .now ().strftime ("%Y-%m-%d" )
148
+ start_time = pd .Timestamp .now ().round (freq = 'h' )
149
+ else :
150
+ start_date = pd .Timestamp (ts ).strftime ("%Y-%m-%d" )
151
+ start_time = pd .Timestamp (ts ).round (freq = 'h' )
152
+
153
+ end_time = start_time + pd .Timedelta (hours = 48 )
154
+
155
+ solar_power_predictor .load_model ()
156
+ predictions = solar_power_predictor .predict_power_output (
157
+ latitude = site .latitude ,
158
+ longitude = site .longitude ,
159
+ start_date = start_date ,
160
+ kwp = site .capacity_kwp ,
161
+ orientation = site .orientation ,
162
+ tilt = site .tilt ,
163
+ )
164
+
165
+ predictions = predictions [
166
+ (predictions ["date" ] >= start_time ) & (predictions ["date" ] < end_time )
167
+ ]
168
+ predictions = predictions .reset_index (drop = True )
169
+ predictions .set_index ("date" , inplace = True )
170
+ return predictions
171
+
172
+ def run_forecast (site : PVSite , model : str = "gb" , ts : datetime | str = None , nwp_source : str = "icon" , access_token : str = None , enphase_system_id : str = None ) -> pd .DataFrame :
173
+ if model == "gb" :
174
+ return predict_ocf (site , None , ts , nwp_source , access_token , enphase_system_id )
175
+ elif model == "xgb" :
176
+ return predict_tryolabs (site , ts )
177
+ else :
178
+ raise ValueError (f"Unsupported model: { model } . Choose between 'xgb' and 'gb'" )
179
+
180
+ def fetch_data_and_run_forecast (access_token : str = None , enphase_system_id : str = None ):
181
+ with st .spinner ("Running forecast..." ):
182
+ try :
183
+ timestamp = datetime .now ().timestamp ()
184
+ timestamp_str = datetime .fromtimestamp (timestamp , tz = timezone .utc ).strftime ('%Y-%m-%d %H:%M:%S' )
185
+ ts = pd .to_datetime (timestamp_str )
186
+
187
+ site_live = PVSite (latitude = 51.75 , longitude = - 1.25 , capacity_kwp = 1.25 , inverter_type = "enphase" )
188
+ site_no_live = PVSite (latitude = 51.75 , longitude = - 1.25 , capacity_kwp = 1.25 )
189
+
190
+ predictions_with_recent_pv_df = run_forecast (site = site_live , ts = ts , access_token = access_token , enphase_system_id = enphase_system_id )
191
+ predictions_df = run_forecast (site = site_no_live , ts = ts )
192
+
193
+ predictions_with_recent_pv_df ["power_kw_no_live_pv" ] = predictions_df ["power_kw" ]
194
+
195
+ return predictions_with_recent_pv_df , ts
196
+
197
+ except Exception as e :
198
+ logger .error (f"An error occurred: { str (e )} " )
199
+ st .error (f"An error occurred: { str (e )} " )
200
+ return None , None
201
+
202
+ # Main app logic
203
+ access_token , enphase_system_id = enphase_authorization ()
204
+
205
+ if st .button ("Run Forecast" ):
206
+ if access_token :
207
+ predictions_with_recent_pv_df , ts = fetch_data_and_run_forecast (access_token , enphase_system_id )
208
+
209
+ if predictions_with_recent_pv_df is not None :
210
+ st .success ("Forecast completed successfully!" )
211
+
212
+ # Display current timestamp
213
+ st .subheader (f"Forecast generated at: { ts } " )
214
+
215
+ # Create three columns
216
+ col1 , col2 , col3 = st .columns (3 )
217
+
218
+ with col1 :
219
+ st .metric ("Current Power" , f"{ predictions_with_recent_pv_df ['power_kw' ].iloc [- 1 ]:.2f} kW" )
220
+
221
+ with col2 :
222
+ total_energy = predictions_with_recent_pv_df ['power_kw' ].sum () * 0.25 # Assuming 15-minute intervals
223
+ st .metric ("Total Forecasted Energy" , f"{ total_energy :.2f} kWh" )
224
+
225
+ with col3 :
226
+ peak_power = predictions_with_recent_pv_df ['power_kw' ].max ()
227
+ st .metric ("Peak Forecasted Power" , f"{ peak_power :.2f} kW" )
228
+
229
+ # Create a line chart of power generation
230
+ fig = px .line (predictions_with_recent_pv_df .reset_index (),
231
+ x = 'index' , y = ['power_kw' , 'power_kw_no_live_pv' ],
232
+ title = 'Forecasted Power Generation Comparison' )
233
+ fig .update_layout (xaxis_title = "Time" , yaxis_title = "Power (kW)" )
234
+ st .plotly_chart (fig , use_container_width = True )
235
+
236
+ # Display raw data
237
+ st .subheader ("Raw Forecast Data" )
238
+ st .dataframe (predictions_with_recent_pv_df .reset_index ())
239
+
240
+ # Some information about the app
241
+
242
+ st .sidebar .info (
243
+ """
244
+ This dashboard runs
245
+
246
+ [Open Climate Fix](https://openclimatefix.org/)'s
247
+
248
+ [Open Source Quartz Solar Forecast](https://github.com/openclimatefix/Open-Source-Quartz-Solar-Forecast/).
249
+
250
+ Click 'Run Forecast' and add the Home-Owner approved authentication URL to see the results.
251
+ """
252
+ )
253
+
254
+ # Footer
255
+ st .markdown ("---" )
256
+ st .markdown (f"Created with ❤️ by [Open Climate Fix](https://openclimatefix.org/)" )
0 commit comments