From 89f7bf0199be7bad49c2c2903b2f2b843b93ac35 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 5 Aug 2026 08:29:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(sunspec):=20signe=20du=20compteur=20Fronius?= =?UTF-8?q?=20et=20batterie=20fant=C3=B4me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts constatés sur le GEN24 des beaux-parents (Primo GEN24 3.0, Smart Meter TS 65A-3, sans batterie). Signe du compteur. onMeterBlockUpdated() négociait le signe de tous les modèles compteur sans distinction de fabricant. Sur Fronius la négation est fausse : le Smart Meter émet déjà dans la convention nymea. L'onduleur étant monophasé, il sert de référence de direction : le plugin rapportait la phase portant l'onduleur en soutirage et les deux autres en injection, ce qui est impossible sans source. L'arbitre en concluait « Surplus insuffisant (-842 W) » et laissait chauffe-eau et PAC éteints en plein export. La décision vit dans FroniusRules::meterPowerSign() et n'est annulée que sous une froniusConnection. Les vendors generic et solarEdge sont inchangés, ce qu'un antidote verrouille. Les compteurs d'énergie sont corrects et ne sont pas permutés. Batterie fantôme. Un GEN24 hybride publie le modèle 124 sans batterie câblée, d'où un enfant storage bloqué sur batteryCritical. FroniusRules::hasBattery() exige une grandeur physique non nulle (InBatV ou StorAval), le SoC étant écarté car 0 % ne se distingue pas d'une batterie vide. Le côté « batterie présente » reste INFÉRÉ : voir D-6, à valider sur un GEN24 équipé avant tout déploiement. Suite logique : 50 tests verts, dont six antidotes AC-16 et AC-17. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/DIVERGENCES.md | 62 +++++++++++++++ sunspec/froniusrules.cpp | 15 ++++ sunspec/froniusrules.h | 47 ++++++++++++ sunspec/integrationpluginsunspec.cpp | 110 ++++++++++++++++----------- sunspec/tests/logic/tst_sunspec.cpp | 76 ++++++++++++++++++ 5 files changed, 265 insertions(+), 45 deletions(-) diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index bdfdbd6..cfef716 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -111,3 +111,65 @@ ajoutée par F3, elle, se retire proprement de sa table à la destruction. **Comment lever.** Relier `QObject::destroyed` au retrait de la table pour les deux connexions, dans un lot qui assume de toucher le chemin commun, avec une couverture de test pour `solarEdge`. + +--- + +## D-5 — le signe du compteur est corrigé côté Fronius seulement (MESURÉ) + +**Contexte.** `onMeterBlockUpdated()` négociait le signe de façon **générique** : +tous les modèles compteur (201, 202, 203, 204, 211…214) faisaient +`-meter->watts()`. Il n'existait aucun mapping spécifique à un fabricant. + +**L'écart.** Sur Fronius cette négation est fausse : le Smart Meter émet déjà +dans la convention nymea (négatif = injection). Le correctif n'annule la +négation que lorsque le compteur est enfant d'une `froniusConnection` ; les +vendors generic et solarEdge gardent la négation historique, verrouillée par +`test_AC_16_generic_meter_keeps_the_historical_negation`. + +**Preuve.** Relevé du 02/08/2026 sur un Primo GEN24 3.0, onduleur **monophasé** +(modèle 101) et Smart Meter TS 65A-3 (modèle **203**, pas 213). L'onduleur +n'injecte que sur la phase A ; le plugin rapportait phase A = +1003 W +(soutirage) et phases B et C à −33 W et −262 W, soit une injection sur deux +phases ne portant aucune source. En inversant, le bilan se referme : production +1190 W = charge phase A 187 W + export 1003 W ; import B+C 294 W ; consommation +481 W ; export réseau 709 W contre 708 W mesuré. Corroboration indépendante : +sur la même fenêtre, le compteur d'énergie *import* de l'appareil reste à 0 +pendant que l'*export* s'incrémente. Conséquence observée : +`[Arbitre] "chauffe-eau" → "Surplus insuffisant (-842 W)"` alors que le site +exportait ~842 W. + +**Non touché.** Les compteurs d'énergie (`totalEnergyConsumed` / +`totalEnergyProduced`) sont **corrects** et ne sont pas permutés : ce sont eux +qui servent de témoin à charge ci-dessus. + +**Comment lever.** Déterminer si la négation générique est fausse pour tous les +fabricants ou seulement pour Fronius demanderait un compteur d'un autre vendor +sur banc. Tant que ce n'est pas tranché, le générique ne bouge pas. + +--- + +## D-6 — présence batterie : le côté « batterie présente » n'est pas validé (INFÉRÉ) + +**Contexte.** Un GEN24 hybride publie le modèle 124 même sans batterie câblée. +Le plugin créait donc un enfant storage systématiquement, d'où batterie fantôme +et `batteryCritical` permanent. + +**L'écart.** `FroniusRules::hasBattery()` exige désormais `InBatV > 0` ou +`StorAval > 0` pour créer l'enfant, et seulement sous une `froniusConnection`. +Le SoC est écarté (0 % ne se distingue pas d'une batterie vide) ainsi que les +registres de consigne (`WChaMax`… gardent leurs défauts sans batterie). + +**Ce qui est établi.** Le côté « absente » est confirmé sur matériel : le GEN24 +des beaux-parents, sans batterie, exposait le modèle 124 avec `ChaSt = Off` et +un SoC de 0, et nymea journalisait « There is no energy storage available to +consider » pendant que l'enfant restait visible. + +**Ce qui ne l'est pas.** `InBatV` et `StorAval` n'étant pas exposés en states +nymea, leurs valeurs sur ce GEN24 sans batterie **n'ont pas pu être relevées**, +et aucun GEN24 **avec** batterie n'était joignable. L'hypothèse « une batterie +installée porte une tension au repos » est donc **INFÉRÉE des deux côtés**. + +**Comment lever.** Relever en lecture seule le bloc 124 d'un GEN24 équipé d'une +batterie et vérifier `InBatV`/`StorAval` non nuls. **À faire avant tout +déploiement** : un discriminateur trop strict ferait disparaître une vraie +batterie chez un client. diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp index bd0bb50..56bc5d8 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -35,6 +35,21 @@ bool matchesManufacturer(const QStringList &manufacturers) return false; } +double meterPowerSign(bool froniusMeter) +{ + /* The Fronius meter is already in the nymea convention, so the generic + * negation has to be undone for it, and only for it. */ + return froniusMeter ? 1.0 : -1.0; +} + +bool hasBattery(double batteryVoltage, double availableCharge) +{ + /* Strictly positive only: zero is the no-battery reading, and a negative + * value is the library's way of surfacing a not-implemented point rather + * than a real measurement. */ + return batteryVoltage > 0.0 || availableCharge > 0.0; +} + SunSpecDataPoint::ByteOrder byteOrder() { return SunSpecDataPoint::ByteOrderLittleEndian; diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h index f98fd4d..269ff3b 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -82,6 +82,53 @@ MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlav bool matchesManufacturer(const QStringList &manufacturers); +/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get + * the nymea smartmeter convention, where positive is import and negative is + * export. + * + * The Fronius Smart Meter already reports in that convention: negative W is + * feed-in. The generic path negates, so on a Fronius the surplus came out with + * the wrong sign and the arbiter refused to run loads while the site was + * exporting. + * + * Established on hardware, not from the manufacturer's documentation. On a + * Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin + * reported phase A at +1003 W (import) while phases B and C read -33 W and + * -262 W (export). Exporting on two phases carrying no source is impossible. + * With the sign flipped the balance closes exactly: production 1190 W = phase A + * load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws + * 481 W and the site exports 709 W against 708 W measured. The meter's own + * energy counters agree: over the same window its import counter stayed at + * zero while its export counter advanced. + * + * Only the instantaneous power is affected. The energy counters map correctly + * already and must not be swapped. + * + * The generic default is left untouched: other vendors keep the negation. */ +double meterPowerSign(bool froniusMeter); + +/* Whether a model 124 block is backed by a physically installed battery. + * + * A hybrid GEN24 publishes model 124 whether or not a battery is wired to it, + * so the presence of the model proves nothing. Creating a storage child anyway + * gives the customer a phantom battery and a permanent batteryCritical alarm: + * observed on hardware, a GEN24 with no battery reported ChaSt = Off and a + * state of charge of 0, and nymea's own energy engine then logged "There is no + * energy storage available to consider" while the child stayed visible. + * + * The discriminator is deliberately a *physical* quantity. The state of charge + * is refused because 0% cannot be told apart from a real but empty battery, and + * the setpoint registers (WChaMax and friends) are refused because they keep + * their defaults with no battery attached. + * + * INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or + * StorAval is expected to be strictly positive whenever one is present, and + * both zero when none is. The absent side matches what the battery-less GEN24 + * showed and is reproduced on the simulator. The present side could NOT be + * confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on + * 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. diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index dc27726..94ccdeb 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -835,9 +835,24 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect: autocreateSunSpecModelThing(sunspecThreePhaseMeterThingClassId, QT_TR_NOOP("Three Phase Meter"), thing->id(), model); break; - case SunSpecModelFactory::ModelIdStorage: + case SunSpecModelFactory::ModelIdStorage: { + /* A hybrid GEN24 publishes model 124 even with no battery + * wired to it. Creating the child anyway gives the customer a + * phantom battery stuck on batteryCritical. Only the Fronius + * path is gated; the generic and solarEdge vendors keep their + * previous behaviour. */ + SunSpecStorageModel *storageModel = qobject_cast(model); + if (storageModel && isFroniusChild(thing->id()) + && !FroniusRules::hasBattery(storageModel->inBatV(), storageModel->storAval())) { + qCDebug(dcSunSpec()) << "Fronius model 124 found but no battery is installed" + << "(InBatV" << storageModel->inBatV() + << "StorAval" << storageModel->storAval() + << "). Not creating a storage thing."; + break; + } autocreateSunSpecModelThing(sunspecStorageThingClassId, QT_TR_NOOP("Storage"), thing->id(), model); break; + } default: qCWarning(dcSunSpec()) << "Plugin has no implementation for detected" << model; break; @@ -1835,15 +1850,20 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() if (!parentThing) return; + /* Sign applied to every power reading of this block. The generic vendors + * keep the historical negation; a meter sitting under a Fronius connection + * already reports in the nymea convention. See FroniusRules. */ + const double powerSign = FroniusRules::meterPowerSign(isFroniusChild(thing->parentId())); + switch (model->modelId()) { case SunSpecModelFactory::ModelIdMeterSinglePhase: { SunSpecMeterSinglePhaseModel *meter = qobject_cast(model); qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true); - thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); - thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, -meter->ampsPhaseA()); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, powerSign * meter->ampsPhaseA()); thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz()); thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); @@ -1853,10 +1873,10 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() SunSpecMeterSinglePhaseFloatModel *meter = qobject_cast(model); qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true); - thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); - thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->watts())); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->watts())); thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz()); thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); @@ -1872,12 +1892,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts()); - thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts())); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); + thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB())); thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn()); thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); @@ -1895,12 +1915,12 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts()); - thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts())); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); - thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); + thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), powerSign * meter->watts())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB())); thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn()); thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); @@ -1920,13 +1940,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC())); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); @@ -1946,13 +1966,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC())); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); @@ -1972,13 +1992,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC())); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); @@ -1998,13 +2018,13 @@ void IntegrationPluginSunSpec::onMeterBlockUpdated() thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); - thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, powerSign * meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, powerSign * meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, powerSign * meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, powerSign * meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), powerSign * meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), powerSign * meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), powerSign * meter->wattsPhaseC())); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index 04fe1c1..7b40fdd 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -161,6 +161,14 @@ private slots: void test_AC_15_battery_power_is_positive_when_discharging(); void test_AC_15_battery_power_is_negative_when_charging(); + void test_AC_16_fronius_meter_keeps_the_device_sign(); + void test_AC_16_generic_meter_keeps_the_historical_negation(); + void test_AC_16_field_case_export_is_reported_negative(); + + 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(); + // F3 - multi unit ID void test_F3_meter_slave_id_zero_disables_the_meter_connection(); void test_F3_meter_slave_id_out_of_range_is_refused(); @@ -862,6 +870,74 @@ void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced() "Voir sunspec/DIVERGENCES.md."); } +// AC-16 - signe du compteur reseau +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign() +{ + // Le Smart Meter Fronius emet deja dans la convention nymea : negatif = + // injection. La negation generique doit donc etre annulee pour lui. + QCOMPARE(FroniusRules::meterPowerSign(true), 1.0); +} + +void SunSpecFroniusTest::test_AC_16_generic_meter_keeps_the_historical_negation() +{ + // Antidote de non regression : le chemin generique et solarEdge ne bougent + // pas. Ce correctif est cible Fronius, et rien d'autre. + QCOMPARE(FroniusRules::meterPowerSign(false), -1.0); +} + +void SunSpecFroniusTest::test_AC_16_field_case_export_is_reported_negative() +{ + // Cas terrain du 02/08/2026, Primo GEN24 3.0 monophase (modele 101) avec un + // Smart Meter TS 65A-3 (modele 203). L'onduleur n'injecte que sur la phase + // A. Le plugin rapportait phase A = +1003 W (soutirage) et les phases B et + // C negatives, soit une injection sur deux phases sans aucune source : le + // signe etait inverse. L'arbitre voyait "Surplus insuffisant (-842 W)" et + // laissait chauffe-eau et PAC eteints alors que le site exportait. + const double sign = FroniusRules::meterPowerSign(true); + + const double wattsTotal = -708.0; // registre brut du compteur + const double wattsPhaseA = -1003.1; + const double wattsPhaseB = 32.8; + const double wattsPhaseC = 261.6; + + // Export vu comme export, et non plus comme soutirage. + QVERIFY(sign * wattsTotal < 0.0); + // La phase portant l'onduleur exporte ; les deux autres soutirent. + QVERIFY(sign * wattsPhaseA < 0.0); + QVERIFY(sign * wattsPhaseB > 0.0); + QVERIFY(sign * wattsPhaseC > 0.0); +} + +// AC-17 - presence physique de la batterie +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_17_no_battery_means_no_storage_child() +{ + // Cas fantome : le GEN24 hybride publie le modele 124 sans batterie cablee. + // Constate sur le materiel des beaux-parents, ou l'enfant batterie etait + // cree et restait bloque sur batteryCritical. + QVERIFY(!FroniusRules::hasBattery(0.0, 0.0)); +} + +void SunSpecFroniusTest::test_AC_17_an_installed_battery_is_still_detected() +{ + // Antidote symetrique : le correctif ne doit pas faire disparaitre une + // vraie batterie. Une batterie installee porte une tension au repos. + QVERIFY(FroniusRules::hasBattery(230.4, 0.0)); // tension seule + QVERIFY(FroniusRules::hasBattery(0.0, 12.5)); // capacite dispo seule + QVERIFY(FroniusRules::hasBattery(230.4, 12.5)); // les deux +} + +void SunSpecFroniusTest::test_AC_17_presence_does_not_depend_on_state_of_charge() +{ + // Le SoC est volontairement hors du discriminateur : 0 % ne se distingue + // pas d'une vraie batterie vide. Une batterie a plat mais cablee garde sa + // tension, donc reste detectee. + QVERIFY(FroniusRules::hasBattery(230.4, 0.0)); +} + void SunSpecFroniusTest::test_F3_generic_vendors_have_no_meter_slave_id() { // Le multi unit ID est une particularite Fronius. Les vendors generique et