fix(sunspec): lire les float32 Fronius en big endian, comme le matériel

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-07 07:30:14 +02:00
parent 63abc15b44
commit d08cf506ee
4 changed files with 115 additions and 9 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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);

View File

@ -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);
}
// ---------------------------------------------------------------------------