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:
rpotter6298
2026-04-28 08:14:00 +02:00
parent ff0c65a794
commit c8e3016fd6
55 changed files with 6385 additions and 633 deletions
+182
View File
@@ -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