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:
rpotter6298
2026-08-30 14:19:01 +02:00
commit 5b562338ab
15 changed files with 2187 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from datetime import datetime, timezone
from os import environ
from typing import Any
from gibil.classes.models import SigenPlantSnapshot
from 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)