kpower-forecast 2026.2.1__tar.gz → 2026.2.2__tar.gz

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.
Files changed (23) hide show
  1. kpower_forecast-2026.2.2/.pre-commit-config.yaml +30 -0
  2. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/PKG-INFO +6 -2
  3. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/pyproject.toml +20 -2
  4. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/scripts/validate_version.py +8 -5
  5. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/src/kpower_forecast/__init__.py +1 -1
  6. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/src/kpower_forecast/core.py +66 -62
  7. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/src/kpower_forecast/storage.py +3 -2
  8. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/src/kpower_forecast/utils.py +3 -5
  9. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/src/kpower_forecast/weather_client.py +30 -23
  10. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/tests/test_core.py +56 -43
  11. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/tests/test_utils.py +3 -2
  12. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/uv.lock +180 -1
  13. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.github/pull_request_template.md +0 -0
  16. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.github/workflows/ci.yml +0 -0
  17. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.github/workflows/deploy.yml +0 -0
  18. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.gitignore +0 -0
  19. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/.python-version +0 -0
  20. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/AGENTS.md +0 -0
  21. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/GEMINI.md +0 -0
  22. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/LICENSE +0 -0
  23. {kpower_forecast-2026.2.1 → kpower_forecast-2026.2.2}/README.md +0 -0
@@ -0,0 +1,30 @@
1
+ repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: ruff-check
5
+ name: ruff check
6
+ entry: uv run ruff check --fix
7
+ language: system
8
+ types: [python]
9
+ require_serial: true
10
+
11
+ - id: ruff-format
12
+ name: ruff format
13
+ entry: uv run ruff format
14
+ language: system
15
+ types: [python]
16
+ require_serial: true
17
+
18
+ - id: mypy
19
+ name: mypy
20
+ entry: uv run mypy src
21
+ language: system
22
+ types: [python]
23
+ pass_filenames: false
24
+
25
+ - id: pytest
26
+ name: pytest
27
+ entry: uv run pytest
28
+ language: system
29
+ pass_filenames: false
30
+ always_run: true
@@ -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
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "kpower-forecast"
3
- version = "2026.2.1"
3
+ version = "2026.2.2"
4
4
  description = "Solar production and power consumption forecasting package."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = { text = "AGPL-3.0" }
8
8
  authors = [
9
- { name = "KPower Team", email = "info@kpower.example" }
9
+ { name = "KSoft.TECH OSS", email = "oss@ksoft.tech" }
10
10
  ]
11
11
  keywords = ["solar", "energy", "forecast", "prophet", "weather"]
