feat(lot D): libellé jusqu'à l'adaptateur, relais comparés par table, paliers publiés

Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.

§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas 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é, et on aurait conclu que le correctif ne marchait pas.

§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. 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. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.

§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; 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.

deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.

tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.

tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-26 10:07:16 +02:00
parent 3b78daca7e
commit a0c02fbd34
15 changed files with 632 additions and 47 deletions

View File

@ -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 <etm.schurig@gmail.com> 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

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -18,6 +18,38 @@ namespace {
constexpr int MaxRelays = 16;
}
RelayStageTable RelayRouter::deriveStages(const QList<LoadConfigRelay> &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<int, QList<QString>> byPower;
for (int mask = 0; mask < (1 << n); ++mask) {
int sum = 0;
QList<QString> 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<int, QList<QString>> byPower;
for (int mask = 0; mask < (1 << n); ++mask) {
int sum = 0;
QList<QString> 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<double>(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<double>(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)

View File

@ -3,6 +3,7 @@
#pragma once
#include <QObject>
#include <QSet>
#include <QDateTime>
#include <QList>
#include <QString>
@ -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<int> levels; //!< Paliers W triés croissants, \c levels[0] == 0.
QList<QList<QString>> 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<QString> &a = mapping.at(i);
const QList<QString> &b = o.mapping.at(i);
if (QSet<QString>(a.begin(), a.end()) != QSet<QString>(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<LoadConfigRelay> &relays);
/*!
* \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault).

View File

@ -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

View File

@ -167,14 +167,24 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
return false;
}
const QList<LoadConfigRelay> ra = a.relaysList();
const QList<LoadConfigRelay> 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;

View File

@ -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).

View File

@ -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

View File

@ -1,2 +1,2 @@
TEMPLATE = subdirs
SUBDIRS += charging simulation spotmarket
SUBDIRS += charging loadmodel simulation spotmarket

View File

@ -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

View File

@ -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 <QTest>
namespace {
//! Une config relay-router minimale, valide, dont on ne fait varier que les relais.
LoadConfig relayLoad(const QList<QPair<QString, int>> &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<int>{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<int>{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<QString>{K1});
QCOMPARE(b.mapping.at(1), QList<QString>{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<QPair<QString, int>> 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)

View File

@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef TESTLOADMODEL_H
#define TESTLOADMODEL_H
#include <QObject>
/*!
* \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

200
tools/repair_energylogs.py Normal file
View File

@ -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<? order by timestamp desc limit 1",
(thing_id, cut)).fetchone()
if row is None:
continue
n = db.execute(
"update thingPower set totalConsumption=?, totalProduction=? "
"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()