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.
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/workflows/ci.yml +4 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/PKG-INFO +17 -1
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/README.md +15 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/pyproject.toml +2 -1
- kpower_forecast-2026.2.1/scripts/validate_version.py +36 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/__init__.py +1 -1
- kpower_forecast-2026.2.1/src/kpower_forecast/core.py +298 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/utils.py +28 -5
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/uv.lock +97 -0
- kpower_forecast-2026.2.0/src/kpower_forecast/core.py +0 -142
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/pull_request_template.md +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/workflows/deploy.yml +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.gitignore +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.python-version +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/AGENTS.md +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/GEMINI.md +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/LICENSE +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/storage.py +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/src/kpower_forecast/weather_client.py +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/tests/test_core.py +0 -0
- {kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kpower-forecast
|
|
3
|
-
Version: 2026.2.
|
|
3
|
+
Version: 2026.2.1
|
|
4
4
|
Summary: Solar production and power consumption forecasting package.
|
|
5
5
|
Author-email: KPower Team <info@kpower.example>
|
|
6
6
|
License: AGPL-3.0
|
|
@@ -16,6 +16,7 @@ Requires-Dist: cmdstanpy>=1.2.0
|
|
|
16
16
|
Requires-Dist: numpy>=1.26.0
|
|
17
17
|
Requires-Dist: pandas>=2.2.0
|
|
18
18
|
Requires-Dist: prophet>=1.1.5
|
|
19
|
+
Requires-Dist: pvlib>=0.11.0
|
|
19
20
|
Requires-Dist: pydantic>=2.6.0
|
|
20
21
|
Requires-Dist: pysolar>=0.8.0
|
|
21
22
|
Requires-Dist: pytz>=2024.1
|
|
@@ -111,6 +112,21 @@ kp_cons = KPowerForecast(
|
|
|
111
112
|
|
|
112
113
|
---
|
|
113
114
|
|
|
115
|
+
## 🔢 Versioning
|
|
116
|
+
|
|
117
|
+
This project follows a custom **Date-Based Versioning** scheme:
|
|
118
|
+
`YYYY.MM.Patch` (e.g., `2026.2.1`)
|
|
119
|
+
|
|
120
|
+
- **YYYY**: Year of release.
|
|
121
|
+
- **MM**: Month of release (no leading zero, 1-12).
|
|
122
|
+
- **Patch**: Incremental counter for releases within the same month.
|
|
123
|
+
|
|
124
|
+
### Enforcement
|
|
125
|
+
- **CI Validation**: Every Pull Request is checked against `scripts/validate_version.py` to ensure adherence.
|
|
126
|
+
- **Consistency**: Both `pyproject.toml` and `src/kpower_forecast/__init__.py` must match exactly.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
114
130
|
## 🧪 Development & Testing
|
|
115
131
|
|
|
116
132
|
We use [uv](https://github.com/astral-sh/uv) for lightning-fast dependency management.
|
|
@@ -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.
|
|
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)
|
|
@@ -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
|
|
File without changes
|
{kpower_forecast-2026.2.0 → kpower_forecast-2026.2.1}/.github/ISSUE_TEMPLATE/feature_request.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|