diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index 7b40fdd..5ecf481 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -165,6 +165,8 @@ private slots: void test_AC_16_generic_meter_keeps_the_historical_negation(); void test_AC_16_field_case_export_is_reported_negative(); + void test_AC_16_three_phase_meter_export_on_every_phase(); + void test_AC_17_no_battery_means_no_storage_child(); void test_AC_17_an_installed_battery_is_still_detected(); void test_AC_17_presence_does_not_depend_on_state_of_charge(); @@ -910,6 +912,23 @@ void SunSpecFroniusTest::test_AC_16_field_case_export_is_reported_negative() QVERIFY(sign * wattsPhaseC > 0.0); } +void SunSpecFroniusTest::test_AC_16_three_phase_meter_export_on_every_phase() +{ + // Reproduit sur le simulateur, profil Symo triphase (onduleur modele 103, + // compteur modele 203). Le compteur emet -500 / -100 / -100 W, soit un + // export sur les trois phases. Le plugin etm6 rapportait +500 / +100 / + // +100, c'est a dire du soutirage sur les trois. Le chemin triphase est + // celui du materiel terrain : il merite son propre verrou. + const double sign = FroniusRules::meterPowerSign(true); + + QCOMPARE(sign * -500.0, -500.0); + QCOMPARE(sign * -100.0, -100.0); + + // Et le total suit la meme regle, quel que soit son signe. + QCOMPARE(sign * -700.0, -700.0); + QCOMPARE(sign * 3262.0, 3262.0); +} + // AC-17 - presence physique de la batterie // --------------------------------------------------------------------------- diff --git a/sunspec/tools/read-model124.py b/sunspec/tools/read-model124.py new file mode 100644 index 0000000..b6564a5 --- /dev/null +++ b/sunspec/tools/read-model124.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Read-only dump of the SunSpec storage block (model 124) of an inverter. + +Answers one question: does this inverter have a battery physically installed? +See sunspec/DIVERGENCES.md D-6 — the "battery present" side of the presence +discriminator is still inferred and needs a reading from real hardware. + +Standard library only: no pymodbus, no pysunspec2, nothing to install. +It only ever issues function code 3 (read holding registers). It never writes. + + python3 read-model124.py 192.168.1.50 + python3 read-model124.py fronius.local --unit 1 + +Report back the InBatV, StorAval and ChaSt lines. +""" + +import argparse +import socket +import struct +import sys + +SUNS = (0x5375, 0x6E53) # "SunS" + +# Model 124, in order, from the SunSpec storage model definition. +# (name, offset, kind, scale-factor name or None) +POINTS = [ + ("WChaMax", 0, "u16", "WChaMax_SF"), + ("WChaGra", 1, "u16", "WChaDisChaGra_SF"), + ("WDisChaGra", 2, "u16", "WChaDisChaGra_SF"), + ("StorCtl_Mod", 3, "u16", None), + ("VAChaMax", 4, "u16", "VAChaMax_SF"), + ("MinRsvPct", 5, "u16", "MinRsvPct_SF"), + ("ChaState", 6, "u16", "ChaState_SF"), + ("StorAval", 7, "u16", "StorAval_SF"), + ("InBatV", 8, "u16", "InBatV_SF"), + ("ChaSt", 9, "u16", None), + ("OutWRte", 10, "i16", "InOutWRte_SF"), + ("InWRte", 11, "i16", "InOutWRte_SF"), + ("ChaGriSet", 15, "u16", None), +] + +SCALE_FACTORS = { + "WChaMax_SF": 16, "WChaDisChaGra_SF": 17, "VAChaMax_SF": 18, + "MinRsvPct_SF": 19, "ChaState_SF": 20, "StorAval_SF": 21, + "InBatV_SF": 22, "InOutWRte_SF": 23, +} + +CHA_ST = {1: "Off", 2: "Empty", 3: "Discharging", 4: "Charging", + 5: "Full", 6: "Holding", 7: "Testing"} + +NOT_IMPLEMENTED_U16 = 0xFFFF +NOT_IMPLEMENTED_I16 = 0x8000 + + +def read_holding(sock, unit, addr, count): + req = struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count) + sock.sendall(req) + hdr = b"" + while len(hdr) < 8: + chunk = sock.recv(8 - len(hdr)) + if not chunk: + raise IOError("connection closed while reading header") + hdr += chunk + _, _, length, _, fc = struct.unpack(">HHHBB", hdr) + body = b"" + while len(body) < length - 2: + chunk = sock.recv(length - 2 - len(body)) + if not chunk: + raise IOError("connection closed while reading body") + body += chunk + if fc & 0x80: + raise IOError("modbus exception %d reading %d registers at %d" + % (body[0], count, addr)) + return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]])) + + +def find_base(sock, unit): + for base in (40000, 50000, 0): + try: + if tuple(read_holding(sock, unit, base, 2)) == SUNS: + return base + except IOError: + continue + return None + + +def find_model(sock, unit, base, wanted): + addr = base + 2 + chain = [] + while addr < base + 5000: + mid, mlen = read_holding(sock, unit, addr, 2) + if mid == 0xFFFF: + return None, chain + chain.append(mid) + if mid == wanted: + return addr + 2, chain + addr += 2 + mlen + return None, chain + + +def scaled(raw, kind, exponent): + if kind == "i16" and raw > 0x7FFF: + raw -= 0x10000 + if exponent is None: + return float(raw) + if exponent > 0x7FFF: + exponent -= 0x10000 + return raw * (10.0 ** exponent) + + +def main(): + ap = argparse.ArgumentParser(description="Read-only dump of SunSpec model 124.") + ap.add_argument("host") + ap.add_argument("--port", type=int, default=502) + ap.add_argument("--unit", type=int, default=1) + args = ap.parse_args() + + sock = socket.create_connection((args.host, args.port), timeout=10) + try: + base = find_base(sock, args.unit) + if base is None: + print("No SunSpec 'SunS' marker at 40000, 50000 or 0 on unit %d." % args.unit) + return 2 + print("SunSpec base register: %d (unit %d)" % (base, args.unit)) + + data, chain = find_model(sock, args.unit, base, 124) + print("Models present: %s" % ", ".join(str(m) for m in chain)) + if data is None: + print("\nNo model 124 on this inverter: it publishes no storage block at all.") + return 0 + + regs = read_holding(sock, args.unit, data, 24) + finally: + sock.close() + + print("\nModel 124 raw block at %d:" % data) + print(" " + " ".join("%04X" % r for r in regs)) + + print("\n%-14s %10s %s" % ("point", "value", "raw")) + for name, off, kind, sf in POINTS: + raw = regs[off] + sentinel = NOT_IMPLEMENTED_I16 if kind == "i16" else NOT_IMPLEMENTED_U16 + if raw == sentinel: + print("%-14s %10s 0x%04X" % (name, "n/impl", raw)) + continue + value = scaled(raw, kind, regs[SCALE_FACTORS[sf]] if sf else None) + extra = " (%s)" % CHA_ST.get(raw, "?") if name == "ChaSt" else "" + print("%-14s %10.3f 0x%04X%s" % (name, value, raw, extra)) + + in_bat_v = scaled(regs[8], "u16", regs[SCALE_FACTORS["InBatV_SF"]]) + stor_aval = scaled(regs[7], "u16", regs[SCALE_FACTORS["StorAval_SF"]]) + print("\n--- what this decides -------------------------------------------") + print("InBatV = %.3f V" % in_bat_v) + print("StorAval = %.3f Ah" % stor_aval) + if in_bat_v > 0 or stor_aval > 0: + print("=> reads as BATTERY PRESENT") + else: + print("=> reads as NO BATTERY") + print("\nOn an inverter that really does have a battery, both being zero would") + print("mean the discriminator is wrong and would hide a real battery. That is") + print("exactly the case to report back.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())