12
12
  classifiers = [
@@ -35,8 +35,26 @@ dev = [
35
35
  "mypy>=1.8.0",
36
36
  "types-requests",
37
37
  "types-pytz",
38
+ "pre-commit",
39
+ "scipy",
40
+ "pandas-stubs",
41
+ "scipy-stubs",
38
42
  ]
39
43
 
44
+ [tool.mypy]
45
+ python_version = "3.13"
46
+ warn_return_any = true
47
+ warn_unused_configs = true
48
+ ignore_missing_imports = true
49
+
50
+ [[tool.mypy.overrides]]
51
+ module = [
52
+ "prophet.*",
53
+ "pvlib.*",
54
+ "pysolar.*",
55
+ ]
56
+ ignore_missing_imports = true
57
+
40
58
  [build-system]
41
59
  requires = ["hatchling", "hatch-vcs"]
42
60
  build-backend = "hatchling.build"
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  # Regex for YYYY.MM.Patch (e.g., 2026.2.1)
6
6
  VERSION_PATTERN = r"^\d{4}\.(?:[1-9]|1[0-2])\.\d+$"
7
7
 
8
+
8
9
  def validate_version(file_path: str, pattern: str):
9
10
  content = Path(file_path).read_text()
10
11
  # Find version in pyproject.toml or __init__.py
@@ -12,25 +13,27 @@ def validate_version(file_path: str, pattern: str):
12
13
  if not match:
13
14
  print(f"❌ Could not find version in {file_path}")
14
15
  return False
15
-
16
+
16
17
  version = match.group(1)
17
18
  if not re.match(VERSION_PATTERN, version):
18
19
  print(f"❌ Invalid version format in {file_path}: '{version}'")
19
20
  print(" Expected format: YYYY.MM.Patch (e.g., 2026.2.1)")
20
21
  return False
21
-
22
+
22
23
  print(f"✅ Version {version} in {file_path} is valid.")
23
24
  return True
24
25
 
26
+
25
27
  if __name__ == "__main__":
26
28
  success = True
27
29
  # Check pyproject.toml
28
30
  if not validate_version("pyproject.toml", r'^version\s*=\s*"([^"]+)"'):
29
31
  success = False
30
-
32
+
31
33
  # Check __init__.py
32
- if not validate_version("src/kpower_forecast/__init__.py", r'^__version__\s*=\s*"([^"]+)"'):
34
+ pattern = r'^__version__\s*=\s*"([^"]+)"'
35
+ if not validate_version("src/kpower_forecast/__init__.py", pattern):
33
36
  success = False
34
-
37
+
35
38
  if not success:
36
39
  sys.exit(1)
@@ -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"
@@ -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...
@@ -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()
@@ -10,91 +10,104 @@ from kpower_forecast.core import KPowerForecast
10
10
  def mock_weather_client():
11
11
  with patch("kpower_forecast.core.WeatherClient") as mock:
12
12
  client_instance = mock.return_value
13
-
13
+
14
14
  # Mock fetch_historical
15
- client_instance.fetch_historical.return_value = pd.DataFrame({
16
- "ds": pd.date_range("2024-01-01", periods=24, freq="h", tz="UTC"),
17
- "temperature_2m": [20]*24,
18
- "cloud_cover": [0]*24,
19
- "shortwave_radiation": [500]*24
20
- })
21
-
15
+ client_instance.fetch_historical.return_value = pd.DataFrame(
16
+ {
17
+ "ds": pd.date_range("2024-01-01", periods=24, freq="h", tz="UTC"),
18
+ "temperature_2m": [20] * 24,
19
+ "cloud_cover": [0] * 24,
20
+ "shortwave_radiation": [500] * 24,
21
+ }
22
+ )
23
+
22
24
  # Mock resample
23
25
  def side_effect_resample(df, interval):
24
26
  # Simple pass through for mock
25
27
  return df
28
+
26
29
  client_instance.resample_weather.side_effect = side_effect_resample
27
-
30
+
28
31
  # Mock fetch_forecast
29
- client_instance.fetch_forecast.return_value = pd.DataFrame({
30
- "ds": pd.date_range("2024-01-02", periods=24, freq="h", tz="UTC"),
31
- "temperature_2m": [20]*24,
32
- "cloud_cover": [0]*24,
33
- "shortwave_radiation": [500]*24
34
- })
35
-
32
+ client_instance.fetch_forecast.return_value = pd.DataFrame(
33
+ {
34
+ "ds": pd.date_range("2024-01-02", periods=24, freq="h", tz="UTC"),
35
+ "temperature_2m": [20] * 24,
36
+ "cloud_cover": [0] * 24,
37
+ "shortwave_radiation": [500] * 24,
38
+ }
39
+ )
40
+
36
41
  yield client_instance
37
42
 
43
+
38
44
  @pytest.fixture
39
45
  def mock_storage():
40
46
  with patch("kpower_forecast.core.ModelStorage") as mock:
41
47
  yield mock.return_value
42
48
 
49
+
43
50
  @pytest.fixture
44
51
  def mock_prophet():
45
52
  with patch("kpower_forecast.core.Prophet") as mock:
46
53
  model_instance = mock.return_value
47
54
  # Mock predict return
48
- model_instance.predict.return_value = pd.DataFrame({
49
- "ds": pd.date_range("2024-01-02", periods=24, freq="h", tz="UTC"),
50
- "yhat": [100]*24
51
- })
55
+ model_instance.predict.return_value = pd.DataFrame(
56
+ {
57
+ "ds": pd.date_range("2024-01-02", periods=24, freq="h", tz="UTC"),
58
+ "yhat": [100] * 24,
59
+ }
60
+ )
52
61
  yield model_instance
53
62
 
63
+
54
64
  def test_train(mock_weather_client, mock_storage, mock_prophet):
55
65
  kp = KPowerForecast("test_model", 0, 0)
56
-
57
- history = pd.DataFrame({
58
- "ds": pd.date_range("2024-01-01", periods=24, freq="h", tz="UTC"),
59
- "y": [100]*24
60
- })
61
-
62
- mock_storage.load_model.return_value = None # No existing model
63
-
66
+
67
+ history = pd.DataFrame(
68
+ {
69
+ "ds": pd.date_range("2024-01-01", periods=24, freq="h", tz="UTC"),
70
+ "y": [100] * 24,
71
+ }
72
+ )
73
+
74
+ mock_storage.load_model.return_value = None # No existing model
75
+
64
76
  kp.train(history)
65
-
77
+
66
78
  assert mock_weather_client.fetch_historical.called
67
79
  assert mock_prophet.fit.called
68
80
  assert mock_storage.save_model.called
69
81
 
82
+
70
83
  def test_predict(mock_weather_client, mock_storage, mock_prophet):
71
84
  kp = KPowerForecast("test_model", 0, 0)
72
-
85
+
73
86
  mock_storage.load_model.return_value = mock_prophet
74
-
87
+
75
88
  forecast = kp.predict(days=1)
76
-
89
+
77
90
  assert mock_storage.load_model.called
78
91
  assert mock_weather_client.fetch_forecast.called
79
92
  assert not forecast.empty
80
93
  assert "yhat" in forecast.columns
81
94
 
95
+
82
96
  def test_consumption_forecast(mock_weather_client, mock_storage, mock_prophet):
83
97
  kp = KPowerForecast(
84
- "test_consumption",
85
- 0, 0,
86
- forecast_type="consumption",
87
- heat_pump_mode=True
98
+ "test_consumption", 0, 0, forecast_type="consumption", heat_pump_mode=True
99
+ )
100
+
101
+ history = pd.DataFrame(
102
+ {
103
+ "ds": pd.date_range("2024-01-01", periods=24, freq="h", tz="UTC"),
104
+ "y": [200] * 24,
105
+ }
88
106
  )
89
-
90
- history = pd.DataFrame({
91
- "ds": pd.date_range("2024-01-01", periods=24, freq="h", tz="UTC"),
92
- "y": [200]*24
93
- })
94
-
107
+
95
108
  mock_storage.load_model.return_value = None
96
109
  kp.train(history)
97
-
110
+
98
111
  # Verify add_regressor was called for temperature
99
112
  mock_prophet.add_regressor.assert_called_with("temperature_2m")
100
113
  assert mock_prophet.fit.called
@@ -6,16 +6,17 @@ from kpower_forecast.utils import calculate_solar_elevation
6
6
  def test_calculate_solar_elevation():
7
7
  # Test location: Equator, Prime Meridian
8
8
  lat, lon = 0.0, 0.0
9
-
9
+
10
10
  # Test time: Noon at Equinox (approx) -> Sun should be high (90 deg ideally)
11
11
  # March 21st, 12:00 UTC
12
12
  times = [datetime.datetime(2024, 3, 21, 12, 0, tzinfo=datetime.timezone.utc)]
13
-
13
+
14
14
  elevations = calculate_solar_elevation(lat, lon, times)
15
15
  assert len(elevations) == 1
16
16
  # Should be close to 90 degrees (zenith)
17
17
  assert 85 < elevations[0] < 95
18
18
 
19
+
19
20
  def test_night_mask():
20
21
  # Test midnight
21
22
  lat, lon = 0.0, 0.0
@@ -28,6 +28,15 @@ wheels = [
28
28
  { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
29
29
  ]
30
30
 
31
+ [[package]]
32
+ name = "cfgv"
33
+ version = "3.5.0"
34
+ source = { registry = "https://pypi.org/simple" }
35
+ sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
36
+ wheels = [
37
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
38
+ ]
39
+
31
40
  [[package]]
32
41
  name = "charset-normalizer"
33
42
  version = "3.4.4"
@@ -157,6 +166,24 @@ wheels = [
157
166
  { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
158
167
  ]
159
168
 
169
+ [[package]]
170
+ name = "distlib"
171
+ version = "0.4.0"
172
+ source = { registry = "https://pypi.org/simple" }
173
+ sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
174
+ wheels = [
175
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
176
+ ]
177
+
178
+ [[package]]
179
+ name = "filelock"
180
+ version = "3.20.3"
181
+ source = { registry = "https://pypi.org/simple" }
182
+ sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
183
+ wheels = [
184
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
185
+ ]
186
+
160
187
  [[package]]
161
188
  name = "fonttools"
162
189
  version = "4.61.1"
@@ -229,6 +256,15 @@ wheels = [
229
256
  { url = "https://files.pythonhosted.org/packages/9b/3d/e3d23cb51f6353931436df4e9d1d4873311d0ed50d273ef1531bdd074958/holidays-0.90-py3-none-any.whl", hash = "sha256:8ed92ea72e2db5ef00f024c37b03641085699809f42fe5ba03b040be6740f72d", size = 1353734, upload-time = "2026-02-02T18:48:16.138Z" },
230
257
  ]
231
258
 
259
+ [[package]]
260
+ name = "identify"
261
+ version = "2.6.16"
262
+ source = { registry = "https://pypi.org/simple" }
263
+ sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
264
+ wheels = [
265
+ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
266
+ ]
267
+
232
268
  [[package]]
233
269
  name = "idna"
234
270
  version = "3.11"
@@ -317,7 +353,7 @@ wheels = [
317
353
 
318
354
  [[package]]
319
355
  name = "kpower-forecast"
320
- version = "2026.2.0"
356
+ version = "2026.2.2"
321
357
  source = { editable = "." }
322
358
  dependencies = [
323
359
  { name = "cmdstanpy" },
@@ -334,8 +370,12 @@ dependencies = [
334
370
  [package.optional-dependencies]
335
371
  dev = [
336
372
  { name = "mypy" },
373
+ { name = "pandas-stubs" },
374
+ { name = "pre-commit" },
337
375
  { name = "pytest" },
338
376
  { name = "ruff" },
377
+ { name = "scipy" },
378
+ { name = "scipy-stubs" },
339
379
  { name = "types-pytz" },
340
380
  { name = "types-requests" },
341
381
  ]
@@ -346,6 +386,8 @@ requires-dist = [
346
386
  { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
347
387
  { name = "numpy", specifier = ">=1.26.0" },
348
388
  { name = "pandas", specifier = ">=2.2.0" },
389
+ { name = "pandas-stubs", marker = "extra == 'dev'" },
390
+ { name = "pre-commit", marker = "extra == 'dev'" },
349
391
  { name = "prophet", specifier = ">=1.1.5" },
350
392
  { name = "pvlib", specifier = ">=0.11.0" },
351
393
  { name = "pydantic", specifier = ">=2.6.0" },
@@ -354,6 +396,8 @@ requires-dist = [
354
396
  { name = "pytz", specifier = ">=2024.1" },
355
397
  { name = "requests", specifier = ">=2.31.0" },
356
398
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" },
399
+ { name = "scipy", marker = "extra == 'dev'" },
400
+ { name = "scipy-stubs", marker = "extra == 'dev'" },
357
401
  { name = "types-pytz", marker = "extra == 'dev'" },
358
402
  { name = "types-requests", marker = "extra == 'dev'" },
359
403
  ]
@@ -483,6 +527,15 @@ wheels = [
483
527
  { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
484
528
  ]
485
529
 
530
+ [[package]]
531
+ name = "nodeenv"
532
+ version = "1.10.0"
533
+ source = { registry = "https://pypi.org/simple" }
534
+ sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
535
+ wheels = [
536
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
537
+ ]
538
+
486
539
  [[package]]
487
540
  name = "numpy"
488
541
  version = "2.4.2"
@@ -533,6 +586,33 @@ wheels = [
533
586
  { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
534
587
  ]
535
588
 
589
+ [[package]]
590
+ name = "numpy-typing-compat"
591
+ version = "20251206.2.4"
592
+ source = { registry = "https://pypi.org/simple" }
593
+ dependencies = [
594
+ { name = "numpy" },
595
+ ]
596
+ sdist = { url = "https://files.pythonhosted.org/packages/42/5f/29fd5f29b0a5d96e2def96ecba3112fc330ecd16e8c97c2b332563c5e201/numpy_typing_compat-20251206.2.4.tar.gz", hash = "sha256:59882d23aaff054a2536da80564012cdce33487657be4d79c5925bb8705fcabc", size = 5011, upload-time = "2025-12-06T20:02:04.942Z" }
597
+ wheels = [
598
+ { url = "https://files.pythonhosted.org/packages/63/7c/5c2892e6bc0628a2ccf4e938e1e2db22794657ccb374672d66e20d73839e/numpy_typing_compat-20251206.2.4-py3-none-any.whl", hash = "sha256:a82e723bd20efaa4cf2886709d4264c144f1f2b609bda83d1545113b7e47a5b5", size = 6300, upload-time = "2025-12-06T20:01:57.578Z" },
599
+ ]
600
+
601
+ [[package]]
602
+ name = "optype"
603
+ version = "0.15.0"
604
+ source = { registry = "https://pypi.org/simple" }
605
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/93/6b9e43138ce36fbad134bd1a50460a7bbda61105b5a964e4cf773fe4d845/optype-0.15.0.tar.gz", hash = "sha256:457d6ca9e7da19967ec16d42bdf94e240b33b5d70a56fbbf5b427e5ea39cf41e", size = 99978, upload-time = "2025-12-08T12:32:41.422Z" }
606
+ wheels = [
607
+ { url = "https://files.pythonhosted.org/packages/07/8b/93f6c496fc5da062fd7e7c4745b5a8dd09b7b576c626075844fe97951a7d/optype-0.15.0-py3-none-any.whl", hash = "sha256:caba40ece9ea39b499fa76c036a82e0d452a432dd4dd3e8e0d30892be2e8c76c", size = 88716, upload-time = "2025-12-08T12:32:39.669Z" },
608
+ ]
609
+
610
+ [package.optional-dependencies]
611
+ numpy = [
612
+ { name = "numpy" },
613
+ { name = "numpy-typing-compat" },
614
+ ]
615
+
536
616
  [[package]]
537
617
  name = "packaging"
538
618
  version = "26.0"
@@ -586,6 +666,18 @@ wheels = [
586
666
  { url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" },
587
667
  ]
588
668
 
669
+ [[package]]
670
+ name = "pandas-stubs"
671
+ version = "3.0.0.260204"
672
+ source = { registry = "https://pypi.org/simple" }
673
+ dependencies = [
674
+ { name = "numpy" },
675
+ ]
676
+ sdist = { url = "https://files.pythonhosted.org/packages/27/1d/297ff2c7ea50a768a2247621d6451abb2a07c0e9be7ca6d36ebe371658e5/pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3", size = 109383, upload-time = "2026-02-04T15:17:17.247Z" }
677
+ wheels = [
678
+ { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540, upload-time = "2026-02-04T15:17:15.615Z" },
679
+ ]
680
+
589
681
  [[package]]
590
682
  name = "pathspec"
591
683
  version = "1.0.4"
@@ -653,6 +745,15 @@ wheels = [
653
745
  { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
654
746
  ]
655
747
 
748
+ [[package]]
749
+ name = "platformdirs"
750
+ version = "4.5.1"
751
+ source = { registry = "https://pypi.org/simple" }
752
+ sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
753
+ wheels = [
754
+ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
755
+ ]
756
+
656
757
  [[package]]
657
758
  name = "pluggy"
658
759
  version = "1.6.0"
@@ -662,6 +763,22 @@ wheels = [
662
763
  { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
663
764
  ]
664
765
 
766
+ [[package]]
767
+ name = "pre-commit"
768
+ version = "4.5.1"
769
+ source = { registry = "https://pypi.org/simple" }
770
+ dependencies = [
771
+ { name = "cfgv" },
772
+ { name = "identify" },
773
+ { name = "nodeenv" },
774
+ { name = "pyyaml" },
775
+ { name = "virtualenv" },
776
+ ]
777
+ sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
778
+ wheels = [
779
+ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
780
+ ]
781
+
665
782
  [[package]]
666
783
  name = "prophet"
667
784
  version = "1.3.0"
@@ -836,6 +953,42 @@ wheels = [
836
953
  { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
837
954
  ]
838
955
 
956
+ [[package]]
957
+ name = "pyyaml"
958
+ version = "6.0.3"
959
+ source = { registry = "https://pypi.org/simple" }
960
+ sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
961
+ wheels = [
962
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
963
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
964
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
965
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
966
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
967
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
968
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
969
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
970
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
971
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
972
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
973
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
974
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
975
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
976
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
977
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
978
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
979
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
980
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
981
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
982
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
983
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
984
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
985
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
986
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
987
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
988
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
989
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
990
+ ]
991
+
839
992
  [[package]]
840
993
  name = "requests"
841
994
  version = "2.32.5"
@@ -927,6 +1080,18 @@ wheels = [
927
1080
  { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" },
928
1081
  ]
929
1082
 
1083
+ [[package]]
1084
+ name = "scipy-stubs"
1085
+ version = "1.17.0.2"
1086
+ source = { registry = "https://pypi.org/simple" }
1087
+ dependencies = [
1088
+ { name = "optype", extra = ["numpy"] },
1089
+ ]
1090
+ sdist = { url = "https://files.pythonhosted.org/packages/40/fe/5fa7da49821ea94d60629ae71277fa8d7e16eb20602f720062b6c30a644c/scipy_stubs-1.17.0.2.tar.gz", hash = "sha256:3981bd7fa4c189a8493307afadaee1a830d9a0de8e3ae2f4603f192b6260ef2a", size = 379897, upload-time = "2026-01-22T19:17:08Z" }
1091
+ wheels = [
1092
+ { url = "https://files.pythonhosted.org/packages/51/e3/20233497e4a27956e7392c3f7879e6ee7f767f268079f24f4b089b70f563/scipy_stubs-1.17.0.2-py3-none-any.whl", hash = "sha256:99d1aa75b7d72a7ee36a68d18bcf1149f62ab577bbd1236c65c471b3b465d824", size = 586137, upload-time = "2026-01-22T19:17:05.802Z" },
1093
+ ]
1094
+
930
1095
  [[package]]
931
1096
  name = "six"
932
1097
  version = "1.17.0"
@@ -1019,3 +1184,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6
1019
1184
  wheels = [
1020
1185
  { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
1021
1186
  ]
1187
+
1188
+ [[package]]
1189
+ name = "virtualenv"
1190
+ version = "20.36.1"
1191
+ source = { registry = "https://pypi.org/simple" }
1192
+ dependencies = [
1193
+ { name = "distlib" },
1194
+ { name = "filelock" },
1195
+ { name = "platformdirs" },
1196
+ ]
1197
+ sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
1198
+ wheels = [
1199
+ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
1200
+ ]