diff --git a/debian/changelog b/debian/changelog index 2811bec..beaea64 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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 diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md index 264d1cf..01bbc38 100644 --- a/sunspec/DIVERGENCES.md +++ b/sunspec/DIVERGENCES.md @@ -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 ». diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp index 659438e..7085240 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -182,7 +182,7 @@ double pvPower(const QList &readings) return total; } -double batteryPower(const QList &readings) +double storageCurrentPower(const QList &readings) { double charging = 0; double discharging = 0; @@ -200,7 +200,25 @@ double batteryPower(const QList &readings) } } - return discharging - charging; + return charging - discharging; +} + +double batteryPower(const QList &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 &readings) +{ + foreach (const ModuleReading &reading, readings) { + const BlockRole role = classifyBlock(reading.inputIdString); + if (role == BlockRoleBatteryCharge || role == BlockRoleBatteryDischarge) + return true; + } + return false; } } diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h index 2730876..3a72b14 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -206,9 +206,35 @@ double pvPower(const QList &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 &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 &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 &readings); + } #endif // FRONIUSRULES_H diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 390a5b5..ac6f737 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -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 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(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(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 IntegrationPluginSunSpec::storageModuleReadings(Thing *thing) const +{ + QList readings; + + SunSpecMpptModel *mppt = m_storageMpptModels.value(thing); + if (!mppt) + return readings; + + foreach (SunSpecModelRepeatingBlock *block, mppt->repeatingBlocks()) { + SunSpecMpptModelRepeatingBlock *module = qobject_cast(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(module->dcPower())}); + } + + return readings; +} + +void IntegrationPluginSunSpec::updateStorageCurrentPower(Thing *thing) +{ + const QList 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(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(model))) + updateStorageCurrentPower(thing); +} + +void IntegrationPluginSunSpec::onStorageNameplateBlockUpdated() +{ + SunSpecModel *model = qobject_cast(sender()); + if (!model) + return; + + foreach (Thing *thing, m_storageNameplateModels.keys(qobject_cast(model))) + updateStorageCapacity(thing); +} + void IntegrationPluginSunSpec::onSolarEdgeBatteryBlockUpdated() { SolarEdgeBattery *battery = qobject_cast(sender()); diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 8ba7aea..703af4a 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -31,6 +31,8 @@ #include #include +#include +#include #include "sunspecthing.h" #include "sunspecstoragelogic.h" @@ -108,6 +110,26 @@ private: QHash m_sunSpecMeters; QHash 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 m_storageMpptModels; + QHash 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 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 &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 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); diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json index 30ec4c6..297147f 100644 --- a/sunspec/integrationpluginsunspec.json +++ b/sunspec/integrationpluginsunspec.json @@ -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", diff --git a/sunspec/sunspecstoragelogic.cpp b/sunspec/sunspecstoragelogic.cpp index 5510a9c..89a343b 100644 --- a/sunspec/sunspecstoragelogic.cpp +++ b/sunspec/sunspecstoragelogic.cpp @@ -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; diff --git a/sunspec/sunspecstoragelogic.h b/sunspec/sunspecstoragelogic.h index 196b311..f13879c 100644 --- a/sunspec/sunspecstoragelogic.h +++ b/sunspec/sunspecstoragelogic.h @@ -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); diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index ad7b433..5f86f29 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -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 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 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 charging = {{"StCha 3", 2000.0}, {"StDisCha 4", 0.0}}; + const QList 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 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 =