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.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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
|
||||
Reference in New Issue
Block a user