kpower-forecast 2026.2.0__py3-none-any.whl → 2026.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  from .core import KPowerForecast
2
2
 
3
3
  __all__ = ["KPowerForecast"]
4
- __version__ = "2026.2.0"
4
+ __version__ = "2026.2.1"
kpower_forecast/core.py CHANGED
@@ -1,16 +1,25 @@
1
1
  import logging
2
- from typing import Literal
2
+ from typing import List, Literal
3
3
 
4
4
  import pandas as pd
5
5
  from prophet import Prophet
6
+ from prophet.diagnostics import cross_validation, performance_metrics
6
7
  from pydantic import BaseModel, ConfigDict, Field, field_validator
7
8
 
8
9
  from .storage import ModelStorage
9
- from .utils import calculate_solar_elevation
10
+ from .utils import calculate_solar_elevation, get_clear_sky_ghi
10
11
  from .weather_client import WeatherClient
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
15
+ class PredictionInterval(BaseModel):
16
+ timestamp: pd.Timestamp
17
+ expected_kwh: float
18
+ lower_bound_kwh: float # P10
19
+ upper_bound_kwh: float # P90
20
+
21
+ model_config = ConfigDict(arbitrary_types_allowed=True)
22
+
14
23
  class KPowerConfig(BaseModel):
15
24
  model_id: str
16
25
  latitude: float = Field(..., ge=-90, le=90)
@@ -19,6 +28,8 @@ class KPowerConfig(BaseModel):
19
28
  interval_minutes: int = Field(15)
20
29
  forecast_type: Literal["solar", "consumption"] = "solar"
21
30
  heat_pump_mode: bool = False
31
+ changepoint_prior_scale: float = 0.05
32
+ seasonality_prior_scale: float = 10.0
22
33
 
23
34
  @field_validator("interval_minutes")
24
35
  @classmethod
@@ -55,6 +66,33 @@ class KPowerForecast:
55
66
  )
56
67
  self.storage = ModelStorage(storage_path=self.config.storage_path)
57
68
 
69
+ def _prepare_features(self, df: pd.DataFrame) -> pd.DataFrame:
70
+ """
71
+ Add physics-informed features and rolling windows.
72
+ """
73
+ df = df.copy()
74
+ df["ds"] = pd.to_datetime(df["ds"], utc=True)
75
+
76
+ # 1. Physics: Clear Sky GHI
77
+ logger.info("Calculating physics-informed Clear Sky GHI...")
78
+ # Ensure index is DatetimeIndex for pvlib
79
+ temp_df = df.set_index("ds")
80
+ df["clear_sky_ghi"] = get_clear_sky_ghi(
81
+ self.config.latitude, self.config.longitude, temp_df.index
82
+ ).values
83
+
84
+ # 2. Rolling Cloud Cover (3-hour window)
85
+ # 3 hours = 180 minutes. Window depends on interval_minutes.
86
+ window_size = 180 // self.config.interval_minutes
87
+ logger.info(f"Adding rolling cloud cover (window={window_size})...")
88
+ df["rolling_cloud_cover"] = (
89
+ df["cloud_cover"]
90
+ .rolling(window=window_size, min_periods=1)
91
+ .mean()
92
+ )
93
+
94
+ return df
95
+
58
96
  def train(self, history_df: pd.DataFrame, force: bool = False):
