from __future__ import annotations from datetime import datetime, timezone from gibil.classes.models import NetPowerForecastRun, PowerForecastPoint, PowerForecastRun from gibil.classes.oracle.config import EnergyForecastConfig from gibil.classes.predictors.net_forecaster import NetPowerForecaster from gibil.classes.predictors.solar_rolling_regression import RollingSolarRegressionOracle from gibil.classes.predictors.usage_daily import DailyUsageOracle from gibil.classes.sigen.store import SigenStore from gibil.classes.weather.store import WeatherStore class EnergyOracleBuilder: """Builds production, load, and net oracle curves.""" def __init__( self, weather_store: WeatherStore, sigen_store: SigenStore, config: EnergyForecastConfig, ) -> None: self.weather_store = weather_store self.sigen_store = sigen_store self.config = config @classmethod def from_env(cls) -> "EnergyOracleBuilder": return cls( weather_store=WeatherStore.from_env(), sigen_store=SigenStore.from_env(), config=EnergyForecastConfig.from_env(), ) def build(self) -> tuple[PowerForecastRun, PowerForecastRun, NetPowerForecastRun]: issued_at = datetime.now(timezone.utc) hourly_solar_run = RollingSolarRegressionOracle( weather_store=self.weather_store, sigen_store=self.sigen_store, config=self.config, ).forecast(issued_at=issued_at) solar_run = self._resample_power_run( hourly_solar_run, issued_at=issued_at, step_minutes=self.config.oracle_step_minutes, ) load_run = DailyUsageOracle( sigen_store=self.sigen_store, config=self.config, ).forecast( target_times=[point.target_at for point in solar_run.points], issued_at=issued_at, ) net_run = NetPowerForecaster().combine(solar_run, load_run) return solar_run, load_run, net_run def _resample_power_run( self, run: PowerForecastRun, issued_at: datetime, step_minutes: int, ) -> PowerForecastRun: if step_minutes <= 0 or len(run.points) < 2: return run points = sorted(run.points, key=lambda point: point.target_at) end_at = min( points[-1].target_at, issued_at + self._timedelta_hours(self.config.horizon_hours), ) target_at = self._ceil_time(issued_at, step_minutes) sampled_points: list[PowerForecastPoint] = [] while target_at <= end_at: point = self._interpolate_power_point(points, target_at, issued_at) if point is not None: sampled_points.append(point) target_at += self._timedelta_minutes(step_minutes) current_point = self._current_power_point(points, issued_at) if current_point is not None: sampled_points.insert(0, current_point) if not sampled_points: return run return PowerForecastRun( issued_at=run.issued_at, kind=run.kind, source=run.source, model_version=f"{run.model_version}_sampled_{step_minutes}m", points=sampled_points, ) def _interpolate_power_point( self, points: list[PowerForecastPoint], target_at: datetime, issued_at: datetime, ) -> PowerForecastPoint | None: if target_at < points[0].target_at or target_at > points[-1].target_at: return None for index in range(len(points) - 1): left = points[index] right = points[index + 1] if left.target_at <= target_at <= right.target_at: ratio = self._time_ratio(left.target_at, right.target_at, target_at) p10 = self._lerp(left.p10_power_w, right.p10_power_w, ratio) p50 = self._lerp(left.p50_power_w, right.p50_power_w, ratio) p90 = self._lerp(left.p90_power_w, right.p90_power_w, ratio) 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=self._lerp(left.confidence, right.confidence, ratio), source=left.source, model_version=left.model_version, metadata={ "interpolated": True, "left_target_at": left.target_at.isoformat(), "right_target_at": right.target_at.isoformat(), }, ) return None def _current_power_point( self, points: list[PowerForecastPoint], issued_at: datetime, ) -> PowerForecastPoint | None: if not points: return None first = points[0] return PowerForecastPoint( target_at=issued_at, horizon_minutes=0, expected_power_w=first.p50_power_w, p10_power_w=first.p10_power_w, p50_power_w=first.p50_power_w, p90_power_w=first.p90_power_w, confidence=first.confidence, source=first.source, model_version=first.model_version, metadata={ "interpolated": True, "anchored_to": first.target_at.isoformat(), }, ) def _ceil_time(self, value: datetime, step_minutes: int) -> datetime: step_seconds = step_minutes * 60 timestamp = value.timestamp() remainder = timestamp % step_seconds if remainder: timestamp += step_seconds - remainder return datetime.fromtimestamp(timestamp, timezone.utc) def _time_ratio( self, left: datetime, right: datetime, value: datetime, ) -> float: span = (right - left).total_seconds() if span <= 0: return 0.0 return (value - left).total_seconds() / span def _lerp(self, left: float, right: float, ratio: float) -> float: return left + (right - left) * ratio def _timedelta_hours(self, hours: int): from datetime import timedelta return timedelta(hours=hours) def _timedelta_minutes(self, minutes: int): from datetime import timedelta return timedelta(minutes=minutes) EnergyForecastBuilder = EnergyOracleBuilder