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