59
97
  """
60
98
  Trains the Prophet model using the provided history.
@@ -89,12 +127,21 @@ class KPowerForecast:
89
127
  if df[weather_cols].isnull().any().any():
90
128
  df = df.dropna(subset=weather_cols)
91
129
 
92
- m = Prophet()
130
+ # Feature Engineering
131
+ df = self._prepare_features(df)
132
+
133
+ # Initialize Prophet with tuned hyperparameters
134
+ m = Prophet(
135
+ changepoint_prior_scale=self.config.changepoint_prior_scale,
136
+ seasonality_prior_scale=self.config.seasonality_prior_scale,
137
+ interval_width=0.8, # Used for P10/P90 (80% interval)
138
+ )
93
139
 
94
140
  if self.config.forecast_type == "solar":
95
141
  m.add_regressor("temperature_2m")
96
- m.add_regressor("cloud_cover")
142
+ m.add_regressor("rolling_cloud_cover")
97
143
  m.add_regressor("shortwave_radiation")
144
+ m.add_regressor("clear_sky_ghi")
98
145
  elif self.config.forecast_type == "consumption":
99
146
  if self.config.heat_pump_mode:
100
147
  m.add_regressor("temperature_2m")
@@ -104,6 +151,71 @@ class KPowerForecast:
104
151
 
105
152
  self.storage.save_model(m, self.config.model_id)
106
153
 
154
+ def tune_model(self, history_df: pd.DataFrame, days: int = 30):
155
+ """
156
+ Find optimal hyperparameters using cross-validation.
157
+ """
158
+ logger.info(f"Tuning model hyperparameters using {days} days of history...")
159
+
160
+ # We need to prepare data first as cross_validation needs the regressors
161
+ # This is a bit complex as we need weather data for history_df
162
+ # For simplicity, we assume train() logic but without fitting.
163
+
164
+ # Prepare data (duplicated logic from train, could be refactored)
165
+ df = history_df.copy()
166
+ df["ds"] = pd.to_datetime(df["ds"], utc=True)
167
+ start_date = df["ds"].min().date()
168
+ end_date = df["ds"].max().date()
169
+ weather_df = self.weather_client.fetch_historical(start_date, end_date)
170
+ weather_df = self.weather_client.resample_weather(
171
+ weather_df, self.config.interval_minutes
172
+ )
173
+ df = pd.merge(df, weather_df, on="ds", how="left")
174
+ weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
175
+ df[weather_cols] = df[weather_cols].interpolate(method="linear").bfill().ffill()
176
+ df = self._prepare_features(df.dropna(subset=weather_cols))
177
+
178
+ param_grid = {
179
+ 'changepoint_prior_scale': [0.001, 0.05, 0.5],
180
+ 'seasonality_prior_scale': [0.01, 1.0, 10.0],
181
+ }
182
+
183
+ # Simplified tuning loop
184
+ best_params = {}
185
+ min_rmse = float('inf')
186
+
187
+ for cps in param_grid['changepoint_prior_scale']:
188
+ for sps in param_grid['seasonality_prior_scale']:
189
+ m = Prophet(changepoint_prior_scale=cps, seasonality_prior_scale=sps)
190
+ if self.config.forecast_type == "solar":
191
+ m.add_regressor("temperature_2m")
192
+ m.add_regressor("rolling_cloud_cover")
193
+ m.add_regressor("shortwave_radiation")
194
+ m.add_regressor("clear_sky_ghi")
195
+ elif (
196
+ self.config.forecast_type == "consumption"
197
+ and self.config.heat_pump_mode
198
+ ):
199
+ m.add_regressor("temperature_2m")
200
+
201
+ m.fit(df)
202
+
203
+ # Cross-validation
204
+ # initial should be at least 3x horizon
205
+ df_cv = cross_validation(
206
+ m, initial=f'{days//2} days', period='5 days', horizon='5 days'
207
+ )
208
+ df_p = performance_metrics(df_cv, rolling_window=1)
209
+ rmse = df_p['rmse'].values[0]
210
+
211
+ if rmse < min_rmse:
212
+ min_rmse = rmse
213
+ best_params = {'cps': cps, 'sps': sps}
214
+
215
+ logger.info(f"Best params found: {best_params} with RMSE {min_rmse}")
216
+ self.config.changepoint_prior_scale = best_params['cps']
217
+ self.config.seasonality_prior_scale = best_params['sps']
218
+
107
219
  def predict(self, days: int = 7) -> pd.DataFrame:
108
220
  """
109
221
  Generates forecast for the next 'days' days.
@@ -127,16 +239,60 @@ class KPowerForecast:
127
239
  future[weather_cols].interpolate(method="linear").bfill().ffill()
128
240
  )
129
241
 
242
+ # Feature Engineering
243
+ future = self._prepare_features(future)
244
+
130
245
  forecast = m.predict(future)
131
- result = forecast[["ds", "yhat"]].copy()
132
246
 
247
+ # Night Mask & Clipping
133
248
  if self.config.forecast_type == "solar":
134
249
  logger.info("Applying night mask for solar forecast...")
135
250
  elevations = calculate_solar_elevation(
136
- self.config.latitude, self.config.longitude, result["ds"]
251
+ self.config.latitude, self.config.longitude, forecast["ds"]
137
252
  )
