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.
- kpower_forecast/__init__.py +1 -1
- kpower_forecast/core.py +165 -9
- kpower_forecast/utils.py +28 -5
- {kpower_forecast-2026.2.0.dist-info → kpower_forecast-2026.2.1.dist-info}/METADATA +17 -1
- kpower_forecast-2026.2.1.dist-info/RECORD +9 -0
- kpower_forecast-2026.2.0.dist-info/RECORD +0 -9
- {kpower_forecast-2026.2.0.dist-info → kpower_forecast-2026.2.1.dist-info}/WHEEL +0 -0
- {kpower_forecast-2026.2.0.dist-info → kpower_forecast-2026.2.1.dist-info}/licenses/LICENSE +0 -0
kpower_forecast/__init__.py
CHANGED
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
|
-
|
|
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("
|
|
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,
|
|
251
|
+
self.config.latitude, self.config.longitude, forecast["ds"]
|
|
137
252
|
)
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,,
|
|
File without changes
|
|
File without changes
|