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.
@@ -1,4 +1,4 @@
1
1
  from .core import KPowerForecast
2
2
 
3
3
  __all__ = ["KPowerForecast"]
4
- __version__ = "2026.2.1"
4
+ __version__ = "2026.2.2"
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 # P10
19
- upper_bound_kwh: float # P90
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, # Used for P10/P90 (80% interval)
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
- 'changepoint_prior_scale': [0.001, 0.05, 0.5],
180
- 'seasonality_prior_scale': [0.01, 1.0, 10.0],
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('inf')
189
+ min_rmse = float("inf")
186
190
 
187
- for cps in param_grid['changepoint_prior_scale']:
188
- for sps in param_grid['seasonality_prior_scale']:
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'{days//2} days', period='5 days', horizon='5 days'
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['rmse'].values[0]
213
+ rmse = df_p["rmse"].values[0]
210
214
 
211
215
  if rmse < min_rmse:
212
216
  min_rmse = rmse
213
- best_params = {'cps': cps, 'sps': sps}
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['cps']
217
- self.config.seasonality_prior_scale = best_params['sps']
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(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
- ))
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) # Avoid div by zero
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})
@@ -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
- "ds": pd.to_datetime(hourly["time"], utc=True),
82
- "temperature_2m": hourly["temperature_2m"],
83
- "cloud_cover": hourly["cloud_cover"],
84
- "shortwave_radiation": hourly["shortwave_radiation"]
85
- })
86
-
87
- # Open-Meteo returns nulls sometimes, fill or drop?
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='linear').bfill().ffill()
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.1
3
+ Version: 2026.2.2
4
4
  Summary: Solar production and power consumption forecasting package.
5
- Author-email: KPower Team <info@kpower.example>
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,,