Files
Astrape/gibil/classes/oracle/builder.py
T
rpotter6298 c8e3016fd6 Add new daemons and debug scripts for Sigenergy and Oracle functionalities
- 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.
2026-04-28 08:14:00 +02:00

192 lines
6.6 KiB
Python

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