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
2f94fd741f
commit
51a211e080
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
|
||||
|
||||
* 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
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
|
||||
double batteryPower(const QList<ModuleReading> &readings)
|
||||
double storageCurrentPower(const QList<ModuleReading> &readings)
|
||||
{
|
||||
double charging = 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
|
||||
* 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);
|
||||
|
||||
/* 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
|
||||
|
||||
@ -413,6 +413,7 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info)
|
||||
if (model) {
|
||||
m_sunSpecStorages.insert(thing, model);
|
||||
connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated);
|
||||
bindStorageCompanionModels(thing, model);
|
||||
qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing;
|
||||
} else {
|
||||
// Note: will be initialized once the connection is available and discovered.
|
||||
@ -472,6 +473,9 @@ void IntegrationPluginSunSpec::thingRemoved(Thing *thing)
|
||||
m_sunSpecMeters.remove(thing);
|
||||
} else if (m_sunSpecStorages.contains(thing)) {
|
||||
m_sunSpecStorages.remove(thing);
|
||||
m_storageMpptModels.remove(thing);
|
||||
m_storageNameplateModels.remove(thing);
|
||||
m_storagesWarnedWithoutPowerSource.remove(thing->id());
|
||||
} else {
|
||||
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);
|
||||
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 {
|
||||
|
||||
@ -1612,6 +1622,12 @@ void IntegrationPluginSunSpec::markThingStatesDisconnected(Thing *thing)
|
||||
thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, 0);
|
||||
} else if (thing->thingClassId() == sunspecStorageThingClassId) {
|
||||
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(sunspecStorageChargingRateStateTypeId, 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
|
||||
foreach (SunSpecThing *sunSpecThing, m_sunSpecThings) {
|
||||
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()
|
||||
{
|
||||
SolarEdgeBattery *battery = qobject_cast<SolarEdgeBattery *>(sender());
|
||||
|
||||
@ -31,6 +31,8 @@
|
||||
|
||||
#include <sunspecconnection.h>
|
||||
#include <models/sunspecmodelfactory.h>
|
||||
#include <models/sunspecmpptmodel.h>
|
||||
#include <models/sunspecnameplatemodel.h>
|
||||
|
||||
#include "sunspecthing.h"
|
||||
#include "sunspecstoragelogic.h"
|
||||
@ -108,6 +110,26 @@ private:
|
||||
QHash<Thing *, SunSpecModel *> m_sunSpecMeters;
|
||||
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
|
||||
* 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. */
|
||||
@ -194,6 +216,19 @@ private:
|
||||
void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation);
|
||||
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:
|
||||
void onRefreshTimer();
|
||||
void onPluginConfigurationChanged(const ParamTypeId ¶mTypeId, const QVariant &value);
|
||||
@ -201,6 +236,8 @@ private slots:
|
||||
void onInverterBlockUpdated();
|
||||
void onMeterBlockUpdated();
|
||||
void onStorageBlockUpdated();
|
||||
void onStorageMpptBlockUpdated();
|
||||
void onStorageNameplateBlockUpdated();
|
||||
void onSolarEdgeBatteryBlockUpdated();
|
||||
|
||||
void evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced);
|
||||
|
||||
@ -1311,7 +1311,7 @@
|
||||
"displayName": "SunSpec Storage",
|
||||
"id": "9a643ba8-346c-4127-a2f8-956a7133d75e",
|
||||
"createMethods": [ "Auto" ],
|
||||
"interfaces": ["battery", "connectable"],
|
||||
"interfaces": ["battery", "energystorage", "connectable"],
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "219beb96-b9fe-4dd2-a386-ecfbbab8786d",
|
||||
@ -1498,6 +1498,24 @@
|
||||
"unit": "Percentage",
|
||||
"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",
|
||||
"name": "version",
|
||||
|
||||
@ -56,6 +56,14 @@ StorageStates readStates(SunSpecStorageModel *storage)
|
||||
return states;
|
||||
}
|
||||
|
||||
double nameplateCapacityKwh(double ratedEnergyWh)
|
||||
{
|
||||
if (ratedEnergyWh <= 0)
|
||||
return -1;
|
||||
|
||||
return ratedEnergyWh / 1000.0;
|
||||
}
|
||||
|
||||
bool isScaledRate(Register reg)
|
||||
{
|
||||
return reg == RegisterInWRte || reg == RegisterOutWRte;
|
||||
|
||||
@ -103,6 +103,20 @@ const int MaximumRevertTimeSeconds = 28800;
|
||||
|
||||
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
|
||||
* be preceded by a fresh read of that scale factor. */
|
||||
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_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_generic_meter_keeps_the_historical_negation();
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/* 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()
|
||||
{
|
||||
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user