kpower-forecast 2026.2.0__tar.gz → 2026.2.1__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.0 → kpower_forecast-2026.2.1}/.github/workflows/ci.yml +4 -0
  2. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/PKG-INFO +17 -1
  3. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/README.md +15 -0
  4. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/pyproject.toml +2 -1
  5. kpower_forecast-2026.2.1/scripts/validate_version.py +36 -0
  6. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/__init__.py +1 -1
  7. kpower_forecast-2026.2.1/src/kpower_forecast/core.py +298 -0
  8. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/utils.py +28 -5
  9. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/uv.lock +97 -0
  10. kpower_forecast-2026.2.0/src/kpower_forecast/core.py +0 -142
  11. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/pull_request_template.md +0 -0
  14. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/workflows/deploy.yml +0 -0
  15. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.gitignore +0 -0
  16. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.python-version +0 -0
  17. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/AGENTS.md +0 -0
  18. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/GEMINI.md +0 -0
  19. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/LICENSE +0 -0
  20. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/storage.py +0 -0
  21. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/weather_client.py +0 -0
  22. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/tests/test_core.py +0 -0
  23. {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/tests/test_utils.py +0 -0
@@ -28,6 +28,10 @@ jobs:
28
28
  run: |
29
29
  uv pip install --system ".[dev]"
30
30
 
31
+ - name: Validate Version Format
32
+ run: |
33
+ python scripts/validate_version.py
34
+
31
35
  - name: Lint with Ruff
32
36
  run: |
33
37
  ruff check .
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kpower-forecast
3
- Version: 2026.2.0
3
+ Version: 2026.2.1
4
4
  Summary: Solar production and power consumption forecasting package.
5
5
  Author-email: KPower Team <info@kpower.example>
6
6
  License: AGPL-3.0
@@ -16,6 +16,7 @@ Requires-Dist: cmdstanpy>=1.2.0
16
16
  Requires-Dist: numpy>=1.26.0
17
17
  Requires-Dist: pandas>=2.2.0
18
18
  Requires-Dist: prophet>=1.1.5
19
+ Requires-Dist: pvlib>=0.11.0
19
20
  Requires-Dist: pydantic>=2.6.0
20
21
  Requires-Dist: pysolar>=0.8.0
21
22
  Requires-Dist: pytz>=2024.1
@@ -111,6 +112,21 @@ kp_cons = KPowerForecast(
111
112
 
112
113
  ---
113
114
 
115
+ ## 🔢 Versioning
116
+
117
+ This project follows a custom **Date-Based Versioning** scheme:
118
+ `YYYY.MM.Patch` (e.g., `2026.2.1`)
119
+
120
+ - **YYYY**: Year of release.
121
+ - **MM**: Month of release (no leading zero, 1-12).
122
+ - **Patch**: Incremental counter for releases within the same month.
123
+
124
+ ### Enforcement
125
+ - **CI Validation**: Every Pull Request is checked against `scripts/validate_version.py` to ensure adherence.
126
+ - **Consistency**: Both `pyproject.toml` and `src/kpower_forecast/__init__.py` must match exactly.
127
+
128
+ ---
129
+
114
130
  ## 🧪 Development & Testing
115
131
 
116
132
  We use [uv](https://github.com/astral-sh/uv) for lightning-fast dependency management.
@@ -81,6 +81,21 @@ kp_cons = KPowerForecast(
81
81
 
82
82
  ---
83
83
 
84
+ ## 🔢 Versioning
85
+
86
+ This project follows a custom **Date-Based Versioning** scheme:
87
+ `YYYY.MM.Patch` (e.g., `2026.2.1`)
88
+
89
+ - **YYYY**: Year of release.
90
+ - **MM**: Month of release (no leading zero, 1-12).
91
+ - **Patch**: Incremental counter for releases within the same month.
92
+
93
+ ### Enforcement
94
+ - **CI Validation**: Every Pull Request is checked against `scripts/validate_version.py` to ensure adherence.
95
+ - **Consistency**: Both `pyproject.toml` and `src/kpower_forecast/__init__.py` must match exactly.
96
+
97
+ ---
98
+
84
99
  ## 🧪 Development & Testing
85
100
 
86
101
  We use [uv](https://github.com/astral-sh/uv) for lightning-fast dependency management.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kpower-forecast"
3
- version = "2026.2.0"
3
+ version = "2026.2.1"
4
4
  description = "Solar production and power consumption forecasting package."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "requests>=2.31.0",
26
26
  "cmdstanpy>=1.2.0",
27
27
  "pytz>=2024.1",
28
+ "pvlib>=0.11.0",
28
29
  ]
29
30
 
30
31
  [project.optional-dependencies]
@@ -0,0 +1,36 @@
1
+ import re
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Regex for YYYY.MM.Patch (e.g., 2026.2.1)
6
+ VERSION_PATTERN = r"^\d{4}\.(?:[1-9]|1[0-2])\.\d+$"
7
+
8
+ def validate_version(file_path: str, pattern: str):
9
+ content = Path(file_path).read_text()
10
+ # Find version in pyproject.toml or __init__.py
11
+ match = re.search(pattern, content, re.MULTILINE)
12
+ if not match:
13
+ print(f"❌ Could not find version in {file_path}")
14
+ return False
15
+
16
+ version = match.group(1)
17
+ if not re.match(VERSION_PATTERN, version):
18
+ print(f"❌ Invalid version format in {file_path}: '{version}'")
19
+ print(" Expected format: YYYY.MM.Patch (e.g., 2026.2.1)")
20
+ return False
21
+
22
+ print(f"✅ Version {version} in {file_path} is valid.")
23
+ return True
24
+
25
+ if __name__ == "__main__":
26
+ success = True
27
+ # Check pyproject.toml
28
+ if not validate_version("pyproject.toml", r'^version\s*=\s*"([^"]+)"'):
29
+ success = False
30
+
31
+ # Check __init__.py
32
+ if not validate_version("src/kpower_forecast/__init__.py", r'^__version__\s*=\s*"([^"]+)"'):
33
+ success = False
34
+
35
+ if not success:
36
+ sys.exit(1)
@@ -1,4 +1,4 @@
1
1
  from .core import KPowerForecast
2
2
 
3
3
  __all__ = ["KPowerForecast"]
4
- __version__ = "2026.2.0"
4
+ __version__ = "2026.2.1"
@@ -0,0 +1,298 @@
1
+ import logging
2
+ from typing import List, Literal
3
+
4
+ import pandas as pd
5
+ from prophet import Prophet
6
+ from prophet.diagnostics import cross_validation, performance_metrics
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
+
9
+ from .storage import ModelStorage
10
+ from .utils import calculate_solar_elevation, get_clear_sky_ghi
11
+ from .weather_client import WeatherClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class PredictionInterval(BaseModel):
16
+ timestamp: pd.Timestamp
17
+ expected_kwh: float
18
+ lower_bound_kwh: float # P10
19
+ upper_bound_kwh: float # P90
20
+
21
+ model_config = ConfigDict(arbitrary_types_allowed=True)
22
+
23
+ class KPowerConfig(BaseModel):
24
+ model_id: str
25
+ latitude: float = Field(..., ge=-90, le=90)
26
+ longitude: float = Field(..., ge=-180, le=180)
27
+ storage_path: str = "./data"
28
+ interval_minutes: int = Field(15)
29
+ forecast_type: Literal["solar", "consumption"] = "solar"
30
+ heat_pump_mode: bool = False
31
+ changepoint_prior_scale: float = 0.05
32
+ seasonality_prior_scale: float = 10.0
33
+
34
+ @field_validator("interval_minutes")
35
+ @classmethod
36
+ def check_interval(cls, v: int) -> int:
37
+ if v not in (15, 60):
38
+ raise ValueError("interval_minutes must be 15 or 60")
39
+ return v
40
+
41
+ model_config = ConfigDict(arbitrary_types_allowed=True)
42
+
43
+ class KPowerForecast:
44
+ def __init__(
45
+ self,
46
+ model_id: str,
47
+ latitude: float,
48
+ longitude: float,
49
+ storage_path: str = "./data",
50
+ interval_minutes: int = 15,
51
+ forecast_type: Literal["solar", "consumption"] = "solar",
52
+ heat_pump_mode: bool = False,
53
+ ):
54
+ self.config = KPowerConfig(
55
+ model_id=model_id,
56
+ latitude=latitude,
57
+ longitude=longitude,
58
+ storage_path=storage_path,
59
+ interval_minutes=interval_minutes,
60
+ forecast_type=forecast_type,
61
+ heat_pump_mode=heat_pump_mode,
62
+ )
63
+
64
+ self.weather_client = WeatherClient(
65
+ lat=self.config.latitude, lon=self.config.longitude
66
+ )
67
+ self.storage = ModelStorage(storage_path=self.config.storage_path)
68
+
69
+ def _prepare_features(self, df: pd.DataFrame) -> pd.DataFrame:
70
+ """
71
+ Add physics-informed features and rolling windows.
72
+ """
73
+ df = df.copy()
74
+ df["ds"] = pd.to_datetime(df["ds"], utc=True)
75
+
76
+ # 1. Physics: Clear Sky GHI
77
+ logger.info("Calculating physics-informed Clear Sky GHI...")
78
+ # Ensure index is DatetimeIndex for pvlib
79
+ temp_df = df.set_index("ds")
80
+ df["clear_sky_ghi"] = get_clear_sky_ghi(
81
+ self.config.latitude, self.config.longitude, temp_df.index
82
+ ).values
83
+
84
+ # 2. Rolling Cloud Cover (3-hour window)
85
+ # 3 hours = 180 minutes. Window depends on interval_minutes.
86
+ window_size = 180 // self.config.interval_minutes
87
+ logger.info(f"Adding rolling cloud cover (window={window_size})...")
88
+ df["rolling_cloud_cover"] = (
89
+ df["cloud_cover"]
90
+ .rolling(window=window_size, min_periods=1)
91
+ .mean()
92
+ )
93
+
94
+ return df
95
+
96
+ def train(self, history_df: pd.DataFrame, force: bool = False):
97
+ """
98
+ Trains the Prophet model using the provided history.
99
+ """
100
+ if not force and self.storage.load_model(self.config.model_id):
101
+ logger.info(
102
+ f"Model {self.config.model_id} already exists. "
103
+ "Use force=True to retrain."
104
+ )
105
+ return
106
+
107
+ df = history_df.copy()
108
+ if "ds" not in df.columns or "y" not in df.columns:
109
+ raise ValueError("history_df must contain 'ds' and 'y' columns")
110
+
111
+ df["ds"] = pd.to_datetime(df["ds"], utc=True)
112
+ df = df.sort_values("ds")
113
+
114
+ start_date = df["ds"].min().date()
115
+ end_date = df["ds"].max().date()
116
+
117
+ weather_df = self.weather_client.fetch_historical(start_date, end_date)
118
+ weather_df = self.weather_client.resample_weather(
119
+ weather_df, self.config.interval_minutes
120
+ )
121
+
122
+ df = pd.merge(df, weather_df, on="ds", how="left")
123
+
124
+ weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
125
+ df[weather_cols] = df[weather_cols].interpolate(method="linear").bfill().ffill()
126
+
127
+ if df[weather_cols].isnull().any().any():
128
+ df = df.dropna(subset=weather_cols)
129
+
130
+ # Feature Engineering
131
+ df = self._prepare_features(df)
132
+
133
+ # Initialize Prophet with tuned hyperparameters
134
+ m = Prophet(
135
+ changepoint_prior_scale=self.config.changepoint_prior_scale,
136
+ seasonality_prior_scale=self.config.seasonality_prior_scale,
137
+ interval_width=0.8, # Used for P10/P90 (80% interval)
138
+ )
139
+
140
+ if self.config.forecast_type == "solar":
141
+ m.add_regressor("temperature_2m")
142
+ m.add_regressor("rolling_cloud_cover")
143
+ m.add_regressor("shortwave_radiation")
144
+ m.add_regressor("clear_sky_ghi")
145
+ elif self.config.forecast_type == "consumption":
146
+ if self.config.heat_pump_mode:
147
+ m.add_regressor("temperature_2m")
148
+
149
+ logger.info(f"Training Prophet model for {self.config.forecast_type}...")
150
+ m.fit(df)
151
+
152
+ self.storage.save_model(m, self.config.model_id)
153
+
154
+ def tune_model(self, history_df: pd.DataFrame, days: int = 30):
155
+ """
156
+ Find optimal hyperparameters using cross-validation.
157
+ """
158
+ logger.info(f"Tuning model hyperparameters using {days} days of history...")
159
+
160
+ # We need to prepare data first as cross_validation needs the regressors
161
+ # This is a bit complex as we need weather data for history_df
162
+ # For simplicity, we assume train() logic but without fitting.
163
+
164
+ # Prepare data (duplicated logic from train, could be refactored)
165
+ df = history_df.copy()
166
+ df["ds"] = pd.to_datetime(df["ds"], utc=True)
167
+ start_date = df["ds"].min().date()
168
+ end_date = df["ds"].max().date()
169
+ weather_df = self.weather_client.fetch_historical(start_date, end_date)
170
+ weather_df = self.weather_client.resample_weather(
171
+ weather_df, self.config.interval_minutes
172
+ )
173
+ df = pd.merge(df, weather_df, on="ds", how="left")
174
+ weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
175
+ df[weather_cols] = df[weather_cols].interpolate(method="linear").bfill().ffill()
176
+ df = self._prepare_features(df.dropna(subset=weather_cols))
177
+
178
+ param_grid = {
179
+ 'changepoint_prior_scale': [0.001, 0.05, 0.5],
180
+ 'seasonality_prior_scale': [0.01, 1.0, 10.0],
181
+ }
182
+
183
+ # Simplified tuning loop
184
+ best_params = {}
185
+ min_rmse = float('inf')
186
+
187
+ for cps in param_grid['changepoint_prior_scale']:
188
+ for sps in param_grid['seasonality_prior_scale']:
189
+ m = Prophet(changepoint_prior_scale=cps, seasonality_prior_scale=sps)
190
+ if self.config.forecast_type == "solar":
191
+ m.add_regressor("temperature_2m")
192
+ m.add_regressor("rolling_cloud_cover")
193
+ m.add_regressor("shortwave_radiation")
194
+ m.add_regressor("clear_sky_ghi")
195
+ elif (
196
+ self.config.forecast_type == "consumption"
197
+ and self.config.heat_pump_mode
198
+ ):
199
+ m.add_regressor("temperature_2m")
200
+
201
+ m.fit(df)
202
+
203
+ # Cross-validation
204
+ # initial should be at least 3x horizon
205
+ df_cv = cross_validation(
206
+ m, initial=f'{days//2} days', period='5 days', horizon='5 days'
207
+ )
208
+ df_p = performance_metrics(df_cv, rolling_window=1)
209
+ rmse = df_p['rmse'].values[0]
210
+
211
+ if rmse < min_rmse:
212
+ min_rmse = rmse
213
+ best_params = {'cps': cps, 'sps': sps}
214
+
215
+ logger.info(f"Best params found: {best_params} with RMSE {min_rmse}")
216
+ self.config.changepoint_prior_scale = best_params['cps']
217
+ self.config.seasonality_prior_scale = best_params['sps']
218
+
219
+ def predict(self, days: int = 7) -> pd.DataFrame:
220
+ """
221
+ Generates forecast for the next 'days' days.
222
+ """
223
+ m = self.storage.load_model(self.config.model_id)
224
+ if m is None:
225
+ raise RuntimeError(
226
+ f"Model {self.config.model_id} not found. Please run train() first."
227
+ )
228
+
229
+ weather_forecast = self.weather_client.fetch_forecast(days=days)
230
+ weather_forecast = self.weather_client.resample_weather(
231
+ weather_forecast, self.config.interval_minutes
232
+ )
233
+
234
+ future = pd.DataFrame({"ds": weather_forecast["ds"]})
235
+ future = pd.merge(future, weather_forecast, on="ds", how="left")
236
+
237
+ weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
238
+ future[weather_cols] = (
239
+ future[weather_cols].interpolate(method="linear").bfill().ffill()
240
+ )
241
+
242
+ # Feature Engineering
243
+ future = self._prepare_features(future)
244
+
245
+ forecast = m.predict(future)
246
+
247
+ # Night Mask & Clipping
248
+ if self.config.forecast_type == "solar":
249
+ logger.info("Applying night mask for solar forecast...")
250
+ elevations = calculate_solar_elevation(
251
+ self.config.latitude, self.config.longitude, forecast["ds"]
252
+ )
253
+ forecast.loc[elevations < 0, ["yhat", "yhat_lower", "yhat_upper"]] = 0
254
+
255
+ for col in ["yhat", "yhat_lower", "yhat_upper"]:
256
+ forecast[col] = forecast[col].clip(lower=0)
257
+
258
+ return forecast
259
+
260
+ def get_prediction_intervals(self, days: int = 7) -> List[PredictionInterval]:
261
+ """
262
+ Returns prediction intervals for EMS.
263
+ """
264
+ forecast = self.predict(days=days)
265
+
266
+ intervals = []
267
+ for _, row in forecast.iterrows():
268
+ intervals.append(PredictionInterval(
269
+ timestamp=row["ds"],
270
+ expected_kwh=row["yhat"],
271
+ lower_bound_kwh=row["yhat_lower"],
272
+ upper_bound_kwh=row["yhat_upper"]
273
+ ))
274
+ return intervals
275
+
276
+ def get_surplus_probability(
277
+ self, threshold_kwh: float, days: int = 7
278
+ ) -> pd.DataFrame:
279
+ """
280
+ Returns probability of exceeding threshold_kwh.
281
+ Prophet doesn't provide direct probabilities, but we can estimate
282
+ from the uncertainty interval (yhat_upper - yhat_lower)
283
+ assuming normal distribution.
284
+ """
285
+ forecast = self.predict(days=days)
286
+
287
+ # Estimate sigma from 80% interval (approx 1.28 * sigma)
288
+ sigma = (forecast["yhat_upper"] - forecast["yhat_lower"]) / (2 * 1.28)
289
+ sigma = sigma.replace(0, 1e-9) # Avoid div by zero
290
+
291
+ from scipy.stats import norm
292
+ z_score = (threshold_kwh - forecast["yhat"]) / sigma
293
+ prob_exceed = 1 - norm.cdf(z_score)
294
+
295
+ return pd.DataFrame({
296
+ "ds": forecast["ds"],
297
+ "surplus_prob": prob_exceed
298
+ })
@@ -3,6 +3,7 @@ from typing import List, Union
3
3
 
4
4
  import numpy as np
5
5
  import pandas as pd
6
+ from pvlib.location import Location
6
7
  from pysolar.solar import get_altitude
7
8
 
8
9
 
@@ -12,13 +13,13 @@ def calculate_solar_elevation(
12
13
  """
13
14
  Calculate solar elevation angles (altitude) for a list of times
14
15
  at a specific location.
15
-
16
+
16
17
  Args:
17
18
  lat: Latitude in decimal degrees.
18
19
  lon: Longitude in decimal degrees.
19
- times: List of datetime objects or pandas DatetimeIndex.
20
+ times: List of datetime objects or pandas DatetimeIndex.
20
21
  Must be timezone-aware or UTC.
21
-
22
+
22
23
  Returns:
23
24
  Numpy array of elevation angles in degrees.
24
25
  """
@@ -28,9 +29,31 @@ def calculate_solar_elevation(
28
29
  if t.tzinfo is None:
29
30
  # Assume UTC if naive, though strictly we should enforce awareness
30
31
  t = t.replace(tzinfo=datetime.timezone.utc)
31
-
32
+
32
33
  # get_altitude returns degrees
33
34
  alt = get_altitude(lat, lon, t)
34
35
  elevations.append(alt)
35
-
36
+
36
37
  return np.array(elevations)
38
+
39
+
40
+ def get_clear_sky_ghi(
41
+ lat: float, lon: float, times: pd.DatetimeIndex
42
+ ) -> pd.Series:
43
+ """
44
+ Calculate Theoretical Clear Sky GHI (Global Horizontal Irradiance)
45
+ using pvlib.
46
+
47
+ Args:
48
+ lat: Latitude.
49
+ lon: Longitude.
50
+ times: Pandas DatetimeIndex (must be timezone aware).
51
+
52
+ Returns:
53
+ Pandas Series of GHI values.
54
+ """
55
+ location = Location(lat, lon)
56
+ # get_clearsky returns GHI, DNI, DHI. We only need GHI.
57
+ # Ineichen is the default model.
58
+ clearsky = location.get_clearsky(times)
59
+ return clearsky["ghi"]
@@ -190,6 +190,33 @@ wheels = [
190
190
  { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
191
191
  ]
192
192
 
193
+ [[package]]
194
+ name = "h5py"
195
+ version = "3.15.1"
196
+ source = { registry = "https://pypi.org/simple" }
197
+ dependencies = [
198
+ { name = "numpy" },
199
+ ]
200
+ sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" }
201
+ wheels = [
202
+ { url = "https://files.pythonhosted.org/packages/88/b3/40207e0192415cbff7ea1d37b9f24b33f6d38a5a2f5d18a678de78f967ae/h5py-3.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8440fd8bee9500c235ecb7aa1917a0389a2adb80c209fa1cc485bd70e0d94a5", size = 3376511, upload-time = "2025-10-16T10:34:38.596Z" },
203
+ { url = "https://files.pythonhosted.org/packages/31/96/ba99a003c763998035b0de4c299598125df5fc6c9ccf834f152ddd60e0fb/h5py-3.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab2219dbc6fcdb6932f76b548e2b16f34a1f52b7666e998157a4dfc02e2c4123", size = 2826143, upload-time = "2025-10-16T10:34:41.342Z" },
204
+ { url = "https://files.pythonhosted.org/packages/6a/c2/fc6375d07ea3962df7afad7d863fe4bde18bb88530678c20d4c90c18de1d/h5py-3.15.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8cb02c3a96255149ed3ac811eeea25b655d959c6dd5ce702c9a95ff11859eb5", size = 4908316, upload-time = "2025-10-16T10:34:44.619Z" },
205
+ { url = "https://files.pythonhosted.org/packages/d9/69/4402ea66272dacc10b298cca18ed73e1c0791ff2ae9ed218d3859f9698ac/h5py-3.15.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:121b2b7a4c1915d63737483b7bff14ef253020f617c2fb2811f67a4bed9ac5e8", size = 5103710, upload-time = "2025-10-16T10:34:48.639Z" },
206
+ { url = "https://files.pythonhosted.org/packages/e0/f6/11f1e2432d57d71322c02a97a5567829a75f223a8c821764a0e71a65cde8/h5py-3.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59b0d63b318bf3cc06687def2b45afd75926bbc006f7b8cd2b1a231299fc8599", size = 4556042, upload-time = "2025-10-16T10:34:51.841Z" },
207
+ { url = "https://files.pythonhosted.org/packages/18/88/3eda3ef16bfe7a7dbc3d8d6836bbaa7986feb5ff091395e140dc13927bcc/h5py-3.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e02fe77a03f652500d8bff288cbf3675f742fc0411f5a628fa37116507dc7cc0", size = 5030639, upload-time = "2025-10-16T10:34:55.257Z" },
208
+ { url = "https://files.pythonhosted.org/packages/e5/ea/fbb258a98863f99befb10ed727152b4ae659f322e1d9c0576f8a62754e81/h5py-3.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:dea78b092fd80a083563ed79a3171258d4a4d307492e7cf8b2313d464c82ba52", size = 2864363, upload-time = "2025-10-16T10:34:58.099Z" },
209
+ { url = "https://files.pythonhosted.org/packages/5d/c9/35021cc9cd2b2915a7da3026e3d77a05bed1144a414ff840953b33937fb9/h5py-3.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:c256254a8a81e2bddc0d376e23e2a6d2dc8a1e8a2261835ed8c1281a0744cd97", size = 2449570, upload-time = "2025-10-16T10:35:00.473Z" },
210
+ { url = "https://files.pythonhosted.org/packages/a0/2c/926eba1514e4d2e47d0e9eb16c784e717d8b066398ccfca9b283917b1bfb/h5py-3.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f4fb0567eb8517c3ecd6b3c02c4f4e9da220c8932604960fd04e24ee1254763", size = 3380368, upload-time = "2025-10-16T10:35:03.117Z" },
211
+ { url = "https://files.pythonhosted.org/packages/65/4b/d715ed454d3baa5f6ae1d30b7eca4c7a1c1084f6a2edead9e801a1541d62/h5py-3.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:954e480433e82d3872503104f9b285d369048c3a788b2b1a00e53d1c47c98dd2", size = 2833793, upload-time = "2025-10-16T10:35:05.623Z" },
212
+ { url = "https://files.pythonhosted.org/packages/ef/d4/ef386c28e4579314610a8bffebbee3b69295b0237bc967340b7c653c6c10/h5py-3.15.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd125c131889ebbef0849f4a0e29cf363b48aba42f228d08b4079913b576bb3a", size = 4903199, upload-time = "2025-10-16T10:35:08.972Z" },
213
+ { url = "https://files.pythonhosted.org/packages/33/5d/65c619e195e0b5e54ea5a95c1bb600c8ff8715e0d09676e4cce56d89f492/h5py-3.15.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28a20e1a4082a479b3d7db2169f3a5034af010b90842e75ebbf2e9e49eb4183e", size = 5097224, upload-time = "2025-10-16T10:35:12.808Z" },
214
+ { url = "https://files.pythonhosted.org/packages/30/30/5273218400bf2da01609e1292f562c94b461fcb73c7a9e27fdadd43abc0a/h5py-3.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8df5267f545b4946df8ca0d93d23382191018e4cda2deda4c2cedf9a010e13", size = 4551207, upload-time = "2025-10-16T10:35:16.24Z" },
215
+ { url = "https://files.pythonhosted.org/packages/d3/39/a7ef948ddf4d1c556b0b2b9559534777bccc318543b3f5a1efdf6b556c9c/h5py-3.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99d374a21f7321a4c6ab327c4ab23bd925ad69821aeb53a1e75dd809d19f67fa", size = 5025426, upload-time = "2025-10-16T10:35:19.831Z" },
216
+ { url = "https://files.pythonhosted.org/packages/b6/d8/7368679b8df6925b8415f9dcc9ab1dab01ddc384d2b2c24aac9191bd9ceb/h5py-3.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:9c73d1d7cdb97d5b17ae385153472ce118bed607e43be11e9a9deefaa54e0734", size = 2865704, upload-time = "2025-10-16T10:35:22.658Z" },
217
+ { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" },
218
+ ]
219
+
193
220
  [[package]]
194
221
  name = "holidays"
195
222
  version = "0.90"
@@ -297,6 +324,7 @@ dependencies = [
297
324
  { name = "numpy" },
298
325
  { name = "pandas" },
299
326
  { name = "prophet" },
327
+ { name = "pvlib" },
300
328
  { name = "pydantic" },
301
329
  { name = "pysolar" },
302
330
  { name = "pytz" },
@@ -319,6 +347,7 @@ requires-dist = [
319
347
  { name = "numpy", specifier = ">=1.26.0" },
320
348
  { name = "pandas", specifier = ">=2.2.0" },
321
349
  { name = "prophet", specifier = ">=1.1.5" },
350
+ { name = "pvlib", specifier = ">=0.11.0" },
322
351
  { name = "pydantic", specifier = ">=2.6.0" },
323
352
  { name = "pysolar", specifier = ">=0.8.0" },
324
353
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
@@ -655,6 +684,23 @@ wheels = [
655
684
  { url = "https://files.pythonhosted.org/packages/d9/9c/af59d9f30e9e72f2b36bd578a47a84c8fbf7f72c2d9771ef40ef16fb36eb/prophet-1.3.0-py3-none-win_amd64.whl", hash = "sha256:6790f9f06cc9e76d2f21c501f5889cf37f75917fba6859e196889dfde4bfec3b", size = 12109233, upload-time = "2026-01-27T22:47:05.284Z" },
656
685
  ]
657
686
 
687
+ [[package]]
688
+ name = "pvlib"
689
+ version = "0.15.0"
690
+ source = { registry = "https://pypi.org/simple" }
691
+ dependencies = [
692
+ { name = "h5py" },
693
+ { name = "numpy" },
694
+ { name = "pandas" },
695
+ { name = "pytz" },
696
+ { name = "requests" },
697
+ { name = "scipy" },
698
+ ]
699
+ sdist = { url = "https://files.pythonhosted.org/packages/79/6c/68a9c8977a3d22adedc4dc49edb7803cadc5b947f1e2d47d81a71d1a0da6/pvlib-0.15.0.tar.gz", hash = "sha256:6baed67485f9765484edfcdab0267b865165cb12ff40f842e943bea9090d378b", size = 38684749, upload-time = "2026-02-03T21:12:27.744Z" }
700
+ wheels = [
701
+ { url = "https://files.pythonhosted.org/packages/33/f0/7f5bcda6df968f1c8d178920c0202f844898d607407de5e1524443e890c4/pvlib-0.15.0-py3-none-any.whl", hash = "sha256:d40aed5188b75e2ac21299af506a91ea1fac013d837caa73e20e27dd49239c5a", size = 19352428, upload-time = "2026-02-03T21:12:24.856Z" },
702
+ ]
703
+
658
704
  [[package]]
659
705
  name = "pydantic"
660
706
  version = "2.12.5"
@@ -830,6 +876,57 @@ wheels = [
830
876
  { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
831
877
  ]
832
878
 
879
+ [[package]]
880
+ name = "scipy"
881
+ version = "1.17.0"
882
+ source = { registry = "https://pypi.org/simple" }
883
+ dependencies = [
884
+ { name = "numpy" },
885
+ ]
886
+ sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" }
887
+ wheels = [
888
+ { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" },
889
+ { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" },
890
+ { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" },
891
+ { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" },
892
+ { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" },
893
+ { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" },
894
+ { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" },
895
+ { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" },
896
+ { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" },
897
+ { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" },
898
+ { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" },
899
+ { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" },
900
+ { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" },
901
+ { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" },
902
+ { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" },
903
+ { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" },
904
+ { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" },
905
+ { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" },
906
+ { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" },
907
+ { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" },
908
+ { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" },
909
+ { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" },
910
+ { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" },
911
+ { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" },
912
+ { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" },
913
+ { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" },
914
+ { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" },
915
+ { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" },
916
+ { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" },
917
+ { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" },
918
+ { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" },
919
+ { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" },
920
+ { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" },
921
+ { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" },
922
+ { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" },
923
+ { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" },
924
+ { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" },
925
+ { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" },
926
+ { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" },
927
+ { 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
+ ]
929
+
833
930
  [[package]]
834
931
  name = "six"
835
932
  version = "1.17.0"
@@ -1,142 +0,0 @@
1
- import logging
2
- from typing import Literal
3
-
4
- import pandas as pd
5
- from prophet import Prophet
6
- from pydantic import BaseModel, ConfigDict, Field, field_validator
7
-
8
- from .storage import ModelStorage
9
- from .utils import calculate_solar_elevation
10
- from .weather_client import WeatherClient
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
- class KPowerConfig(BaseModel):
15
- model_id: str
16
- latitude: float = Field(..., ge=-90, le=90)
17
- longitude: float = Field(..., ge=-180, le=180)
18
- storage_path: str = "./data"
19
- interval_minutes: int = Field(15)
20
- forecast_type: Literal["solar", "consumption"] = "solar"
21
- heat_pump_mode: bool = False
22
-
23
- @field_validator("interval_minutes")
24
- @classmethod
25
- def check_interval(cls, v: int) -> int:
26
- if v not in (15, 60):
27
- raise ValueError("interval_minutes must be 15 or 60")
28
- return v
29
-
30
- model_config = ConfigDict(arbitrary_types_allowed=True)
31
-
32
- class KPowerForecast:
33
- def __init__(
34
- self,
35
- model_id: str,
36
- latitude: float,
37
- longitude: float,
38
- storage_path: str = "./data",
39
- interval_minutes: int = 15,
40
- forecast_type: Literal["solar", "consumption"] = "solar",
41
- heat_pump_mode: bool = False,
42
- ):
43
- self.config = KPowerConfig(
44
- model_id=model_id,
45
- latitude=latitude,
46
- longitude=longitude,
47
- storage_path=storage_path,
48
- interval_minutes=interval_minutes,
49
- forecast_type=forecast_type,
50
- heat_pump_mode=heat_pump_mode,
51
- )
52
-
53
- self.weather_client = WeatherClient(
54
- lat=self.config.latitude, lon=self.config.longitude
55
- )
56
- self.storage = ModelStorage(storage_path=self.config.storage_path)
57
-
58
- def train(self, history_df: pd.DataFrame, force: bool = False):
59
- """
60
- Trains the Prophet model using the provided history.
61
- """
62
- if not force and self.storage.load_model(self.config.model_id):
63
- logger.info(
64
- f"Model {self.config.model_id} already exists. "
65
- "Use force=True to retrain."
66
- )
67
- return
68
-
69
- df = history_df.copy()
70
- if "ds" not in df.columns or "y" not in df.columns:
71
- raise ValueError("history_df must contain 'ds' and 'y' columns")
72
-
73
- df["ds"] = pd.to_datetime(df["ds"], utc=True)
74
- df = df.sort_values("ds")
75
-
76
- start_date = df["ds"].min().date()
77
- end_date = df["ds"].max().date()
78
-
79
- weather_df = self.weather_client.fetch_historical(start_date, end_date)
80
- weather_df = self.weather_client.resample_weather(
81
- weather_df, self.config.interval_minutes
82
- )
83
-
84
- df = pd.merge(df, weather_df, on="ds", how="left")
85
-
86
- weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
87
- df[weather_cols] = df[weather_cols].interpolate(method="linear").bfill().ffill()
88
-
89
- if df[weather_cols].isnull().any().any():
90
- df = df.dropna(subset=weather_cols)
91
-
92
- m = Prophet()
93
-
94
- if self.config.forecast_type == "solar":
95
- m.add_regressor("temperature_2m")
96
- m.add_regressor("cloud_cover")
97
- m.add_regressor("shortwave_radiation")
98
- elif self.config.forecast_type == "consumption":
99
- if self.config.heat_pump_mode:
100
- m.add_regressor("temperature_2m")
101
-
102
- logger.info(f"Training Prophet model for {self.config.forecast_type}...")
103
- m.fit(df)
104
-
105
- self.storage.save_model(m, self.config.model_id)
106
-
107
- def predict(self, days: int = 7) -> pd.DataFrame:
108
- """
109
- Generates forecast for the next 'days' days.
110
- """
111
- m = self.storage.load_model(self.config.model_id)
112
- if m is None:
113
- raise RuntimeError(
114
- f"Model {self.config.model_id} not found. Please run train() first."
115
- )
116
-
117
- weather_forecast = self.weather_client.fetch_forecast(days=days)
118
- weather_forecast = self.weather_client.resample_weather(
119
- weather_forecast, self.config.interval_minutes
120
- )
121
-
122
- future = pd.DataFrame({"ds": weather_forecast["ds"]})
123
- future = pd.merge(future, weather_forecast, on="ds", how="left")
124
-
125
- weather_cols = ["temperature_2m", "cloud_cover", "shortwave_radiation"]
126
- future[weather_cols] = (
127
- future[weather_cols].interpolate(method="linear").bfill().ffill()
128
- )
129
-
130
- forecast = m.predict(future)
131
- result = forecast[["ds", "yhat"]].copy()
132
-
133
- if self.config.forecast_type == "solar":
134
- logger.info("Applying night mask for solar forecast...")
135
- elevations = calculate_solar_elevation(
136
- self.config.latitude, self.config.longitude, result["ds"]
137
- )
138
- result.loc[elevations < 0, "yhat"] = 0
139
-
140
- result["yhat"] = result["yhat"].clip(lower=0)
141
-
142
- return result