From d08cf506ee0a20709769b29b57c080d9cbb9a9a7 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 7 Aug 2026 07:30:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(sunspec):=20lire=20les=20float32=20Fronius?= =?UTF-8?q?=20en=20big=20endian,=20comme=20le=20mat=C3=A9riel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit byteOrder() renvoyait ByteOrderLittleEndian au motif qu'on « supposait le défaut SunSpec ». La supposition confondait le défaut de la SPÉCIFICATION avec celui de libnymea-sunspec, qui est son contraire : convertToUInt32 lit registers.at(1) puis .at(0) en little endian. Garder ce défaut revient à lire tous les float32 avec leurs deux mots permutés. Mesuré le 2026-08-07 sur un Symo réel, en lecture seule : le compteur 213 sur l'unité 200 sert Hz = 4248 0000, soit 50,0000 en big endian et 2,3777e-41 en mots inversés. L'onduleur 113 concorde. Le coût n'était pas un décalage mais une absurdité. Reproduit sur banc avec le simulateur émettant l'ordre du matériel : un compteur important 2,7 kW rapporté comme exportant 107 MW, et l'arbitre enclenchant toutes les charges sur un « Surplus PV 107622496 W ». Le thing reste connected, sans erreur Modbus. Conséquence sur l'ordre des correctifs : meterPowerSign() reste juste mais n'est observable qu'une fois celui-ci appliqué — corriger le signe d'un dénormal ne produit rien. solarEdge force déjà big endian de son côté et le générique garde son paramètre utilisateur : aucun des deux chemins n'est touché. test_AC_18 fige le raisonnement plutôt que la seule valeur. Voir DIVERGENCES.md D-8. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/DIVERGENCES.md | 43 ++++++++++++++++++++++++++++ sunspec/froniusrules.cpp | 4 ++- sunspec/froniusrules.h | 33 +++++++++++++++++++--- sunspec/tests/logic/tst_sunspec.cpp | 44 ++++++++++++++++++++++++++--- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index 3d50d93..264d1cf 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -200,3 +200,46 @@ garde de présence doit donc être rejouée sur banc, pas seulement en unitaire. **Comment lever.** Étendre `tests/plugin`, qui instancie réellement le plugin, à un scénario de setup initial avec un modèle 124 à zéro. Hors périmètre ici. + +--- + +## D-8 — ordre des mots des float32 : le défaut de la bibliothèque n'est pas celui de la spec (MESURÉ) + +**Contexte.** `FroniusRules::byteOrder()` renvoyait `ByteOrderLittleEndian`, avec +pour justification « non documenté par le constructeur, on suppose le défaut +SunSpec ». + +**L'écart.** La supposition confondait deux « défauts ». Un float32 SunSpec +occupe deux registres, **mot de poids fort d'abord**. Or +`SunSpecDataPoint::convertToUInt32` lit `registers.at(0)` puis `.at(1)` en +`ByteOrderBigEndian`, et l'**inverse** en `ByteOrderLittleEndian` — qui est le +défaut de libnymea-sunspec. Garder ce défaut revient à lire tous les float32 de +la personnalité Fronius avec leurs deux mots permutés. + +**Mesure.** Relevé du 2026-08-07 sur un Fronius Symo réel, en lecture seule +(`sunspec/tools/read-float-order.py`) : + +| point | registres bruts | big endian | mots inversés | +|---|---|---|---| +| compteur 213 u200 `Hz` | `4248 0000` | **50,0000** | 2,3777e−41 | +| compteur 213 u200 `PhVphA` | `436a 8000` | **234,5000** | −2,4184e−41 | +| onduleur 113 u1 `Hz` | `4247 fc48` | **49,9964** | −4,1592e+36 | + +**Ce que cela coûtait.** Pas un décalage : une absurdité. Reproduit sur banc avec +le simulateur émettant l'ordre du matériel — un compteur **important 2,7 kW** a +été rapporté comme **exportant 107 MW**, `Current load on root meter: +-1.07621e+08W`, et l'arbitre a enclenché toutes les charges sur un « Surplus PV +107622496 W ». Un tel appareil se présente `connected = true`, sans aucune +erreur Modbus. + +**Ordre des correctifs.** `meterPowerSign()` (D-5) reste juste, mais n'est +**observable** qu'une fois l'ordre des mots corrigé : corriger le signe d'un +dénormal ne produit rien. + +**Non touché.** Le vendor solarEdge force déjà big endian sur son propre chemin ; +le vendor générique conserve son paramètre `endianness` réglable par +l'utilisateur. Aucun des deux n'est modifié. + +**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et +`test_AC_18_swapped_words_yield_denormals` fige le raisonnement : les registres +réels lus à l'envers donnent un dénormal, jamais une mesure plausible. diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp index 56bc5d8..659438e 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -52,7 +52,9 @@ bool hasBattery(double batteryVoltage, double availableCharge) SunSpecDataPoint::ByteOrder byteOrder() { - return SunSpecDataPoint::ByteOrderLittleEndian; + /* Big endian is what the hardware emits, and what SunSpec specifies. The + * library's default is the opposite; see the header for the measurement. */ + return SunSpecDataPoint::ByteOrderBigEndian; } MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId) diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h index 269ff3b..2730876 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -129,10 +129,35 @@ double meterPowerSign(bool froniusMeter); * a GEN24 with a battery before this ships. See DIVERGENCES.md. */ bool hasBattery(double batteryVoltage, double availableCharge); -/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer, - * so the SunSpec default is assumed. The forced big endian of the SolarEdge - * vendor is an anomaly specific to SolarEdge and must not be extended here. - * To be confirmed on the bench, then on hardware. */ +/* Word order of the float32 points, measured on hardware. + * + * This used to read "INFERRED: not documented by the manufacturer, so the + * SunSpec default is assumed. To be confirmed on the bench, then on hardware." + * It has now been confirmed, and the assumption was wrong: what was assumed to + * be "the SunSpec default" was in fact libnymea-sunspec's own default, which is + * the opposite of the specification. + * + * A float32 spans two registers. SunSpec puts the high word first. + * SunSpecDataPoint::convertToUInt32 reads registers.at(0) then .at(1) for + * ByteOrderBigEndian, and the reverse for ByteOrderLittleEndian — and little + * endian is the library's default. Keeping that default on a Fronius means + * reading every float32 with its two words swapped. + * + * Measured 2026-08-07 on a Fronius Symo, read only: + * meter 213, unit 200 : Hz = 4248 0000 -> 50.0000 big endian + * PhVphA = 436a 8000 -> 234.5000 + * inverter 113, unit 1: Hz = 4247 fc48 -> 49.9964 + * Swapped, those same registers give 2.4e-41 and -4.2e+36: denormals, the + * signature of a wrong interpretation rather than a measurement. + * + * What it costs to get this wrong is not a small offset. Reproduced on the + * bench with the simulator emitting the hardware's order: a meter importing + * 2.7 kW was reported as exporting 107 MW, and the arbiter switched every load + * on. Correcting the SIGN of a denormal changes nothing, so meterPowerSign() + * only becomes observable once this is right. + * + * The SolarEdge vendor already forces big endian on its own path; the generic + * vendor keeps its user-settable parameter. Neither is affected. */ SunSpecDataPoint::ByteOrder byteOrder(); int clampUpdateInterval(int requestedSeconds); diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index 5ecf481..ad7b433 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -167,6 +167,8 @@ private slots: void test_AC_16_three_phase_meter_export_on_every_phase(); + void test_AC_18_swapped_words_yield_denormals(); + 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(); @@ -421,10 +423,44 @@ void SunSpecFroniusTest::test_AC_5_manufacturer_filter_rejects_other_manufacture void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order() { - // INFERE : non documente par le constructeur, on suppose le defaut SunSpec. - // Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a - // SolarEdge et ne doit pas etre etendue. - QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian); + // MESURE le 2026-08-07 sur un Symo reel, en lecture seule : l'appareil emet + // en big endian, mot de poids fort d'abord, conformement a la specification + // SunSpec. + // + // compteur 213 unite 200 : Hz = 4248 0000 -> 50.0000 + // onduleur 113 unite 1 : Hz = 4247 fc48 -> 49.9964 + // + // Ce test affirmait l'inverse, avec pour justification « on suppose le + // defaut SunSpec ». La supposition confondait le defaut de la SPEC avec + // celui de libnymea-sunspec, qui est son contraire. Le nom du test est + // conserve pour ne pas casser les references, mais « sunspec_default » + // designe desormais le defaut de la specification, pas celui de la lib. + QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderBigEndian); +} + +void SunSpecFroniusTest::test_AC_18_swapped_words_yield_denormals() +{ + // Antidote : verrouille le RAISON du choix, pas seulement le choix. + // Les registres ci-dessous sont ceux releves sur le Symo. Lus dans le bon + // ordre ils donnent une frequence reseau ; lus mots inverses, un denormal. + // Si quelqu'un rebascule byteOrder() en little endian, c'est cette lecture + // absurde que le plugin produira sur du materiel reel. + const quint16 hi = 0x4248, lo = 0x0000; // Hz du compteur 213 + + auto asFloat = [](quint16 first, quint16 second) { + const quint32 raw = (quint32(first) << 16) | second; + float value; + memcpy(&value, &raw, sizeof(raw)); + return value; + }; + + // Big endian : le registre de poids fort d'abord. + QVERIFY(qAbs(asFloat(hi, lo) - 50.0f) < 0.01f); + + // Mots inverses : un denormal, jamais une mesure plausible. + const float swapped = asFloat(lo, hi); + QVERIFY(swapped > 0.0f); + QVERIFY(swapped < 1.0e-40f); } // ---------------------------------------------------------------------------