Merge PR #24: feat(meter): read the SDM630 house meter into electricity.PRAD2
This commit is contained in:
commit
3705a57155
4 files changed
+433
No files matched your search
@@ -0,0 +1,99 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import home.gateway.adapters.meter.Sdm630Registers.Register;
|
||||
import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** The Eastron SDM630 house meter: all input registers, one point per poll. */
|
||||
public final class HouseMeterDevice implements Device {
|
||||
|
||||
private static final int UNIT_ID = 1;
|
||||
private static final int MAX_REGISTERS_PER_REQUEST = 125;
|
||||
private static final Duration INTERVAL = Duration.ofSeconds(60);
|
||||
private static final int DECIMALS = 2;
|
||||
|
||||
private record Block(int address, int count, List<Register> registers) {
|
||||
}
|
||||
|
||||
private final ModbusClient modbus;
|
||||
private final List<Block> blocks = blocksOf(Sdm630Registers.INPUT);
|
||||
|
||||
public HouseMeterDevice(ModbusClient modbus) {
|
||||
this.modbus = modbus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "house-meter";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration interval() {
|
||||
return INTERVAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Reading> read(Instant now) throws IOException {
|
||||
Map<String, Double> fields = new LinkedHashMap<>();
|
||||
for (Block block : blocks) {
|
||||
readBlock(block, fields);
|
||||
}
|
||||
if (fields.isEmpty()) {
|
||||
throw new IOException("house meter returned no finite value");
|
||||
}
|
||||
return List.of(new Reading("electricity", "PRAD2", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
}
|
||||
|
||||
private void readBlock(Block block, Map<String, Double> fields) throws IOException {
|
||||
int[] words = modbus.readInputRegisters(UNIT_ID, block.address(), block.count());
|
||||
if (words.length != block.count()) {
|
||||
throw new IOException("house meter block at " + block.address() + " returned "
|
||||
+ words.length + " registers, expected " + block.count());
|
||||
}
|
||||
for (Register register : block.registers()) {
|
||||
int offset = register.address() - block.address();
|
||||
float value = Float.intBitsToFloat((words[offset] << 16) | words[offset + 1]);
|
||||
// The old Python code lost the whole point on NaN or infinity; one bad value
|
||||
// should not cost the other 70.
|
||||
if (Float.isFinite(value)) {
|
||||
fields.put(register.name(), round(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same as Python's {@code round(float(x), 2)}: the exact binary value, ties to even. */
|
||||
private static double round(float value) {
|
||||
return new BigDecimal(value).setScale(DECIMALS, RoundingMode.HALF_EVEN).doubleValue();
|
||||
}
|
||||
|
||||
private static List<Block> blocksOf(List<Register> registers) {
|
||||
Map<Integer, List<Register>> byBatch = new TreeMap<>();
|
||||
for (Register register : registers) {
|
||||
byBatch.computeIfAbsent(register.batch(), batch -> new ArrayList<>()).add(register);
|
||||
}
|
||||
List<Block> result = new ArrayList<>();
|
||||
for (List<Register> batch : byBatch.values()) {
|
||||
int first = batch.getFirst().address();
|
||||
int count = batch.getLast().address() + Sdm630Registers.WORDS_PER_VALUE - first;
|
||||
if (count > MAX_REGISTERS_PER_REQUEST) {
|
||||
throw new IllegalStateException("block at " + first + " has " + count
|
||||
+ " registers, limit " + MAX_REGISTERS_PER_REQUEST);
|
||||
}
|
||||
result.add(new Block(first, count, batch));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The SDM630 input registers, as sdm_modbus 0.4.4 reads them in {@code read_all()}. Names are the
|
||||
* field names of {@code electricity.PRAD2}. Every value is a float32 in two registers, unscaled.
|
||||
* The batch number is the library's: registers of one batch are fetched with one request.
|
||||
*/
|
||||
final class Sdm630Registers {
|
||||
|
||||
static final int WORDS_PER_VALUE = 2;
|
||||
|
||||
record Register(String name, int address, int batch) {
|
||||
}
|
||||
|
||||
static final List<Register> INPUT = List.of(
|
||||
new Register("p1_voltage", 0x0000, 1),
|
||||
new Register("p2_voltage", 0x0002, 1),
|
||||
new Register("p3_voltage", 0x0004, 1),
|
||||
new Register("p1_current", 0x0006, 1),
|
||||
new Register("p2_current", 0x0008, 1),
|
||||
new Register("p3_current", 0x000a, 1),
|
||||
new Register("p1_power_active", 0x000c, 1),
|
||||
new Register("p2_power_active", 0x000e, 1),
|
||||
new Register("p3_power_active", 0x0010, 1),
|
||||
new Register("p1_power_apparent", 0x0012, 1),
|
||||
new Register("p2_power_apparent", 0x0014, 1),
|
||||
new Register("p3_power_apparent", 0x0016, 1),
|
||||
new Register("p1_power_reactive", 0x0018, 1),
|
||||
new Register("p2_power_reactive", 0x001a, 1),
|
||||
new Register("p3_power_reactive", 0x001c, 1),
|
||||
new Register("p1_power_factor", 0x001e, 1),
|
||||
new Register("p2_power_factor", 0x0020, 1),
|
||||
new Register("p3_power_factor", 0x0022, 1),
|
||||
new Register("p1_phase_angle", 0x0024, 1),
|
||||
new Register("p2_phase_angle", 0x0026, 1),
|
||||
new Register("p3_phase_angle", 0x0028, 1),
|
||||
new Register("voltage_ln", 0x002a, 1),
|
||||
new Register("current_ln", 0x002e, 1),
|
||||
new Register("total_line_current", 0x0030, 1),
|
||||
new Register("total_power_active", 0x0034, 1),
|
||||
new Register("total_power_apparent", 0x0038, 1),
|
||||
new Register("total_power_reactive", 0x003c, 1),
|
||||
new Register("total_power_factor", 0x003e, 1),
|
||||
new Register("total_phase_angle", 0x0042, 1),
|
||||
new Register("frequency", 0x0046, 1),
|
||||
new Register("import_energy_active", 0x0048, 1),
|
||||
new Register("export_energy_active", 0x004a, 1),
|
||||
new Register("import_energy_reactive", 0x004c, 1),
|
||||
new Register("export_energy_reactive", 0x004e, 1),
|
||||
new Register("total_energy_apparent", 0x0050, 2),
|
||||
new Register("total_current", 0x0052, 2),
|
||||
new Register("total_import_demand_power_active", 0x0054, 2),
|
||||
new Register("maximum_import_demand_power_apparent", 0x0056, 2),
|
||||
new Register("import_demand_power_active", 0x0058, 2),
|
||||
new Register("maximum_import_demand_power_active", 0x005a, 2),
|
||||
new Register("export_demand_power_active", 0x005c, 2),
|
||||
new Register("maximum_export_demand_power_active", 0x005e, 2),
|
||||
new Register("total_demand_power_apparent", 0x0064, 2),
|
||||
new Register("maximum_demand_power_apparent", 0x0066, 2),
|
||||
new Register("neutral_demand_current", 0x0068, 2),
|
||||
new Register("maximum_neutral_demand_current", 0x006a, 2),
|
||||
new Register("p12_voltage", 0x00c8, 3),
|
||||
new Register("p23_voltage", 0x00ca, 3),
|
||||
new Register("p31_voltage", 0x00cc, 3),
|
||||
new Register("voltage_ll", 0x00ce, 3),
|
||||
new Register("neutral_current", 0x00e0, 3),
|
||||
new Register("p1n_voltage_thd", 0x00ea, 3),
|
||||
new Register("p2n_voltage_thd", 0x00ec, 3),
|
||||
new Register("p3n_voltage_thd", 0x00ee, 3),
|
||||
new Register("p1_current_thd", 0x00f0, 3),
|
||||
new Register("p2_current_thd", 0x00f2, 3),
|
||||
new Register("p3_current_thd", 0x00f4, 3),
|
||||
new Register("voltage_ln_thd", 0x00f8, 3),
|
||||
new Register("current_thd", 0x00fa, 3),
|
||||
new Register("p1_demand_current", 0x0102, 3),
|
||||
new Register("p2_demand_current", 0x0104, 3),
|
||||
new Register("p3_demand_current", 0x0106, 3),
|
||||
new Register("maximum_p1_demand_current", 0x0108, 3),
|
||||
new Register("maximum_p2_demand_current", 0x010a, 3),
|
||||
new Register("maximum_p3_demand_current", 0x010c, 3),
|
||||
new Register("p12_voltage_thd", 0x014e, 4),
|
||||
new Register("p23_voltage_thd", 0x0150, 4),
|
||||
new Register("p31_voltage_thd", 0x0152, 4),
|
||||
new Register("voltage_ll_thd", 0x0154, 4),
|
||||
new Register("total_energy_active", 0x0156, 4),
|
||||
new Register("total_energy_reactive", 0x0158, 4)
|
||||
);
|
||||
|
||||
private Sdm630Registers() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.domain.Reading;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class HouseMeterDeviceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T10:00:00Z");
|
||||
|
||||
@Test
|
||||
void reportsOneReadingWithEveryPrad2FieldRoundedLikeTheOldService() throws Exception {
|
||||
Map<String, Double> lastPoint = lastPoint();
|
||||
FakeMeter meter = new FakeMeter();
|
||||
lastPoint.forEach((name, value) -> meter.set(name, value.floatValue()));
|
||||
|
||||
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
|
||||
|
||||
assertEquals(1, readings.size());
|
||||
Reading reading = readings.getFirst();
|
||||
assertEquals("electricity", reading.database());
|
||||
assertEquals("PRAD2", reading.measurement());
|
||||
assertEquals(Map.of("location", "dom-szczepancowa"), reading.tags());
|
||||
assertEquals(NOW, reading.time());
|
||||
assertEquals(lastPoint.keySet(), reading.fields().keySet());
|
||||
lastPoint.forEach((name, expected) ->
|
||||
assertEquals(expected, reading.fields().get(name), 0.01, name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsFourBlocksOfUnitOneOneAfterAnother() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.set("frequency", 50f);
|
||||
|
||||
new HouseMeterDevice(meter).read(NOW);
|
||||
|
||||
assertEquals(List.of("1:0+80", "1:80+28", "1:200+70", "1:334+12"), meter.requests);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundsHalfToEvenOnTheExactValue() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.set("p1_voltage", 0.125f);
|
||||
meter.set("p2_voltage", 2.675f);
|
||||
meter.set("p3_voltage", -0.375f);
|
||||
|
||||
Map<String, Double> fields = new HouseMeterDevice(meter).read(NOW).getFirst().fields();
|
||||
|
||||
assertEquals(0.12, fields.get("p1_voltage"));
|
||||
assertEquals(2.67, fields.get("p2_voltage"));
|
||||
assertEquals(-0.38, fields.get("p3_voltage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsNanAndInfiniteValuesButKeepsTheRest() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.set("frequency", 50f);
|
||||
meter.set("p1_voltage", Float.NaN);
|
||||
meter.set("p2_voltage", Float.POSITIVE_INFINITY);
|
||||
|
||||
Map<String, Double> fields = new HouseMeterDevice(meter).read(NOW).getFirst().fields();
|
||||
|
||||
assertEquals(50.0, fields.get("frequency"));
|
||||
assertFalse(fields.containsKey("p1_voltage"));
|
||||
assertFalse(fields.containsKey("p2_voltage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenNoValueIsFinite() {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.fillWith(Float.NaN);
|
||||
|
||||
assertThrows(IOException.class, () -> new HouseMeterDevice(meter).read(NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenAnyBlockFails() {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.failAt = 200;
|
||||
|
||||
assertThrows(IOException.class, () -> new HouseMeterDevice(meter).read(NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenABlockComesBackShort() {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.shortAt = 80;
|
||||
|
||||
assertThrows(IOException.class, () -> new HouseMeterDevice(meter).read(NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isNamedAndPolledEveryMinute() {
|
||||
HouseMeterDevice device = new HouseMeterDevice(new FakeMeter());
|
||||
|
||||
assertEquals("house-meter", device.name());
|
||||
assertEquals(Duration.ofSeconds(60), device.interval());
|
||||
}
|
||||
|
||||
private static Map<String, Double> lastPoint() throws IOException {
|
||||
try (InputStream in = HouseMeterDeviceTest.class.getResourceAsStream("prad2-last-point.json")) {
|
||||
Map<String, Object> point = new ObjectMapper().readValue(in, new TypeReference<>() { });
|
||||
Map<String, Double> fields = new HashMap<>();
|
||||
point.forEach((name, value) -> {
|
||||
if (!Set.of("time", "location").contains(name)) {
|
||||
fields.put(name, ((Number) value).doubleValue());
|
||||
}
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
|
||||
/** A meter whose input registers hold float32 values, big-endian words; unset ones are 0. */
|
||||
private static final class FakeMeter implements ModbusClient {
|
||||
|
||||
final int[] registers = new int[400];
|
||||
final List<String> requests = new ArrayList<>();
|
||||
int failAt = -1;
|
||||
int shortAt = -1;
|
||||
|
||||
void set(String name, float value) {
|
||||
Sdm630Registers.Register register = Sdm630Registers.INPUT.stream()
|
||||
.filter(r -> r.name().equals(name)).findFirst().orElseThrow();
|
||||
int bits = Float.floatToRawIntBits(value);
|
||||
registers[register.address()] = bits >>> 16;
|
||||
registers[register.address() + 1] = bits & 0xFFFF;
|
||||
}
|
||||
|
||||
void fillWith(float value) {
|
||||
Sdm630Registers.INPUT.forEach(register -> set(register.name(), value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] readInputRegisters(int unitId, int address, int count) throws IOException {
|
||||
requests.add(unitId + ":" + address + "+" + count);
|
||||
if (address == failAt) {
|
||||
throw new IOException("timeout");
|
||||
}
|
||||
int returned = address == shortAt ? count - 1 : count;
|
||||
int[] words = new int[returned];
|
||||
System.arraycopy(registers, address, words, 0, returned);
|
||||
return words;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] readHoldingRegisters(int unitId, int address, int count) {
|
||||
throw new UnsupportedOperationException("the meter is read through input registers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"time": "2026-10-23T22:13:40Z",
|
||||
"current_ln": 0.85,
|
||||
"current_thd": 95.36,
|
||||
"export_demand_power_active": 0,
|
||||
"export_energy_active": 66677.11,
|
||||
"export_energy_reactive": 26926.61,
|
||||
"frequency": 50.01,
|
||||
"import_demand_power_active": 0,
|
||||
"import_energy_active": 67514.27,
|
||||
"import_energy_reactive": 6433.02,
|
||||
"location": "dom-szczepancowa",
|
||||
"maximum_demand_power_apparent": 10989.65,
|
||||
"maximum_export_demand_power_active": 0,
|
||||
"maximum_import_demand_power_active": 0,
|
||||
"maximum_import_demand_power_apparent": -10986.21,
|
||||
"maximum_neutral_demand_current": 20.28,
|
||||
"maximum_p1_demand_current": 21.31,
|
||||
"maximum_p2_demand_current": 20.79,
|
||||
"maximum_p3_demand_current": 16.35,
|
||||
"neutral_current": 0.67,
|
||||
"neutral_demand_current": 0.77,
|
||||
"p12_voltage": 408.07,
|
||||
"p12_voltage_thd": 0,
|
||||
"p1_current": 0.32,
|
||||
"p1_current_thd": 114.36,
|
||||
"p1_demand_current": 0.33,
|
||||
"p1_phase_angle": 41.52,
|
||||
"p1_power_active": 55.94,
|
||||
"p1_power_apparent": 74.71,
|
||||
"p1_power_factor": 0.75,
|
||||
"p1_power_reactive": 49.52,
|
||||
"p1_voltage": 235.69,
|
||||
"p1n_voltage_thd": 3.48,
|
||||
"p23_voltage": 405.05,
|
||||
"p23_voltage_thd": 0,
|
||||
"p2_current": 0.88,
|
||||
"p2_current_thd": 99.27,
|
||||
"p2_demand_current": 0.98,
|
||||
"p2_phase_angle": -30.11,
|
||||
"p2_power_active": 179.47,
|
||||
"p2_power_apparent": 207.46,
|
||||
"p2_power_factor": 0.87,
|
||||
"p2_power_reactive": -104.07,
|
||||
"p2_voltage": 235.51,
|
||||
"p2n_voltage_thd": 3.82,
|
||||
"p31_voltage": 405.21,
|
||||
"p31_voltage_thd": 0,
|
||||
"p3_current": 0.67,
|
||||
"p3_current_thd": 72.45,
|
||||
"p3_demand_current": 0.86,
|
||||
"p3_phase_angle": 9.81,
|
||||
"p3_power_active": 153.38,
|
||||
"p3_power_apparent": 155.65,
|
||||
"p3_power_factor": 0.99,
|
||||
"p3_power_reactive": 26.52,
|
||||
"p3_voltage": 232.2,
|
||||
"p3n_voltage_thd": 3.64,
|
||||
"total_current": 262147.94,
|
||||
"total_demand_power_apparent": 507.11,
|
||||
"total_energy_active": 134191.39,
|
||||
"total_energy_apparent": 138275.8,
|
||||
"total_energy_reactive": 33359.63,
|
||||
"total_import_demand_power_active": 452.07,
|
||||
"total_line_current": 2.55,
|
||||
"total_phase_angle": -4.12,
|
||||
"total_power_active": 388.78,
|
||||
"total_power_apparent": 437.82,
|
||||
"total_power_factor": 1,
|
||||
"total_power_reactive": -28.04,
|
||||
"voltage_ll": 406.11,
|
||||
"voltage_ll_thd": 0,
|
||||
"voltage_ln": 234.47,
|
||||
"voltage_ln_thd": 3.65
|
||||
}
|
||||
Reference in new issue
Block a user