kpower-forecast 2026.2.1__py3-none-any.whl → 2026.2.2__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 +66 -62
- kpower_forecast/storage.py +3 -2
- kpower_forecast/utils.py +3 -5
- kpower_forecast/weather_client.py +30 -23
- {kpower_forecast-2026.2.1.dist-info → kpower_forecast-2026.2.2.dist-info}/METADATA +6 -2
- kpower_forecast-2026.2.2.dist-info/RECORD +9 -0
- kpower_forecast-2026.2.1.dist-info/RECORD +0 -9
- {kpower_forecast-2026.2.1.dist-info → kpower_forecast-2026.2.2.dist-info}/WHEEL +0 -0
- {kpower_forecast-2026.2.1.dist-info → kpower_forecast-2026.2.2.dist-info}/licenses/LICENSE +0 -0
kpower_forecast/__init__.py
CHANGED
kpower_forecast/core.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from typing import List, Literal
|
|
2
|
+
from typing import List, Literal, cast
|
|
3
3
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
from prophet import Prophet
|
|
@@ -12,14 +12,16 @@ from .weather_client import WeatherClient
|
|
|
12
12
|
|
|
13
13
|
logger = logging.getLogger(__name__)
|
|
14
14
|
|
|
15
|
+
|
|
15
16
|
class PredictionInterval(BaseModel):
|
|
16
17
|
timestamp: pd.Timestamp
|
|
17
18
|
expected_kwh: float
|
|
18
|
-
lower_bound_kwh: float
|
|
19
|
-
upper_bound_kwh: float
|
|
20
|
-
|
|
19
|
+
lower_bound_kwh: float # P10
|
|
20
|
+
upper_bound_kwh: float # P90
|
|
21
|
+
|
|
21
22
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
22
23
|
|
|
24
|
+
|
|
23
25
|
class KPowerConfig(BaseModel):
|
|
24
26
|
model_id: str
|
|
25
27
|
latitude: float = Field(..., ge=-90, le=90)
|
|
@@ -37,9 +39,10 @@ class KPowerConfig(BaseModel):
|
|
|
37
39
|
if v not in (15, 60):
|
|
38
40
|
raise ValueError("interval_minutes must be 15 or 60")
|
|
39
41
|
return v
|
|
40
|
-
|
|
42
|
+
|
|
41
43
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
42
44
|
|
|
45
|
+
|
|
43
46
|
class KPowerForecast:
|
|
44
47
|
def __init__(
|
|
45
48
|
self,
|
|
@@ -60,7 +63,7 @@ class KPowerForecast:
|
|
|
60
63
|
forecast_type=forecast_type,
|
|
61
64
|
heat_pump_mode=heat_pump_mode,
|
|
62
65
|
)
|
|
63
|
-
|
|
66
|
+
|
|
64
67
|
self.weather_client = WeatherClient(
|
|
65
68
|
lat=self.config.latitude, lon=self.config.longitude
|
|
66
69
|
)
|
|
@@ -72,25 +75,26 @@ class KPowerForecast:
|
|
|
72
75
|
"""
|
|
73
76
|
df = df.copy()
|
|
74
77
|
df["ds"] = pd.to_datetime(df["ds"], utc=True)
|
|
75
|
-
|
|
78
|
+
|
|
76
79
|
# 1. Physics: Clear Sky GHI
|
|
77
80
|
logger.info("Calculating physics-informed Clear Sky GHI...")
|
|
78
81
|
# Ensure index is DatetimeIndex for pvlib
|
|
79
82
|
temp_df = df.set_index("ds")
|
|
83
|
+
if not isinstance(temp_df.index, pd.DatetimeIndex):
|
|
84
|
+
raise ValueError("Index must be DatetimeIndex")
|
|
85
|
+
|
|
80
86
|
df["clear_sky_ghi"] = get_clear_sky_ghi(
|
|
81
87
|
self.config.latitude, self.config.longitude, temp_df.index
|
|
82
88
|
).values
|
|
83
|
-
|
|
89
|
+
|
|
84
90
|
# 2. Rolling Cloud Cover (3-hour window)
|
|
85
91
|
# 3 hours = 180 minutes. Window depends on interval_minutes.
|
|
86
92
|
window_size = 180 // self.config.interval_minutes
|
|
87
93
|
logger.info(f"Adding rolling cloud cover (window={window_size})...")
|
|
88
94
|
df["rolling_cloud_cover"] = (
|
|
89
|
-
df["cloud_cover"]
|
|
90
|
-
.rolling(window=window_size, min_periods=1)
|
|
91
|
-
.mean()
|
|
95
|
+
df["cloud_cover"].rolling(window=window_size, min_periods=1).mean()
|
|
92
96
|
)
|
|
93
|
-
|
|
97
|
+
|
|
94
98
|
return df
|
|
95
99
|
|
|
96
100
|
def train(self, history_df: pd.DataFrame, force: bool = False):
|
|
@@ -107,23 +111,23 @@ class KPowerForecast:
|
|
|
107
111
|
df = history_df.copy()
|
|
108
112
|
if "ds" not in df.columns or "y" not in df.columns:
|
|
109
113
|
raise ValueError("history_df must contain 'ds' and 'y' columns")
|
|
110
|
-
|
|
114
|
+
|
|
111
115
|
df["ds"] = pd.to_datetime(df["ds"], utc=True)
|
|
112
116
|
df = df.sort_values("ds")
|
|
113
|
-
|
|
117
|
+
|
|
114
118
|
start_date = df["ds"].min().date()
|
|
115
119
|
end_date = df["ds"].max().date()
|
|
116
|
-
|
|
120
|
+
|
|
117
121
|
weather_df = self.weather_client.fetch_historical(start_date, end_date)
|
|
118
122
|
weather_df = self.weather_client.resample_weather(
|
|
119
123
|
weather_df, self.config.interval_minutes
|
|
120
124
|
)
|
|
121
|
-
|
|
125
|
+
|
|
122
126
|
df = pd.merge(df, weather_df, on="ds", how="left")
|
|
123
|
-
|
|
127
|
+
|
|
124
128
|
weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
|
|
125
129
|
df[weather_cols] = df[weather_cols].interpolate(method="linear").bfill().ffill()
|
|
126
|
-
|
|
130
|
+
|
|
127
131
|
if df[weather_cols].isnull().any().any():
|
|
128
132
|
df = df.dropna(subset=weather_cols)
|
|
129
133
|
|
|
@@ -134,9 +138,9 @@ class KPowerForecast:
|
|
|
134
138
|
m = Prophet(
|
|
135
139
|
changepoint_prior_scale=self.config.changepoint_prior_scale,
|
|
136
140
|
seasonality_prior_scale=self.config.seasonality_prior_scale,
|
|
137
|
-
interval_width=0.8,
|
|
141
|
+
interval_width=0.8, # Used for P10/P90 (80% interval)
|
|
138
142
|
)
|
|
139
|
-
|
|
143
|
+
|
|
140
144
|
if self.config.forecast_type == "solar":
|
|
141
145
|
m.add_regressor("temperature_2m")
|
|
142
146
|
m.add_regressor("rolling_cloud_cover")
|
|
@@ -145,10 +149,10 @@ class KPowerForecast:
|
|
|
145
149
|
elif self.config.forecast_type == "consumption":
|
|
146
150
|
if self.config.heat_pump_mode:
|
|
147
151
|
m.add_regressor("temperature_2m")
|
|
148
|
-
|
|
152
|
+
|
|
149
153
|
logger.info(f"Training Prophet model for {self.config.forecast_type}...")
|
|
150
154
|
m.fit(df)
|
|
151
|
-
|
|
155
|
+
|
|
152
156
|
self.storage.save_model(m, self.config.model_id)
|
|
153
157
|
|
|
154
158
|
def tune_model(self, history_df: pd.DataFrame, days: int = 30):
|
|
@@ -156,11 +160,11 @@ class KPowerForecast:
|
|
|
156
160
|
Find optimal hyperparameters using cross-validation.
|
|
157
161
|
"""
|
|
158
162
|
logger.info(f"Tuning model hyperparameters using {days} days of history...")
|
|
159
|
-
|
|
163
|
+
|
|
160
164
|
# We need to prepare data first as cross_validation needs the regressors
|
|
161
165
|
# This is a bit complex as we need weather data for history_df
|
|
162
166
|
# For simplicity, we assume train() logic but without fitting.
|
|
163
|
-
|
|
167
|
+
|
|
164
168
|
# Prepare data (duplicated logic from train, could be refactored)
|
|
165
169
|
df = history_df.copy()
|
|
166
170
|
df["ds"] = pd.to_datetime(df["ds"], utc=True)
|
|
@@ -176,16 +180,16 @@ class KPowerForecast:
|
|
|
176
180
|
df = self._prepare_features(df.dropna(subset=weather_cols))
|
|
177
181
|
|
|
178
182
|
param_grid = {
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
"changepoint_prior_scale": [0.001, 0.05, 0.5],
|
|
184
|
+
"seasonality_prior_scale": [0.01, 1.0, 10.0],
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
# Simplified tuning loop
|
|
184
188
|
best_params = {}
|
|
185
|
-
min_rmse = float(
|
|
189
|
+
min_rmse = float("inf")
|
|
186
190
|
|
|
187
|
-
for cps in param_grid[
|
|
188
|
-
for sps in param_grid[
|
|
191
|
+
for cps in param_grid["changepoint_prior_scale"]:
|
|
192
|
+
for sps in param_grid["seasonality_prior_scale"]:
|
|
189
193
|
m = Prophet(changepoint_prior_scale=cps, seasonality_prior_scale=sps)
|
|
190
194
|
if self.config.forecast_type == "solar":
|
|
191
195
|
m.add_regressor("temperature_2m")
|
|
@@ -197,24 +201,24 @@ class KPowerForecast:
|
|
|
197
201
|
and self.config.heat_pump_mode
|
|
198
202
|
):
|
|
199
203
|
m.add_regressor("temperature_2m")
|
|
200
|
-
|
|
204
|
+
|
|
201
205
|
m.fit(df)
|
|
202
|
-
|
|
206
|
+
|
|
203
207
|
# Cross-validation
|
|
204
208
|
# initial should be at least 3x horizon
|
|
205
209
|
df_cv = cross_validation(
|
|
206
|
-
m, initial=f
|
|
210
|
+
m, initial=f"{days // 2} days", period="5 days", horizon="5 days"
|
|
207
211
|
)
|
|
208
212
|
df_p = performance_metrics(df_cv, rolling_window=1)
|
|
209
|
-
rmse = df_p[
|
|
213
|
+
rmse = df_p["rmse"].values[0]
|
|
210
214
|
|
|
211
215
|
if rmse < min_rmse:
|
|
212
216
|
min_rmse = rmse
|
|
213
|
-
best_params = {
|
|
217
|
+
best_params = {"cps": cps, "sps": sps}
|
|
214
218
|
|
|
215
219
|
logger.info(f"Best params found: {best_params} with RMSE {min_rmse}")
|
|
216
|
-
self.config.changepoint_prior_scale = best_params[
|
|
217
|
-
self.config.seasonality_prior_scale = best_params[
|
|
220
|
+
self.config.changepoint_prior_scale = best_params["cps"]
|
|
221
|
+
self.config.seasonality_prior_scale = best_params["sps"]
|
|
218
222
|
|
|
219
223
|
def predict(self, days: int = 7) -> pd.DataFrame:
|
|
220
224
|
"""
|
|
@@ -225,25 +229,25 @@ class KPowerForecast:
|
|
|
225
229
|
raise RuntimeError(
|
|
226
230
|
f"Model {self.config.model_id} not found. Please run train() first."
|
|
227
231
|
)
|
|
228
|
-
|
|
232
|
+
|
|
229
233
|
weather_forecast = self.weather_client.fetch_forecast(days=days)
|
|
230
234
|
weather_forecast = self.weather_client.resample_weather(
|
|
231
235
|
weather_forecast, self.config.interval_minutes
|
|
232
236
|
)
|
|
233
|
-
|
|
237
|
+
|
|
234
238
|
future = pd.DataFrame({"ds": weather_forecast["ds"]})
|
|
235
239
|
future = pd.merge(future, weather_forecast, on="ds", how="left")
|
|
236
|
-
|
|
240
|
+
|
|
237
241
|
weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
|
|
238
242
|
future[weather_cols] = (
|
|
239
243
|
future[weather_cols].interpolate(method="linear").bfill().ffill()
|
|
240
244
|
)
|
|
241
|
-
|
|
245
|
+
|
|
242
246
|
# Feature Engineering
|
|
243
247
|
future = self._prepare_features(future)
|
|
244
|
-
|
|
248
|
+
|
|
245
249
|
forecast = m.predict(future)
|
|
246
|
-
|
|
250
|
+
|
|
247
251
|
# Night Mask & Clipping
|
|
248
252
|
if self.config.forecast_type == "solar":
|
|
249
253
|
logger.info("Applying night mask for solar forecast...")
|
|
@@ -251,26 +255,28 @@ class KPowerForecast:
|
|
|
251
255
|
self.config.latitude, self.config.longitude, forecast["ds"]
|
|
252
256
|
)
|
|
253
257
|
forecast.loc[elevations < 0, ["yhat", "yhat_lower", "yhat_upper"]] = 0
|
|
254
|
-
|
|
258
|
+
|
|
255
259
|
for col in ["yhat", "yhat_lower", "yhat_upper"]:
|
|
256
260
|
forecast[col] = forecast[col].clip(lower=0)
|
|
257
|
-
|
|
258
|
-
return forecast
|
|
261
|
+
|
|
262
|
+
return cast(pd.DataFrame, forecast)
|
|
259
263
|
|
|
260
264
|
def get_prediction_intervals(self, days: int = 7) -> List[PredictionInterval]:
|
|
261
265
|
"""
|
|
262
266
|
Returns prediction intervals for EMS.
|
|
263
267
|
"""
|
|
264
268
|
forecast = self.predict(days=days)
|
|
265
|
-
|
|
269
|
+
|
|
266
270
|
intervals = []
|
|
267
271
|
for _, row in forecast.iterrows():
|
|
268
|
-
intervals.append(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
intervals.append(
|
|
273
|
+
PredictionInterval(
|
|
274
|
+
timestamp=row["ds"],
|
|
275
|
+
expected_kwh=row["yhat"],
|
|
276
|
+
lower_bound_kwh=row["yhat_lower"],
|
|
277
|
+
upper_bound_kwh=row["yhat_upper"],
|
|
278
|
+
)
|
|
279
|
+
)
|
|
274
280
|
return intervals
|
|
275
281
|
|
|
276
282
|
def get_surplus_probability(
|
|
@@ -278,21 +284,19 @@ class KPowerForecast:
|
|
|
278
284
|
) -> pd.DataFrame:
|
|
279
285
|
"""
|
|
280
286
|
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)
|
|
287
|
+
Prophet doesn't provide direct probabilities, but we can estimate
|
|
288
|
+
from the uncertainty interval (yhat_upper - yhat_lower)
|
|
283
289
|
assuming normal distribution.
|
|
284
290
|
"""
|
|
285
291
|
forecast = self.predict(days=days)
|
|
286
|
-
|
|
292
|
+
|
|
287
293
|
# Estimate sigma from 80% interval (approx 1.28 * sigma)
|
|
288
294
|
sigma = (forecast["yhat_upper"] - forecast["yhat_lower"]) / (2 * 1.28)
|
|
289
|
-
sigma = sigma.replace(0, 1e-9)
|
|
290
|
-
|
|
295
|
+
sigma = sigma.replace(0, 1e-9) # Avoid div by zero
|
|
296
|
+
|
|
291
297
|
from scipy.stats import norm
|
|
298
|
+
|
|
292
299
|
z_score = (threshold_kwh - forecast["yhat"]) / sigma
|
|
293
300
|
prob_exceed = 1 - norm.cdf(z_score)
|
|
294
|
-
|
|
295
|
-
return pd.DataFrame({
|
|
296
|
-
"ds": forecast["ds"],
|
|
297
|
-
"surplus_prob": prob_exceed
|
|
298
|
-
})
|
|
301
|
+
|
|
302
|
+
return pd.DataFrame({"ds": forecast["ds"], "surplus_prob": prob_exceed})
|
kpower_forecast/storage.py
CHANGED
|
@@ -8,6 +8,7 @@ from prophet.serialize import model_from_json, model_to_json
|
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
class ModelStorage:
|
|
12
13
|
def __init__(self, storage_path: str):
|
|
13
14
|
self.storage_path = Path(storage_path)
|
|
@@ -50,8 +51,8 @@ class ModelStorage:
|
|
|
50
51
|
return model_from_json(json.load(f))
|
|
51
52
|
except Exception as e:
|
|
52
53
|
logger.error(f"Failed to load model {model_id}: {e}")
|
|
53
|
-
# If load fails, we might want to return None to trigger retraining,
|
|
54
|
-
# or raise to alert the user.
|
|
54
|
+
# If load fails, we might want to return None to trigger retraining,
|
|
55
|
+
# or raise to alert the user.
|
|
55
56
|
# Given "production-grade", maybe explicit failure is safer
|
|
56
57
|
# than silent fallback?
|
|
57
58
|
# But the prompt says "If a model exists, load it...
|
kpower_forecast/utils.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import datetime
|
|
2
|
-
from typing import List, Union
|
|
2
|
+
from typing import List, Union, cast
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pandas as pd
|
|
@@ -37,9 +37,7 @@ def calculate_solar_elevation(
|
|
|
37
37
|
return np.array(elevations)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def get_clear_sky_ghi(
|
|
41
|
-
lat: float, lon: float, times: pd.DatetimeIndex
|
|
42
|
-
) -> pd.Series:
|
|
40
|
+
def get_clear_sky_ghi(lat: float, lon: float, times: pd.DatetimeIndex) -> pd.Series:
|
|
43
41
|
"""
|
|
44
42
|
Calculate Theoretical Clear Sky GHI (Global Horizontal Irradiance)
|
|
45
43
|
using pvlib.
|
|
@@ -56,4 +54,4 @@ def get_clear_sky_ghi(
|
|
|
56
54
|
# get_clearsky returns GHI, DNI, DHI. We only need GHI.
|
|
57
55
|
# Ineichen is the default model.
|
|
58
56
|
clearsky = location.get_clearsky(times)
|
|
59
|
-
return clearsky["ghi"]
|
|
57
|
+
return cast(pd.Series, clearsky["ghi"])
|
|
@@ -8,6 +8,7 @@ from pydantic import BaseModel
|
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
class WeatherConfig(BaseModel):
|
|
12
13
|
base_url: str = "https://api.open-meteo.com/v1/forecast"
|
|
13
14
|
# Historical API is different, usually:
|
|
@@ -19,27 +20,28 @@ class WeatherConfig(BaseModel):
|
|
|
19
20
|
# For simplicity, let's allow passing the base_url.
|
|
20
21
|
archive_url: str = "https://archive-api.open-meteo.com/v1/archive"
|
|
21
22
|
|
|
23
|
+
|
|
22
24
|
class WeatherClient:
|
|
23
25
|
def __init__(self, lat: float, lon: float, config: Optional[WeatherConfig] = None):
|
|
24
26
|
self.lat = lat
|
|
25
27
|
self.lon = lon
|
|
26
28
|
self.config = config or WeatherConfig()
|
|
27
|
-
|
|
29
|
+
|
|
28
30
|
def fetch_historical(
|
|
29
31
|
self, start_date: datetime.date, end_date: datetime.date
|
|
30
32
|
) -> pd.DataFrame:
|
|
31
33
|
"""
|
|
32
34
|
Fetch historical weather data for training.
|
|
33
35
|
"""
|
|
34
|
-
params = {
|
|
36
|
+
params: dict[str, str | float | list[str]] = {
|
|
35
37
|
"latitude": self.lat,
|
|
36
38
|
"longitude": self.lon,
|
|
37
39
|
"start_date": start_date.isoformat(),
|
|
38
40
|
"end_date": end_date.isoformat(),
|
|
39
41
|
"hourly": ["temperature_2m", "cloud_cover", "shortwave_radiation"],
|
|
40
|
-
"timezone": "UTC"
|
|
42
|
+
"timezone": "UTC",
|
|
41
43
|
}
|
|
42
|
-
|
|
44
|
+
|
|
43
45
|
try:
|
|
44
46
|
logger.info(f"Fetching historical weather from {self.config.archive_url}")
|
|
45
47
|
response = requests.get(self.config.archive_url, params=params, timeout=10)
|
|
@@ -54,14 +56,14 @@ class WeatherClient:
|
|
|
54
56
|
"""
|
|
55
57
|
Fetch weather forecast for prediction.
|
|
56
58
|
"""
|
|
57
|
-
params = {
|
|
59
|
+
params: dict[str, str | float | int | list[str]] = {
|
|
58
60
|
"latitude": self.lat,
|
|
59
61
|
"longitude": self.lon,
|
|
60
62
|
"hourly": ["temperature_2m", "cloud_cover", "shortwave_radiation"],
|
|
61
63
|
"forecast_days": days,
|
|
62
|
-
"timezone": "UTC"
|
|
64
|
+
"timezone": "UTC",
|
|
63
65
|
}
|
|
64
|
-
|
|
66
|
+
|
|
65
67
|
try:
|
|
66
68
|
logger.info(f"Fetching forecast weather from {self.config.base_url}")
|
|
67
69
|
response = requests.get(self.config.base_url, params=params, timeout=10)
|
|
@@ -76,18 +78,20 @@ class WeatherClient:
|
|
|
76
78
|
hourly = data.get("hourly", {})
|
|
77
79
|
if not hourly:
|
|
78
80
|
raise ValueError("No hourly data in response")
|
|
79
|
-
|
|
80
|
-
df = pd.DataFrame(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
|
|
82
|
+
df = pd.DataFrame(
|
|
83
|
+
{
|
|
84
|
+
"ds": pd.to_datetime(hourly["time"], utc=True),
|
|
85
|
+
"temperature_2m": hourly["temperature_2m"],
|
|
86
|
+
"cloud_cover": hourly["cloud_cover"],
|
|
87
|
+
"shortwave_radiation": hourly["shortwave_radiation"],
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Open-Meteo returns nulls sometimes, fill or drop?
|
|
88
92
|
# Linear interpolation is usually safe for weather gaps
|
|
89
|
-
df = df.interpolate(method=
|
|
90
|
-
|
|
93
|
+
df = df.interpolate(method="linear").bfill().ffill()
|
|
94
|
+
|
|
91
95
|
return df
|
|
92
96
|
|
|
93
97
|
def resample_weather(self, df: pd.DataFrame, interval_minutes: int) -> pd.DataFrame:
|
|
@@ -97,19 +101,22 @@ class WeatherClient:
|
|
|
97
101
|
"""
|
|
98
102
|
if df.empty:
|
|
99
103
|
return df
|
|
100
|
-
|
|
104
|
+
|
|
101
105
|
df = df.set_index("ds").sort_index()
|
|
102
|
-
|
|
106
|
+
|
|
103
107
|
# Check if we need resampling
|
|
108
|
+
if not isinstance(df.index, pd.DatetimeIndex):
|
|
109
|
+
raise ValueError("Index must be DatetimeIndex")
|
|
110
|
+
|
|
104
111
|
current_freq = pd.infer_freq(df.index)
|
|
105
112
|
target_freq = f"{interval_minutes}min"
|
|
106
|
-
|
|
113
|
+
|
|
107
114
|
if current_freq == target_freq:
|
|
108
115
|
return df.reset_index()
|
|
109
|
-
|
|
116
|
+
|
|
110
117
|
# Resample and interpolate
|
|
111
118
|
# Cubic is good for temperature/radiation curves
|
|
112
119
|
df_resampled = df.resample(target_freq).interpolate(method="cubic")
|
|
113
|
-
|
|
120
|
+
|
|
114
121
|
# Reset index to get 'ds' column back
|
|
115
122
|
return df_resampled.reset_index()
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kpower-forecast
|
|
3
|
-
Version: 2026.2.
|
|
3
|
+
Version: 2026.2.2
|
|
4
4
|
Summary: Solar production and power consumption forecasting package.
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: "KSoft.TECH OSS" <oss@ksoft.tech>
|
|
6
6
|
License: AGPL-3.0
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Keywords: energy,forecast,prophet,solar,weather
|
|
@@ -23,8 +23,12 @@ Requires-Dist: pytz>=2024.1
|
|
|
23
23
|
Requires-Dist: requests>=2.31.0
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pandas-stubs; extra == 'dev'
|
|
27
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
26
28
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
27
29
|
Requires-Dist: ruff>=0.2.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: scipy; extra == 'dev'
|
|
31
|
+
Requires-Dist: scipy-stubs; extra == 'dev'
|
|
28
32
|
Requires-Dist: types-pytz; extra == 'dev'
|
|
29
33
|
Requires-Dist: types-requests; extra == 'dev'
|
|
30
34
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
kpower_forecast/__init__.py,sha256=K5T2i1xP5ZNLgeTdRpEd_E7_WmhaoBTiHHur-GTfY9o,88
|
|
2
|
+
kpower_forecast/core.py,sha256=-cIHwUJb-2qqVfCAlS8Nb1ONcsCEDnVDk4Ljes7U0Q8,11237
|
|
3
|
+
kpower_forecast/storage.py,sha256=3dwejuB2QKP1XMXcKR8nCAYFTCzylSW4jIS9xfjyaZM,2225
|
|
4
|
+
kpower_forecast/utils.py,sha256=-X6e58osfN2z3oBcZHth3YXybgWNf8Ep2o_nuqsT1OM,1670
|
|
5
|
+
kpower_forecast/weather_client.py,sha256=T9bi_rT0LWYVx8ug7a27GwzY-KTPlMdxI9J4SwNnfdw,4350
|
|
6
|
+
kpower_forecast-2026.2.2.dist-info/METADATA,sha256=u79c7nhk3haudnOuuRyuRhYPdOI4si-6RNjU8Ywagw8,5198
|
|
7
|
+
kpower_forecast-2026.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
kpower_forecast-2026.2.2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
9
|
+
kpower_forecast-2026.2.2.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|