Copilot refactor
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
from typing import Iterable, List
|
||||
|
||||
from .models import PowerMeterReadings
|
||||
|
||||
|
||||
class PowerMeterDataHandler:
|
||||
"""Parses Modbus RTU responses and computes derived power metrics."""
|
||||
|
||||
def parse_read_holding_registers_response(self, response: bytes) -> List[int]:
|
||||
# Response layout: [slave][func][byte_count][data...][crc_lo][crc_hi]
|
||||
# We keep validation intentionally lightweight to match prior script behavior.
|
||||
if len(response) < 5:
|
||||
raise RuntimeError("No valid response")
|
||||
|
||||
byte_count = response[2]
|
||||
data_start = 3
|
||||
data_end = data_start + byte_count
|
||||
if len(response) < data_end:
|
||||
raise RuntimeError("Truncated response")
|
||||
|
||||
data = response[data_start:data_end]
|
||||
if len(data) % 2 != 0:
|
||||
raise RuntimeError("Invalid byte_count (must be even)")
|
||||
|
||||
return self._bytes_to_registers_be(data)
|
||||
|
||||
def decode_readings(self, registers: List[int]) -> PowerMeterReadings:
|
||||
active_power = self.float_dcba(registers, 0)
|
||||
rms_current = self.float_dcba(registers, 2)
|
||||
voltage = self.float_dcba(registers, 4)
|
||||
frequency = self.float_dcba(registers, 6)
|
||||
power_factor = self.float_dcba(registers, 8)
|
||||
annual_power_consumption = self.float_dcba(registers, 10)
|
||||
active_consumption = self.float_dcba(registers, 12)
|
||||
reactive_consumption = self.float_dcba(registers, 14)
|
||||
load_time_hours = self.float_dcba(registers, 16) / 60.0
|
||||
work_hours_per_day = int(registers[18])
|
||||
device_address = int(registers[19])
|
||||
|
||||
s_from_vi = self.apparent_from_vi(voltage, rms_current)
|
||||
s_from_pf = self.apparent_from_pf(active_power, power_factor)
|
||||
q_calculated = self.reactive_from_p_s(active_power, s_from_pf)
|
||||
s_from_pq = self.apparent_from_p_q(active_consumption, reactive_consumption)
|
||||
|
||||
return PowerMeterReadings(
|
||||
active_power_w=active_power,
|
||||
rms_current_a=rms_current,
|
||||
voltage_v=voltage,
|
||||
frequency_hz=frequency,
|
||||
power_factor=power_factor,
|
||||
annual_power_consumption_kwh=annual_power_consumption,
|
||||
active_consumption_kwh=active_consumption,
|
||||
reactive_consumption_kwh=reactive_consumption,
|
||||
load_time_hours=load_time_hours,
|
||||
work_hours_per_day=work_hours_per_day,
|
||||
device_address=device_address,
|
||||
apparent_power_vi_va=s_from_vi,
|
||||
apparent_power_pf_va=s_from_pf,
|
||||
reactive_power_var=q_calculated,
|
||||
apparent_consumption_kvah=s_from_pq,
|
||||
)
|
||||
|
||||
def readings_to_flat_dict(self, readings: PowerMeterReadings) -> dict:
|
||||
# dataclasses.asdict() is fine, but we keep this local to the data layer
|
||||
# so the entrypoint stays minimal.
|
||||
return {
|
||||
"active_power_w": readings.active_power_w,
|
||||
"rms_current_a": readings.rms_current_a,
|
||||
"voltage_v": readings.voltage_v,
|
||||
"frequency_hz": readings.frequency_hz,
|
||||
"power_factor": readings.power_factor,
|
||||
"annual_power_consumption_kwh": readings.annual_power_consumption_kwh,
|
||||
"active_consumption_kwh": readings.active_consumption_kwh,
|
||||
"reactive_consumption_kwh": readings.reactive_consumption_kwh,
|
||||
"load_time_hours": readings.load_time_hours,
|
||||
"work_hours_per_day": readings.work_hours_per_day,
|
||||
"device_address": readings.device_address,
|
||||
"apparent_power_vi_va": readings.apparent_power_vi_va,
|
||||
"apparent_power_pf_va": readings.apparent_power_pf_va,
|
||||
"reactive_power_var": readings.reactive_power_var,
|
||||
"apparent_consumption_kvah": readings.apparent_consumption_kvah,
|
||||
}
|
||||
|
||||
def readings_to_json(self, readings: PowerMeterReadings, *, ensure_ascii: bool = False) -> str:
|
||||
return json.dumps(self.readings_to_flat_dict(readings), ensure_ascii=ensure_ascii)
|
||||
|
||||
def print_readings_json(self, readings: PowerMeterReadings) -> None:
|
||||
print(self.readings_to_json(readings, ensure_ascii=False))
|
||||
|
||||
@staticmethod
|
||||
def _bytes_to_registers_be(data: bytes) -> List[int]:
|
||||
registers: List[int] = []
|
||||
for i in range(0, len(data), 2):
|
||||
registers.append((data[i] << 8) | data[i + 1])
|
||||
return registers
|
||||
|
||||
@staticmethod
|
||||
def float_dcba(regs: List[int], index: int) -> float:
|
||||
w1 = regs[index]
|
||||
w2 = regs[index + 1]
|
||||
b = bytes(
|
||||
[
|
||||
(w2 & 0xFF),
|
||||
(w2 >> 8) & 0xFF,
|
||||
(w1 & 0xFF),
|
||||
(w1 >> 8) & 0xFF,
|
||||
]
|
||||
)
|
||||
return struct.unpack(">f", b)[0]
|
||||
|
||||
@staticmethod
|
||||
def apparent_from_vi(voltage_v: float, current_a: float) -> float:
|
||||
if voltage_v == 0 or current_a == 0:
|
||||
return 0.0
|
||||
return voltage_v * current_a
|
||||
|
||||
@staticmethod
|
||||
def apparent_from_pf(active_power_w: float, power_factor: float) -> float:
|
||||
if power_factor == 0:
|
||||
return 0.0
|
||||
return active_power_w / power_factor
|
||||
|
||||
@staticmethod
|
||||
def reactive_from_p_s(active_power_w: float, apparent_power_va: float) -> float:
|
||||
if apparent_power_va < active_power_w:
|
||||
return 0.0
|
||||
return math.sqrt(apparent_power_va**2 - active_power_w**2)
|
||||
|
||||
@staticmethod
|
||||
def apparent_from_p_q(active_kwh: float, reactive_kwh: float) -> float:
|
||||
if active_kwh == 0 and reactive_kwh == 0:
|
||||
return 0.0
|
||||
return math.sqrt(active_kwh**2 + reactive_kwh**2)
|
||||
@@ -0,0 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PowerMeterReadings:
|
||||
active_power_w: float
|
||||
rms_current_a: float
|
||||
voltage_v: float
|
||||
frequency_hz: float
|
||||
power_factor: float
|
||||
annual_power_consumption_kwh: float
|
||||
active_consumption_kwh: float
|
||||
reactive_consumption_kwh: float
|
||||
load_time_hours: float
|
||||
work_hours_per_day: int
|
||||
device_address: int
|
||||
|
||||
apparent_power_vi_va: float
|
||||
apparent_power_pf_va: float
|
||||
reactive_power_var: float
|
||||
apparent_consumption_kvah: float
|
||||
@@ -0,0 +1,115 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SerialConfig:
|
||||
port: str = "/dev/ttyUSB0"
|
||||
baudrate: int = 9600
|
||||
timeout: float = 0.1
|
||||
bytesize: int = serial.EIGHTBITS
|
||||
parity: str = serial.PARITY_NONE
|
||||
stopbits: int = serial.STOPBITS_ONE
|
||||
|
||||
|
||||
class ModbusRtuSerialClient:
|
||||
"""Minimal Modbus RTU client over a serial port.
|
||||
|
||||
Only implements function 0x03 (Read Holding Registers).
|
||||
"""
|
||||
|
||||
def __init__(self, config: SerialConfig):
|
||||
self._config = config
|
||||
self._ser: Optional[serial.Serial] = None
|
||||
|
||||
def __enter__(self) -> "ModbusRtuSerialClient":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._ser is not None and self._ser.is_open
|
||||
|
||||
def open(self) -> None:
|
||||
if self.is_open:
|
||||
return
|
||||
self._ser = serial.Serial(
|
||||
port=self._config.port,
|
||||
baudrate=self._config.baudrate,
|
||||
bytesize=self._config.bytesize,
|
||||
parity=self._config.parity,
|
||||
stopbits=self._config.stopbits,
|
||||
timeout=self._config.timeout,
|
||||
)
|
||||
self._ser.reset_input_buffer()
|
||||
self._ser.reset_output_buffer()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._ser is not None:
|
||||
try:
|
||||
self._ser.close()
|
||||
finally:
|
||||
self._ser = None
|
||||
|
||||
def read_holding_registers(
|
||||
self,
|
||||
slave_id: int,
|
||||
start_register: int,
|
||||
register_count: int,
|
||||
*,
|
||||
read_max_bytes: int = 64,
|
||||
exact_response_length: bool = True,
|
||||
) -> bytes:
|
||||
if not self.is_open:
|
||||
raise RuntimeError("Serial port is not open")
|
||||
if not (0 <= slave_id <= 247):
|
||||
raise ValueError("slave_id must be in range 0..247")
|
||||
if not (0 <= start_register <= 0xFFFF):
|
||||
raise ValueError("start_register must be in range 0..65535")
|
||||
if not (1 <= register_count <= 125):
|
||||
raise ValueError("register_count must be in range 1..125")
|
||||
|
||||
request_wo_crc = bytes(
|
||||
[
|
||||
slave_id,
|
||||
0x03,
|
||||
(start_register >> 8) & 0xFF,
|
||||
start_register & 0xFF,
|
||||
(register_count >> 8) & 0xFF,
|
||||
register_count & 0xFF,
|
||||
]
|
||||
)
|
||||
request = request_wo_crc + self._crc16_modbus_le(request_wo_crc)
|
||||
|
||||
ser = self._ser
|
||||
assert ser is not None
|
||||
ser.reset_input_buffer()
|
||||
ser.reset_output_buffer()
|
||||
ser.write(request)
|
||||
ser.flush()
|
||||
|
||||
if exact_response_length:
|
||||
# Expected RTU response length for 0x03:
|
||||
# slave(1) + func(1) + byte_count(1) + data(2*N) + crc(2)
|
||||
expected_len = 5 + (2 * register_count)
|
||||
return ser.read(expected_len)
|
||||
|
||||
return ser.read(read_max_bytes)
|
||||
|
||||
@staticmethod
|
||||
def _crc16_modbus_le(payload: bytes) -> bytes:
|
||||
"""Compute Modbus CRC16 and return as little-endian 2 bytes."""
|
||||
crc = 0xFFFF
|
||||
for byte in payload:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x0001:
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
else:
|
||||
crc >>= 1
|
||||
return bytes([crc & 0xFF, (crc >> 8) & 0xFF])
|
||||
Reference in New Issue
Block a user