Initial commit: astrape-ems split out of the combined Astrape tree
Everything that talks to the energy backend: the Sigenergy Modbus client, its register map, the plant store, and the polling daemon. Sits at the base of the Astrape dependency chain, because the plant store is what satisfies gibil's PlantHistoryPort -- the oracle cannot forecast without observed plant history. EnvLoader lives here for the same reason: it is the only place all three repos can see it without a fourth repo for 37 lines. EnvLoader no longer resolves env/ from __file__. Where a package is installed says nothing about where the deployment keeps its secrets; it now uses the working directory, or ASTRAPE_ENV_DIR. Extracted from a single repository where the forecasting engine, the database plumbing, the hardware client, the ingest daemons and the web UI all imported each other freely. Verified that every module imports with gibil resolved from its own installed checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
@@ -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 astrape_ems.classes.modbus import SigenModbusClient
|
||||
from astrape_ems.classes.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,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class EnvLoader:
|
||||
"""Loads Astrape env files without overriding process environment.
|
||||
|
||||
The directory is resolved against the working directory, or
|
||||
ASTRAPE_ENV_DIR if set. Deliberately not derived from __file__: where a
|
||||
package happens to be installed says nothing about where the deployment
|
||||
keeps its secrets, and tying the two breaks the moment the package moves.
|
||||
Every unit sets WorkingDirectory=/srv/astrape, so the default is correct
|
||||
for the daemons and for a shell started in the project root.
|
||||
"""
|
||||
|
||||
def __init__(self, env_dir: Path | None = None) -> None:
|
||||
if env_dir is None:
|
||||
env_dir = Path(environ.get("ASTRAPE_ENV_DIR", "env"))
|
||||
self.env_dir = env_dir
|
||||
|
||||
def load(self) -> None:
|
||||
if not self.env_dir.exists():
|
||||
return
|
||||
|
||||
for path in sorted(self.env_dir.glob("*.env")):
|
||||
self._load_file(path)
|
||||
|
||||
def _load_file(self, path: Path) -> None:
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = self._clean_value(value.strip())
|
||||
if key and key not in environ:
|
||||
environ[key] = value
|
||||
|
||||
def _clean_value(self, value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
@@ -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 astrape_ems.classes.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,561 @@
|
||||
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_power_series(
|
||||
self,
|
||||
step_seconds: int,
|
||||
lookback: timedelta = timedelta(days=30),
|
||||
) -> list[dict[str, object]]:
|
||||
"""Bucketed plant power history at an arbitrary resolution.
|
||||
|
||||
Backs sequence-model training, which needs the same history resampled
|
||||
at several scales. Buckets with no underlying poll are absent rather
|
||||
than null, so callers must tolerate gaps.
|
||||
"""
|
||||
start_at = datetime.now(timezone.utc) - lookback
|
||||
bucket = self._bucket_interval(step_seconds)
|
||||
with self._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
time_bucket('{bucket}', observed_at) AS bucket,
|
||||
avg(load_power_w) AS load_power_w,
|
||||
avg(solar_power_w) AS solar_power_w,
|
||||
avg(grid_import_w) AS grid_import_w,
|
||||
avg(grid_export_w) AS grid_export_w,
|
||||
avg(battery_power_w) AS battery_power_w,
|
||||
avg(battery_soc_pct) AS battery_soc_pct
|
||||
FROM sigen_plant_snapshots
|
||||
WHERE observed_at >= %s
|
||||
AND observed_at <= now()
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
""",
|
||||
(start_at,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"bucket": row[0],
|
||||
"load_power_w": row[1],
|
||||
"solar_power_w": row[2],
|
||||
"grid_import_w": row[3],
|
||||
"grid_export_w": row[4],
|
||||
"battery_power_w": row[5],
|
||||
"battery_soc_pct": row[6],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def _bucket_interval(self, step_seconds: int) -> str:
|
||||
if step_seconds % 60 == 0:
|
||||
return f"{step_seconds // 60} minutes"
|
||||
return f"{step_seconds} seconds"
|
||||
|
||||
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
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from os import environ
|
||||
from sys import stderr
|
||||
from time import sleep
|
||||
|
||||
from astrape_ems.classes.env import EnvLoader
|
||||
from astrape_ems.classes.client import SigenPlantClient
|
||||
from astrape_ems.classes.store import SigenStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SigenDaemonConfig:
|
||||
poll_seconds: float
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "SigenDaemonConfig":
|
||||
return cls(
|
||||
poll_seconds=float(environ.get("SIGEN_POLL_SECONDS", "5")),
|
||||
)
|
||||
|
||||
|
||||
class SigenDaemon:
|
||||
"""Polls Sigenergy plant metrics and stores normalized snapshots."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SigenDaemonConfig,
|
||||
plant_client: SigenPlantClient,
|
||||
sigen_store: SigenStore,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.plant_client = plant_client
|
||||
self.sigen_store = sigen_store
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "SigenDaemon":
|
||||
return cls(
|
||||
config=SigenDaemonConfig.from_env(),
|
||||
plant_client=SigenPlantClient.from_env(),
|
||||
sigen_store=SigenStore.from_env(),
|
||||
)
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.sigen_store.initialize()
|
||||
|
||||
def run_once(self) -> int:
|
||||
snapshot = self.plant_client.fetch_snapshot()
|
||||
return self.sigen_store.save_snapshot(snapshot)
|
||||
|
||||
def run_forever(self) -> None:
|
||||
self.initialize()
|
||||
while True:
|
||||
try:
|
||||
saved_count = self.run_once()
|
||||
print(f"stored_sigen_plant_snapshots={saved_count}", flush=True)
|
||||
except Exception as error:
|
||||
print(f"sigen_poll_error={error}", file=stderr, flush=True)
|
||||
|
||||
sleep(self.config.poll_seconds)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
EnvLoader().load()
|
||||
daemon = SigenDaemon.from_env()
|
||||
args = parse_args()
|
||||
if args.once:
|
||||
daemon.initialize()
|
||||
saved_count = daemon.run_once()
|
||||
print(f"stored_sigen_plant_snapshots={saved_count}", flush=True)
|
||||
return
|
||||
|
||||
daemon.run_forever()
|
||||
except Exception as error:
|
||||
print(f"sigen_daemon_startup_error={error}", file=stderr)
|
||||
raise SystemExit(1) from error
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Poll Sigenergy plant metrics into Astrape's database."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="Initialize storage, save one snapshot, and exit.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug script to inspect stored Sigenergy plant snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import timezone
|
||||
import json
|
||||
|
||||
from astrape_ems.classes.env import EnvLoader
|
||||
from astrape_ems.classes.store import SigenStore
|
||||
|
||||
|
||||
def main() -> None:
|
||||
EnvLoader().load()
|
||||
args = parse_args()
|
||||
store = SigenStore.from_env()
|
||||
|
||||
if args.view == "raw":
|
||||
rows = load_raw_snapshots(store, args.limit)
|
||||
print_raw_snapshots(rows)
|
||||
return
|
||||
|
||||
rows = load_rollup(store, args.view, args.limit)
|
||||
print_rollup(rows, args.view)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect stored Sigenergy plant snapshots."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--view",
|
||||
choices=("raw", "1m", "15m", "1h"),
|
||||
default="raw",
|
||||
help="View to inspect. Defaults to raw.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of most recent rows/buckets to show. Defaults to 10.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_raw_snapshots(store: SigenStore, limit: int) -> list[tuple]:
|
||||
with store._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
observed_at,
|
||||
received_at,
|
||||
solar_power_w,
|
||||
load_power_w,
|
||||
battery_soc_pct,
|
||||
battery_power_w,
|
||||
grid_import_w,
|
||||
grid_export_w,
|
||||
plant_active_power_w,
|
||||
accumulated_pv_energy_kwh,
|
||||
daily_consumed_energy_kwh,
|
||||
raw_values
|
||||
FROM sigen_plant_snapshots
|
||||
ORDER BY observed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
def load_rollup(store: SigenStore, view: str, limit: int) -> list[tuple]:
|
||||
view_name = {
|
||||
"1m": "sigen_plant_snapshots_1m",
|
||||
"15m": "sigen_plant_snapshots_15m",
|
||||
"1h": "sigen_plant_snapshots_1h",
|
||||
}[view]
|
||||
|
||||
with store._connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
bucket,
|
||||
sample_count,
|
||||
avg_solar_power_w,
|
||||
max_solar_power_w,
|
||||
avg_load_power_w,
|
||||
max_load_power_w,
|
||||
avg_grid_import_w,
|
||||
max_grid_import_w,
|
||||
avg_grid_export_w,
|
||||
max_grid_export_w,
|
||||
avg_battery_soc_pct
|
||||
FROM {view_name}
|
||||
ORDER BY bucket DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
def print_raw_snapshots(rows: list[tuple]) -> None:
|
||||
print(f"raw_snapshots={len(rows)}")
|
||||
for row in rows:
|
||||
(
|
||||
observed_at,
|
||||
received_at,
|
||||
solar_power_w,
|
||||
load_power_w,
|
||||
battery_soc_pct,
|
||||
battery_power_w,
|
||||
grid_import_w,
|
||||
grid_export_w,
|
||||
plant_active_power_w,
|
||||
accumulated_pv_energy_kwh,
|
||||
daily_consumed_energy_kwh,
|
||||
raw_values,
|
||||
) = row
|
||||
print(
|
||||
f"{_fmt_time(observed_at)} "
|
||||
f"solar={_fmt_w(solar_power_w)} "
|
||||
f"load={_fmt_w(load_power_w)} "
|
||||
f"soc={_fmt_pct(battery_soc_pct)} "
|
||||
f"battery={_fmt_w(battery_power_w)} "
|
||||
f"import={_fmt_w(grid_import_w)} "
|
||||
f"export={_fmt_w(grid_export_w)} "
|
||||
f"plant={_fmt_w(plant_active_power_w)} "
|
||||
f"pv_total={_fmt_kwh(accumulated_pv_energy_kwh)} "
|
||||
f"load_today={_fmt_kwh(daily_consumed_energy_kwh)} "
|
||||
f"lag_s={(received_at - observed_at).total_seconds():.1f}"
|
||||
)
|
||||
if raw_values and any(key.endswith("_error") for key in raw_values):
|
||||
errors = {
|
||||
key: value
|
||||
for key, value in raw_values.items()
|
||||
if key.endswith("_error")
|
||||
}
|
||||
print(f" errors={json.dumps(errors, default=str)}")
|
||||
|
||||
|
||||
def print_rollup(rows: list[tuple], view: str) -> None:
|
||||
print(f"{view}_buckets={len(rows)}")
|
||||
for row in rows:
|
||||
(
|
||||
bucket,
|
||||
sample_count,
|
||||
avg_solar_power_w,
|
||||
max_solar_power_w,
|
||||
avg_load_power_w,
|
||||
max_load_power_w,
|
||||
avg_grid_import_w,
|
||||
max_grid_import_w,
|
||||
avg_grid_export_w,
|
||||
max_grid_export_w,
|
||||
avg_battery_soc_pct,
|
||||
) = row
|
||||
print(
|
||||
f"{_fmt_time(bucket)} samples={sample_count:4} "
|
||||
f"solar_avg={_fmt_w(avg_solar_power_w)} "
|
||||
f"solar_max={_fmt_w(max_solar_power_w)} "
|
||||
f"load_avg={_fmt_w(avg_load_power_w)} "
|
||||
f"load_max={_fmt_w(max_load_power_w)} "
|
||||
f"import_avg={_fmt_w(avg_grid_import_w)} "
|
||||
f"import_max={_fmt_w(max_grid_import_w)} "
|
||||
f"export_avg={_fmt_w(avg_grid_export_w)} "
|
||||
f"export_max={_fmt_w(max_grid_export_w)} "
|
||||
f"soc_avg={_fmt_pct(avg_battery_soc_pct)}"
|
||||
)
|
||||
|
||||
|
||||
def _fmt_time(value) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _fmt_w(value) -> str:
|
||||
if value is None:
|
||||
return "None"
|
||||
return f"{value:.0f}W"
|
||||
|
||||
|
||||
def _fmt_pct(value) -> str:
|
||||
if value is None:
|
||||
return "None"
|
||||
return f"{value:.1f}%"
|
||||
|
||||
|
||||
def _fmt_kwh(value) -> str:
|
||||
if value is None:
|
||||
return "None"
|
||||
return f"{value:.2f}kWh"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explore a Sigenergy plant or inverter over Modbus TCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from os import environ
|
||||
|
||||
from astrape_ems.classes.client import SigenPlantClient
|
||||
from astrape_ems.classes.env import EnvLoader
|
||||
from astrape_ems.classes.modbus import (
|
||||
ModbusReadError,
|
||||
ModbusReadResult,
|
||||
RegisterKind,
|
||||
SigenModbusClient,
|
||||
)
|
||||
from astrape_ems.classes.registers import (
|
||||
DEFAULT_INVERTER_REGISTER_NAMES,
|
||||
DEFAULT_PLANT_REGISTER_NAMES,
|
||||
INVERTER_REGISTERS,
|
||||
PLANT_PARAMETER_REGISTERS,
|
||||
PLANT_REGISTERS,
|
||||
SigenRegister,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_KINDS: tuple[RegisterKind, ...] = ("holding", "input")
|
||||
ALL_KINDS: tuple[RegisterKind, ...] = ("holding", "input", "coil", "discrete")
|
||||
DEFAULT_UNIT_CANDIDATES = (0, 1, 2, 3, 247, 255)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
EnvLoader().load()
|
||||
args = parse_args()
|
||||
|
||||
if args.command == "units":
|
||||
results = probe_units(args)
|
||||
print_results(results, errors=True)
|
||||
return
|
||||
if args.command == "catalog":
|
||||
if args.group in {"plant", "all"}:
|
||||
print_catalog("Plant Sensors", PLANT_REGISTERS)
|
||||
if args.group in {"params", "all"}:
|
||||
print_catalog("Plant Parameters", PLANT_PARAMETER_REGISTERS)
|
||||
return
|
||||
if args.command == "snapshot":
|
||||
snapshot = SigenPlantClient.from_env().fetch_snapshot()
|
||||
print(json.dumps(asdict(snapshot), indent=2, default=str))
|
||||
return
|
||||
|
||||
with SigenModbusClient(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
unit_id=args.unit_id,
|
||||
timeout=args.timeout,
|
||||
retries=args.retries,
|
||||
trace=args.trace,
|
||||
) as client:
|
||||
if args.command == "probe":
|
||||
print(
|
||||
f"Connected to {args.host}:{args.port} "
|
||||
f"with unit id {args.unit_id}"
|
||||
)
|
||||
return
|
||||
if args.command == "plant":
|
||||
print_known_registers(client, args.register, PLANT_REGISTERS)
|
||||
return
|
||||
if args.command == "inverter":
|
||||
print_known_registers(client, args.register, INVERTER_REGISTERS)
|
||||
return
|
||||
if args.command == "read":
|
||||
try:
|
||||
result = client.read(args.kind, args.address, args.count)
|
||||
print_results([result], errors=True)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"{args.kind:8} {args.address:5} "
|
||||
f"+{args.count:<3} ERROR {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
results: list[ModbusReadResult | ModbusReadError] = []
|
||||
for kind in args.kind:
|
||||
results.extend(
|
||||
client.scan(
|
||||
kind=kind,
|
||||
start=args.start,
|
||||
count=args.count,
|
||||
chunk_size=args.chunk_size,
|
||||
)
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps([asdict(result) for result in results], indent=2))
|
||||
else:
|
||||
print_results(results, errors=args.errors)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Minimal Modbus TCP explorer for a Sigenergy plant/inverter."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=environ.get("SIGEN_MODBUS_HOST"),
|
||||
required="SIGEN_MODBUS_HOST" not in environ,
|
||||
help="Modbus TCP host or IP. Can also be set as SIGEN_MODBUS_HOST.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=int(environ.get("SIGEN_MODBUS_PORT", "502")),
|
||||
help="Modbus TCP port. Defaults to 502.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--unit-id",
|
||||
type=int,
|
||||
default=int(environ.get("SIGEN_MODBUS_UNIT_ID", "1")),
|
||||
help="Modbus unit/slave id. Defaults to 1.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=float(environ.get("SIGEN_MODBUS_TIMEOUT", "5")),
|
||||
help="Socket timeout in seconds. Defaults to 5.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retries",
|
||||
type=int,
|
||||
default=int(environ.get("SIGEN_MODBUS_RETRIES", "3")),
|
||||
help="Modbus request retries. Defaults to 3.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trace",
|
||||
action="store_true",
|
||||
help="Print Modbus TCP packet bytes to stderr.",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("probe", help="Open a connection and report success.")
|
||||
subparsers.add_parser(
|
||||
"snapshot",
|
||||
help="Read core plant metrics and print the builder snapshot as JSON.",
|
||||
)
|
||||
|
||||
catalog = subparsers.add_parser(
|
||||
"catalog",
|
||||
help="List known Sigenergy plant sensors and settable parameters.",
|
||||
)
|
||||
catalog.add_argument(
|
||||
"group",
|
||||
choices=("plant", "params", "all"),
|
||||
nargs="?",
|
||||
default="all",
|
||||
help="Catalog group to list. Defaults to all.",
|
||||
)
|
||||
|
||||
units = subparsers.add_parser(
|
||||
"units",
|
||||
help="Try small reads against likely unit ids.",
|
||||
)
|
||||
units.add_argument(
|
||||
"--candidate",
|
||||
action="append",
|
||||
type=int,
|
||||
default=None,
|
||||
help=(
|
||||
"Unit id candidate to try. Repeat for multiple ids. "
|
||||
"Defaults to 0, 1, 2, 3, 247, and 255."
|
||||
),
|
||||
)
|
||||
units.add_argument(
|
||||
"--kind",
|
||||
action="append",
|
||||
choices=ALL_KINDS,
|
||||
default=None,
|
||||
help=(
|
||||
"Register table to test. Repeat for multiple kinds. "
|
||||
"Defaults to holding and input."
|
||||
),
|
||||
)
|
||||
units.add_argument(
|
||||
"--address",
|
||||
type=int,
|
||||
default=30000,
|
||||
help="Address to test. Defaults to 30000.",
|
||||
)
|
||||
units.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of values to request. Defaults to 1.",
|
||||
)
|
||||
|
||||
plant = subparsers.add_parser(
|
||||
"plant",
|
||||
help="Read a small set of known Sigenergy plant registers.",
|
||||
)
|
||||
plant.add_argument(
|
||||
"--register",
|
||||
action="append",
|
||||
choices=sorted(PLANT_REGISTERS),
|
||||
default=None,
|
||||
help="Known plant register to read. Repeat for multiple registers.",
|
||||
)
|
||||
|
||||
inverter = subparsers.add_parser(
|
||||
"inverter",
|
||||
help="Read a small set of known Sigenergy inverter registers.",
|
||||
)
|
||||
inverter.add_argument(
|
||||
"--register",
|
||||
action="append",
|
||||
choices=sorted(INVERTER_REGISTERS),
|
||||
default=None,
|
||||
help="Known inverter register to read. Repeat for multiple registers.",
|
||||
)
|
||||
|
||||
read = subparsers.add_parser(
|
||||
"read",
|
||||
help="Read one raw Modbus register range.",
|
||||
)
|
||||
read.add_argument(
|
||||
"kind",
|
||||
choices=ALL_KINDS,
|
||||
help="Register table to read.",
|
||||
)
|
||||
read.add_argument(
|
||||
"address",
|
||||
type=int,
|
||||
help="Modbus address to read.",
|
||||
)
|
||||
read.add_argument(
|
||||
"count",
|
||||
type=int,
|
||||
nargs="?",
|
||||
default=1,
|
||||
help="Number of values to read. Defaults to 1.",
|
||||
)
|
||||
|
||||
scan = subparsers.add_parser("scan", help="Scan register ranges in chunks.")
|
||||
scan.add_argument(
|
||||
"--kind",
|
||||
action="append",
|
||||
choices=ALL_KINDS,
|
||||
default=None,
|
||||
help=(
|
||||
"Register table to scan. Repeat for multiple kinds. "
|
||||
"Defaults to holding and input."
|
||||
),
|
||||
)
|
||||
scan.add_argument(
|
||||
"--start",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Starting zero-based Modbus address. Defaults to 0.",
|
||||
)
|
||||
scan.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of addresses to scan. Defaults to 100.",
|
||||
)
|
||||
scan.add_argument(
|
||||
"--chunk-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Addresses per Modbus request. Defaults to 10.",
|
||||
)
|
||||
scan.add_argument(
|
||||
"--errors",
|
||||
action="store_true",
|
||||
help="Show failed chunks as well as successful reads.",
|
||||
)
|
||||
scan.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print raw result objects as JSON.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "scan" and args.kind is None:
|
||||
args.kind = list(DEFAULT_KINDS)
|
||||
if args.command == "units":
|
||||
if args.kind is None:
|
||||
args.kind = list(DEFAULT_KINDS)
|
||||
if args.candidate is None:
|
||||
args.candidate = list(DEFAULT_UNIT_CANDIDATES)
|
||||
if args.command == "plant" and args.register is None:
|
||||
args.register = list(DEFAULT_PLANT_REGISTER_NAMES)
|
||||
if args.command == "inverter" and args.register is None:
|
||||
args.register = list(DEFAULT_INVERTER_REGISTER_NAMES)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def probe_units(args: argparse.Namespace) -> list[ModbusReadResult | ModbusReadError]:
|
||||
results: list[ModbusReadResult | ModbusReadError] = []
|
||||
for unit_id in args.candidate:
|
||||
with SigenModbusClient(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
unit_id=unit_id,
|
||||
timeout=args.timeout,
|
||||
retries=args.retries,
|
||||
trace=args.trace,
|
||||
) as client:
|
||||
for kind in args.kind:
|
||||
try:
|
||||
result = client.read(kind, args.address, args.count)
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
ModbusReadError(
|
||||
kind=kind,
|
||||
address=args.address,
|
||||
count=args.count,
|
||||
error=f"unit {unit_id}: {exc}",
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def print_known_registers(
|
||||
client: SigenModbusClient,
|
||||
register_names: list[str],
|
||||
registers: dict[str, SigenRegister],
|
||||
) -> None:
|
||||
for register_name in register_names:
|
||||
register = registers[register_name]
|
||||
try:
|
||||
result = client.read(register.kind, register.address, register.count)
|
||||
value = register.decode(result.values)
|
||||
unit = f" {register.unit}" if register.unit else ""
|
||||
raw_values = " ".join(str(value) for value in result.values)
|
||||
print(
|
||||
f"{register.name:32} {value}{unit:4} "
|
||||
f"({register.kind} {register.address} +{register.count}: {raw_values})"
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"{register.name:32} ERROR "
|
||||
f"({register.kind} {register.address} +{register.count}: {exc})"
|
||||
)
|
||||
|
||||
|
||||
def print_catalog(title: str, registers: dict[str, SigenRegister]) -> None:
|
||||
print(title)
|
||||
print("-" * len(title))
|
||||
for register in registers.values():
|
||||
unit = register.unit or ""
|
||||
description = register.description or ""
|
||||
print(
|
||||
f"{register.name:48} {register.kind:7} "
|
||||
f"{register.address:5} +{register.count:<2} "
|
||||
f"{register.data_type:6} gain={register.gain:<7g} "
|
||||
f"{unit:5} {description}"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
def print_results(
|
||||
results: list[ModbusReadResult | ModbusReadError],
|
||||
errors: bool,
|
||||
) -> None:
|
||||
for result in results:
|
||||
if isinstance(result, ModbusReadError):
|
||||
if errors:
|
||||
print(
|
||||
f"{result.kind:8} {result.address:5} "
|
||||
f"+{result.count:<3} ERROR {result.error}"
|
||||
)
|
||||
continue
|
||||
|
||||
values = " ".join(str(value) for value in result.values)
|
||||
print(f"{result.kind:8} {result.address:5} +{result.count:<3} {values}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "astrape-ems"
|
||||
version = "0.1.0"
|
||||
description = "Energy backend connection: Sigenergy Modbus client and plant store"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["gibil", "psycopg[binary]>=3.2,<4", "pymodbus>=3.8,<4"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["astrape_ems*"]
|
||||
Reference in New Issue
Block a user