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:
rpotter6298
2026-04-28 08:14:00 +02:00
parent ff0c65a794
commit c8e3016fd6
55 changed files with 6385 additions and 633 deletions
@@ -0,0 +1,142 @@
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 HistoricalUsageOracle:
"""Forecasts load from time-of-week history blended with recent load."""
model_version = "historical_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.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(),
)
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="historical_usage_oracle",
model_version=self.model_version,
points=points,
)
def _forecast_point(
self,
target_at: datetime,
issued_at: datetime,
profile: dict[tuple[int, 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
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)
confidence = min(0.65, 0.35 + float(profile_values["sample_count"]) / 500.0)
sample_count = int(profile_values["sample_count"])
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_week_load_profile",
model_version=self.model_version,
metadata={
"profile_key": profile_key,
"profile_sample_count": sample_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) -> tuple[int, int]:
local = target_at.astimezone(self._local_timezone())
minute_of_day = local.hour * 60 + local.minute
bucket = (
minute_of_day // self.config.load_profile_bucket_minutes
) * self.config.load_profile_bucket_minutes
return local.isoweekday(), bucket
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)