Skip to content

Commit 3e11e48

Browse files
authored
Create abstract base class for inverters (#183)
* wip * wip * solarman * update token * update inverters * undo some changes * add docstring * mock inverter docstring * remove dotenv * update givenergy * enphase * try to fix tests running twice * delete event * pr comments * revert workflow changes * split inverters into separate modules * import * Revert "import" This reverts commit f9f3c5f. * Revert "split inverters into separate modules" This reverts commit 94a9e70. * add pydantic_settings * set config within settings classes * use named argument * fix access token issue * add a line to the docs
1 parent ff7b1c8 commit 3e11e48

File tree

11 files changed

+229
-133
lines changed

11 files changed

+229
-133
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ quartz_solar_forecast.egg-info
66
.env
77
venv
88
frontend/node_modules
9-
frontend/.vite
9+
frontend/.vite
10+
__pycache__/
11+
.cache.sqlite

quartz_solar_forecast/data.py

Lines changed: 17 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,19 @@
11
""" Function to get NWP data and create fake PV dataset"""
22
import ssl
3-
from datetime import datetime, timedelta
4-
import os
3+
from datetime import datetime
4+
from typing import Optional
5+
56
import numpy as np
6-
import pandas as pd
7-
import xarray as xr
87
import openmeteo_requests
8+
import pandas as pd
99
import requests_cache
10-
import asyncio
11-
10+
import xarray as xr
1211
from retry_requests import retry
13-
from typing import Optional
1412

1513
from quartz_solar_forecast.pydantic_models import PVSite
16-
from quartz_solar_forecast.inverters.enphase import get_enphase_data
17-
from quartz_solar_forecast.inverters.solis import get_solis_data
18-
from quartz_solar_forecast.inverters.givenergy import get_givenergy_data
19-
from quartz_solar_forecast.inverters.solarman import get_solarman_data
2014

2115
ssl._create_default_https_context = ssl._create_unverified_context
2216

23-
from dotenv import load_dotenv
24-
25-
load_dotenv()
2617

2718
def get_nwp(site: PVSite, ts: datetime, nwp_source: str = "icon") -> xr.Dataset:
2819
"""
@@ -41,13 +32,13 @@ def get_nwp(site: PVSite, ts: datetime, nwp_source: str = "icon") -> xr.Dataset:
4132

4233
# Define the variables we want. Visibility is handled separately after the main request
4334
variables = [
44-
"temperature_2m",
45-
"precipitation",
46-
"cloud_cover_low",
47-
"cloud_cover_mid",
48-
"cloud_cover_high",
49-
"wind_speed_10m",
50-
"shortwave_radiation",
35+
"temperature_2m",
36+
"precipitation",
37+
"cloud_cover_low",
38+
"cloud_cover_mid",
39+
"cloud_cover_high",
40+
"wind_speed_10m",
41+
"shortwave_radiation",
5142
"direct_radiation"
5243
]
5344

@@ -59,7 +50,7 @@ def get_nwp(site: PVSite, ts: datetime, nwp_source: str = "icon") -> xr.Dataset:
5950
# check whether the time stamp is more than 3 months in the past
6051
if (datetime.now() - ts).days > 90:
6152
print("Warning: The requested timestamp is more than 3 months in the past. The weather data are provided by a reanalyse model and not ICON or GFS.")
62-
53+
6354
# load data from open-meteo Historical Weather API
6455
url = "https://archive-api.open-meteo.com/v1/archive"
6556

@@ -104,7 +95,7 @@ def get_nwp(site: PVSite, ts: datetime, nwp_source: str = "icon") -> xr.Dataset:
10495
hourly_data["dswrf"] = hourly.Variables(6).ValuesAsNumpy()
10596
hourly_data["dlwrf"] = hourly.Variables(7).ValuesAsNumpy()
10697

107-
# handle visibility
98+
# handle visibility
10899
if (datetime.now() - ts).days <= 90:
109100
# load data from open-meteo gfs model
110101
params = {
@@ -144,61 +135,11 @@ def format_nwp_data(df: pd.DataFrame, nwp_source:str, site: PVSite):
144135
)
145136
return data_xr
146137

147-
def fetch_enphase_data() -> Optional[pd.DataFrame]:
148-
system_id = os.getenv('ENPHASE_SYSTEM_ID')
149-
if not system_id:
150-
print("Error: Enphase inverter ID is not provided in the environment variables.")
151-
return None
152-
return get_enphase_data(system_id)
153-
154-
def fetch_solis_data() -> Optional[pd.DataFrame]:
155-
try:
156-
return asyncio.run(get_solis_data())
157-
except Exception as e:
158-
print(f"Error retrieving Solis data: {str(e)}")
159-
return None
160-
161-
def fetch_givenergy_data() -> Optional[pd.DataFrame]:
162-
try:
163-
return get_givenergy_data()
164-
except Exception as e:
165-
print(f"Error retrieving GivEnergy data: {str(e)}")
166-
return None
167-
168-
def fetch_solarman_data() -> pd.DataFrame:
169-
try:
170-
end_date = datetime.now()
171-
start_date = end_date - timedelta(weeks=1)
172-
solarman_data = get_solarman_data(start_date, end_date)
173-
174-
# Filter out rows with null power_kw values
175-
valid_data = solarman_data.dropna(subset=['power_kw'])
176-
177-
if valid_data.empty:
178-
print("No valid Solarman data found.")
179-
return pd.DataFrame(columns=['timestamp', 'power_kw'])
180-
181-
return valid_data
182-
except Exception as e:
183-
print(f"Error retrieving Solarman data: {str(e)}")
184-
return pd.DataFrame(columns=['timestamp', 'power_kw'])
185-
186-
def fetch_live_generation_data(inverter_type: str) -> Optional[pd.DataFrame]:
187-
if inverter_type == 'enphase':
188-
return fetch_enphase_data()
189-
elif inverter_type == 'solis':
190-
return fetch_solis_data()
191-
elif inverter_type == 'givenergy':
192-
return fetch_givenergy_data()
193-
elif inverter_type == 'solarman':
194-
return fetch_solarman_data()
195-
else:
196-
return pd.DataFrame(columns=['timestamp', 'power_kw'])
197138

198139
def process_pv_data(live_generation_kw: Optional[pd.DataFrame], ts: pd.Timestamp, site: 'PVSite') -> xr.Dataset:
199140
"""
200141
Process PV data and create an xarray Dataset.
201-
142+
202143
:param live_generation_kw: DataFrame containing live generation data, or None
203144
:param ts: Current timestamp
204145
:param site: PV site information
@@ -231,15 +172,15 @@ def process_pv_data(live_generation_kw: Optional[pd.DataFrame], ts: pd.Timestamp
231172

232173
return da
233174

234-
def make_pv_data(site: 'PVSite', ts: pd.Timestamp) -> xr.Dataset:
175+
def make_pv_data(site: PVSite, ts: pd.Timestamp) -> xr.Dataset:
235176
"""
236177
Make PV data by combining live data from various inverters.
237178
238179
:param site: the PV site
239180
:param ts: the timestamp of the site
240181
:return: The combined PV dataset in xarray form
241182
"""
242-
live_generation_kw = fetch_live_generation_data(site.inverter_type)
183+
live_generation_kw = site.get_inverter().get_data(ts)
243184
# Process the PV data
244185
da = process_pv_data(live_generation_kw, ts, site)
245186

quartz_solar_forecast/inverters/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Open-Source-Quartz-Solar-Forecast/
3434
* `pydantic_models.py`: Contains the PVSite class
3535
* `inverters/`:
3636
* This is the directory where you'd want to create a new file among the other `<inverter_name>.py` files to add your inverter
37+
* You will need to create a new inverter model that extends `AbstractInverter` which is defined in `inverter.py`
3738
* You will need to follow the appropriate authentication flow as mentioned in the documentation of the inverter you're trying to add
3839
* We need the past 7 days data formatted in intervals of 5 minutes for this model. Given below is an example with Enphase
3940

quartz_solar_forecast/inverters/enphase.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,50 @@
11
import http.client
22
import os
3+
from typing import Optional
4+
35
import pandas as pd
46
import json
57
import base64
68
from datetime import datetime, timedelta, timezone
79

8-
from dotenv import load_dotenv
10+
from urllib.parse import urlencode
11+
12+
from quartz_solar_forecast.inverters.inverter import AbstractInverter
13+
from pydantic import Field
14+
from pydantic_settings import BaseSettings, SettingsConfigDict
915

10-
load_dotenv()
1116

12-
from urllib.parse import urlencode
17+
class EnphaseSettings(BaseSettings):
18+
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
19+
20+
client_id: str = Field(alias="ENPHASE_CLIENT_ID")
21+
system_id: str = Field(alias="ENPHASE_SYSTEM_ID")
22+
api_key: str = Field(alias="ENPHASE_API_KEY")
23+
client_secret: str = Field(alias="ENPHASE_CLIENT_SECRET")
24+
25+
26+
class EnphaseInverter(AbstractInverter):
1327

14-
def get_enphase_auth_url():
28+
def __init__(self, settings: EnphaseSettings):
29+
self.__settings = settings
30+
31+
def get_data(self, ts: pd.Timestamp) -> Optional[pd.DataFrame]:
32+
return get_enphase_data(self.__settings)
33+
34+
35+
def get_enphase_auth_url(settings: Optional[EnphaseSettings] = None):
1536
"""
1637
Generate the authorization URL for the Enphase API.
1738
18-
:param None
39+
:param settings: the Enphase settings
1940
:return: Authentication URL
2041
"""
21-
client_id = os.getenv('ENPHASE_CLIENT_ID')
42+
if settings is None:
43+
# Because this uses env variables we don't want to set it as a default argument, otherwise it will be evaluated
44+
# even if the method is not called
45+
settings = EnphaseSettings()
46+
47+
client_id = settings.client_id
2248

2349
redirect_uri = (
2450
"https://api.enphaseenergy.com/oauth/redirect_uri" # Or your own redirect URI
@@ -51,17 +77,23 @@ def get_enphase_authorization_code(auth_url):
5177
return code
5278

5379

54-
def get_enphase_access_token(auth_code=None):
80+
def get_enphase_access_token(auth_code: Optional[str] = None, settings: Optional[EnphaseSettings] = None):
5581
"""
5682
Obtain an access token for the Enphase API using the Authorization Code Grant flow.
5783
:param auth_code: Optional authorization code. If not provided, it will be obtained.
84+
:param settings: Optional Enphase settings
5885
:return: Access Token
5986
"""
60-
client_id = os.getenv('ENPHASE_CLIENT_ID')
61-
client_secret = os.getenv('ENPHASE_CLIENT_SECRET')
87+
if settings is None:
88+
# Because this uses env variables we don't want to set it as a default argument, otherwise it will be evaluated
89+
# even if the method is not called
90+
settings = EnphaseSettings()
91+
92+
client_id = settings.client_id
93+
client_secret = settings.client_secret
6294

6395
if auth_code is None:
64-
auth_url = get_enphase_auth_url()
96+
auth_url = get_enphase_auth_url(settings)
6597
auth_code = get_enphase_authorization_code(auth_url)
6698

6799
credentials = f"{client_id}:{client_secret}"
@@ -90,7 +122,7 @@ def get_enphase_access_token(auth_code=None):
90122
return access_token
91123

92124

93-
def process_enphase_data(data_json: dict, start_at: int) -> pd.DataFrame:
125+
def process_enphase_data(data_json: dict, start_at: int) -> pd.DataFrame:
94126
# Check if 'intervals' key exists in the response
95127
if 'intervals' not in data_json:
96128
return pd.DataFrame(columns=["timestamp", "power_kw"])
@@ -106,7 +138,7 @@ def process_enphase_data(data_json: dict, start_at: int) -> pd.DataFrame:
106138
timestamp = datetime.fromtimestamp(end_at, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
107139

108140
# Append the data to the list
109-
data_list.append({"timestamp": timestamp, "power_kw": interval['powr']/1000})
141+
data_list.append({"timestamp": timestamp, "power_kw": interval['powr'] / 1000})
110142

111143
# Convert the list to a DataFrame
112144
live_generation_kw = pd.DataFrame(data_list)
@@ -120,18 +152,19 @@ def process_enphase_data(data_json: dict, start_at: int) -> pd.DataFrame:
120152

121153
return live_generation_kw
122154

123-
def get_enphase_data(enphase_system_id: str) -> pd.DataFrame:
155+
156+
def get_enphase_data(settings: EnphaseSettings) -> pd.DataFrame:
124157
"""
125158
Get live PV generation data from Enphase API v4
159+
:param settings: the Enphase settings
126160
:param enphase_system_id: System ID for Enphase API
127161
:return: Live PV generation in Watt-hours, assumes to be a floating-point number
128162
"""
129-
api_key = os.getenv('ENPHASE_API_KEY')
130163
access_token = os.getenv('ENPHASE_ACCESS_TOKEN')
131164

132165
# If access token is not in environment variables, get a new one
133166
if not access_token:
134-
access_token = get_enphase_access_token()
167+
access_token = get_enphase_access_token(settings=settings)
135168

136169
# Set the start time to 1 week ago
137170
start_at = int((datetime.now() - timedelta(weeks=1)).timestamp())
@@ -142,11 +175,11 @@ def get_enphase_data(enphase_system_id: str) -> pd.DataFrame:
142175
conn = http.client.HTTPSConnection("api.enphaseenergy.com")
143176
headers = {
144177
"Authorization": f"Bearer {access_token}",
145-
"key": api_key
178+
"key": settings.api_key
146179
}
147180

148181
# Add the system_id and duration parameters to the URL
149-
url = f"/api/v4/systems/{enphase_system_id}/telemetry/production_micro?start_at={start_at}&granularity={granularity}"
182+
url = f"/api/v4/systems/{settings.system_id}/telemetry/production_micro?start_at={start_at}&granularity={granularity}"
150183
conn.request("GET", url, headers=headers)
151184

152185
res = conn.getresponse()
@@ -161,4 +194,4 @@ def get_enphase_data(enphase_system_id: str) -> pd.DataFrame:
161194
# Process the data using the new function
162195
live_generation_kw = process_enphase_data(data_json, start_at)
163196

164-
return live_generation_kw
197+
return live_generation_kw

quartz_solar_forecast/inverters/givenergy.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
1-
import os
1+
from typing import Optional
2+
23
import requests
34
import pandas as pd
45
from datetime import datetime
5-
from dotenv import load_dotenv
66

7-
# Load environment variables
8-
load_dotenv()
7+
from pydantic import Field
8+
from pydantic_settings import BaseSettings, SettingsConfigDict
9+
10+
from quartz_solar_forecast.inverters.inverter import AbstractInverter
11+
12+
13+
class GivEnergySettings(BaseSettings):
14+
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
15+
16+
api_key: str = Field(alias="GIVENERGY_API_KEY")
17+
918

10-
def get_inverter_serial_number():
19+
class GivEnergyInverter(AbstractInverter):
20+
21+
def __init__(self, settings: GivEnergySettings):
22+
self.__settings = settings
23+
24+
def get_data(self, ts: pd.Timestamp) -> Optional[pd.DataFrame]:
25+
try:
26+
return get_givenergy_data(self.__settings)
27+
except Exception as e:
28+
print(f"Error retrieving GivEnergy data: {e}")
29+
return None
30+
31+
32+
def get_inverter_serial_number(settings: GivEnergySettings):
1133
"""
1234
Fetch the inverter serial number from the GivEnergy communication device API.
1335
1436
:return: Inverter serial number as a string
1537
"""
16-
api_key = os.getenv('GIVENERGY_API_KEY')
38+
api_key = settings.api_key
1739

1840
if not api_key:
1941
raise ValueError("GIVENERGY_API_KEY not set in environment variables")
@@ -38,18 +60,19 @@ def get_inverter_serial_number():
3860
inverter_serial_number = data[0]['inverter']['serial']
3961
return inverter_serial_number
4062

41-
def get_givenergy_data():
63+
64+
def get_givenergy_data(settings: GivEnergySettings):
4265
"""
4366
Fetch the latest data from the GivEnergy API and return a DataFrame.
4467
4568
:return: DataFrame with timestamp and power_kw columns
4669
"""
47-
api_key = os.getenv('GIVENERGY_API_KEY')
70+
api_key = settings.api_key
4871

4972
if not api_key:
5073
raise ValueError("GIVENERGY_API_KEY not set in environment variables")
5174

52-
inverter_serial_number = get_inverter_serial_number()
75+
inverter_serial_number = get_inverter_serial_number(settings)
5376

5477
url = f'https://api.givenergy.cloud/v1/inverter/{inverter_serial_number}/system-data/latest'
5578

0 commit comments

Comments
 (0)