fix(sunspec): rendre la batterie SunSpec visible au moteur (energystorage)
sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur son tableau de bord), la réserve de stockage de la recharge VE, et celle du waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle était invisible. Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W, signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les déclarer sans les câbler aurait donné une batterie éternellement inerte — pire que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de « je ne sais pas ». D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax est un maximum, StorAval est en ampères-heures. La puissance instantanée vit dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW` est un uint16 toujours positif, l'inactif à zéro. D'où `currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est l'INVERSE de la convention constructeur du contrat §4, et `FroniusRules::batteryPower()` est réécrite comme la négation stricte de la nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule. `capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt que mis à zéro quand le nameplate ne l'implémente pas. Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée par MQTT, registres relus en lecture seule : +2000 W -> StCha 2000,0 W StDisCha 0,0 W ChaSt Charging -1500 W -> StCha 0,0 W StDisCha 1500,0 W ChaSt Discharging Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre. Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le vendor générique, et les interfaces se déclarent par thingClass. Un appareil générique publiant le 124 sans le 160 porte désormais `energystorage` avec un `currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass Fronius distincte détruirait le Thing existant et son historique. Le plugin avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance, en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne remplace pas la mesure, mais ça retire le silence. La cohérence des unités avec l'interface est vérifiée à la COMPILATION par nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer le build. Onze tests `test_D9_*` figent le reste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
This commit is contained in:
parent
de55c9fb39
commit
ee768679e8
32
debian/changelog
vendored
32
debian/changelog
vendored
@ -1,3 +1,35 @@
|
|||||||
|
etm-powersync-plugins-modbus (1.15.2+etm10) trixie; urgency=medium
|
||||||
|
|
||||||
|
* sunspec: make the SunSpec storage visible to the energy engine. The
|
||||||
|
sunspecStorage thing class declared "battery" but not "energystorage", so
|
||||||
|
the three consumers that filter by that interface never found it: nymea's
|
||||||
|
power balance (currentPowerStorage, the figure the customer reads on their
|
||||||
|
dashboard), the storage reserve of the EV surplus charging, and the reserve
|
||||||
|
of the whole waterfall. Observed on the bench: GetPowerBalance answering
|
||||||
|
currentPowerStorage 0 while the battery reported 50 % and "Holding". It was
|
||||||
|
not at zero, it was invisible.
|
||||||
|
* sunspec: publish currentPower and capacity on the storage, wired to
|
||||||
|
registers rather than declared empty. Model 124 carries no power at all -
|
||||||
|
WChaMax is a maximum, StorAval is in amp hours - so the instantaneous power
|
||||||
|
is read from model 160, whose StCha and StDisCha blocks each carry an
|
||||||
|
unsigned DCW with the inactive one at zero. currentPower is therefore
|
||||||
|
DCW[StCha] - DCW[StDisCha], positive when charging, per the convention of
|
||||||
|
libnymea/interfaces/energystorage.json. capacity comes from WHRtg of the
|
||||||
|
nameplate model 120, and is left alone rather than zeroed when the
|
||||||
|
nameplate does not implement it.
|
||||||
|
* sunspec: the sign is measured, not deduced. Read only on the bench with the
|
||||||
|
battery power driven over MQTT: a +2000 W charge order gives StCha 2000 W,
|
||||||
|
StDisCha 0 W and ChaSt Charging; a -1500 W discharge order gives StCha 0 W,
|
||||||
|
StDisCha 1500 W and ChaSt Discharging. The same run showed DCW_SF moving
|
||||||
|
from -2 to -1 between idle and load, which is why the scale factor is never
|
||||||
|
cached across a poll.
|
||||||
|
* sunspec: warn once per thing when a storage has no power source - no model
|
||||||
|
160 on its connection, or a model 160 with no StCha/StDisCha block. Such a
|
||||||
|
thing now claims energystorage while reporting a flat zero, and a silent
|
||||||
|
zero is what made the original defect expensive. See DIVERGENCES.md D-9.
|
||||||
|
|
||||||
|
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 26 Aug 2026 14:00:00 +0200
|
||||||
|
|
||||||
etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium
|
etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium
|
||||||
|
|
||||||
* sunspec: read Fronius float32 points big endian, the way the hardware
|
* sunspec: read Fronius float32 points big endian, the way the hardware
|
||||||
|
|||||||
@ -243,3 +243,114 @@ l'utilisateur. Aucun des deux n'est modifié.
|
|||||||
**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et
|
**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et
|
||||||
`test_AC_18_swapped_words_yield_denormals` fige le raisonnement : les registres
|
`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.
|
réels lus à l'envers donnent un dénormal, jamais une mesure plausible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D-9 — `sunspecStorage` déclare `energystorage` mais tous ses porteurs ne savent pas mesurer (MESURÉ)
|
||||||
|
|
||||||
|
**Ce que demande le brief.** `BRIEF_sunspec_energystorage.md` : le Thing déclarait
|
||||||
|
`battery` + `connectable`, donc les trois consommateurs qui filtrent par
|
||||||
|
`filterByInterface("energystorage")` ne le trouvaient pas — le bilan de puissance
|
||||||
|
que le client lit, la réserve de recharge VE, la réserve du waterfall. Sur `.75`,
|
||||||
|
`currentPowerStorage` valait 0 pendant que la batterie affichait 50 % et
|
||||||
|
« Holding » : elle n'était pas à zéro, elle était invisible.
|
||||||
|
|
||||||
|
**Ce que fait le code.** `sunspecStorage` déclare désormais
|
||||||
|
`["battery", "energystorage", "connectable"]` et porte `currentPower` (W) et
|
||||||
|
`capacity` (kWh), câblés sur des registres.
|
||||||
|
|
||||||
|
### D'où vient `currentPower`
|
||||||
|
|
||||||
|
**Le modèle 124 ne porte aucune puissance.** Il décrit ce que la batterie *est*
|
||||||
|
— SoC, statut, consignes — pas ce qu'elle *fait* en watts. `WChaMax` est une
|
||||||
|
puissance **maximale**, `StorAval` est en ampères-heures. Vérifié registre par
|
||||||
|
registre sur le banc.
|
||||||
|
|
||||||
|
La puissance instantanée vit dans le **modèle 160**, réparti sur deux blocs
|
||||||
|
distincts que Fronius nomme `StCha` (charge) et `StDisCha` (décharge). `DCW` est
|
||||||
|
un `uint16` : chaque bloc est toujours positif, et le bloc inactif est à zéro.
|
||||||
|
D'où
|
||||||
|
|
||||||
|
```
|
||||||
|
currentPower = DCW[StCha] − DCW[StDisCha]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Le signe est mesuré, pas déduit.** Relevé du 2026-08-26, banc `.211` unité 1,
|
||||||
|
modèle 160 à 40265, consigne batterie imposée par MQTT (`banc/cmd/manual`) et
|
||||||
|
registres relus en lecture seule :
|
||||||
|
|
||||||
|
| consigne banc | `banc/etat/batterie` | `StCha` | `StDisCha` | `ChaSt` (124) | `StCha − StDisCha` |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| +2000 W (charge) | +2000,0 W | **2000,0 W** | 0,0 W | Charging | **+2000,0 W** |
|
||||||
|
| −1500 W (décharge) | −1500,0 W | 0,0 W | **1500,0 W** | Discharging | **−1500,0 W** |
|
||||||
|
| 0 W | 0,0 W | 0,0 W | 0,0 W | Holding | 0,0 W |
|
||||||
|
|
||||||
|
`libnymea/interfaces/energystorage.json` : « positive is 'consumed' ». Une
|
||||||
|
batterie qui charge consomme, donc charge = positif. C'est **l'inverse** de la
|
||||||
|
convention constructeur du contrat §4 et de `FroniusRules::batteryPower()`. Les
|
||||||
|
deux fonctions coexistent, et `batteryPower()` est écrite comme la négation de
|
||||||
|
`storageCurrentPower()` pour qu'aucune des deux ne puisse dériver seule.
|
||||||
|
|
||||||
|
**Ce que le relevé a montré en plus.** `DCW_SF` est passé de **−2 à −1** entre la
|
||||||
|
lecture au repos et la lecture en charge, sans hystérésis (I5 du contrat). Un
|
||||||
|
plugin qui mettrait ce facteur en cache à la découverte lirait des puissances
|
||||||
|
fausses d'un facteur 10. libnymea-sunspec ne le met pas en cache : les 90
|
||||||
|
registres du modèle 160 sont lus en une seule transaction Modbus, et
|
||||||
|
`processBlockData()` relit les facteurs d'échelle du bloc fixe **avant** de
|
||||||
|
traiter les blocs répétés. La propriété est donc tenue, mais elle tient à cet
|
||||||
|
ordre — la casser en amont casserait cette lecture sans aucun signe extérieur.
|
||||||
|
|
||||||
|
### D'où vient `capacity`
|
||||||
|
|
||||||
|
`WHRtg` du modèle 120 (plaque signalétique), Wh → kWh.
|
||||||
|
`SunSpecStorageLogic::nameplateCapacityKwh()` rend une valeur **négative** quand
|
||||||
|
le nameplate n'en porte pas, et le plugin laisse alors l'état tranquille plutôt
|
||||||
|
que de publier un zéro qui se lirait comme une mesure.
|
||||||
|
|
||||||
|
**Non vérifiable au banc.** Le simulateur sert les modèles 120 à 123 en
|
||||||
|
**coquilles vides** (choix assumé du lot 1) : les 26 registres du 120 sont à
|
||||||
|
zéro, `WHRtg` compris. `capacity` reste donc à 0 sur `.75`, et le chemin de
|
||||||
|
lecture n'est éprouvé que par les tests unitaires. **Comment lever** : peupler
|
||||||
|
`WHRtg` dans la vue `sunspec-fronius` du banc, ou relever le modèle 120 d'un
|
||||||
|
GEN24 réel équipé d'une batterie. Aucun des trois consommateurs cités par le
|
||||||
|
brief ne lit `capacity` — la conséquence d'un 0 est bornée à l'affichage.
|
||||||
|
|
||||||
|
### L'écart assumé
|
||||||
|
|
||||||
|
**`sunspecStorage` est partagé entre les vendors `sunspec` générique et Fronius**,
|
||||||
|
et les interfaces se déclarent par thingClass, pas par thing. Un appareil SunSpec
|
||||||
|
générique qui publie le modèle 124 **sans** modèle 160 — c'est le cas du
|
||||||
|
simulateur générique `.210` — porte désormais l'interface `energystorage` avec un
|
||||||
|
`currentPower` qui ne bougera jamais de zéro. C'est exactement le défaut que le
|
||||||
|
brief décrit : un zéro se lit « ni charge ni décharge » là où l'absence se lisait
|
||||||
|
« je ne sais pas ».
|
||||||
|
|
||||||
|
**Pourquoi l'accepter quand même.** Les trois alternatives sont pires :
|
||||||
|
|
||||||
|
- créer une thingClass Fronius distincte (à la `solarEdgeBattery`) contredit le
|
||||||
|
contrat §A′ ligne 44, et **détruit le Thing existant** de `.75` avec son
|
||||||
|
historique, puisque le balayage des orphelins ferait disparaître l'ancien ;
|
||||||
|
- ne pas déclarer l'interface laisse les trois consommateurs aveugles, ce qui est
|
||||||
|
le défaut d'origine ;
|
||||||
|
- refuser de créer l'enfant storage quand la puissance n'est pas mesurable ferait
|
||||||
|
disparaître des appareils génériques qui fonctionnent aujourd'hui.
|
||||||
|
|
||||||
|
**Ce qui rend l'écart audible.** Le plugin **avertit une fois par thing** quand il
|
||||||
|
ne trouve pas de source de puissance, en distinguant les deux cas :
|
||||||
|
pas de modèle 160 du tout sur la connexion, ou modèle 160 sans bloc
|
||||||
|
`StCha`/`StDisCha` (un GEN24 sans batterie publie `N=2`). L'avertissement ne
|
||||||
|
remplace pas la mesure, mais il retire le silence — qui était le vrai coût du
|
||||||
|
défaut d'origine.
|
||||||
|
|
||||||
|
**Comment lever.** Que nymea permette de déclarer une interface par thing et non
|
||||||
|
par thingClass, ou que le vendor générique reçoive sa propre thingClass storage
|
||||||
|
au moment où un appareil générique avec batterie entre réellement au parc.
|
||||||
|
|
||||||
|
**Verrous.** `test_D9_*` dans `tests/logic` : la déclaration d'interface, les deux
|
||||||
|
états et leurs unités, le signe dans les deux sens, l'opposition stricte avec
|
||||||
|
`batteryPower()`, l'exclusion des strings PV, et la distinction entre « batterie
|
||||||
|
à l'arrêt » et « pas de bloc batterie ». La cohérence des unités avec l'interface
|
||||||
|
est en outre vérifiée **à la compilation** par `nymea-plugininfocompiler` :
|
||||||
|
essayer `KiloWattHour` sur `currentPower` fait échouer le build avec
|
||||||
|
« claims to implement interface "energystorage" but state "currentPower" has not
|
||||||
|
matching unit ».
|
||||||
|
|||||||
@ -182,7 +182,7 @@ double pvPower(const QList<ModuleReading> &readings)
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
double batteryPower(const QList<ModuleReading> &readings)
|
double storageCurrentPower(const QList<ModuleReading> &readings)
|
||||||
{
|
{
|
||||||
double charging = 0;
|
double charging = 0;
|
||||||
double discharging = 0;
|
double discharging = 0;
|
||||||
@ -200,7 +200,25 @@ double batteryPower(const QList<ModuleReading> &readings)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return discharging - charging;
|
return charging - discharging;
|
||||||
|
}
|
||||||
|
|
||||||
|
double batteryPower(const QList<ModuleReading> &readings)
|
||||||
|
{
|
||||||
|
/* Written as the negation rather than as a second sum: two independent
|
||||||
|
* implementations of the same subtraction are two places for the sign to
|
||||||
|
* rot, and only one of them has a test watching it. */
|
||||||
|
return -storageCurrentPower(readings);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasBatteryBlocks(const QList<ModuleReading> &readings)
|
||||||
|
{
|
||||||
|
foreach (const ModuleReading &reading, readings) {
|
||||||
|
const BlockRole role = classifyBlock(reading.inputIdString);
|
||||||
|
if (role == BlockRoleBatteryCharge || role == BlockRoleBatteryDischarge)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,9 +206,35 @@ double pvPower(const QList<ModuleReading> &readings);
|
|||||||
|
|
||||||
/* Discharge minus charge, so positive means discharging. DCW is a uint16 and
|
/* Discharge minus charge, so positive means discharging. DCW is a uint16 and
|
||||||
* therefore always positive: the sign is carried by whichever block is active,
|
* therefore always positive: the sign is carried by whichever block is active,
|
||||||
* the inactive one reading zero. */
|
* the inactive one reading zero.
|
||||||
|
*
|
||||||
|
* This is the MANUFACTURER convention, the one the contract speaks in. It is
|
||||||
|
* the negation of storageCurrentPower() below, and is written as such so the
|
||||||
|
* two can never drift apart. */
|
||||||
double batteryPower(const QList<ModuleReading> &readings);
|
double batteryPower(const QList<ModuleReading> &readings);
|
||||||
|
|
||||||
|
/* Charge minus discharge, so positive means charging. This is the NYMEA
|
||||||
|
* convention and the opposite of batteryPower() above.
|
||||||
|
*
|
||||||
|
* libnymea/interfaces/energystorage.json: "The currentPower state follows the
|
||||||
|
* usual convention that positive is 'consumed' energy and negative values
|
||||||
|
* represent 'produced' or in this case 'returned' energy." A battery that is
|
||||||
|
* charging consumes, so charging is positive.
|
||||||
|
*
|
||||||
|
* Getting this backwards is not a cosmetic error: the storage reserve of the
|
||||||
|
* arbiter would read a discharging battery as charging and free budget it does
|
||||||
|
* not have. Measured on the bench rather than deduced - see DIVERGENCES.md D-9. */
|
||||||
|
double storageCurrentPower(const QList<ModuleReading> &readings);
|
||||||
|
|
||||||
|
/* Whether the model 160 blocks carry a battery channel at all. A GEN24 with no
|
||||||
|
* battery publishes N=2 (the two PV strings) and no StCha/StDisCha at all, so
|
||||||
|
* the absence of both blocks is what "not measurable" looks like on the wire.
|
||||||
|
*
|
||||||
|
* The distinction matters because storageCurrentPower() answers 0 both for a
|
||||||
|
* battery sitting idle and for an inverter that never reports one, and those
|
||||||
|
* two zeros mean opposite things to a reserve rule. */
|
||||||
|
bool hasBatteryBlocks(const QList<ModuleReading> &readings);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif // FRONIUSRULES_H
|
#endif // FRONIUSRULES_H
|
||||||
|
|||||||
@ -413,6 +413,7 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info)
|
|||||||
if (model) {
|
if (model) {
|
||||||
m_sunSpecStorages.insert(thing, model);
|
m_sunSpecStorages.insert(thing, model);
|
||||||
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
|
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
|
||||||
|
bindStorageCompanionModels(thing, model);
|
||||||
qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing;
|
qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing;
|
||||||
} else {
|
} else {
|
||||||
// Note: will be initialized once the connection is available and discovered.
|
// Note: will be initialized once the connection is available and discovered.
|
||||||
@ -472,6 +473,9 @@ void IntegrationPluginSunSpec::thingRemoved(Thing *thing)
|
|||||||
m_sunSpecMeters.remove(thing);
|
m_sunSpecMeters.remove(thing);
|
||||||
} else if (m_sunSpecStorages.contains(thing)) {
|
} else if (m_sunSpecStorages.contains(thing)) {
|
||||||
m_sunSpecStorages.remove(thing);
|
m_sunSpecStorages.remove(thing);
|
||||||
|
m_storageMpptModels.remove(thing);
|
||||||
|
m_storageNameplateModels.remove(thing);
|
||||||
|
m_storagesWarnedWithoutPowerSource.remove(thing->id());
|
||||||
} else {
|
} else {
|
||||||
Q_ASSERT_X(false, "thingRemoved", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8());
|
Q_ASSERT_X(false, "thingRemoved", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8());
|
||||||
}
|
}
|
||||||
@ -800,6 +804,12 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne
|
|||||||
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
|
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
|
||||||
qCDebug(dcSunSpec()) << "Model initialized successfully for" << modelThing;
|
qCDebug(dcSunSpec()) << "Model initialized successfully for" << modelThing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Outside the guard above: a storage thing bound to its model
|
||||||
|
* 124 before its connection finished discovering has no
|
||||||
|
* companion models yet, and would never get them if this only
|
||||||
|
* ran on the cycle that created the binding. */
|
||||||
|
bindStorageCompanionModels(modelThing, model);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@ -1612,6 +1622,12 @@ void IntegrationPluginSunSpec::markThingStatesDisconnected(Thing *thing)
|
|||||||
thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, 0);
|
thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, 0);
|
||||||
} else if (thing->thingClassId() == sunspecStorageThingClassId) {
|
} else if (thing->thingClassId() == sunspecStorageThingClassId) {
|
||||||
thing->setStateValue(sunspecStorageConnectedStateTypeId, false);
|
thing->setStateValue(sunspecStorageConnectedStateTypeId, false);
|
||||||
|
/* Zeroed, like every other power state of this plugin: nymea's power
|
||||||
|
* balance keeps summing a disconnected energystorage, and a stale
|
||||||
|
* value would be asserted as a measurement long after the inverter
|
||||||
|
* stopped answering. `capacity` is deliberately left alone - it is a
|
||||||
|
* nameplate figure, and losing the link does not shrink the battery. */
|
||||||
|
thing->setStateValue(sunspecStorageCurrentPowerStateTypeId, 0);
|
||||||
thing->setStateValue(sunspecStorageGridChargingStateTypeId, false);
|
thing->setStateValue(sunspecStorageGridChargingStateTypeId, false);
|
||||||
thing->setStateValue(sunspecStorageChargingRateStateTypeId, 0);
|
thing->setStateValue(sunspecStorageChargingRateStateTypeId, 0);
|
||||||
thing->setStateValue(sunspecStorageDischargingRateStateTypeId, 0);
|
thing->setStateValue(sunspecStorageDischargingRateStateTypeId, 0);
|
||||||
@ -1644,6 +1660,21 @@ void IntegrationPluginSunSpec::onRefreshTimer()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Companion models of the storage things. Deduplicated: two storage things
|
||||||
|
* on the same inverter share one model 160, and reading it twice per cycle
|
||||||
|
* would double the Modbus traffic for the same 90 registers. */
|
||||||
|
QSet<SunSpecModel *> storageCompanions;
|
||||||
|
foreach (SunSpecMpptModel *model, m_storageMpptModels.values())
|
||||||
|
storageCompanions.insert(model);
|
||||||
|
foreach (SunSpecNameplateModel *model, m_storageNameplateModels.values())
|
||||||
|
storageCompanions.insert(model);
|
||||||
|
|
||||||
|
foreach (SunSpecModel *model, storageCompanions) {
|
||||||
|
if (model->connection()->connected()) {
|
||||||
|
model->readBlockData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update all other sunspec thing blocks
|
// Update all other sunspec thing blocks
|
||||||
foreach (SunSpecThing *sunSpecThing, m_sunSpecThings) {
|
foreach (SunSpecThing *sunSpecThing, m_sunSpecThings) {
|
||||||
if (sunSpecThing->connection()->connected()) {
|
if (sunSpecThing->connection()->connected()) {
|
||||||
@ -2099,6 +2130,154 @@ void IntegrationPluginSunSpec::onStorageBlockUpdated()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model 124 describes what the battery IS - its state of charge, its status,
|
||||||
|
* its setpoints - and nothing about what it is DOING in watts. There is no
|
||||||
|
* power point anywhere in that model. The instantaneous power of a Fronius
|
||||||
|
* storage lives in model 160, split over two blocks named `StCha` and
|
||||||
|
* `StDisCha`, and the rated capacity lives in WHRtg of model 120.
|
||||||
|
*
|
||||||
|
* Hence this binding: the storage thing stays keyed on its model 124, and
|
||||||
|
* borrows the two other models of the same connection to fill the two states
|
||||||
|
* the energystorage interface requires. */
|
||||||
|
void IntegrationPluginSunSpec::bindStorageCompanionModels(Thing *thing, SunSpecModel *storageModel)
|
||||||
|
{
|
||||||
|
if (!thing || !storageModel || !storageModel->connection())
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* The same connection as the model 124, never a search across the parent:
|
||||||
|
* a Fronius thing carries two connections, and the model 160 of the
|
||||||
|
* inverter must not be paired with a storage read from the meter unit. */
|
||||||
|
SunSpecConnection *connection = storageModel->connection();
|
||||||
|
|
||||||
|
foreach (SunSpecModel *model, connection->models()) {
|
||||||
|
if (model->modelId() == SunSpecModelFactory::ModelIdMppt) {
|
||||||
|
SunSpecMpptModel *mppt = qobject_cast<SunSpecMpptModel *>(model);
|
||||||
|
if (mppt && m_storageMpptModels.value(thing) != mppt) {
|
||||||
|
m_storageMpptModels.insert(thing, mppt);
|
||||||
|
connect(mppt, &SunSpecModel::blockUpdated, this,
|
||||||
|
&IntegrationPluginSunSpec::onStorageMpptBlockUpdated, Qt::UniqueConnection);
|
||||||
|
qCDebug(dcSunSpec()) << "Storage" << thing->name() << "takes its power from" << mppt;
|
||||||
|
}
|
||||||
|
} else if (model->modelId() == SunSpecModelFactory::ModelIdNameplate) {
|
||||||
|
SunSpecNameplateModel *nameplate = qobject_cast<SunSpecNameplateModel *>(model);
|
||||||
|
if (nameplate && m_storageNameplateModels.value(thing) != nameplate) {
|
||||||
|
m_storageNameplateModels.insert(thing, nameplate);
|
||||||
|
connect(nameplate, &SunSpecModel::blockUpdated, this,
|
||||||
|
&IntegrationPluginSunSpec::onStorageNameplateBlockUpdated, Qt::UniqueConnection);
|
||||||
|
qCDebug(dcSunSpec()) << "Storage" << thing->name() << "takes its capacity from" << nameplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_storageMpptModels.contains(thing)) {
|
||||||
|
/* Said once, and said loudly. This thing now claims the energystorage
|
||||||
|
* interface, so nymea's power balance and the storage reserve of the
|
||||||
|
* arbiter will read its currentPower - and it will never leave zero.
|
||||||
|
* A filter that finds a battery reporting a flat zero is worse than a
|
||||||
|
* filter that finds none: the first reads as "neither charging nor
|
||||||
|
* discharging", the second as "no battery". */
|
||||||
|
if (!m_storagesWarnedWithoutPowerSource.contains(thing->id())) {
|
||||||
|
m_storagesWarnedWithoutPowerSource.insert(thing->id());
|
||||||
|
qCWarning(dcSunSpec()) << "Storage" << thing->name() << "has no model 160 on its connection:"
|
||||||
|
<< "currentPower has no register to read and will stay at 0."
|
||||||
|
<< "This storage is visible to the energy balance but not measurable.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publish straight away when the companion models already carry data, so a
|
||||||
|
* thing bound after a discovery does not wait a whole refresh cycle. */
|
||||||
|
if (!m_storageMpptModels.value(thing)->repeatingBlocks().isEmpty())
|
||||||
|
updateStorageCurrentPower(thing);
|
||||||
|
|
||||||
|
updateStorageCapacity(thing);
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<FroniusRules::ModuleReading> IntegrationPluginSunSpec::storageModuleReadings(Thing *thing) const
|
||||||
|
{
|
||||||
|
QList<FroniusRules::ModuleReading> readings;
|
||||||
|
|
||||||
|
SunSpecMpptModel *mppt = m_storageMpptModels.value(thing);
|
||||||
|
if (!mppt)
|
||||||
|
return readings;
|
||||||
|
|
||||||
|
foreach (SunSpecModelRepeatingBlock *block, mppt->repeatingBlocks()) {
|
||||||
|
SunSpecMpptModelRepeatingBlock *module = qobject_cast<SunSpecMpptModelRepeatingBlock *>(block);
|
||||||
|
if (!module)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
/* Keyed on the ID string, never on the block index: on a three or four
|
||||||
|
* MPPT inverter the battery blocks are not in position 3 and 4. */
|
||||||
|
readings.append({module->inputIdSting(), static_cast<double>(module->dcPower())});
|
||||||
|
}
|
||||||
|
|
||||||
|
return readings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void IntegrationPluginSunSpec::updateStorageCurrentPower(Thing *thing)
|
||||||
|
{
|
||||||
|
const QList<FroniusRules::ModuleReading> readings = storageModuleReadings(thing);
|
||||||
|
|
||||||
|
if (!FroniusRules::hasBatteryBlocks(readings)) {
|
||||||
|
/* An inverter with no battery publishes N=2 and no StCha/StDisCha at
|
||||||
|
* all. Summing an empty set would answer a confident 0 W. */
|
||||||
|
if (!m_storagesWarnedWithoutPowerSource.contains(thing->id())) {
|
||||||
|
m_storagesWarnedWithoutPowerSource.insert(thing->id());
|
||||||
|
qCWarning(dcSunSpec()) << "Storage" << thing->name() << "has a model 160 but no StCha/StDisCha block:"
|
||||||
|
<< "currentPower has nothing to read and will stay at 0.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_storagesWarnedWithoutPowerSource.remove(thing->id());
|
||||||
|
|
||||||
|
/* Charge minus discharge. The manufacturer counts the other way round and
|
||||||
|
* so does FroniusRules::batteryPower(); the interface this state belongs to
|
||||||
|
* counts a charging battery as consuming, hence positive. */
|
||||||
|
const double currentPower = FroniusRules::storageCurrentPower(readings);
|
||||||
|
thing->setStateValue(sunspecStorageCurrentPowerStateTypeId, currentPower);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IntegrationPluginSunSpec::updateStorageCapacity(Thing *thing)
|
||||||
|
{
|
||||||
|
SunSpecNameplateModel *nameplate = m_storageNameplateModels.value(thing);
|
||||||
|
if (!nameplate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const double capacity = SunSpecStorageLogic::nameplateCapacityKwh(nameplate->whRtg());
|
||||||
|
if (capacity < 0) {
|
||||||
|
/* The nameplate does not carry a rated storage capacity. Left at its
|
||||||
|
* previous value rather than overwritten with a zero that would read
|
||||||
|
* as a measurement. */
|
||||||
|
qCDebug(dcSunSpec()) << "Storage" << thing->name() << "nameplate carries no WHRtg; capacity left untouched.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thing->setStateValue(sunspecStorageCapacityStateTypeId, capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IntegrationPluginSunSpec::onStorageMpptBlockUpdated()
|
||||||
|
{
|
||||||
|
SunSpecModel *model = qobject_cast<SunSpecModel *>(sender());
|
||||||
|
if (!model)
|
||||||
|
return;
|
||||||
|
|
||||||
|
/* Several storage things may share one model 160 - one connection, one
|
||||||
|
* inverter, and in theory more than one storage block. */
|
||||||
|
foreach (Thing *thing, m_storageMpptModels.keys(qobject_cast<SunSpecMpptModel *>(model)))
|
||||||
|
updateStorageCurrentPower(thing);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IntegrationPluginSunSpec::onStorageNameplateBlockUpdated()
|
||||||
|
{
|
||||||
|
SunSpecModel *model = qobject_cast<SunSpecModel *>(sender());
|
||||||
|
if (!model)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (Thing *thing, m_storageNameplateModels.keys(qobject_cast<SunSpecNameplateModel *>(model)))
|
||||||
|
updateStorageCapacity(thing);
|
||||||
|
}
|
||||||
|
|
||||||
void IntegrationPluginSunSpec::onSolarEdgeBatteryBlockUpdated()
|
void IntegrationPluginSunSpec::onSolarEdgeBatteryBlockUpdated()
|
||||||
{
|
{
|
||||||
SolarEdgeBattery *battery = qobject_cast<SolarEdgeBattery *>(sender());
|
SolarEdgeBattery *battery = qobject_cast<SolarEdgeBattery *>(sender());
|
||||||
|
|||||||
@ -31,6 +31,8 @@
|
|||||||
|
|
||||||
#include <sunspecconnection.h>
|
#include <sunspecconnection.h>
|
||||||
#include <models/sunspecmodelfactory.h>
|
#include <models/sunspecmodelfactory.h>
|
||||||
|
#include <models/sunspecmpptmodel.h>
|
||||||
|
#include <models/sunspecnameplatemodel.h>
|
||||||
|
|
||||||
#include "sunspecthing.h"
|
#include "sunspecthing.h"
|
||||||
#include "sunspecstoragelogic.h"
|
#include "sunspecstoragelogic.h"
|
||||||
@ -108,6 +110,26 @@ private:
|
|||||||
QHash<Thing *, SunSpecModel *> m_sunSpecMeters;
|
QHash<Thing *, SunSpecModel *> m_sunSpecMeters;
|
||||||
QHash<Thing *, SunSpecModel *> m_sunSpecStorages;
|
QHash<Thing *, SunSpecModel *> m_sunSpecStorages;
|
||||||
|
|
||||||
|
/* Companion models of a storage thing, on the same connection as its model
|
||||||
|
* 124. They are not things of their own and never will be: they describe
|
||||||
|
* the same battery, from the two registers model 124 does not carry.
|
||||||
|
*
|
||||||
|
* - model 160 gives the instantaneous power, through its StCha /
|
||||||
|
* StDisCha blocks. Without it the `currentPower` of the energystorage
|
||||||
|
* interface has no source at all;
|
||||||
|
* - model 120 gives the rated capacity in WHRtg.
|
||||||
|
*
|
||||||
|
* Both may legitimately be absent, and a storage thing without them keeps
|
||||||
|
* working exactly as it did before - minus the two states. */
|
||||||
|
QHash<Thing *, SunSpecMpptModel *> m_storageMpptModels;
|
||||||
|
QHash<Thing *, SunSpecNameplateModel *> m_storageNameplateModels;
|
||||||
|
|
||||||
|
/* Storage things already warned about a missing model 160. The warning has
|
||||||
|
* to be said once and not on every poll, but it does have to be said: an
|
||||||
|
* energystorage whose currentPower can never move is exactly the silent
|
||||||
|
* failure this whole change exists to remove. */
|
||||||
|
QSet<ThingId> m_storagesWarnedWithoutPowerSource;
|
||||||
|
|
||||||
/* Connections having completed at least one successful discovery. The
|
/* Connections having completed at least one successful discovery. The
|
||||||
* orphan sweep needs to know whether it is looking at the whole picture or
|
* orphan sweep needs to know whether it is looking at the whole picture or
|
||||||
* at half of it. Entries are removed when the connection is destroyed. */
|
* at half of it. Entries are removed when the connection is destroyed. */
|
||||||
@ -194,6 +216,19 @@ private:
|
|||||||
void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation);
|
void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation);
|
||||||
void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
|
void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
|
||||||
|
|
||||||
|
/* Binds a storage thing to the model 160 and model 120 of its own
|
||||||
|
* connection, and starts publishing currentPower and capacity from them.
|
||||||
|
* Idempotent: called from both paths that bind a storage thing to its model
|
||||||
|
* 124, and from either order. */
|
||||||
|
void bindStorageCompanionModels(Thing *thing, SunSpecModel *storageModel);
|
||||||
|
|
||||||
|
/* The model 160 blocks of a storage thing, in the shape the pure rules
|
||||||
|
* take. Empty when the thing has no model 160 bound. */
|
||||||
|
QList<FroniusRules::ModuleReading> storageModuleReadings(Thing *thing) const;
|
||||||
|
|
||||||
|
void updateStorageCurrentPower(Thing *thing);
|
||||||
|
void updateStorageCapacity(Thing *thing);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onRefreshTimer();
|
void onRefreshTimer();
|
||||||
void onPluginConfigurationChanged(const ParamTypeId ¶mTypeId, const QVariant &value);
|
void onPluginConfigurationChanged(const ParamTypeId ¶mTypeId, const QVariant &value);
|
||||||
@ -201,6 +236,8 @@ private slots:
|
|||||||
void onInverterBlockUpdated();
|
void onInverterBlockUpdated();
|
||||||
void onMeterBlockUpdated();
|
void onMeterBlockUpdated();
|
||||||
void onStorageBlockUpdated();
|
void onStorageBlockUpdated();
|
||||||
|
void onStorageMpptBlockUpdated();
|
||||||
|
void onStorageNameplateBlockUpdated();
|
||||||
void onSolarEdgeBatteryBlockUpdated();
|
void onSolarEdgeBatteryBlockUpdated();
|
||||||
|
|
||||||
void evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced);
|
void evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced);
|
||||||
|
|||||||
@ -1311,7 +1311,7 @@
|
|||||||
"displayName": "SunSpec Storage",
|
"displayName": "SunSpec Storage",
|
||||||
"id": "9a643ba8-346c-4127-a2f8-956a7133d75e",
|
"id": "9a643ba8-346c-4127-a2f8-956a7133d75e",
|
||||||
"createMethods": [ "Auto" ],
|
"createMethods": [ "Auto" ],
|
||||||
"interfaces": ["battery", "connectable"],
|
"interfaces": ["battery", "energystorage", "connectable"],
|
||||||
"paramTypes": [
|
"paramTypes": [
|
||||||
{
|
{
|
||||||
"id": "219beb96-b9fe-4dd2-a386-ecfbbab8786d",
|
"id": "219beb96-b9fe-4dd2-a386-ecfbbab8786d",
|
||||||
@ -1498,6 +1498,24 @@
|
|||||||
"unit": "Percentage",
|
"unit": "Percentage",
|
||||||
"defaultValue": 0
|
"defaultValue": 0
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "58083d0c-026f-4ad8-bdf4-587b33962e31",
|
||||||
|
"name": "currentPower",
|
||||||
|
"displayName": "Current power",
|
||||||
|
"displayNameEvent": "Current power changed",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Watt",
|
||||||
|
"defaultValue": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c02a512d-e9b4-480e-87cc-ed81fb023a66",
|
||||||
|
"name": "capacity",
|
||||||
|
"displayName": "Capacity",
|
||||||
|
"displayNameEvent": "Capacity changed",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "KiloWattHour",
|
||||||
|
"defaultValue": 0
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "fca0c151-d8fa-4d6e-a5d7-b3bc965abc09",
|
"id": "fca0c151-d8fa-4d6e-a5d7-b3bc965abc09",
|
||||||
"name": "version",
|
"name": "version",
|
||||||
|
|||||||
@ -56,6 +56,14 @@ StorageStates readStates(SunSpecStorageModel *storage)
|
|||||||
return states;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double nameplateCapacityKwh(double ratedEnergyWh)
|
||||||
|
{
|
||||||
|
if (ratedEnergyWh <= 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return ratedEnergyWh / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
bool isScaledRate(Register reg)
|
bool isScaledRate(Register reg)
|
||||||
{
|
{
|
||||||
return reg == RegisterInWRte || reg == RegisterOutWRte;
|
return reg == RegisterInWRte || reg == RegisterOutWRte;
|
||||||
|
|||||||
@ -103,6 +103,20 @@ const int MaximumRevertTimeSeconds = 28800;
|
|||||||
|
|
||||||
StorageStates readStates(SunSpecStorageModel *storage);
|
StorageStates readStates(SunSpecStorageModel *storage);
|
||||||
|
|
||||||
|
/* Nameplate rated storage capacity, Wh -> kWh, for the `capacity` state of the
|
||||||
|
* energystorage interface.
|
||||||
|
*
|
||||||
|
* Model 124 carries no energy capacity: WChaMax is a power, StorAval is in amp
|
||||||
|
* hours and reads zero on every machine measured so far. The rated capacity of
|
||||||
|
* the storage lives in WHRtg of the nameplate model 120.
|
||||||
|
*
|
||||||
|
* Returns a NEGATIVE value when the nameplate carries no capacity, so that the
|
||||||
|
* caller can leave the state alone instead of publishing a fabricated zero. A
|
||||||
|
* nameplate that does not implement WHRtg reads 0xFFFF, which libnymea-sunspec
|
||||||
|
* reports as an invalid data point and leaves at 0 - indistinguishable here
|
||||||
|
* from a nameplate that genuinely says zero, and both mean "unknown". */
|
||||||
|
double nameplateCapacityKwh(double ratedEnergyWh);
|
||||||
|
|
||||||
/* True for the registers carried by InOutWRte_SF, i.e. those whose write should
|
/* True for the registers carried by InOutWRte_SF, i.e. those whose write should
|
||||||
* be preceded by a fresh read of that scale factor. */
|
* be preceded by a fresh read of that scale factor. */
|
||||||
bool isScaledRate(Register reg);
|
bool isScaledRate(Register reg);
|
||||||
|
|||||||
@ -161,6 +161,19 @@ private slots:
|
|||||||
void test_AC_15_battery_power_is_positive_when_discharging();
|
void test_AC_15_battery_power_is_positive_when_discharging();
|
||||||
void test_AC_15_battery_power_is_negative_when_charging();
|
void test_AC_15_battery_power_is_negative_when_charging();
|
||||||
|
|
||||||
|
// D-9 - interface energystorage du sunspecStorage
|
||||||
|
void test_D9_storage_declares_energystorage_without_losing_battery();
|
||||||
|
void test_D9_storage_exposes_current_power_in_watt();
|
||||||
|
void test_D9_storage_exposes_capacity_in_kilowatthour();
|
||||||
|
void test_D9_current_power_is_positive_when_charging();
|
||||||
|
void test_D9_current_power_is_negative_when_discharging();
|
||||||
|
void test_D9_current_power_is_the_opposite_of_battery_power();
|
||||||
|
void test_D9_current_power_ignores_the_pv_strings();
|
||||||
|
void test_D9_battery_blocks_are_detected_by_id_string();
|
||||||
|
void test_D9_an_inverter_without_battery_has_no_battery_block();
|
||||||
|
void test_D9_capacity_converts_watthours_to_kilowatthours();
|
||||||
|
void test_D9_a_nameplate_without_capacity_is_refused();
|
||||||
|
|
||||||
void test_AC_16_fronius_meter_keeps_the_device_sign();
|
void test_AC_16_fronius_meter_keeps_the_device_sign();
|
||||||
void test_AC_16_generic_meter_keeps_the_historical_negation();
|
void test_AC_16_generic_meter_keeps_the_historical_negation();
|
||||||
void test_AC_16_field_case_export_is_reported_negative();
|
void test_AC_16_field_case_export_is_reported_negative();
|
||||||
@ -911,6 +924,157 @@ void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced()
|
|||||||
// AC-16 - signe du compteur reseau
|
// AC-16 - signe du compteur reseau
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/* D-9 - Le Thing declare `battery` mais pas `energystorage`, donc les trois
|
||||||
|
* consommateurs qui filtrent par `energystorage` ne le trouvent pas : le bilan
|
||||||
|
* de puissance (currentPowerStorage, celui du tableau de bord client), la
|
||||||
|
* reserve de recharge VE, et la reserve du waterfall. Voir DIVERGENCES.md D-9. */
|
||||||
|
void SunSpecFroniusTest::test_D9_storage_declares_energystorage_without_losing_battery()
|
||||||
|
{
|
||||||
|
const QJsonObject storage = thingClassByName("sunspecStorage");
|
||||||
|
QVERIFY(!storage.isEmpty());
|
||||||
|
|
||||||
|
QStringList interfaces;
|
||||||
|
foreach (const QJsonValue &value, storage.value("interfaces").toArray())
|
||||||
|
interfaces.append(value.toString());
|
||||||
|
|
||||||
|
QVERIFY2(interfaces.contains("energystorage"),
|
||||||
|
"sans energystorage, filterByInterface ne trouve pas la batterie");
|
||||||
|
|
||||||
|
// `battery` est conservee : energystorage l'etend, et d'autres choses
|
||||||
|
// peuvent en dependre.
|
||||||
|
QVERIFY(interfaces.contains("battery"));
|
||||||
|
QVERIFY(interfaces.contains("connectable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SunSpecFroniusTest::test_D9_storage_exposes_current_power_in_watt()
|
||||||
|
{
|
||||||
|
const QJsonObject storage = thingClassByName("sunspecStorage");
|
||||||
|
QVERIFY(!storage.isEmpty());
|
||||||
|
|
||||||
|
const QJsonObject state = stateByName(storage, "currentPower");
|
||||||
|
QVERIFY2(!state.isEmpty(), "energystorage exige currentPower");
|
||||||
|
|
||||||
|
// Type et unite sont verifies par PluginMetadata contre l'interface : s'ils
|
||||||
|
// divergent, le plugin ne se charge plus du tout.
|
||||||
|
QCOMPARE(state.value("type").toString(), QString("double"));
|
||||||
|
QCOMPARE(state.value("unit").toString(), QString("Watt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SunSpecFroniusTest::test_D9_storage_exposes_capacity_in_kilowatthour()
|
||||||
|
{
|
||||||
|
const QJsonObject storage = thingClassByName("sunspecStorage");
|
||||||
|
QVERIFY(!storage.isEmpty());
|
||||||
|
|
||||||
|
const QJsonObject state = stateByName(storage, "capacity");
|
||||||
|
QVERIFY2(!state.isEmpty(), "energystorage exige capacity");
|
||||||
|
|
||||||
|
QCOMPARE(state.value("type").toString(), QString("double"));
|
||||||
|
QCOMPARE(state.value("unit").toString(), QString("KiloWattHour"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Convention nymea, relevee dans libnymea/interfaces/energystorage.json :
|
||||||
|
* positif = consomme. Une batterie qui charge consomme.
|
||||||
|
*
|
||||||
|
* Mesure du 2026-08-26 sur le banc (.211 unit 1, modele 160 @40265), consigne
|
||||||
|
* batterie imposee par MQTT et registres relus en lecture seule :
|
||||||
|
* consigne +2000 W -> StCha 2000,0 W StDisCha 0,0 W ChaSt Charging
|
||||||
|
* consigne -1500 W -> StCha 0,0 W StDisCha 1500,0 W ChaSt Discharging
|
||||||
|
* Le signe n'est donc pas deduit de la documentation, il est constate. */
|
||||||
|
void SunSpecFroniusTest::test_D9_current_power_is_positive_when_charging()
|
||||||
|
{
|
||||||
|
using namespace FroniusRules;
|
||||||
|
|
||||||
|
const QList<ModuleReading> readings = {
|
||||||
|
{"MPPT 1", 496.5},
|
||||||
|
{"MPPT 2", 496.5},
|
||||||
|
{"StCha 3", 2000.0},
|
||||||
|
{"StDisCha 4", 0.0}
|
||||||
|
};
|
||||||
|
|
||||||
|
QCOMPARE(storageCurrentPower(readings), 2000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SunSpecFroniusTest::test_D9_current_power_is_negative_when_discharging()
|
||||||
|
{
|
||||||
|
using namespace FroniusRules;
|
||||||
|
|
||||||
|
const QList<ModuleReading> readings = {
|
||||||
|
{"MPPT 1", 621.8},
|
||||||
|
{"MPPT 2", 621.8},
|
||||||
|
{"StCha 3", 0.0},
|
||||||
|
{"StDisCha 4", 1500.0}
|
||||||
|
};
|
||||||
|
|
||||||
|
QCOMPARE(storageCurrentPower(readings), -1500.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Les deux conventions coexistent dans le plugin - celle du constructeur pour
|
||||||
|
* le contrat, celle de nymea pour l'etat. Ce test les tient dos a dos : une
|
||||||
|
* seule des deux peut changer de signe sans que rien ne le dise. */
|
||||||
|
void SunSpecFroniusTest::test_D9_current_power_is_the_opposite_of_battery_power()
|
||||||
|
{
|
||||||
|
using namespace FroniusRules;
|
||||||
|
|
||||||
|
const QList<ModuleReading> charging = {{"StCha 3", 2000.0}, {"StDisCha 4", 0.0}};
|
||||||
|
const QList<ModuleReading> discharging = {{"StCha 3", 0.0}, {"StDisCha 4", 1500.0}};
|
||||||
|
|
||||||
|
QCOMPARE(storageCurrentPower(charging), -batteryPower(charging));
|
||||||
|
QCOMPARE(storageCurrentPower(discharging), -batteryPower(discharging));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SunSpecFroniusTest::test_D9_current_power_ignores_the_pv_strings()
|
||||||
|
{
|
||||||
|
using namespace FroniusRules;
|
||||||
|
|
||||||
|
// Les strings PV ont ete a 746,4 W chacun pendant la phase de repos du
|
||||||
|
// releve, batterie a l'arret. Les compter donnerait 1492,8 W de charge.
|
||||||
|
const QList<ModuleReading> readings = {
|
||||||
|
{"MPPT 1", 746.4},
|
||||||
|
{"MPPT 2", 746.4},
|
||||||
|
{"StCha 3", 0.0},
|
||||||
|
{"StDisCha 4", 0.0}
|
||||||
|
};
|
||||||
|
|
||||||
|
QCOMPARE(storageCurrentPower(readings), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SunSpecFroniusTest::test_D9_battery_blocks_are_detected_by_id_string()
|
||||||
|
{
|
||||||
|
using namespace FroniusRules;
|
||||||
|
|
||||||
|
QVERIFY(hasBatteryBlocks({{"MPPT 1", 1200.0}, {"StCha 3", 0.0}}));
|
||||||
|
QVERIFY(hasBatteryBlocks({{"MPPT 1", 1200.0}, {"StDisCha 4", 0.0}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* La distinction que ce test protege : storageCurrentPower() repond 0 aussi
|
||||||
|
* bien pour une batterie a l'arret que pour un onduleur qui n'en a pas. Sans
|
||||||
|
* hasBatteryBlocks(), le plugin publierait un 0 confiant sur une machine qui
|
||||||
|
* n'a rien a mesurer - exactement le zero qui est pire que l'absence. */
|
||||||
|
void SunSpecFroniusTest::test_D9_an_inverter_without_battery_has_no_battery_block()
|
||||||
|
{
|
||||||
|
using namespace FroniusRules;
|
||||||
|
|
||||||
|
// Un GEN24 sans batterie publie N=2 : les deux strings PV, rien d'autre.
|
||||||
|
QVERIFY(!hasBatteryBlocks({{"MPPT 1", 1200.0}, {"MPPT 2", 800.0}}));
|
||||||
|
QVERIFY(!hasBatteryBlocks({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SunSpecFroniusTest::test_D9_capacity_converts_watthours_to_kilowatthours()
|
||||||
|
{
|
||||||
|
QCOMPARE(SunSpecStorageLogic::nameplateCapacityKwh(10000.0), 10.0);
|
||||||
|
QCOMPARE(SunSpecStorageLogic::nameplateCapacityKwh(7500.0), 7.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WHRtg non implemente vaut 0xFFFF, que libnymea-sunspec rend comme un point de
|
||||||
|
* donnee invalide laisse a 0 - indistinguable d'un nameplate qui annonce
|
||||||
|
* vraiment zero. Les deux veulent dire "inconnu", et aucun des deux ne doit
|
||||||
|
* etre publie comme une mesure. */
|
||||||
|
void SunSpecFroniusTest::test_D9_a_nameplate_without_capacity_is_refused()
|
||||||
|
{
|
||||||
|
QVERIFY(SunSpecStorageLogic::nameplateCapacityKwh(0.0) < 0);
|
||||||
|
QVERIFY(SunSpecStorageLogic::nameplateCapacityKwh(-1.0) < 0);
|
||||||
|
}
|
||||||
|
|
||||||
void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign()
|
void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign()
|
||||||
{
|
{
|
||||||
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =
|
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user