diff --git a/debian-qt5/changelog b/debian-qt5/changelog index e4c528f..56fedc7 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,55 @@ +powersync-energy-plugin-nymea (1.15.2+etm16) trixie; urgency=medium + + * Lot D — les constats de l'agent app, dans l'ordre de ce qu'ils coûtent. + * Renommer une charge atteint enfin l'adaptateur. updateSoftConfig() ne portait que + priority et needs : un changement de libellé seul était compté « inchangée », l'adaptateur + gardait son ancien nom, et le journal de la box contredisait GetLoadConfig jusqu'à la + prochaine reconstruction. Pour qui suit journalctl pendant une mise en service, les deux + sources se contredisaient. `label` entre dans la signature et dans le prédicat `soft` de + l'arbitre — sans entrer dans sameHardware() : c'est une mise à jour EN PLACE, pas une + reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité d'avant le lot + B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester — n'aurait rien changé. + * relays[] n'est plus comparé par index. Réordonner les mêmes relais aux mêmes puissances + reconstruisait la charge : contacts ouverts par l'état sûr, verrous minOn/minOff réarmés à + froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à couper sa charge, + et annuler son geste en coûtait une SECONDE. Les comparer comme un ensemble aurait été faux + dans l'autre sens : à somme égale la table retient la première combinaison rencontrée, donc + échanger deux relais de MÊME puissance change le contact qui sert ce palier — autre + contacteur qui s'use, autre résistance qui chauffe. sameHardware() compare désormais la + TABLE DÉRIVÉE : deux listes qui produisent les mêmes paliers avec les mêmes contacts sont + matériellement identiques, par construction et non par pari. + * Les paliers atteignables sont publiés — mechanism.stagesW, en télémétrie. Ils ne sont + déclarés nulle part : ils se dérivent des relais, et sans publication un client qui veut les + montrer doit réimplémenter la combinatoire. Seconde implémentation, divergence certaine à la + première correction d'un seul côté, et c'est le client qui aurait tort sans le savoir. + Publiés là et PAS dans GetLoadConfig, à dessein : la télémétrie n'a aucun chemin d'écriture, + donc « lecture seule » y est vrai par construction, alors que GetLoadConfig aurait cassé + l'aller-retour neutre dont l'app dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, + le champ serait revenu dans SetLoadConfig, et un refus en bloc aurait emporté toutes les + charges. + * RelayRouter::deriveStages() : implémentation UNIQUE de la combinatoire, partagée par la + construction du routeur, la comparaison matérielle et la publication. C'était le fond de la + demande : une seule règle, donc pas de divergence possible. + * sameHardware() passe en public. Ce prédicat décide si une écriture coûte un geste MATÉRIEL + ou une mise à jour en place — un élément de contrat, que le client annonce à l'installateur + avant d'enregistrer. Statique et sans état. + * tests/auto/loadmodel — cible neuve, volontairement légère : aucun serveur nymea, 2 ms. Elle + a attrapé un défaut de la correction elle-même avant le banc : la première version de + RelayStageTable::operator== comparait les listes de ThingIds, donc voyait une différence + entre fermer {K1,K2} et fermer {K2,K1}. Même geste électrique, deux listes. Les contacts + d'un palier se comparent maintenant comme un ensemble. + * tools/repair_energylogs.py — répare les cumuls d'énergie empoisonnés par une lecture de + compteur absurde. N'est PAS un correctif de ce plugin : la cause est extérieure (SunSpec + lisant les float32 Fronius aux mots permutés, corrigé par etm-powersync-plugins-modbus + d08cf50, daté 05:30:14 UTC — la seconde même du premier échantillon empoisonné du banc). Ce + que le script rattrape est la SÉQUELLE : EnergyManagerImpl accumule sans contrôle de + plausibilité dans un compteur persistant, et à 7,5e33 kWh le plus petit incrément + représentable en double vaut 1,67e18 — le compteur n'était pas seulement faux, il était + GELÉ, et rechargé tel quel à chaque démarrage. Appliqué sur .75 : 62 726 anomalies → 0, + accumulation reprise et vérifiée en direct. + + -- Patrick Schurig Wed, 26 Aug 2026 08:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm15) trixie; urgency=medium * La PAC entre en configuration : le bloc d'enregistrement codé en dur d'energypluginnymea.cpp diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index b8ffac3..01367f8 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -108,10 +108,11 @@ public: */ LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; - //! \brief Met à jour rang et besoins sans reconstruire (ECS-412). + //! \brief Met à jour libellé, rang et besoins sans reconstruire (ECS-412). + //! \param label Nouveau libellé — il part au journal DÈS ce cycle. //! \param priority Nouveau rang de service. \param needs Nouveaux besoins. - void updateSoftConfig(int priority, const LoadNeeds &needs) override - { m_priority = priority; m_needs = needs; } + void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override + { m_label = label; m_priority = priority; m_needs = needs; } //! \brief Sans effet : cet adaptateur n'implémente pas encore l'échelle de défaut ECS-410. //! La généralisation est portée par **ECS-414** (lot de mise en configuration diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 79801b9..8e662ee 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -72,9 +72,10 @@ public: LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100, - //! cf. « Dette 3g »). \param priority Ignoré. \param needs Ignoré. - void updateSoftConfig(int priority, const LoadNeeds &needs) override - { Q_UNUSED(priority) Q_UNUSED(needs) } + //! cf. « Dette 3g »), et son nom d'affichage vient du Thing, pas de la config. + //! \param label Ignoré. \param priority Ignoré. \param needs Ignoré. + void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override + { Q_UNUSED(label) Q_UNUSED(priority) Q_UNUSED(needs) } //! \brief Sans effet : l'EV n'implémente pas encore l'échelle de défaut ECS-410. //! La généralisation est portée par **ECS-414** (lot de mise en configuration diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index acc5473..382b878 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -128,7 +128,8 @@ public: * DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis * la config ignorerait sans bruit les changements de rang. */ - virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0; + virtual void updateSoftConfig(const QString &label, int priority, + const LoadNeeds &needs) = 0; /*! * \brief Lève le verrou de défaut de la charge (ECS-410). diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 04ba98c..06fa77e 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -18,6 +18,38 @@ namespace { constexpr int MaxRelays = 16; } +RelayStageTable RelayRouter::deriveStages(const QList &relays) +{ + int n = relays.size(); + if (n > MaxRelays) + n = MaxRelays; // l'avertissement est émis à la construction, pas ici + + // Toutes les sommes de sous-ensembles, dédupliquées par puissance, triées (QMap = clés + // croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances identiques + // (ex. deux relais 1000 W), on garde la PREMIÈRE combinaison rencontrée — d'où le fait + // que l'ordre de déclaration soit significatif dans ce cas, et lui seul. + QMap> byPower; + for (int mask = 0; mask < (1 << n); ++mask) { + int sum = 0; + QList set; + for (int i = 0; i < n; ++i) { + if (mask & (1 << i)) { + sum += relays.at(i).powerW; + set.append(relays.at(i).thingId); + } + } + if (!byPower.contains(sum)) + byPower.insert(sum, set); + } + + RelayStageTable t; + for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) { + t.levels.append(it.key()); + t.mapping.append(it.value()); + } + return t; +} + RelayRouter::RelayRouter(ThingManager *thingManager, const QString &id, const QString &label, @@ -49,26 +81,11 @@ RelayRouter::RelayRouter(ThingManager *thingManager, for (int i = 0; i < n; ++i) m_relayNominalW.insert(relays.at(i).thingId, relays.at(i).powerW); - // Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées - // (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances - // identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée. - QMap> byPower; - for (int mask = 0; mask < (1 << n); ++mask) { - int sum = 0; - QList set; - for (int i = 0; i < n; ++i) { - if (mask & (1 << i)) { - sum += relays.at(i).powerW; - set.append(relays.at(i).thingId); - } - } - if (!byPower.contains(sum)) - byPower.insert(sum, set); - } - for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) { - m_levels.append(it.key()); - m_relayMapping.append(it.value()); - } + // La combinatoire vit dans deriveStages() — une seule implémentation pour les trois + // usages (construction, comparaison matérielle, publication en télémétrie). + const RelayStageTable table = deriveStages(relays); + m_levels = table.levels; + m_relayMapping = table.mapping; // byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0. Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0); @@ -268,10 +285,18 @@ int RelayRouter::stageForPower(double powerW) const return stage; } -void RelayRouter::updateSoftConfig(int priority, const LoadNeeds &needs) +void RelayRouter::updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) { // ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent, // aucun relais n'est réécrit. + // + // Le libellé en fait partie. Il ne change RIEN au routage — d'où son absence de + // sameHardware() — mais il nomme la charge dans chaque ligne de journal. Sans cette + // affectation, renommer « chauffe-eau » en « Ballon 200 L » laissait le journal + // imprimer l'ancien nom jusqu'à la prochaine reconstruction, alors que GetLoadConfig + // rendait déjà le nouveau. Pour qui suit journalctl pendant une mise en service, les + // deux sources se contredisaient. + m_label = label; m_priority = priority; m_needs = needs; } @@ -497,6 +522,23 @@ LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const v.mechanism.insert(QStringLiteral("maxStageW"), m_levels.isEmpty() ? 0.0 : static_cast(m_levels.last())); + // Paliers atteignables, publiés en LECTURE SEULE (lot D §4). + // + // Ils ne sont déclarés nulle part : ils se dérivent des relais. Sans cette publication, + // un client qui veut les montrer doit réimplémenter la combinatoire — seconde + // implémentation, divergence certaine à la première correction d'un seul côté, et c'est + // le client qui aurait tort sans le savoir. + // + // Ici et pas dans GetLoadConfig, à dessein : la télémétrie n'a AUCUN chemin d'écriture, + // donc « lecture seule » y est vrai par construction. Les publier dans GetLoadConfig + // aurait cassé l'aller-retour neutre dont l'app dépend pour écrire — elle renvoie + // verbatim ce qu'elle a lu, le champ serait revenu dans SetLoadConfig, et un refus en + // bloc aurait emporté toutes les charges. + QVariantList stagesW; + for (int w : m_levels) + stagesW.append(static_cast(w)); + v.mechanism.insert(QStringLiteral("stagesW"), stagesW); + // ECS-410 — le défaut sort en CODE. Il n'a aujourd'hui qu'une cause : l'échelle d'écriture // épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase. if (m_faulted) diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index ea97c1a..68d535e 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -3,6 +3,7 @@ #pragma once #include +#include #include #include #include @@ -39,6 +40,53 @@ class ThingManager; * (repli L2). **Temps = paramètre** (cf. \c ILoadAdapter) : \c now reçu, jamais l'horloge. * \invariant Transition relais en **off-before-on** : coupe d'abord les relais hors-cible. */ +/*! + * \brief Table de paliers dérivée d'une liste de relais — ce que le routeur SAIT faire. + * + * Deux listes de relais qui produisent la même table sont **matériellement identiques** : + * mêmes puissances atteignables, mêmes contacts fermés pour chacune. C'est la seule + * comparaison qui ait un sens, et elle est vraie par construction plutôt que par pari. + * + * \note L'ordre de déclaration des relais n'est PAS inerte, et c'est pourquoi comparer + * les listes comme des ensembles serait faux : à somme égale, la table retient la + * **première** combinaison rencontrée. Deux relais de même puissance échangés dans la + * liste donnent donc les mêmes paliers mais un **contact différent** pour ce palier — + * autre contacteur qui s'use, autre résistance qui chauffe. La table le voit ; un + * ensemble ne le verrait pas. + */ +struct RelayStageTable +{ + QList levels; //!< Paliers W triés croissants, \c levels[0] == 0. + QList> mapping; //!< ThingIds fermés pour chaque palier de \c levels. + + /*! + * \brief Égalité MATÉRIELLE : mêmes paliers, mêmes contacts fermés pour chacun. + * + * Les ThingIds d'une combinaison sont comparés comme un **ensemble**, pas comme une + * liste. Ils sont collectés dans l'ordre de déclaration des relais, si bien que + * fermer {K1,K2} et fermer {K2,K1} produit deux listes différentes pour un geste + * électrique identique. Comparer les listes ferait reconstruire la charge sur un + * simple réordonnancement — exactement le défaut que cette table corrige. + * + * L'ordre reste significatif là où il l'est vraiment : dans le CHOIX de la + * combinaison retenue à somme égale, que \c levels et l'appariement position par + * position capturent. + */ + bool operator==(const RelayStageTable &o) const + { + if (levels != o.levels || mapping.size() != o.mapping.size()) + return false; + for (int i = 0; i < mapping.size(); ++i) { + const QList &a = mapping.at(i); + const QList &b = o.mapping.at(i); + if (QSet(a.begin(), a.end()) != QSet(b.begin(), b.end())) + return false; + } + return true; + } + bool operator!=(const RelayStageTable &o) const { return !(*this == o); } +}; + class RelayRouter : public QObject, public ILoadAdapter { Q_OBJECT @@ -93,7 +141,21 @@ public: //! \param needs Nouveaux besoins déclarés. //! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire //! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais. - void updateSoftConfig(int priority, const LoadNeeds &needs) override; + void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override; + + /*! + * \brief Dérive la table de paliers d'une liste de relais. **Implémentation unique.** + * + * Trois appelants, une seule règle : la construction du routeur, la comparaison de + * \c EnergyArbitrator::sameHardware(), et la publication en télémétrie. Dupliquer + * cette combinatoire ailleurs — y compris côté app — expose à une divergence que + * personne ne verrait passer. + * + * \param relays Relais déclarés, dans leur ordre de configuration (l'ordre compte, + * cf. \c RelayStageTable). + * \return La table dérivée. Toujours non vide : le sous-ensemble vide donne le palier 0. + */ + static RelayStageTable deriveStages(const QList &relays); /*! * \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault). diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index c08870b..941cd31 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -94,9 +94,10 @@ public: //! \brief Sans effet : la PAC du banc est codée en dur, pas construite depuis //! \c LoadConfig (à basculer avec la couche config). + //! \param label Nouveau libellé — repris DÈS ce cycle, il nomme la PAC au journal. //! \param priority Ignoré. \param needs Ignoré. - void updateSoftConfig(int priority, const LoadNeeds &needs) override - { Q_UNUSED(priority) Q_UNUSED(needs) } + void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override + { m_label = label; Q_UNUSED(priority) Q_UNUSED(needs) } //! \brief Sans effet : la PAC SG-Ready n'implémente pas encore l'échelle de défaut ECS-410. //! La généralisation est portée par **ECS-414** (lot de mise en configuration diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 6678f9a..c12f8eb 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -167,14 +167,24 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b) return false; } - const QList ra = a.relaysList(); - const QList rb = b.relaysList(); - if (ra.size() != rb.size()) - return false; - for (int i = 0; i < ra.size(); ++i) - if (ra.at(i).thingId != rb.at(i).thingId || ra.at(i).powerW != rb.at(i).powerW) - return false; - return true; + // On ne compare pas les LISTES de relais, on compare la TABLE qu'elles produisent. + // + // Comparer index par index déclarait « matériel changé » sur un simple + // réordonnancement — mêmes Things, mêmes puissances — et coûtait donc une + // reconstruction complète : contacts ouverts par l'état sûr, verrous minOn/minOff + // réarmés à froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à + // couper sa charge, et annuler son geste en coûtait une seconde. + // + // Comparer les listes comme des ENSEMBLES aurait été faux dans l'autre sens : + // à somme égale la table retient la première combinaison rencontrée, donc échanger + // deux relais de MÊME puissance change le contact qui sert ce palier — autre + // contacteur qui s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas. + // + // La table tranche les deux cas d'un coup, et sans pari : elle EST ce que le routeur + // exécute. deriveStages() est l'implémentation unique, partagée avec la construction + // du routeur et la publication en télémétrie. + return RelayRouter::deriveStages(a.relaysList()) + == RelayRouter::deriveStages(b.relaysList()); } bool EnergyArbitrator::clearLoadFault(const QString &loadId) @@ -241,11 +251,17 @@ void EnergyArbitrator::rebuildLoadAdapters() // Matériel inchangé : on GARDE l'adaptateur — donc m_lastSwitch et le palier // courant — et on ne met à jour que ce qui ne touche pas au matériel. const LoadConfig &old = m_builtFrom[c.id()]; - const bool soft = (old.priority() != c.priority()) + // Le LIBELLÉ compte ici, et nulle part ailleurs : il ne touche pas au matériel + // (donc pas à sameHardware()), mais il nomme la charge dans chaque ligne de + // journal. L'omettre de ce prédicat faisait compter un renommage seul comme + // « inchangée », l'adaptateur gardait son ancien nom, et le journal contredisait + // GetLoadConfig jusqu'à la prochaine reconstruction. + const bool soft = (old.label() != c.label()) + || (old.priority() != c.priority()) || (old.needs().dailyDeadline() != c.needs().dailyDeadline()) || (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay()); if (soft) { - existing->updateSoftConfig(c.priority(), needs); + existing->updateSoftConfig(c.label(), c.priority(), needs); ++updated; } else { ++reused; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index ce70655..38a76a7 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -187,6 +187,21 @@ public: */ void publishTelemetryHeartbeat(); + /*! + * \brief Vrai si deux configs décrivent le MÊME matériel (câblage, paliers, verrous). + * + * \param a Config ayant servi à construire l'adaptateur. \param b Nouvelle config. + * \return Vrai si l'adaptateur peut être conservé — ECS-412. Rang, besoins et libellé + * sont volontairement ignorés : ils se mettent à jour en place. + * + * \note **Publique à dessein.** Ce prédicat décide si une écriture coûte un geste + * MATÉRIEL — contacts ouverts par l'état sûr, verrous réarmés à froid — ou une simple + * mise à jour en place. C'est à ce titre un élément de contrat, que le client annonce + * à l'installateur avant d'enregistrer, et il est éprouvé comme tel + * (\c tests/auto/loadmodel). Statique et sans état : l'exposer n'ouvre rien. + */ + static bool sameHardware(const LoadConfig &a, const LoadConfig &b); + signals: /*! * \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur. @@ -198,6 +213,8 @@ signals: */ void loadTelemetryChanged(const QVariantMap &telemetry); + + protected: /*! * \brief Boucle principale ETM — surcharge SmartChargingManager::update(). @@ -248,11 +265,6 @@ private: */ void applyActionsToAdapters(const Slot &slot, const QDateTime &now); - //! \brief Vrai si deux configs décrivent le MÊME matériel (câblage, paliers, verrous). - //! \param a Config ayant servi à construire l'adaptateur. \param b Nouvelle config. - //! \return Vrai si l'adaptateur peut être conservé — ECS-412. Rang, besoins et libellé - //! sont volontairement ignorés : ils se mettent à jour en place. - static bool sameHardware(const LoadConfig &a, const LoadConfig &b); /*! * \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3). diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 49b5176..8b69308 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -689,6 +689,10 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() mechItem.insert("kind", enumValueName(String)); // "relay" | "variable" | "sgReady" mechItem.insert("o:stageW", enumValueName(Double)); // relay mechItem.insert("o:maxStageW", enumValueName(Double)); // relay + // Paliers atteignables, dérivés des relais. LECTURE SEULE : aucun équivalent en + // écriture, et SetLoadConfig ne l'accepte pas — powerLevels reste vide sur ce + // mécanisme, la table se dérive et ne se déclare pas. + mechItem.insert("o:stagesW", QVariantList() << enumValueName(Double)); // relay mechItem.insert("o:setpointW", enumValueName(Double)); // variable mechItem.insert("o:percent", enumValueName(Double)); // variable mechItem.insert("o:maxPowerW", enumValueName(Double)); // variable diff --git a/tests/auto/auto.pro b/tests/auto/auto.pro index 57618b5..2f92ee8 100644 --- a/tests/auto/auto.pro +++ b/tests/auto/auto.pro @@ -1,2 +1,2 @@ TEMPLATE = subdirs -SUBDIRS += charging simulation spotmarket +SUBDIRS += charging loadmodel simulation spotmarket diff --git a/tests/auto/loadmodel/loadmodel.pro b/tests/auto/loadmodel/loadmodel.pro new file mode 100644 index 0000000..bb77ff1 --- /dev/null +++ b/tests/auto/loadmodel/loadmodel.pro @@ -0,0 +1,17 @@ +include(../common/common.pri) +include(../../../energyplugin/energyplugin.pri) + +CONFIG += testcase + +TARGET = nymea-energy-loadmodel + +# Cible VOLONTAIREMENT légère : aucun serveur nymea, aucun EnergyTestBase. +# La table de paliers et sameHardware() sont de la logique pure — les éprouver ici les rend +# rejouables en quelques millisecondes, là où la suite de simulation coûte un serveur complet +# par test (cf. AGENTS.md, « NE JAMAIS lancer la suite de simulation en un seul processus »). + +HEADERS += testloadmodel.h +SOURCES += testloadmodel.cpp + +target.path = $$[QT_INSTALL_PREFIX]/bin +INSTALLS += target diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp new file mode 100644 index 0000000..65cc448 --- /dev/null +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "testloadmodel.h" + +#include "etm/adapters/relayrouter.h" +#include "etm/energyarbitrator.h" +#include "etm/types/loadconfig.h" + +#include + +namespace { + +//! Une config relay-router minimale, valide, dont on ne fait varier que les relais. +LoadConfig relayLoad(const QList> &relays, + const QString &label = QStringLiteral("chauffe-eau"), + int priority = 1) +{ + QVariantList list; + for (const auto &r : relays) { + QVariantMap m; + m.insert("thingId", r.first); + m.insert("powerW", r.second); + list.append(m); + } + QVariantMap map; + map.insert("id", "chauffe-eau"); + map.insert("label", label); + map.insert("adapter", "relay-router"); + map.insert("mode", "fixed"); + map.insert("priority", priority); + map.insert("enabled", true); + map.insert("minOnS", 60); + map.insert("minOffS", 60); + map.insert("relays", list); + return LoadConfig::fromMap(map); +} + +const QString K1 = QStringLiteral("{aaaaaaaa-0000-0000-0000-000000000001}"); +const QString K2 = QStringLiteral("{bbbbbbbb-0000-0000-0000-000000000002}"); +const QString K3 = QStringLiteral("{cccccccc-0000-0000-0000-000000000003}"); + +} // namespace + +void TestLoadModel::testStageTableIsDerivedFromSubsetSums() +{ + // La configuration réelle du banc .75 : trois contacteurs 500 / 1 000 / 2 000 W. + const RelayStageTable t = RelayRouter::deriveStages( + relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList()); + + QCOMPARE(t.levels, (QList{0, 500, 1000, 1500, 2000, 2500, 3000, 3500})); + QCOMPARE(t.mapping.size(), t.levels.size()); + + // Le palier 0 est le sous-ensemble vide : aucun contact fermé. C'est ce qui garantit + // qu'un routeur sait toujours s'arrêter. + QCOMPARE(t.levels.first(), 0); + QVERIFY(t.mapping.first().isEmpty()); + + // Et le maximum est bien la somme de tout — c'est le maxStageW publié en télémétrie. + QCOMPARE(t.levels.last(), 3500); + QCOMPARE(t.mapping.last().size(), 3); +} + +void TestLoadModel::testRelayOrderIsInertWhenPowersAreDistinct() +{ + const RelayStageTable direct = RelayRouter::deriveStages( + relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList()); + const RelayStageTable inverse = RelayRouter::deriveStages( + relayLoad({{K3, 2000}, {K2, 1000}, {K1, 500}}).relaysList()); + + // Chaque somme n'a qu'une seule combinaison possible : la déduplication n'arbitre rien, + // donc l'ordre de déclaration ne peut rien changer d'ÉLECTRIQUE. + QCOMPARE(direct.levels, inverse.levels); + + // Les listes de ThingIds, elles, diffèrent : elles sont collectées dans l'ordre de + // déclaration, donc le palier 1 500 W sort {K1,K2} d'un côté et {K2,K1} de l'autre. + // C'est le même geste électrique — d'où la comparaison par ENSEMBLE dans la table. + // Ce cas a fait échouer la première version de operator==, qui comparait les listes. + QVERIFY2(direct == inverse, + "un réordonnancement à puissances distinctes doit rendre la même table"); +} + +void TestLoadModel::testRelayOrderDecidesWhichContactServesATiedStage() +{ + // Deux relais de MÊME puissance : le palier 1 000 W est atteignable par K1 seul comme + // par K2 seul. La table retient la PREMIÈRE combinaison rencontrée. + const RelayStageTable a = RelayRouter::deriveStages( + relayLoad({{K1, 1000}, {K2, 1000}}).relaysList()); + const RelayStageTable b = RelayRouter::deriveStages( + relayLoad({{K2, 1000}, {K1, 1000}}).relaysList()); + + // Mêmes puissances atteignables… + QCOMPARE(a.levels, (QList{0, 1000, 2000})); + QCOMPARE(a.levels, b.levels); + + // …mais pas le même contacteur pour le palier 1 000 W. C'est un vrai changement + // matériel : autre contact qui s'use, autre résistance qui chauffe. + QCOMPARE(a.mapping.at(1), QList{K1}); + QCOMPARE(b.mapping.at(1), QList{K2}); + QVERIFY(a != b); +} + +void TestLoadModel::testReorderingDistinctRelaysKeepsTheAdapter() +{ + const LoadConfig avant = relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}); + const LoadConfig apres = relayLoad({{K3, 2000}, {K1, 500}, {K2, 1000}}); + + // Le geste que l'installateur fait dans l'app : remonter une ligne. Mêmes Things, + // mêmes puissances, même table — donc aucune reconstruction, donc pas de contacts + // ouverts par l'état sûr ni de verrous réarmés à froid. + QVERIFY2(EnergyArbitrator::sameHardware(avant, apres), + "un réordonnancement sans effet sur la table coupait la charge"); +} + +void TestLoadModel::testSwappingTiedRelaysRebuilds() +{ + const LoadConfig avant = relayLoad({{K1, 1000}, {K2, 1000}}); + const LoadConfig apres = relayLoad({{K2, 1000}, {K1, 1000}}); + + // Ici la table change : le palier 1 000 W passe d'un contacteur à l'autre. Comparer les + // relais comme un ENSEMBLE aurait manqué ce changement et laissé l'adaptateur commander + // le mauvais contact. + QVERIFY2(!EnergyArbitrator::sameHardware(avant, apres), + "un échange de contacts à puissance égale doit reconstruire"); +} + +void TestLoadModel::testSoftFieldsNeverRebuild() +{ + const QList> relays{{K1, 500}, {K2, 1000}}; + const LoadConfig avant = relayLoad(relays, QStringLiteral("chauffe-eau"), 1); + const LoadConfig apres = relayLoad(relays, QStringLiteral("Ballon 200 L"), 3); + + // Renommer et changer de rang se met à jour EN PLACE (ECS-412). Le libellé part + // désormais à l'adaptateur par updateSoftConfig() — sans quoi le journal de la box + // continuait d'imprimer l'ancien nom alors que GetLoadConfig rendait le nouveau. + QVERIFY(EnergyArbitrator::sameHardware(avant, apres)); +} + +QTEST_MAIN(TestLoadModel) diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h new file mode 100644 index 0000000..e924082 --- /dev/null +++ b/tests/auto/loadmodel/testloadmodel.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef TESTLOADMODEL_H +#define TESTLOADMODEL_H + +#include + +/*! + * \brief Logique pure du modèle de charge : table de paliers et identité matérielle. + * + * Aucun serveur nymea ici. Ce qui est éprouvé ne dépend ni d'un ThingManager ni d'un + * cycle d'arbitrage : la dérivation des paliers est une combinatoire, et + * \c EnergyArbitrator::sameHardware() une comparaison. + */ +class TestLoadModel : public QObject +{ + Q_OBJECT + +private slots: + //! La table dérive de TOUTES les sommes de sous-ensembles, triées, 0 compris. + void testStageTableIsDerivedFromSubsetSums(); + + //! Puissances distinctes : l'ordre de déclaration n'a aucun effet observable. + void testRelayOrderIsInertWhenPowersAreDistinct(); + + //! Puissances égales : l'ordre décide QUEL contact sert le palier — et ça se voit. + void testRelayOrderDecidesWhichContactServesATiedStage(); + + //! Un réordonnancement sans effet ne doit PAS coûter une reconstruction. + void testReorderingDistinctRelaysKeepsTheAdapter(); + + //! Mais un échange de contacts à puissance égale, si. + void testSwappingTiedRelaysRebuilds(); + + //! Libellé, rang et besoins se mettent à jour en place : jamais de reconstruction. + void testSoftFieldsNeverRebuild(); +}; + +#endif // TESTLOADMODEL_H diff --git a/tools/repair_energylogs.py b/tools/repair_energylogs.py new file mode 100644 index 0000000..cd75cc1 --- /dev/null +++ b/tools/repair_energylogs.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Répare les cumuls d'énergie empoisonnés par une lecture de compteur absurde. + +## Le défaut que ce script rattrape + +`EnergyManagerImpl::updatePowerBalance()` accumule, sans aucun contrôle de plausibilité, +la différence des compteurs d'un Thing dans quatre totaux **persistés** : + + m_totalAcquisition += newAcquisition - oldAcquisition; // idem Return, Production + +Une seule lecture absurde suffit donc à empoisonner l'accumulateur **définitivement** : +rien ne redescend jamais, et à grande magnitude le compteur devient littéralement +**incapable de bouger**. À 7,5e33 kWh, le plus petit incrément représentable en double est +1,67e18 kWh — un delta réel de 0,001 kWh est absorbé sans laisser de trace : + + >>> 7.5345e33 + 0.001 == 7.5345e33 + True + +Le compteur n'est pas seulement faux : il est **gelé**. Aucune purge côté client, aucun +redémarrage ne le débloque, parce que la valeur est rechargée au démarrage depuis le +journal (`latestLogEntry()`). + +## Cas d'origine — banc `.75`, 2026-08-07 + +Le plugin SunSpec lisait les float32 Fronius avec leurs deux mots permutés. Corrigé par +`etm-powersync-plugins-modbus` `d08cf50` (« lire les float32 Fronius en big endian »), +daté **05:30:14 UTC** — soit la seconde même du premier échantillon empoisonné. Deux +cycles de lecture ont suffi : la puissance instantanée est revenue à 06:00, les cumuls +jamais. + +## Ce que le script fait, et ce qu'il ne fait pas + +- **Reporte** les quatre colonnes `total*` de `powerBalance`, et les deux de `thingPower`, + à leur dernière valeur SAINE, pour toutes les lignes ≥ seuil. +- **Annule** (NULL) les puissances instantanées absurdes — elles sont rares et isolées. +- **Ne touche pas** à `thingCache` : il porte les compteurs réels du Thing et il est sain. + C'est lui qui réarme les caches de delta au démarrage. +- **Ne reconstruit pas** l'énergie de la période empoisonnée : les relevés bruts du + compteur pour ces journées ne sont nulle part dans cette base. Cette énergie est + **perdue**, et le script l'assume par un palier plat plutôt que par une rampe inventée. + +Le script est **rejouable** : il recalcule tout depuis le seuil, sans dépendre d'un état +antérieur. Le relancer sur une base déjà réparée ne change rien. + +## Usage + + python3 tools/repair_energylogs.py --check energylogs.sqlite + python3 tools/repair_energylogs.py --repair energylogs.sqlite --since 2026-08-07T05:30 + +Sur la box, la base appartient à root et nymead la tient ouverte : + + sudo systemctl stop nymead + sudo cp /var/lib/nymea/energylogs.sqlite /var/lib/nymea/energylogs.sqlite.bak-$(date +%F) + sudo python3 repair_energylogs.py --repair /var/lib/nymea/energylogs.sqlite --since ... + sudo systemctl start nymead + +Le redémarrage est **nécessaire** : les totaux vivent en mémoire et ne sont relus qu'au +démarrage du service. +""" + +import argparse +import datetime +import sqlite3 +import sys + +PLAUSIBLE_POWER_W = 1e9 # 1 GW — au-delà, aucune installation domestique +PLAUSIBLE_TOTAL_KWH = 1e9 # 1 TWh — au-delà, aucun compteur de bâtiment + +TOTALS = ["totalConsumption", "totalProduction", "totalAcquisition", "totalReturn"] +POWERS = ["consumption", "production", "acquisition", "storage"] + + +def ms(iso: str) -> int: + return int(datetime.datetime.fromisoformat(iso).replace( + tzinfo=datetime.UTC).timestamp() * 1000) + + +def human(t: int) -> str: + return datetime.datetime.fromtimestamp(t / 1000, datetime.UTC).strftime("%Y-%m-%d %H:%M") + + +def check(db: sqlite3.Connection) -> int: + """Rend le nombre d'anomalies trouvées. 0 = base saine.""" + bad = 0 + + print("── powerBalance ──") + for col in TOTALS: + n, mx = db.execute( + f"select count(*), max(abs({col})) from powerBalance where abs({col})>?", + (PLAUSIBLE_TOTAL_KWH,)).fetchone() + if n: + print(f" ⚠ {col:18} : {n:>6} ligne(s) invraisemblable(s), max {mx:.4e} kWh") + bad += n + for col in POWERS: + n, = db.execute( + f"select count(*) from powerBalance where abs({col})>?", + (PLAUSIBLE_POWER_W,)).fetchone() + if n: + print(f" ⚠ {col:18} : {n:>6} ligne(s) de puissance invraisemblable(s)") + bad += n + + print("── thingPower ──") + for col in ("currentPower", "totalConsumption", "totalProduction"): + limit = PLAUSIBLE_POWER_W if col == "currentPower" else PLAUSIBLE_TOTAL_KWH + n, = db.execute( + f"select count(*) from thingPower where abs({col})>?", (limit,)).fetchone() + if n: + print(f" ⚠ {col:18} : {n:>6} ligne(s)") + bad += n + + print("── monotonie des cumuls (sampleRate=15) ──") + for col in ("totalAcquisition", "totalReturn", "totalProduction"): + prev, breaks = None, 0 + for (v,) in db.execute( + f"select {col} from powerBalance where sampleRate=15 order by timestamp"): + if prev is not None and v is not None and v < prev - 1e-6: + breaks += 1 + prev = v if v is not None else prev + flag = "⚠" if breaks else " " + print(f" {flag} {col:18} : {breaks} rupture(s) de monotonie") + bad += breaks + + print(f"\n{'BASE SAINE' if bad == 0 else f'{bad} ANOMALIE(S)'}") + return bad + + +def repair(db: sqlite3.Connection, cut: int) -> None: + print(f"Seuil : {human(cut)} UTC\n") + + # ── 1. powerBalance : dernière ligne saine avant le seuil ──────────────── + base = db.execute( + f"select {','.join(TOTALS)} from powerBalance " + "where timestamp < ? order by timestamp desc limit 1", (cut,)).fetchone() + if base is None: + sys.exit("Aucune ligne avant le seuil : rien à reporter, seuil probablement trop tôt.") + print("Dernières valeurs saines (powerBalance) :") + for name, v in zip(TOTALS, base): + print(f" {name:18} = {v:.4f}") + + sets = ", ".join(f"{c}=?" for c in TOTALS) + n = db.execute(f"update powerBalance set {sets} where timestamp>=?", + (*base, cut)).rowcount + print(f"\n {n} ligne(s) powerBalance : cumuls reportés au palier sain") + + # ── 2. puissances instantanées absurdes → NULL ─────────────────────────── + for col in POWERS: + n = db.execute( + f"update powerBalance set {col}=NULL where abs({col})>?", + (PLAUSIBLE_POWER_W,)).rowcount + if n: + print(f" {n} ligne(s) : {col} invraisemblable → NULL") + + # ── 3. thingPower, Thing par Thing ─────────────────────────────────────── + for (thing_id,) in db.execute("select distinct thingId from thingPower"): + row = db.execute( + "select totalConsumption,totalProduction from thingPower " + "where thingId=? and timestamp=?", (*row, thing_id, cut)).rowcount + print(f" {n} ligne(s) thingPower {thing_id[:10]} : " + f"cumuls reportés ({row[0]:.3f} / {row[1]:.3f})") + + n = db.execute("update thingPower set currentPower=NULL where abs(currentPower)>?", + (PLAUSIBLE_POWER_W,)).rowcount + if n: + print(f" {n} ligne(s) thingPower : currentPower invraisemblable → NULL") + + db.commit() + print("\n⚠ thingCache NON touché — il porte les compteurs réels du Thing, et c'est lui " + "qui réarme les caches de delta au démarrage.") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("database") + ap.add_argument("--check", action="store_true", help="diagnostic seul, aucune écriture") + ap.add_argument("--repair", action="store_true", help="écrit dans la base") + ap.add_argument("--since", metavar="ISO", + help="seuil UTC, ex. 2026-08-07T05:30 — requis avec --repair") + a = ap.parse_args() + + if not (a.check or a.repair): + ap.error("choisir --check ou --repair") + if a.repair and not a.since: + ap.error("--repair exige --since : le seuil ne se devine pas") + + db = sqlite3.connect(a.database) + if a.repair: + repair(db, ms(a.since)) + print("\n── contrôle après réparation ──") + sys.exit(1 if check(db) else 0) + + +if __name__ == "__main__": + main()