mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-15 18:41:57 +00:00
feat: complete 15-minute optimization support
This commit is contained in:
@@ -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))
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user