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,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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user