sunspec: antidote triphasé et outil de relevé du modèle 124
L'antidote AC-16 triphasé verrouille le chemin réellement emprunté par le matériel terrain, reproduit sur le simulateur en profil Symo (onduleur modèle 103, compteur modèle 203) : compteur à -500/-100/-100 W, que le plugin etm6 rapportait en +500/+100/+100. tools/read-model124.py relève en lecture seule le bloc storage d'un onduleur, sans aucune dépendance à installer. Il sert à lever D-6 : le côté « batterie présente » du discriminateur reste inféré faute d'un GEN24 équipé. Sur le simulateur il montre pourquoi ni le SoC ni les consignes ne peuvent servir de discriminateur : le bloc annonce un SoC de 50 %, un statut Holding et WChaMax à 10 kW alors qu'InBatV et StorAval sont nuls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05d68117cd
commit
c459ba2f9b
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
167
sunspec/tools/read-model124.py
Normal file
167
sunspec/tools/read-model124.py
Normal file
@ -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())
|
||||
Loading…
x
Reference in New Issue
Block a user