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
+11
View File
@@ -0,0 +1,11 @@
from gibil.classes.sigen.builder import SigenBuilder, SigenPlantClient
from gibil.classes.sigen.modbus import SigenModbusClient
from gibil.classes.sigen.store import SigenStore, SigenStoreConfig
__all__ = [
"SigenBuilder",
"SigenModbusClient",
"SigenPlantClient",
"SigenStore",
"SigenStoreConfig",
]
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from datetime import datetime, timezone
from os import environ
from typing import Any
from gibil.classes.models import SigenPlantSnapshot
from gibil.classes.sigen.modbus import SigenModbusClient
from gibil.classes.sigen.registers import PLANT_REGISTERS, SigenRegister
CORE_PLANT_REGISTER_NAMES = (
"plant_system_time",
"plant_ems_work_mode",
"plant_grid_sensor_status",
"plant_grid_sensor_active_power",
"plant_ess_soc",
"plant_active_power",
"plant_sigen_photovoltaic_power",
"plant_ess_power",
"plant_running_state",
"plant_ess_soh",
"plant_accumulated_pv_energy",
"plant_daily_consumed_energy",
"plant_accumulated_consumed_energy",
"plant_total_load_power",
)
class SigenPlantClient:
"""Fetches plant-level Sigenergy metrics over Modbus TCP."""
def __init__(self, modbus_client: SigenModbusClient) -> None:
self.modbus_client = modbus_client
@classmethod
def from_env(cls) -> "SigenPlantClient":
host = environ.get("SIGEN_MODBUS_HOST")
if not host:
raise RuntimeError("SIGEN_MODBUS_HOST is required for Sigen Modbus reads")
return cls(
SigenModbusClient(
host=host,
port=int(environ.get("SIGEN_MODBUS_PORT", "502")),
unit_id=int(environ.get("SIGEN_MODBUS_UNIT_ID", "247")),
timeout=float(environ.get("SIGEN_MODBUS_TIMEOUT", "20")),
retries=int(environ.get("SIGEN_MODBUS_RETRIES", "3")),
)
)
def fetch_snapshot(
self,
register_names: tuple[str, ...] = CORE_PLANT_REGISTER_NAMES,
) -> SigenPlantSnapshot:
with self.modbus_client as client:
values = self._read_values(client, register_names)
return SigenBuilder().build_snapshot(values)
def _read_values(
self,
client: SigenModbusClient,
register_names: tuple[str, ...],
) -> dict[str, int | float | str | bool | None]:
values: dict[str, int | float | str | bool | None] = {}
for name in register_names:
register = PLANT_REGISTERS[name]
try:
values[name] = self._read_value(client, register)
except Exception as exc:
values[name] = None
values[f"{name}_error"] = str(exc)
return values
def _read_value(
self,
client: SigenModbusClient,
register: SigenRegister,
) -> int | float | str | bool | None:
result = client.read(register.kind, register.address, register.count)
return register.decode(result.values)
class SigenBuilder:
"""Builds database-ready Sigenergy plant snapshots from decoded registers."""
max_plant_clock_drift_seconds = 300
def build_snapshot(
self,
values: dict[str, Any],
received_at: datetime | None = None,
) -> SigenPlantSnapshot:
if received_at is None:
received_at = datetime.now(timezone.utc)
plant_epoch_seconds = self._int_or_none(values.get("plant_system_time"))
observed_at = self._observed_at(plant_epoch_seconds, received_at)
grid_power_w = self._kw_to_w(values.get("plant_grid_sensor_active_power"))
return SigenPlantSnapshot(
observed_at=observed_at,
received_at=received_at,
plant_epoch_seconds=plant_epoch_seconds,
plant_ems_work_mode=self._int_or_none(values.get("plant_ems_work_mode")),
plant_running_state=self._int_or_none(values.get("plant_running_state")),
grid_sensor_status=self._int_or_none(
values.get("plant_grid_sensor_status")
),
solar_power_w=self._kw_to_w(
values.get("plant_sigen_photovoltaic_power")
),
battery_soc_pct=self._float_or_none(values.get("plant_ess_soc")),
battery_soh_pct=self._float_or_none(values.get("plant_ess_soh")),
battery_power_w=self._kw_to_w(values.get("plant_ess_power")),
grid_power_w=grid_power_w,
grid_import_w=max(grid_power_w, 0.0) if grid_power_w is not None else None,
grid_export_w=abs(min(grid_power_w, 0.0))
if grid_power_w is not None
else None,
load_power_w=self._kw_to_w(values.get("plant_total_load_power")),
plant_active_power_w=self._kw_to_w(values.get("plant_active_power")),
accumulated_pv_energy_kwh=self._float_or_none(
values.get("plant_accumulated_pv_energy")
),
daily_consumed_energy_kwh=self._float_or_none(
values.get("plant_daily_consumed_energy")
),
accumulated_consumed_energy_kwh=self._float_or_none(
values.get("plant_accumulated_consumed_energy")
),
raw_values=dict(values),
)
def _observed_at(
self,
plant_epoch_seconds: int | None,
fallback: datetime,
) -> datetime:
if plant_epoch_seconds is None:
return fallback
try:
plant_time = datetime.fromtimestamp(plant_epoch_seconds, timezone.utc)
except (OverflowError, OSError, ValueError):
return fallback
drift_seconds = abs((fallback - plant_time).total_seconds())
if drift_seconds > self.max_plant_clock_drift_seconds:
return fallback
return plant_time
def _kw_to_w(self, value: Any) -> float | None:
numeric = self._float_or_none(value)
if numeric is None:
return None
return numeric * 1000
def _float_or_none(self, value: Any) -> float | None:
if value is None:
return None
if isinstance(value, bool):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _int_or_none(self, value: Any) -> int | None:
numeric = self._float_or_none(value)
if numeric is None:
return None
return int(numeric)
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from dataclasses import dataclass
from inspect import signature
import sys
from typing import Literal
try:
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException
except ImportError: # pragma: no cover - exercised only before dependency install
ModbusTcpClient = None # type: ignore[assignment]
class ModbusException(Exception):
pass
RegisterKind = Literal["holding", "input", "coil", "discrete"]
@dataclass(frozen=True)
class ModbusReadResult:
kind: RegisterKind
address: int
count: int
values: list[int] | list[bool]
@dataclass(frozen=True)
class ModbusReadError:
kind: RegisterKind
address: int
count: int
error: str
class SigenModbusClient:
"""Small Modbus TCP client for exploring a Sigenergy plant or inverter."""
def __init__(
self,
host: str,
port: int = 502,
unit_id: int = 1,
timeout: float = 5.0,
retries: int = 3,
trace: bool = False,
) -> None:
if ModbusTcpClient is None:
raise RuntimeError(
"pymodbus is not installed. Install dependencies with "
"`python3 -m pip install -r requirements.txt`."
)
self.host = host
self.port = port
self.unit_id = unit_id
self.timeout = timeout
self.retries = retries
self.trace = trace
self._client = ModbusTcpClient(
host=host,
port=port,
timeout=timeout,
retries=retries,
trace_packet=self._trace_packet if trace else None,
)
self._unit_keyword = self._detect_unit_keyword()
def __enter__(self) -> SigenModbusClient:
self.connect()
return self
def __exit__(self, *args: object) -> None:
self.close()
def connect(self) -> None:
if not self._client.connect():
raise ConnectionError(
f"Could not connect to Modbus TCP target {self.host}:{self.port}"
)
def close(self) -> None:
self._client.close()
def read(
self,
kind: RegisterKind,
address: int,
count: int = 1,
) -> ModbusReadResult:
if count < 1:
raise ValueError("count must be at least 1")
if address < 0:
raise ValueError("address must be zero or greater")
response = self._read_raw(kind, address, count)
if response.isError():
raise ModbusException(str(response))
values = getattr(response, "registers", None)
if values is None:
values = getattr(response, "bits", [])
values = list(values[:count])
return ModbusReadResult(
kind=kind,
address=address,
count=count,
values=list(values),
)
def scan(
self,
kind: RegisterKind,
start: int,
count: int,
chunk_size: int = 10,
) -> list[ModbusReadResult | ModbusReadError]:
if count < 1:
raise ValueError("count must be at least 1")
if chunk_size < 1:
raise ValueError("chunk_size must be at least 1")
results: list[ModbusReadResult | ModbusReadError] = []
stop = start + count
address = start
while address < stop:
current_count = min(chunk_size, stop - address)
try:
results.append(self.read(kind, address, current_count))
except Exception as exc:
results.append(
ModbusReadError(
kind=kind,
address=address,
count=current_count,
error=str(exc),
)
)
address += current_count
return results
def _read_raw(self, kind: RegisterKind, address: int, count: int):
if kind == "holding":
return self._call_read(self._client.read_holding_registers, address, count)
if kind == "input":
return self._call_read(self._client.read_input_registers, address, count)
if kind == "coil":
return self._call_read(self._client.read_coils, address, count)
if kind == "discrete":
return self._call_read(self._client.read_discrete_inputs, address, count)
raise ValueError(f"Unsupported register kind: {kind}")
def _call_read(self, method, address: int, count: int):
kwargs = {
"address": address,
"count": count,
self._unit_keyword: self.unit_id,
}
try:
return method(**kwargs)
except TypeError as exc:
if self._unit_keyword not in str(exc):
raise
kwargs.pop(self._unit_keyword)
return method(address, self.unit_id, **kwargs)
def _detect_unit_keyword(self) -> str:
read_signature = signature(self._client.read_holding_registers)
for keyword in ("device_id", "slave", "unit"):
if keyword in read_signature.parameters:
return keyword
return "slave"
def _trace_packet(self, sending: bool, packet: bytes) -> bytes:
direction = "TX" if sending else "RX"
print(f"{direction} {packet.hex(' ')}", file=sys.stderr)
return packet
+530
View File
@@ -0,0 +1,530 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from gibil.classes.sigen.modbus import RegisterKind
SigenDataType = Literal["u16", "u32", "u64", "s16", "s32", "string"]
@dataclass(frozen=True)
class SigenRegister:
name: str
kind: RegisterKind
address: int
count: int
data_type: SigenDataType
gain: float = 1
unit: str | None = None
description: str | None = None
def decode(self, registers: list[int] | list[bool]) -> int | float | str:
numeric_registers = [int(register) for register in registers[: self.count]]
if self.data_type == "string":
return self._decode_string(numeric_registers)
raw_value = self._combine(numeric_registers)
if self.data_type.startswith("s"):
bits = 16 * self.count
sign_bit = 1 << (bits - 1)
if raw_value & sign_bit:
raw_value -= 1 << bits
if self.gain == 1:
return raw_value
return raw_value / self.gain
def _combine(self, registers: list[int]) -> int:
value = 0
for register in registers:
value = (value << 16) | (register & 0xFFFF)
return value
def _decode_string(self, registers: list[int]) -> str:
raw_bytes = bytearray()
for register in registers:
raw_bytes.append((register >> 8) & 0xFF)
raw_bytes.append(register & 0xFF)
return raw_bytes.rstrip(b"\x00").decode("ascii", errors="replace").strip()
PLANT_REGISTERS: dict[str, SigenRegister] = {
"plant_system_time": SigenRegister(
name="plant_system_time",
kind="input",
address=30000,
count=2,
data_type="u32",
unit="s",
),
"plant_ems_work_mode": SigenRegister(
name="plant_ems_work_mode",
kind="input",
address=30003,
count=1,
data_type="u16",
),
"plant_grid_sensor_status": SigenRegister(
name="plant_grid_sensor_status",
kind="input",
address=30004,
count=1,
data_type="u16",
),
"plant_grid_sensor_active_power": SigenRegister(
name="plant_grid_sensor_active_power",
kind="input",
address=30005,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"plant_ess_soc": SigenRegister(
name="plant_ess_soc",
kind="input",
address=30014,
count=1,
data_type="u16",
gain=10,
unit="%",
),
"plant_active_power": SigenRegister(
name="plant_active_power",
kind="input",
address=30031,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"plant_sigen_photovoltaic_power": SigenRegister(
name="plant_sigen_photovoltaic_power",
kind="input",
address=30035,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"plant_ess_power": SigenRegister(
name="plant_ess_power",
kind="input",
address=30037,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"plant_running_state": SigenRegister(
name="plant_running_state",
kind="input",
address=30051,
count=1,
data_type="u16",
),
"plant_ess_rated_energy_capacity": SigenRegister(
name="plant_ess_rated_energy_capacity",
kind="input",
address=30083,
count=2,
data_type="u32",
gain=100,
unit="kWh",
),
"plant_ess_soh": SigenRegister(
name="plant_ess_soh",
kind="input",
address=30087,
count=1,
data_type="u16",
gain=10,
unit="%",
),
"plant_accumulated_pv_energy": SigenRegister(
name="plant_accumulated_pv_energy",
kind="input",
address=30088,
count=4,
data_type="u64",
gain=100,
unit="kWh",
),
"plant_daily_consumed_energy": SigenRegister(
name="plant_daily_consumed_energy",
kind="input",
address=30092,
count=2,
data_type="u32",
gain=100,
unit="kWh",
),
"plant_accumulated_consumed_energy": SigenRegister(
name="plant_accumulated_consumed_energy",
kind="input",
address=30094,
count=4,
data_type="u64",
gain=100,
unit="kWh",
),
"plant_general_load_power": SigenRegister(
name="plant_general_load_power",
kind="input",
address=30282,
count=2,
data_type="s32",
gain=1000,
unit="kW",
description="General load power",
),
"plant_total_load_power": SigenRegister(
name="plant_total_load_power",
kind="input",
address=30284,
count=2,
data_type="s32",
gain=1000,
unit="kW",
description="Total load power",
),
}
PLANT_PARAMETER_REGISTERS: dict[str, SigenRegister] = {
"plant_start_stop": SigenRegister(
name="plant_start_stop",
kind="holding",
address=40000,
count=1,
data_type="u16",
description="Start/Stop (0: Stop, 1: Start)",
),
"plant_active_power_fixed_target": SigenRegister(
name="plant_active_power_fixed_target",
kind="holding",
address=40001,
count=2,
data_type="s32",
gain=1000,
unit="kW",
description="Active power fixed adjustment target value",
),
"plant_reactive_power_fixed_target": SigenRegister(
name="plant_reactive_power_fixed_target",
kind="holding",
address=40003,
count=2,
data_type="s32",
gain=1000,
unit="kvar",
description="Reactive power fixed adjustment target value",
),
"plant_active_power_percentage_target": SigenRegister(
name="plant_active_power_percentage_target",
kind="holding",
address=40005,
count=1,
data_type="s16",
gain=100,
unit="%",
description="Active power percentage target. Range: -100.00 to 100.00",
),
"plant_qs_ratio_target": SigenRegister(
name="plant_qs_ratio_target",
kind="holding",
address=40006,
count=1,
data_type="s16",
gain=100,
unit="%",
description="Q/S adjustment target value",
),
"plant_power_factor_target": SigenRegister(
name="plant_power_factor_target",
kind="holding",
address=40007,
count=1,
data_type="s16",
gain=1000,
description="Power factor adjustment target value",
),
"plant_remote_ems_enable": SigenRegister(
name="plant_remote_ems_enable",
kind="holding",
address=40029,
count=1,
data_type="u16",
description="Remote EMS enable (0: disabled, 1: enabled)",
),
"plant_independent_phase_power_control_enable": SigenRegister(
name="plant_independent_phase_power_control_enable",
kind="holding",
address=40030,
count=1,
data_type="u16",
description="Independent phase power control enable (0: disabled, 1: enabled)",
),
"plant_remote_ems_control_mode": SigenRegister(
name="plant_remote_ems_control_mode",
kind="holding",
address=40031,
count=1,
data_type="u16",
description=(
"Remote EMS control mode: 0 PCS remote, 1 standby, "
"2 self-consumption, 3 charge grid first, 4 charge PV first, "
"5 discharge PV first, 6 discharge ESS first"
),
),
"plant_ess_max_charging_limit": SigenRegister(
name="plant_ess_max_charging_limit",
kind="holding",
address=40032,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="ESS max charging limit",
),
"plant_ess_max_discharging_limit": SigenRegister(
name="plant_ess_max_discharging_limit",
kind="holding",
address=40034,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="ESS max discharging limit",
),
"plant_pv_max_power_limit": SigenRegister(
name="plant_pv_max_power_limit",
kind="holding",
address=40036,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="PV max power limit",
),
"plant_grid_point_maximum_export_limitation": SigenRegister(
name="plant_grid_point_maximum_export_limitation",
kind="holding",
address=40038,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="Grid point maximum export limitation",
),
"plant_grid_maximum_import_limitation": SigenRegister(
name="plant_grid_maximum_import_limitation",
kind="holding",
address=40040,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="Grid point maximum import limitation",
),
"plant_pcs_maximum_export_limitation": SigenRegister(
name="plant_pcs_maximum_export_limitation",
kind="holding",
address=40042,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="PCS maximum export limitation",
),
"plant_pcs_maximum_import_limitation": SigenRegister(
name="plant_pcs_maximum_import_limitation",
kind="holding",
address=40044,
count=2,
data_type="u32",
gain=1000,
unit="kW",
description="PCS maximum import limitation",
),
"plant_backup_soc": SigenRegister(
name="plant_backup_soc",
kind="holding",
address=40046,
count=1,
data_type="u16",
gain=10,
unit="%",
description="ESS backup SOC. Range: 0 to 100.0",
),
"plant_charge_cut_off_soc": SigenRegister(
name="plant_charge_cut_off_soc",
kind="holding",
address=40047,
count=1,
data_type="u16",
gain=10,
unit="%",
description="ESS charge cut-off SOC. Range: 0 to 100.0",
),
"plant_discharge_cut_off_soc": SigenRegister(
name="plant_discharge_cut_off_soc",
kind="holding",
address=40048,
count=1,
data_type="u16",
gain=10,
unit="%",
description="ESS discharge cut-off SOC. Range: 0 to 100.0",
),
}
INVERTER_REGISTERS: dict[str, SigenRegister] = {
"inverter_model_type": SigenRegister(
name="inverter_model_type",
kind="input",
address=30500,
count=15,
data_type="string",
),
"inverter_serial_number": SigenRegister(
name="inverter_serial_number",
kind="input",
address=30515,
count=10,
data_type="string",
),
"inverter_machine_firmware_version": SigenRegister(
name="inverter_machine_firmware_version",
kind="input",
address=30525,
count=15,
data_type="string",
),
"inverter_rated_active_power": SigenRegister(
name="inverter_rated_active_power",
kind="input",
address=30540,
count=2,
data_type="u32",
gain=1000,
unit="kW",
),
"inverter_running_state": SigenRegister(
name="inverter_running_state",
kind="input",
address=30578,
count=1,
data_type="u16",
),
"inverter_active_power": SigenRegister(
name="inverter_active_power",
kind="input",
address=30587,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"inverter_reactive_power": SigenRegister(
name="inverter_reactive_power",
kind="input",
address=30589,
count=2,
data_type="s32",
gain=1000,
unit="kvar",
),
"inverter_ess_charge_discharge_power": SigenRegister(
name="inverter_ess_charge_discharge_power",
kind="input",
address=30599,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"inverter_ess_battery_soc": SigenRegister(
name="inverter_ess_battery_soc",
kind="input",
address=30601,
count=1,
data_type="u16",
gain=10,
unit="%",
),
"inverter_ess_battery_soh": SigenRegister(
name="inverter_ess_battery_soh",
kind="input",
address=30602,
count=1,
data_type="u16",
gain=10,
unit="%",
),
"inverter_pv_power": SigenRegister(
name="inverter_pv_power",
kind="input",
address=31035,
count=2,
data_type="s32",
gain=1000,
unit="kW",
),
"inverter_daily_pv_energy": SigenRegister(
name="inverter_daily_pv_energy",
kind="input",
address=31509,
count=2,
data_type="u32",
gain=100,
unit="kWh",
),
"inverter_accumulated_pv_energy": SigenRegister(
name="inverter_accumulated_pv_energy",
kind="input",
address=31511,
count=4,
data_type="u64",
gain=100,
unit="kWh",
),
}
DEFAULT_PLANT_REGISTER_NAMES = (
"plant_system_time",
"plant_ems_work_mode",
"plant_grid_sensor_status",
"plant_grid_sensor_active_power",
"plant_ess_soc",
"plant_active_power",
"plant_sigen_photovoltaic_power",
"plant_ess_power",
"plant_running_state",
"plant_ess_rated_energy_capacity",
"plant_ess_soh",
"plant_accumulated_pv_energy",
"plant_daily_consumed_energy",
"plant_accumulated_consumed_energy",
"plant_general_load_power",
"plant_total_load_power",
)
DEFAULT_INVERTER_REGISTER_NAMES = (
"inverter_model_type",
"inverter_serial_number",
"inverter_machine_firmware_version",
"inverter_rated_active_power",
"inverter_running_state",
"inverter_active_power",
"inverter_reactive_power",
"inverter_ess_charge_discharge_power",
"inverter_ess_battery_soc",
"inverter_ess_battery_soh",
"inverter_pv_power",
"inverter_daily_pv_energy",
"inverter_accumulated_pv_energy",
)
+508
View File
@@ -0,0 +1,508 @@
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from os import environ
from typing import Iterator
from gibil.classes.models import SigenPlantSnapshot
class SigenStoreConfigurationError(RuntimeError):
pass
@dataclass(frozen=True)
class SigenStoreConfig:
database_url: str
@classmethod
def from_env(cls) -> "SigenStoreConfig":
database_url = environ.get("ASTRAPE_DATABASE_URL")
if not database_url:
raise SigenStoreConfigurationError(
"ASTRAPE_DATABASE_URL is required for Sigen storage"
)
return cls(database_url=database_url)
class SigenStore:
"""Persists Sigenergy plant snapshots in TimescaleDB."""
def __init__(self, config: SigenStoreConfig) -> None:
self.config = config
@classmethod
def from_env(cls) -> "SigenStore":
return cls(SigenStoreConfig.from_env())
def initialize(self) -> None:
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS sigen_plant_snapshots (
observed_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
source TEXT NOT NULL,
plant_epoch_seconds BIGINT,
plant_ems_work_mode INTEGER,
plant_running_state INTEGER,
grid_sensor_status INTEGER,
solar_power_w DOUBLE PRECISION,
battery_soc_pct DOUBLE PRECISION,
battery_soh_pct DOUBLE PRECISION,
battery_power_w DOUBLE PRECISION,
grid_power_w DOUBLE PRECISION,
grid_import_w DOUBLE PRECISION,
grid_export_w DOUBLE PRECISION,
load_power_w DOUBLE PRECISION,
plant_active_power_w DOUBLE PRECISION,
accumulated_pv_energy_kwh DOUBLE PRECISION,
daily_consumed_energy_kwh DOUBLE PRECISION,
accumulated_consumed_energy_kwh DOUBLE PRECISION,
raw_values JSONB NOT NULL DEFAULT '{}'::jsonb,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (observed_at, source)
)
"""
)
cursor.execute(
"""
SELECT create_hypertable(
'sigen_plant_snapshots',
'observed_at',
if_not_exists => TRUE
)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS sigen_plant_snapshots_received_at_idx
ON sigen_plant_snapshots (received_at DESC)
"""
)
self._create_rollup_view(
cursor,
view_name="sigen_plant_snapshots_1m",
bucket="1 minute",
)
self._create_rollup_view(
cursor,
view_name="sigen_plant_snapshots_15m",
bucket="15 minutes",
)
self._create_rollup_view(
cursor,
view_name="sigen_plant_snapshots_1h",
bucket="1 hour",
)
connection.commit()
def save_snapshot(self, snapshot: SigenPlantSnapshot) -> int:
with self._connection() as connection:
with connection.cursor() as cursor:
try:
from psycopg.types.json import Jsonb
except ImportError as error:
raise SigenStoreConfigurationError(
"Install dependencies with `python3 -m pip install -r requirements.txt`"
) from error
cursor.execute(
"""
INSERT INTO sigen_plant_snapshots (
observed_at,
received_at,
source,
plant_epoch_seconds,
plant_ems_work_mode,
plant_running_state,
grid_sensor_status,
solar_power_w,
battery_soc_pct,
battery_soh_pct,
battery_power_w,
grid_power_w,
grid_import_w,
grid_export_w,
load_power_w,
plant_active_power_w,
accumulated_pv_energy_kwh,
daily_consumed_energy_kwh,
accumulated_consumed_energy_kwh,
raw_values
)
VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
ON CONFLICT (observed_at, source)
DO UPDATE SET
received_at = EXCLUDED.received_at,
plant_epoch_seconds = EXCLUDED.plant_epoch_seconds,
plant_ems_work_mode = EXCLUDED.plant_ems_work_mode,
plant_running_state = EXCLUDED.plant_running_state,
grid_sensor_status = EXCLUDED.grid_sensor_status,
solar_power_w = EXCLUDED.solar_power_w,
battery_soc_pct = EXCLUDED.battery_soc_pct,
battery_soh_pct = EXCLUDED.battery_soh_pct,
battery_power_w = EXCLUDED.battery_power_w,
grid_power_w = EXCLUDED.grid_power_w,
grid_import_w = EXCLUDED.grid_import_w,
grid_export_w = EXCLUDED.grid_export_w,
load_power_w = EXCLUDED.load_power_w,
plant_active_power_w = EXCLUDED.plant_active_power_w,
accumulated_pv_energy_kwh = EXCLUDED.accumulated_pv_energy_kwh,
daily_consumed_energy_kwh = EXCLUDED.daily_consumed_energy_kwh,
accumulated_consumed_energy_kwh = EXCLUDED.accumulated_consumed_energy_kwh,
raw_values = EXCLUDED.raw_values,
inserted_at = now()
""",
(
snapshot.observed_at,
snapshot.received_at,
snapshot.source,
snapshot.plant_epoch_seconds,
snapshot.plant_ems_work_mode,
snapshot.plant_running_state,
snapshot.grid_sensor_status,
snapshot.solar_power_w,
snapshot.battery_soc_pct,
snapshot.battery_soh_pct,
snapshot.battery_power_w,
snapshot.grid_power_w,
snapshot.grid_import_w,
snapshot.grid_export_w,
snapshot.load_power_w,
snapshot.plant_active_power_w,
snapshot.accumulated_pv_energy_kwh,
snapshot.daily_consumed_energy_kwh,
snapshot.accumulated_consumed_energy_kwh,
Jsonb(snapshot.raw_values),
),
)
connection.commit()
return 1
def load_latest_snapshot(self) -> SigenPlantSnapshot | None:
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
observed_at,
received_at,
source,
plant_epoch_seconds,
plant_ems_work_mode,
plant_running_state,
grid_sensor_status,
solar_power_w,
battery_soc_pct,
battery_soh_pct,
battery_power_w,
grid_power_w,
grid_import_w,
grid_export_w,
load_power_w,
plant_active_power_w,
accumulated_pv_energy_kwh,
daily_consumed_energy_kwh,
accumulated_consumed_energy_kwh,
raw_values
FROM sigen_plant_snapshots
ORDER BY observed_at DESC
LIMIT 1
"""
)
row = cursor.fetchone()
if row is None:
return None
return SigenPlantSnapshot(
observed_at=row[0],
received_at=row[1],
source=row[2],
plant_epoch_seconds=row[3],
plant_ems_work_mode=row[4],
plant_running_state=row[5],
grid_sensor_status=row[6],
solar_power_w=row[7],
battery_soc_pct=row[8],
battery_soh_pct=row[9],
battery_power_w=row[10],
grid_power_w=row[11],
grid_import_w=row[12],
grid_export_w=row[13],
load_power_w=row[14],
plant_active_power_w=row[15],
accumulated_pv_energy_kwh=row[16],
daily_consumed_energy_kwh=row[17],
accumulated_consumed_energy_kwh=row[18],
raw_values=row[19] or {},
)
def load_recent_power_summary(
self,
lookback: timedelta = timedelta(minutes=30),
) -> dict[str, float | None]:
start_at = datetime.now(timezone.utc) - lookback
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
avg(load_power_w),
percentile_cont(0.10) WITHIN GROUP (ORDER BY load_power_w),
percentile_cont(0.50) WITHIN GROUP (ORDER BY load_power_w),
percentile_cont(0.90) WITHIN GROUP (ORDER BY load_power_w),
max(load_power_w),
max(solar_power_w)
FROM sigen_plant_snapshots
WHERE observed_at >= %s
""",
(start_at,),
)
row = cursor.fetchone()
return {
"load_avg_w": row[0],
"load_p10_w": row[1],
"load_p50_w": row[2],
"load_p90_w": row[3],
"load_max_w": row[4],
"solar_max_w": row[5],
}
def load_load_profile(
self,
lookback: timedelta = timedelta(days=30),
bucket_minutes: int = 15,
min_samples: int = 5,
timezone_name: str = "UTC",
) -> dict[tuple[int, int], dict[str, float | int]]:
if bucket_minutes <= 0:
raise ValueError("bucket_minutes must be greater than zero")
start_at = datetime.now(timezone.utc) - lookback
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
WITH localized AS (
SELECT
observed_at AT TIME ZONE %s AS local_observed_at,
load_power_w
FROM sigen_plant_snapshots
WHERE observed_at >= %s
AND observed_at <= now()
AND load_power_w IS NOT NULL
)
SELECT
EXTRACT(ISODOW FROM local_observed_at)::int AS iso_dow,
(
EXTRACT(HOUR FROM local_observed_at)::int * 60
+ FLOOR(EXTRACT(MINUTE FROM local_observed_at)::int / %s)::int * %s
) AS minute_bucket,
percentile_cont(0.10) WITHIN GROUP (ORDER BY load_power_w) AS p10,
percentile_cont(0.50) WITHIN GROUP (ORDER BY load_power_w) AS p50,
percentile_cont(0.90) WITHIN GROUP (ORDER BY load_power_w) AS p90,
avg(load_power_w) AS avg_load_power_w,
max(load_power_w) AS max_load_power_w,
count(*) AS sample_count
FROM localized
GROUP BY iso_dow, minute_bucket
HAVING count(*) >= %s
""",
(
timezone_name,
start_at,
bucket_minutes,
bucket_minutes,
min_samples,
),
)
rows = cursor.fetchall()
return {
(int(row[0]), int(row[1])): {
"p10": float(row[2]),
"p50": float(row[3]),
"p90": float(row[4]),
"avg_load_power_w": float(row[5]),
"max_load_power_w": float(row[6]),
"sample_count": int(row[7]),
}
for row in rows
}
def load_recent_actual_points(
self,
lookback: timedelta = timedelta(hours=24),
bucket: str = "5 minutes",
) -> list[dict[str, object]]:
start_at = datetime.now(timezone.utc) - lookback
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT
time_bucket('{bucket}', observed_at) AS bucket,
avg(solar_power_w) AS solar_power_w,
avg(load_power_w) AS load_power_w,
avg(solar_power_w - load_power_w) AS net_power_w,
avg(grid_import_w) AS grid_import_w,
avg(grid_export_w) AS grid_export_w,
count(*) AS sample_count
FROM sigen_plant_snapshots
WHERE observed_at >= %s
AND observed_at <= now()
GROUP BY bucket
ORDER BY bucket
LIMIT 10000
""",
(start_at,),
)
rows = cursor.fetchall()
return [
{
"target_at": row[0],
"solar_power_w": row[1],
"load_power_w": row[2],
"net_power_w": row[3],
"grid_import_w": row[4],
"grid_export_w": row[5],
"sample_count": row[6],
}
for row in rows
]
def load_recent_solar_peak_w(
self,
lookback: timedelta = timedelta(days=14),
) -> float | None:
start_at = datetime.now(timezone.utc) - lookback
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT max(solar_power_w)
FROM sigen_plant_snapshots
WHERE observed_at >= %s
""",
(start_at,),
)
row = cursor.fetchone()
return row[0] if row else None
def load_solar_training_samples(
self,
lookback: timedelta = timedelta(days=30),
min_samples_per_hour: int = 3,
) -> list[dict[str, float | int | object]]:
start_at = datetime.now(timezone.utc) - lookback
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
WITH hourly_solar AS (
SELECT
time_bucket('1 hour', observed_at) AS target_at,
avg(solar_power_w) AS avg_solar_power_w,
count(*) AS sample_count
FROM sigen_plant_snapshots
WHERE observed_at >= %s
AND solar_power_w IS NOT NULL
GROUP BY target_at
),
latest_weather AS (
SELECT
target_at,
shortwave_radiation_w_m2,
cloud_cover_pct,
ROW_NUMBER() OVER (
PARTITION BY target_at
ORDER BY issued_at DESC
) AS rn
FROM weather_forecast_points
WHERE target_at >= %s
)
SELECT
h.target_at,
h.avg_solar_power_w,
h.sample_count,
w.shortwave_radiation_w_m2,
w.cloud_cover_pct
FROM hourly_solar h
JOIN latest_weather w
ON w.target_at = h.target_at
AND w.rn = 1
WHERE h.sample_count >= %s
AND w.shortwave_radiation_w_m2 IS NOT NULL
ORDER BY h.target_at
""",
(start_at, start_at, min_samples_per_hour),
)
rows = cursor.fetchall()
return [
{
"target_at": row[0],
"solar_power_w": float(row[1]),
"sample_count": int(row[2]),
"shortwave_radiation_w_m2": float(row[3]),
"cloud_cover_pct": float(row[4]) if row[4] is not None else 0.0,
}
for row in rows
]
def _create_rollup_view(self, cursor: object, view_name: str, bucket: str) -> None:
cursor.execute(
f"""
CREATE OR REPLACE VIEW {view_name} AS
SELECT
time_bucket('{bucket}', observed_at) AS bucket,
source,
avg(solar_power_w) AS avg_solar_power_w,
min(solar_power_w) AS min_solar_power_w,
max(solar_power_w) AS max_solar_power_w,
avg(load_power_w) AS avg_load_power_w,
min(load_power_w) AS min_load_power_w,
max(load_power_w) AS max_load_power_w,
avg(grid_import_w) AS avg_grid_import_w,
max(grid_import_w) AS max_grid_import_w,
avg(grid_export_w) AS avg_grid_export_w,
max(grid_export_w) AS max_grid_export_w,
avg(battery_power_w) AS avg_battery_power_w,
min(battery_power_w) AS min_battery_power_w,
max(battery_power_w) AS max_battery_power_w,
avg(battery_soc_pct) AS avg_battery_soc_pct,
min(battery_soc_pct) AS min_battery_soc_pct,
max(battery_soc_pct) AS max_battery_soc_pct,
min(accumulated_pv_energy_kwh) AS start_accumulated_pv_energy_kwh,
max(accumulated_pv_energy_kwh) AS end_accumulated_pv_energy_kwh,
count(*) AS sample_count
FROM sigen_plant_snapshots
GROUP BY bucket, source
"""
)
@contextmanager
def _connection(self) -> Iterator[object]:
try:
import psycopg
except ImportError as error:
raise SigenStoreConfigurationError(
"Install dependencies with `python3 -m pip install -r requirements.txt`"
) from error
with psycopg.connect(self.config.database_url) as connection:
yield connection