feat: complete 15-minute optimization support

This commit is contained in:
Andreas
2026-07-14 17:00:07 +02:00
parent 81a36cf355
commit 92a8a093e8
31 changed files with 1812 additions and 1032 deletions
@@ -176,6 +176,19 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
return series_data
@staticmethod
def _resolution_seconds(series: pd.Series) -> int:
"""Infer the current native market interval from recent timestamps."""
if len(series) < 2:
return 3600
index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds()
deltas = deltas[deltas > 0].tail(96)
if deltas.empty:
return 3600
resolution = int(round(float(deltas.median())))
return resolution if resolution > 0 and 3600 % resolution == 0 else 3600
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
@@ -250,37 +263,64 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
)
# Generate history array for prediction
history = self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
amount_datasets = len(self.records)
if not self.highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
raw_series = self.key_to_series(
key="elecprice_marketprice_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = self._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
history = self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
if needed_hours <= 0:
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
covered_slots = 0
if self.highest_orig_datetime >= self.ems_start_datetime:
covered_slots = (
int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
+ 1
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.ems_start_datetime}"
"No prediction needed. needed_slots={}, hours={}, resolution_seconds={}, "
"highest_orig_datetime={}, start_datetime={}",
needed_slots,
self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime,
self.ems_start_datetime,
) # this might keep data longer than self.ems_start_datetime + self.config.prediction.hours in the records
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, hours=needed_hours)
weekly_history_slots = 800 * slots_per_hour
daily_history_slots = 168 * slots_per_hour
if len(history) > weekly_history_slots:
prediction = self._predict_ets(
history,
seasonal_periods=168 * slots_per_hour,
hours=needed_slots,
)
elif len(history) > daily_history_slots:
prediction = self._predict_ets(
history,
seasonal_periods=24 * slots_per_hour,
hours=needed_slots,
)
elif len(history) > 0:
prediction = self._predict_median(history, hours=needed_slots)
else:
logger.error("No data available for prediction")
raise ValueError("No data available")
@@ -289,7 +329,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
for i in range(len(prediction))
],
)
+66 -12
View File
@@ -34,7 +34,40 @@ query TibberPriceInfo {
total
}
}
priceInfoRange(resolution: QUARTER_HOURLY, last: 960) {
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
nodes {
startsAt
total
}
}
}
}
}
}
"""
# Same query, but requesting priceInfo (and therefore its today/tomorrow
# fields) at quarter-hourly resolution. Tibber defines ``resolution`` on
# Subscription.priceInfo, not on the nested PriceInfo.today/tomorrow fields.
# Tried first; on a GraphQL schema error from an older API the provider falls
# back to TIBBER_PRICE_QUERY.
TIBBER_PRICE_QUERY_QUARTER_HOURLY = """
query TibberPriceInfo {
viewer {
homes {
id
currentSubscription {
priceInfo(resolution: QUARTER_HOURLY) {
today {
startsAt
total
}
tomorrow {
startsAt
total
}
}
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
nodes {
startsAt
total
@@ -185,17 +218,38 @@ class ElecPriceTibber(ElecPriceProvider):
if not access_token:
raise ValueError("Tibber access_token is required")
response = requests.post(
TIBBER_GRAPHQL_URL,
json={"query": TIBBER_PRICE_QUERY},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=30,
)
logger.debug(f"Response from Tibber GraphQL API: {response}")
response.raise_for_status()
# Prefer quarter-hourly today/tomorrow prices; fall back to the hourly
# query when the Tibber API rejects the resolution argument. Tibber
# signals schema errors either as HTTP 400 or as HTTP 200 with an
# "errors" array, so both must route to the fallback (raise_for_status
# must NOT run before the fallback check).
response = None
queries = (TIBBER_PRICE_QUERY_QUARTER_HOURLY, TIBBER_PRICE_QUERY)
for attempt, query in enumerate(queries, start=1):
response = requests.post(
TIBBER_GRAPHQL_URL,
json={"query": query},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=30,
)
logger.debug(f"Response from Tibber GraphQL API: {response}")
if response.ok and b'"errors"' not in response.content:
break
if attempt < len(queries):
logger.info(
"Tibber rejected the quarter-hourly priceInfo query "
"(HTTP {}): {} - falling back to hourly today/tomorrow prices.",
response.status_code,
response.text[:300],
)
else:
# Final (hourly) attempt failed for real - surface the error.
response.raise_for_status()
if response is None: # pragma: no cover - the query tuple is never empty
raise RuntimeError("No Tibber GraphQL query was attempted")
tibber_data = self._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return tibber_data
@@ -85,15 +85,18 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
return series_data
def _predict_prices(self, history, hours: int):
def _predict_prices(self, history, slots: int, slots_per_hour: int):
energycharts = ElecPriceEnergyCharts()
amount_datasets = len(self.records)
if amount_datasets > 800:
return energycharts._predict_ets(history, seasonal_periods=168, hours=hours)
if amount_datasets > 168:
return energycharts._predict_ets(history, seasonal_periods=24, hours=hours)
if amount_datasets > 0:
return energycharts._predict_median(history, hours=hours)
if len(history) > 800 * slots_per_hour:
return energycharts._predict_ets(
history, seasonal_periods=168 * slots_per_hour, hours=slots
)
if len(history) > 168 * slots_per_hour:
return energycharts._predict_ets(
history, seasonal_periods=24 * slots_per_hour, hours=slots
)
if len(history) > 0:
return energycharts._predict_median(history, hours=slots)
logger.error("No feed-in tariff data available for Energy-Charts prediction")
raise ValueError("No data available")
@@ -141,38 +144,52 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
self.highest_orig_datetime,
)
history = self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
if not self.highest_orig_datetime:
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
raw_series = self.key_to_series(
key="feed_in_tariff_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = ElecPriceEnergyCharts._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
history = self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
if needed_hours <= 0:
covered_slots = 0
if self.highest_orig_datetime >= self.ems_start_datetime:
covered_slots = (
int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
+ 1
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
logger.warning(
"No feed-in tariff prediction needed. needed_hours={}, hours={}, "
"highest_orig_datetime={}, start_datetime={}",
needed_hours,
"No feed-in tariff prediction needed. needed_slots={}, hours={}, "
"resolution_seconds={}, highest_orig_datetime={}, start_datetime={}",
needed_slots,
self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime,
self.ems_start_datetime,
)
return
prediction = self._predict_prices(history, needed_hours)
prediction = self._predict_prices(history, needed_slots, slots_per_hour)
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
for i in range(len(prediction))
],
)
+22 -9
View File
@@ -15,31 +15,44 @@ class SelfConsumptionProbabilityInterpolator:
# Load the RegularGridInterpolator
with open(self.filepath, "rb") as file:
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
self.load_power_min_w = float(self.interpolator.grid[0][0])
self.load_power_max_w = float(self.interpolator.grid[0][-1])
self.minute_load_max_w = float(self.interpolator.grid[1][-1])
def _generate_points(
self, load_1h_power: float, pv_power: float
self, mean_load_power_w: float, pv_power_w: float
) -> tuple[np.ndarray, np.ndarray]:
"""Generate the grid points for interpolation."""
partial_loads = np.arange(0, pv_power + 50, 50)
points = np.array([np.full_like(partial_loads, load_1h_power), partial_loads]).T
"""Generate in-bounds grid points for interpolation.
The bundled probability table was calibrated from a one-hour mean load
and one-minute samples. Sub-hourly optimization still passes *power* in
watts here; a native 15-minute mean is therefore a documented
approximation until a separately calibrated table is available.
"""
bounded_mean_load_w = float(
np.clip(mean_load_power_w, self.load_power_min_w, self.load_power_max_w)
)
bounded_pv_power_w = float(np.clip(pv_power_w, 0.0, self.minute_load_max_w))
partial_loads = np.arange(0.0, bounded_pv_power_w + 1.0, 50.0)
points = np.column_stack((np.full(partial_loads.shape, bounded_mean_load_w), partial_loads))
return points, partial_loads
@cache_energy_management
def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
def calculate_self_consumption(self, mean_load_power_w: float, pv_power_w: float) -> float:
"""Calculate the PV self-consumption rate using RegularGridInterpolator.
The results are cached until the start of the next energy management run/ optimization.
Args:
- last_1h_power: 1h power levels (W).
- pv_power: Current PV power output (W).
- mean_load_power_w: Mean load power for the current forecast interval (W).
- pv_power_w: Current PV power output (W).
Returns:
- Self-consumption rate as a float.
"""
points, partial_loads = self._generate_points(load_1h_power, pv_power)
points, _ = self._generate_points(mean_load_power_w, pv_power_w)
probabilities = self.interpolator(points)
return probabilities.sum()
return float(np.clip(probabilities.sum(), 0.0, 1.0))
# def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
# """Calculate the PV self-consumption rate using RegularGridInterpolator.
+27 -30
View File
@@ -82,7 +82,6 @@ elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_tibber = ElecPriceTibber()
elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport()
@@ -102,33 +101,34 @@ weather_openmeteo = WeatherOpenMeteo()
weather_import = WeatherImport()
def prediction_providers() -> list[
Union[
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
LoadImport,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastPVNode,
PVForecastForecastSolar,
PVForecastSolcast,
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
def prediction_providers() -> (
list[
Union[
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
LoadImport,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastPVNode,
PVForecastForecastSolar,
PVForecastSolcast,
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
]
]
]:
):
"""Return list of prediction providers.
Factory for prediction container.
@@ -139,7 +139,6 @@ def prediction_providers() -> list[
elecprice_tibber, \
elecprice_fixed, \
elecprice_import, \
elecprice_tibber, \
feedintariff_energy_charts, \
feedintariff_fixed, \
feedintariff_import, \
@@ -165,7 +164,6 @@ def prediction_providers() -> list[
elecprice_tibber,
elecprice_fixed,
elecprice_import,
elecprice_tibber,
feedintariff_energy_charts,
feedintariff_fixed,
feedintariff_import,
@@ -196,7 +194,6 @@ class Prediction(PredictionContainer):
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,