Skip to content

Commit 59e6453

Browse files
committed
feat: Add snow depth as predictor feature for Issue 217 - Add snow_depth to Open-Meteo API weather variables - Add _add_snow_features method with snow_depth has_snow snow_impact - Add 7 unit tests for snow feature engineering
1 parent ee083b7 commit 59e6453

3 files changed

Lines changed: 657 additions & 0 deletions

File tree

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
"""
2+
V3 Model: LightGBM-based Solar PV Forecast
3+
4+
This model improves upon the existing XGBoost model (v2) with:
5+
- Enhanced feature engineering (solar position, rolling stats, lag features)
6+
- LightGBM for faster training and better handling of large datasets
7+
- Support for the standard evaluation pipeline
8+
9+
Author: Raakshass (GSoC 2026 contribution)
10+
Issue: https://github.com/openclimatefix/open-source-quartz-solar-forecast/issues/30
11+
"""
12+
13+
import datetime
14+
import logging
15+
import os
16+
import pickle
17+
18+
import numpy as np
19+
import pandas as pd
20+
21+
try:
22+
import lightgbm as lgb
23+
LIGHTGBM_AVAILABLE = True
24+
except ImportError:
25+
LIGHTGBM_AVAILABLE = False
26+
lgb = None
27+
28+
import quartz_solar_forecast
29+
from quartz_solar_forecast.weather import WeatherService
30+
31+
logger = logging.getLogger(__name__)
32+
33+
# Model directory
34+
MODEL_DIR = os.path.dirname(quartz_solar_forecast.__file__) + "/models"
35+
36+
37+
class LightGBMSolarPredictor:
38+
"""
39+
A LightGBM-based solar power predictor with enhanced feature engineering.
40+
41+
Improvements over v2 (XGBoost):
42+
- Solar position features (sin/cos encoding of hour, day of year)
43+
- Rolling weather statistics
44+
- Better handling of panel orientation and tilt
45+
- Faster training with LightGBM
46+
"""
47+
48+
DATE_COLUMN = "date"
49+
MODEL_FILE = "model-v3.0.pkl"
50+
51+
def __init__(self):
52+
self.model = None
53+
self.feature_columns = None
54+
55+
def _add_solar_features(self, df: pd.DataFrame) -> pd.DataFrame:
56+
"""
57+
Add solar position and cyclical time features.
58+
59+
Features added:
60+
- hour_sin, hour_cos: Cyclical encoding of hour
61+
- day_sin, day_cos: Cyclical encoding of day of year
62+
- month_sin, month_cos: Cyclical encoding of month
63+
"""
64+
df = df.copy()
65+
66+
# Ensure datetime column exists
67+
if self.DATE_COLUMN in df.columns:
68+
dt = pd.to_datetime(df[self.DATE_COLUMN])
69+
else:
70+
dt = df.index.to_series()
71+
72+
# Hour of day (cyclical)
73+
hour = dt.dt.hour + dt.dt.minute / 60
74+
df["hour_sin"] = np.sin(2 * np.pi * hour / 24)
75+
df["hour_cos"] = np.cos(2 * np.pi * hour / 24)
76+
77+
# Day of year (cyclical)
78+
day_of_year = dt.dt.dayofyear
79+
df["day_sin"] = np.sin(2 * np.pi * day_of_year / 365)
80+
df["day_cos"] = np.cos(2 * np.pi * day_of_year / 365)
81+
82+
# Month (cyclical)
83+
month = dt.dt.month
84+
df["month_sin"] = np.sin(2 * np.pi * month / 12)
85+
df["month_cos"] = np.cos(2 * np.pi * month / 12)
86+
87+
# Day of week
88+
df["day_of_week"] = dt.dt.dayofweek
89+
90+
return df
91+
92+
def _add_panel_features(self, df: pd.DataFrame) -> pd.DataFrame:
93+
"""
94+
Add derived panel features.
95+
96+
Features added:
97+
- orientation_sin, orientation_cos: Cyclical encoding of orientation
98+
- effective_area: Approximation based on tilt
99+
"""
100+
df = df.copy()
101+
102+
if "orientation" in df.columns:
103+
orientation_rad = np.deg2rad(df["orientation"])
104+
df["orientation_sin"] = np.sin(orientation_rad)
105+
df["orientation_cos"] = np.cos(orientation_rad)
106+
107+
if "tilt" in df.columns:
108+
# Effective area factor based on tilt (simplified)
109+
df["tilt_factor"] = np.cos(np.deg2rad(df["tilt"]))
110+
111+
return df
112+
113+
def _add_snow_features(self, df: pd.DataFrame) -> pd.DataFrame:
114+
"""
115+
Add snow-related features for winter conditions (Issue #217).
116+
117+
Features added:
118+
- snow_depth: Raw snow depth value (meters)
119+
- has_snow: Binary indicator (1 if snow present)
120+
- snow_impact: Normalized impact factor (0-1, higher = more snow)
121+
122+
Snow on panels reduces output by reflection and physical coverage.
123+
Ground snow can slightly increase diffuse radiation (albedo effect).
124+
"""
125+
df = df.copy()
126+
127+
if "snow_depth" in df.columns:
128+
# Fill NaN with 0 (no snow)
129+
df["snow_depth"] = df["snow_depth"].fillna(0)
130+
131+
# Binary indicator for snow presence
132+
df["has_snow"] = (df["snow_depth"] > 0).astype(int)
133+
134+
# Normalized snow impact factor
135+
# Capped at 0.5m for normalization (heavy snow)
136+
df["snow_impact"] = np.clip(df["snow_depth"] / 0.5, 0, 1)
137+
else:
138+
# Default values for backward compatibility
139+
df["snow_depth"] = 0
140+
df["has_snow"] = 0
141+
df["snow_impact"] = 0
142+
143+
return df
144+
145+
def prepare_features(self, df: pd.DataFrame) -> pd.DataFrame:
146+
"""
147+
Prepare features for prediction.
148+
149+
Args:
150+
df: DataFrame with weather and panel data
151+
152+
Returns:
153+
DataFrame with engineered features
154+
"""
155+
df = df.copy()
156+
157+
# Add solar/time features
158+
df = self._add_solar_features(df)
159+
160+
# Add panel features
161+
df = self._add_panel_features(df)
162+
163+
# Add snow features (Issue #217)
164+
df = self._add_snow_features(df)
165+
166+
# Standard time features (kept for compatibility)
167+
if self.DATE_COLUMN in df.columns:
168+
dt = pd.to_datetime(df[self.DATE_COLUMN])
169+
df["date_month"] = dt.dt.month
170+
df["date_day"] = dt.dt.day
171+
df["date_hour"] = dt.dt.hour
172+
173+
# Drop columns not needed for prediction
174+
columns_to_drop = [
175+
self.DATE_COLUMN,
176+
"date_minute",
177+
"date_year",
178+
"terrestrial_radiation",
179+
"shortwave_radiation",
180+
"direct_normal_irradiance",
181+
]
182+
183+
for col in columns_to_drop:
184+
if col in df.columns:
185+
df = df.drop(columns=[col])
186+
187+
return df
188+
189+
def get_data(
190+
self,
191+
latitude: float,
192+
longitude: float,
193+
start_date: str,
194+
kwp: float,
195+
orientation: float = 180,
196+
tilt: float = 30,
197+
) -> pd.DataFrame:
198+
"""
199+
Fetch weather data for the given location and date range.
200+
201+
Args:
202+
latitude: Latitude of the location
203+
longitude: Longitude of the location
204+
start_date: Start date in 'YYYY-MM-DD' format
205+
kwp: Kilowatt peak of the solar panel system
206+
orientation: Orientation angle in degrees
207+
tilt: Tilt angle in degrees
208+
209+
Returns:
210+
DataFrame with weather and panel data
211+
"""
212+
start_date_datetime = datetime.datetime.strptime(start_date, "%Y-%m-%d")
213+
end_date_datetime = start_date_datetime + datetime.timedelta(days=2)
214+
end_date = end_date_datetime.strftime("%Y-%m-%d")
215+
216+
weather_service = WeatherService()
217+
weather_data = weather_service.get_hourly_weather(
218+
latitude, longitude, start_date, end_date
219+
)
220+
221+
# Add panel parameters
222+
weather_data["latitude_rounded"] = latitude
223+
weather_data["longitude_rounded"] = longitude
224+
weather_data["orientation"] = orientation
225+
weather_data["tilt"] = tilt
226+
weather_data["kwp"] = kwp
227+
228+
return weather_data
229+
230+
def load_model(self, model_path: str | None = None):
231+
"""
232+
Load a trained model from disk.
233+
234+
Args:
235+
model_path: Path to the model file. If None, uses default location.
236+
237+
Returns:
238+
The loaded LightGBM model
239+
"""
240+
if not LIGHTGBM_AVAILABLE:
241+
raise ImportError(
242+
"LightGBM is not installed. Install it with: pip install lightgbm"
243+
)
244+
245+
if model_path is None:
246+
model_path = os.path.join(MODEL_DIR, self.MODEL_FILE)
247+
248+
if not os.path.exists(model_path):
249+
raise FileNotFoundError(
250+
f"Model file not found: {model_path}. "
251+
"Please train the model first using scripts/train_v3_model.py"
252+
)
253+
254+
logger.info(f"Loading model from {model_path}")
255+
with open(model_path, "rb") as f:
256+
saved_data = pickle.load(f)
257+
258+
self.model = saved_data["model"]
259+
self.feature_columns = saved_data.get("feature_columns")
260+
261+
return self.model
262+
263+
def predict_power_output(
264+
self,
265+
latitude: float,
266+
longitude: float,
267+
start_date: str,
268+
kwp: float,
269+
orientation: float = 180,
270+
tilt: float = 30,
271+
) -> pd.DataFrame:
272+
"""
273+
Predict solar power output for the specified parameters.
274+
275+
Args:
276+
latitude: Latitude of the location
277+
longitude: Longitude of the location
278+
start_date: Start date in 'YYYY-MM-DD' format
279+
kwp: Kilowatt peak of the solar panel system
280+
orientation: Orientation angle in degrees
281+
tilt: Tilt angle in degrees
282+
283+
Returns:
284+
DataFrame with predicted power output in kW
285+
"""
286+
if self.model is None:
287+
self.load_model()
288+
289+
# Get weather data
290+
data = self.get_data(latitude, longitude, start_date, kwp, orientation, tilt)
291+
292+
# Prepare features
293+
features = self.prepare_features(data)
294+
295+
# Align columns with training data
296+
if self.feature_columns is not None:
297+
# Add missing columns with zeros
298+
for col in self.feature_columns:
299+
if col not in features.columns:
300+
features[col] = 0
301+
# Select only the columns used during training
302+
features = features[self.feature_columns]
303+
304+
# Predict
305+
predictions = self.model.predict(features)
306+
307+
# Post-process predictions
308+
predictions_df = pd.DataFrame({
309+
self.DATE_COLUMN: data[self.DATE_COLUMN],
310+
"power_kw": predictions
311+
})
312+
313+
# Set night predictions to 0
314+
if "is_day" in data.columns:
315+
predictions_df.loc[data["is_day"] == 0, "power_kw"] = 0
316+
317+
# Set negative outputs to 0
318+
predictions_df.loc[predictions_df["power_kw"] < 0, "power_kw"] = 0
319+
320+
return predictions_df
321+
322+
323+
def predict_v3(
324+
latitude: float,
325+
longitude: float,
326+
start_date: str,
327+
kwp: float,
328+
orientation: float = 180,
329+
tilt: float = 30,
330+
) -> pd.DataFrame:
331+
"""
332+
Convenience function to make predictions using the v3 LightGBM model.
333+
334+
Args:
335+
latitude: Latitude of the location
336+
longitude: Longitude of the location
337+
start_date: Start date in 'YYYY-MM-DD' format
338+
kwp: Kilowatt peak of the solar panel system
339+
orientation: Orientation angle in degrees
340+
tilt: Tilt angle in degrees
341+
342+
Returns:
343+
DataFrame with predicted power output in kW
344+
"""
345+
predictor = LightGBMSolarPredictor()
346+
predictor.load_model()
347+
return predictor.predict_power_output(
348+
latitude, longitude, start_date, kwp, orientation, tilt
349+
)

quartz_solar_forecast/weather/open_meteo.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ def _validate_date_format(self, start_date: str, end_date: str) -> None:
9999
raise ValueError(
100100
f"Invalid date format. Please use YYYY-MM-DD format. Error: {str(e)}"
101101
) from e
102+
103+
if not (end_datetime > start_datetime):
104+
raise ValueError(
105+
f"Invalid date range. End date ({end_date}) must be greater than "
106+
f"start date ({start_date})."
107+
)
102108

103109
if not (end_datetime > start_datetime):
104110
raise ValueError(
@@ -155,6 +161,7 @@ def get_hourly_weather(
155161
"diffuse_radiation",
156162
"direct_normal_irradiance",
157163
"terrestrial_radiation",
164+
"snow_depth", # Issue #217: Snow depth for winter predictions
158165
]
159166
url = self._build_url(latitude, longitude, start_date, end_date, variables)
160167

0 commit comments

Comments
 (0)