c8e3016fd6
- Implement `sigen_daemon.py` to poll Sigenergy plant metrics and store snapshots. - Create `web_daemon.py` for serving a web interface with various endpoints. - Add debug scripts: - `debug_duplicates.py` to find duplicate target times in forecast data. - `debug_energy_forecast.py` to print baseline energy forecast curves. - `debug_oracle_evaluations.py` to run the oracle evaluator. - `debug_sigen.py` to inspect stored Sigenergy plant snapshots. - `debug_weather.py` to trace resolved truth data. - `modbus_test.py` for exploring Sigenergy plants or inverters over Modbus TCP. - Introduce `oracle_evaluator.py` for evaluating stored oracle predictions against actuals. - Add TCN training scripts in `tcn` directory for training usage sequence models.
189 lines
6.8 KiB
Python
189 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from gibil.classes.models import ForecastKind, PowerForecastPoint, PowerForecastRun
|
|
from gibil.classes.oracle.config import EnergyForecastConfig
|
|
from gibil.classes.sigen.store import SigenStore
|
|
|
|
|
|
class DailyUsageOracle:
|
|
"""Forecasts load from time-of-day history blended with recent load."""
|
|
|
|
model_version = "daily_usage_profile_v1"
|
|
|
|
def __init__(
|
|
self,
|
|
sigen_store: SigenStore,
|
|
config: EnergyForecastConfig,
|
|
) -> None:
|
|
self.sigen_store = sigen_store
|
|
self.config = config
|
|
|
|
def forecast(
|
|
self,
|
|
target_times: list[datetime],
|
|
issued_at: datetime | None = None,
|
|
) -> PowerForecastRun:
|
|
if issued_at is None:
|
|
issued_at = datetime.now(timezone.utc)
|
|
|
|
recent_summary = self.sigen_store.load_recent_power_summary(
|
|
lookback=timedelta(minutes=self.config.load_lookback_minutes)
|
|
)
|
|
profile = self._daily_profile()
|
|
latest = self.sigen_store.load_latest_snapshot()
|
|
fallback_load_w = latest.load_power_w if latest else 0.0
|
|
recent_p50 = self._number(recent_summary.get("load_p50_w"), fallback_load_w)
|
|
recent_p10 = self._number(recent_summary.get("load_p10_w"), recent_p50 * 0.7)
|
|
recent_p90 = self._number(recent_summary.get("load_p90_w"), recent_p50 * 1.5)
|
|
blend = min(max(self.config.load_recent_blend, 0.0), 1.0)
|
|
|
|
points = [
|
|
self._forecast_point(
|
|
target_at=target_at,
|
|
issued_at=issued_at,
|
|
profile=profile,
|
|
recent_p10=recent_p10,
|
|
recent_p50=recent_p50,
|
|
recent_p90=recent_p90,
|
|
blend=blend,
|
|
)
|
|
for target_at in target_times
|
|
]
|
|
|
|
return PowerForecastRun(
|
|
issued_at=issued_at,
|
|
kind=ForecastKind.LOAD,
|
|
source="daily_usage_oracle",
|
|
model_version=self.model_version,
|
|
points=points,
|
|
)
|
|
|
|
def _daily_profile(self) -> dict[int, dict[str, float | int]]:
|
|
weekly_profile = self.sigen_store.load_load_profile(
|
|
lookback=timedelta(days=self.config.load_profile_days),
|
|
bucket_minutes=self.config.load_profile_bucket_minutes,
|
|
min_samples=self.config.load_profile_min_samples,
|
|
timezone_name=self._local_timezone_name(),
|
|
)
|
|
grouped: dict[int, list[dict[str, float | int]]] = {}
|
|
for (_iso_dow, minute_bucket), values in weekly_profile.items():
|
|
grouped.setdefault(minute_bucket, []).append(values)
|
|
|
|
return {
|
|
minute_bucket: self._weighted_profile(values)
|
|
for minute_bucket, values in grouped.items()
|
|
}
|
|
|
|
def _weighted_profile(
|
|
self,
|
|
values: list[dict[str, float | int]],
|
|
) -> dict[str, float | int]:
|
|
total_samples = sum(int(value["sample_count"]) for value in values)
|
|
if total_samples <= 0:
|
|
total_samples = len(values)
|
|
|
|
return {
|
|
"p10": self._weighted_average(values, "p10", total_samples),
|
|
"p50": self._weighted_average(values, "p50", total_samples),
|
|
"p90": self._weighted_average(values, "p90", total_samples),
|
|
"avg_load_power_w": self._weighted_average(
|
|
values,
|
|
"avg_load_power_w",
|
|
total_samples,
|
|
),
|
|
"max_load_power_w": max(float(value["max_load_power_w"]) for value in values),
|
|
"sample_count": total_samples,
|
|
"weekday_bucket_count": len(values),
|
|
}
|
|
|
|
def _weighted_average(
|
|
self,
|
|
values: list[dict[str, float | int]],
|
|
key: str,
|
|
total_samples: int,
|
|
) -> float:
|
|
return sum(
|
|
float(value[key]) * int(value["sample_count"])
|
|
for value in values
|
|
) / total_samples
|
|
|
|
def _forecast_point(
|
|
self,
|
|
target_at: datetime,
|
|
issued_at: datetime,
|
|
profile: dict[int, dict[str, float | int]],
|
|
recent_p10: float,
|
|
recent_p50: float,
|
|
recent_p90: float,
|
|
blend: float,
|
|
) -> PowerForecastPoint:
|
|
profile_key = self._profile_key(target_at)
|
|
profile_values = profile.get(profile_key)
|
|
|
|
if profile_values is None:
|
|
p10 = max(0.0, recent_p10)
|
|
p50 = max(0.0, recent_p50)
|
|
p90 = max(p50 * 1.25, recent_p90)
|
|
confidence = 0.25
|
|
sample_count = 0
|
|
weekday_bucket_count = 0
|
|
else:
|
|
p10 = self._blend(float(profile_values["p10"]), recent_p10, blend)
|
|
p50 = self._blend(float(profile_values["p50"]), recent_p50, blend)
|
|
p90 = self._blend(float(profile_values["p90"]), recent_p90, blend)
|
|
p10 = max(0.0, min(p10, p50))
|
|
p90 = max(p90, p50 * 1.15)
|
|
sample_count = int(profile_values["sample_count"])
|
|
weekday_bucket_count = int(profile_values["weekday_bucket_count"])
|
|
confidence = min(0.65, 0.35 + sample_count / 750.0)
|
|
|
|
return PowerForecastPoint(
|
|
target_at=target_at,
|
|
horizon_minutes=max(
|
|
0, round((target_at - issued_at).total_seconds() / 60)
|
|
),
|
|
expected_power_w=p50,
|
|
p10_power_w=p10,
|
|
p50_power_w=p50,
|
|
p90_power_w=p90,
|
|
confidence=confidence,
|
|
source="time_of_day_load_profile",
|
|
model_version=self.model_version,
|
|
metadata={
|
|
"profile_key": profile_key,
|
|
"profile_sample_count": sample_count,
|
|
"weekday_bucket_count": weekday_bucket_count,
|
|
"recent_blend": blend,
|
|
"lookback_days": self.config.load_profile_days,
|
|
"bucket_minutes": self.config.load_profile_bucket_minutes,
|
|
},
|
|
)
|
|
|
|
def _profile_key(self, target_at: datetime) -> int:
|
|
local = target_at.astimezone(self._local_timezone())
|
|
minute_of_day = local.hour * 60 + local.minute
|
|
return (
|
|
minute_of_day // self.config.load_profile_bucket_minutes
|
|
) * self.config.load_profile_bucket_minutes
|
|
|
|
def _local_timezone(self) -> ZoneInfo:
|
|
return ZoneInfo(self._local_timezone_name())
|
|
|
|
def _local_timezone_name(self) -> str:
|
|
try:
|
|
ZoneInfo(self.config.local_timezone)
|
|
except ZoneInfoNotFoundError:
|
|
return "UTC"
|
|
return self.config.local_timezone
|
|
|
|
def _blend(self, profile_value: float, recent_value: float, blend: float) -> float:
|
|
return profile_value * (1.0 - blend) + recent_value * blend
|
|
|
|
def _number(self, value: object, fallback: float) -> float:
|
|
if value is None:
|
|
return float(fallback)
|
|
return float(value)
|