|
| 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 | + ) |
0 commit comments