138
- result.loc[elevations < 0, "yhat"] = 0
253
+ forecast.loc[elevations < 0, ["yhat", "yhat_lower", "yhat_upper"]] = 0
254
+
255
+ for col in ["yhat", "yhat_lower", "yhat_upper"]:
256
+ forecast[col] = forecast[col].clip(lower=0)
257
+
258
+ return forecast
259
+
260
+ def get_prediction_intervals(self, days: int = 7) -> List[PredictionInterval]:
261
+ """
262
+ Returns prediction intervals for EMS.
263
+ """
264
+ forecast = self.predict(days=days)
265
+
266
+ intervals = []
267
+ for _, row in forecast.iterrows():
268
+ intervals.append(PredictionInterval(
269
+ timestamp=row["ds"],
270
+ expected_kwh=row["yhat"],
271
+ lower_bound_kwh=row["yhat_lower"],
272
+ upper_bound_kwh=row["yhat_upper"]
273
+ ))
274
+ return intervals
275
+
276
+ def get_surplus_probability(
277
+ self, threshold_kwh: float, days: int = 7
278
+ ) -> pd.DataFrame:
279
+ """
280
+ Returns probability of exceeding threshold_kwh.
281
+ Prophet doesn't provide direct probabilities, but we can estimate
282
+ from the uncertainty interval (yhat_upper - yhat_lower)
283
+ assuming normal distribution.
284
+ """
285
+ forecast = self.predict(days=days)
286
+
287
+ # Estimate sigma from 80% interval (approx 1.28 * sigma)
288
+ sigma = (forecast["yhat_upper"] - forecast["yhat_lower"]) / (2 * 1.28)
289
+ sigma = sigma.replace(0, 1e-9) # Avoid div by zero
139
290
 
140
- result["yhat"] = result["yhat"].clip(lower=0)
291
+ from scipy.stats import norm
292
+ z_score = (threshold_kwh - forecast["yhat"]) / sigma
293
+ prob_exceed = 1 - norm.cdf(z_score)
141
294
 
142
- return result
295
+ return pd.DataFrame({
296
+ "ds": forecast["ds"],
297
+ "surplus_prob": prob_exceed
298
+ })
kpower_forecast/utils.py CHANGED
@@ -3,6 +3,7 @@ from typing import List, Union
3
3
 
4
4
  import numpy as np
5
5
  import pandas as pd
6
+ from pvlib.location import Location
6
7
  from pysolar.solar import get_altitude
7
8
 
8
9
 
