d5f3c0f7ac
The checkout is now astrape/astrape-ems, not astrape/astrape-ems/<package>. The package contents move to the repository root and pyproject maps the package name to '.' via package-dir, with the subpackages listed explicitly. The systemd units move out to /srv/astrape/deploy/systemd, outside every repository, because they describe this deployment rather than any one component. Verified both install modes: editable, which is how the deployment runs, and a plain wheel install, which is what the container build does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
6.0 KiB
Python
176 lines
6.0 KiB
Python
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)
|