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.
192 lines
6.6 KiB
Python
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
|