@@ -12,13 +13,13 @@ def calculate_solar_elevation(
12
13
  """
13
14
  Calculate solar elevation angles (altitude) for a list of times
14
15
  at a specific location.
15
-
16
+
16
17
  Args:
17
18
  lat: Latitude in decimal degrees.
18
19
  lon: Longitude in decimal degrees.
19
- times: List of datetime objects or pandas DatetimeIndex.
20
+ times: List of datetime objects or pandas DatetimeIndex.
20
21
  Must be timezone-aware or UTC.
21
-
22
+
22
23
  Returns:
23
24
  Numpy array of elevation angles in degrees.
24
25
  """
@@ -28,9 +29,31 @@ def calculate_solar_elevation(
28
29
  if t.tzinfo is None:
29
30
  # Assume UTC if naive, though strictly we should enforce awareness
30
31
  t = t.replace(tzinfo=datetime.timezone.utc)
31
-
32
+
32
33
  # get_altitude returns degrees
33
34
  alt = get_altitude(lat, lon, t)
34
35
  elevations.append(alt)
35
-
36
+
36
37
  return np.array(elevations)
38
+
39
+
40
+ def get_clear_sky_ghi(
41
+ lat: float, lon: float, times: pd.DatetimeIndex
42
+ ) -> pd.Series:
43
+ """
44
+ Calculate Theoretical Clear Sky GHI (Global Horizontal Irradiance)
45
+ using pvlib.
46
+
47
+ Args:
48
+ lat: Latitude.
49
+ lon: Longitude.
50
+ times: Pandas DatetimeIndex (must be timezone aware).
51
+
52
+ Returns:
53
+ Pandas Series of GHI values.
54
+ """
55
+ location = Location(lat, lon)
56
+ # get_clearsky returns GHI, DNI, DHI. We only need GHI.
57
+ # Ineichen is the default model.
58
+ clearsky = location.get_clearsky(times)
59
+ return clearsky["ghi"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kpower-forecast
3
- Version: 2026.2.0
3
+ Version: 2026.2.1
4
4
  Summary: Solar production and power consumption forecasting package.
5
5
  Author-email: KPower Team <info@kpower.example>
6
6
  License: AGPL-3.0
@@ -16,6 +16,7 @@ Requires-Dist: cmdstanpy>=1.2.0
16
16
  Requires-Dist: numpy>=1.26.0
17
17
  Requires-Dist: pandas>=2.2.0
18
18
  Requires-Dist: prophet>=1.1.5
19
+ Requires-Dist: pvlib>=0.11.0
19
20
  Requires-Dist: pydantic>=2.6.0
20
21
  Requires-Dist: pysolar>=0.8.0
21
22
  Requires-Dist: pytz>=2024.1
@@ -111,6 +112,21 @@ kp_cons = KPowerForecast(
111
112
 
112
113
  ---
113
114
 
115
+ ## 🔢 Versioning
116
+
117
+ This project follows a custom **Date-Based Versioning** scheme:
118
+ `YYYY.MM.Patch` (e.g., `2026.2.1`)
119
+
120
+ - **YYYY**: Year of release.
121
+ - **MM**: Month of release (no leading zero, 1-12).
122
+ - **Patch**: Incremental counter for releases within the same month.
123
+
124
+ ### Enforcement
125
+ - **CI Validation**: Every Pull Request is checked against `scripts/validate_version.py` to ensure adherence.
126
+ - **Consistency**: Both `pyproject.toml` and `src/kpower_forecast/__init__.py` must match exactly.
127
+
128
+ ---
129
+
114
130
  ## 🧪 Development & Testing
115
131
 
116
132
  We use [uv](https://github.com/astral-sh/uv) for lightning-fast dependency management.
@@ -0,0 +1,9 @@
1
+ kpower_forecast/__init__.py,sha256=WDpPbJ-2UQwUE0I2U1j0tyXPq-KOkLAuquOrMNk9oac,88
2
+ kpower_forecast/core.py,sha256=Kg9Pc6S6N0jG4NMYMfjP03dGkULchTWggektEjh9u6w,11355
3
+ kpower_forecast/storage.py,sha256=GBpqiirt3QG9RF_FMQ1SjKio2FR0VtkukWqtbYYaf_g,2226
4
+ kpower_forecast/utils.py,sha256=LZLGrXq-hTFOlWIE61lEmxaW4YKVT7LMs4ZQA8sRg-g,1652
5
+ kpower_forecast/weather_client.py,sha256=cRp2lmOfvrft2GZl3nM161OqiE5I8lnKrtJTJi7n2uw,4210
6
+ kpower_forecast-2026.2.1.dist-info/METADATA,sha256=7H3zy8oN2hhZZIkUm_9gV1YO98WI5PEfqcD83RnxWVc,5032
7
+ kpower_forecast-2026.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ kpower_forecast-2026.2.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
9
+ kpower_forecast-2026.2.1.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- kpower_forecast/__init__.py,sha256=cHOO5TdFOeVt1rmNUDwzRDX2ie2KwsBog6aV2D23APg,88
2
- kpower_forecast/core.py,sha256=aE2b0XBj5wcYo1l6hgTP0roWOARtsVYkj4Uy5DPHqT8,5027
3
- kpower_forecast/storage.py,sha256=GBpqiirt3QG9RF_FMQ1SjKio2FR0VtkukWqtbYYaf_g,2226
4
- kpower_forecast/utils.py,sha256=0ID3XKpbxUvphiR7apg7Mzy72c-bDtQ8AhGtVcaoN8g,1079
5
- kpower_forecast/weather_client.py,sha256=cRp2lmOfvrft2GZl3nM161OqiE5I8lnKrtJTJi7n2uw,4210
6
- kpower_forecast-2026.2.0.dist-info/METADATA,sha256=6oFS_xfcTu_UsfB1est4MjMJea_i_X1sS1vtEodDr_Y,4501
7
- kpower_forecast-2026.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
- kpower_forecast-2026.2.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
9
- kpower_forecast-2026.2.0.dist-info/RECORD,,