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)