c8e3016fd6
- 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.
183 lines
5.3 KiB
Python
183 lines
5.3 KiB
Python
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
|