fix(moteur): « acheté » compté sur l'énergie mesurée, pas sur la consigne (correctif c)

`noterAchatEco()` intégrait la CIBLE éco avant palier et verrou : sur un relay-router, une
cible de 167 ou 495 W laisse le relais ouvert et ces watts étaient comptés achetés ; sous un
optimiseur, l'ombre rule-based notait sa propre cible, jamais exécutée.

Règle (choix par défaut du brief) : achatWh += ΔE × partÉco, ΔE = énergie MESURÉE de la charge
depuis le cycle précédent (sessionWh), partÉco = min(1, Σ targetW « eco » / estimatedPowerW)
de l'action APPLIQUÉE au cycle précédent (m_actionsAppliquees, lue avant le dispatch). Niveau
unique `optimizer`, charge délestée ou rien d'appliqué : part 0. Un recul ne contribue jamais.

- Sans mesure d'énergie : purchasedWh ABSENT (EcoProgress::purchased), jamais 0 ni la commande.
  Non borné par l'import du site : attribution ambiguë avec plusieurs charges.
- Comptage à chaque cycle, AVANT ecoProgress() : la valeur publiée inclut l'intervalle écoulé,
  et l'intervalle qui enjambe l'échéance va à la période close (base gardée à la bascule).
- Aucune décision ne lit purchasedWh : consignes inchangées. update() inchangé.
- INTERFACE.md : contrat réécrit (« publié dans TOUS les régimes » retiré).
- Recorder `decisions` : purchasedWh absent sans mesure, comme la RPC.
- Tests : testPurchasedIsCountedOnTheMeasuredEnergy (simulation, cas i-vi) et
  testThePlanOfAnOptimizerBuysNothingEco (socketscheduler, cas vii).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 01:51:21 +02:00
parent ff7c8011e2
commit 6481515b64
8 changed files with 384 additions and 60 deletions

View File

@ -534,18 +534,47 @@ namespace {
constexpr double BruitAvancementWh = 50.0;
}
void EnergyArbitrator::noterAchatEco(const QString &loadId, double achatW,
const QDateTime &now) const
void EnergyArbitrator::noterAchatEco(const LoadContext &lc) const
{
const QDateTime precedent = m_ecoDernierAchat.value(loadId);
// Le premier cycle d'une période ne contribue RIEN : il n'a pas de durée derrière lui, et
// en inventer une serait fabriquer de l'énergie. L'écart d'un cycle est borné et connu.
if (precedent.isValid() && achatW > 0) {
const double heures = precedent.secsTo(now) / 3600.0;
if (heures > 0)
m_ecoAchatWh[loadId] += achatW * heures;
// Aucune DURÉE n'est intégrée ici : l'intervalle est celui de la mesure, d'un cycle à
// l'autre. SANS MESURE, RIEN N'EST ACHETÉ QU'ON PUISSE DIRE. La base est lâchée : au retour, le
// premier cycle ne contribue rien, faute d'avoir vu l'intervalle qui le précède.
if (!lc.telemetry.sessionMeasurable) {
m_ecoAchatBaseWh.remove(lc.id);
return;
}
m_ecoDernierAchat.insert(loadId, now);
const double sessionWh = lc.telemetry.sessionWh;
if (!m_ecoAchatBaseWh.contains(lc.id)) {
// Ouverture (période neuve, retour de mesure, premier cycle) : rien derrière.
m_ecoAchatBaseWh.insert(lc.id, sessionWh);
return;
}
const double deltaWh = sessionWh - m_ecoAchatBaseWh.value(lc.id);
if (deltaWh < 0) {
// Un recul ne contribue jamais. Infime : bruit, la base reste au-dessus. Franc sur un
// accumulateur de session : nouvelle session, base reprise. Franc sur un compteur de
// VIE : anomalie, la base saine est conservée — même règle qu'ecoProgress().
if (deltaWh < -BruitAvancementWh && !lc.telemetry.sessionCumulative)
m_ecoAchatBaseWh.insert(lc.id, sessionWh);
return;
}
// LA PART ÉCO DE CE QUI A ÉTÉ APPLIQUÉ au cycle précédent — c'est cette action-là qui a
// produit l'énergie de l'intervalle. Le dispatch de ce cycle n'a pas encore eu lieu : la
// table est encore celle du précédent. Pas d'entrée (premier cycle, charge délestée par L4),
// rien d'appliqué, ou niveau unique `optimizer` : la part vaut 0.
double partEco = 0;
const auto applique = m_actionsAppliquees.constFind(lc.id);
if (applique != m_actionsAppliquees.constEnd() && applique->estimatedPowerW > 0) {
double ecoW = 0;
for (const LoadAction::Level &n : applique->levels)
if (n.level == QLatin1String("eco"))
ecoW += n.targetW;
partEco = qBound(0.0, ecoW / applique->estimatedPowerW, 1.0);
}
m_ecoAchatWh[lc.id] += deltaWh * partEco;
m_ecoAchatBaseWh.insert(lc.id, sessionWh);
}
void EnergyArbitrator::noterCauseEco(const QString &loadId, const QString &cause) const
@ -635,8 +664,9 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
// LM-1014-b — les deux CUMULS de la période repartent à zéro, et la date de mesure
// avec eux. Les laisser courir ferait porter au lendemain la dépense de la veille et
// dater la mesure d'une période close.
// La BASE d'achat, elle, est gardée : elle date du cycle courant (noterAchatEco() passe
// avant), et l'intervalle suivant appartient tout entier à la période neuve.
m_ecoAchatWh.remove(lc.id);
m_ecoDernierAchat.remove(lc.id);
m_ecoMesureDepuis.remove(lc.id);
// Le cumul de période repart de ZÉRO, comme tout ce qui est rapporté à la période
// (\rule{LM-1014-b}) — et les trous avec lui : ils comptaient les interruptions de la
@ -649,12 +679,9 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
// C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit.
// `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un
// avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait.
// L'ACHETÉ est connu sans mesure — c'est notre propre commande — donc il est publié dans
// TOUS les régimes, y compris `unmeasurable`. C'est ce qui distingue « ce qu'on a dépensé »
// de « ce qui a été livré » : le premier est un fait du moteur, le second une observation
// qu'il ne peut pas faire ici.
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
// L'ACHETÉ suit la même règle depuis le correctif c : il se compte sur l'énergie MESURÉE
// (noterAchatEco()), donc sans mesure il n'est pas connu, et il est absent — jamais 0,
// jamais la commande. `purchasedWh` n'est rempli que sous `measured`, plus bas.
if (!lc.telemetry.sessionMeasurable) {
p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable);
// CLÔTURE DU TRONÇON, pas abandon. Un aller-retour de régime dans la même période — le
@ -674,7 +701,6 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
// `m_ecoMesureDepuis` est CONSERVÉ : il date le PREMIER instant mesuré de la période, et
// les trous sont dits séparément. Le remettre à zéro ferait croire à une mesure continue
// qui commencerait au retour du lien.
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
return p;
}
@ -686,7 +712,7 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
// déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro.
m_ecoBaseWh.insert(lc.id, sessionWh);
// DEPUIS QUAND on mesure. Sans cette date, une bascule `unmeasurable` → `measured` en
// cours de période ferait afficher « 0,5 sur 4,0 » alors que 3 kWh ont pu être achetés
// cours de période ferait afficher « 0,5 sur 4,0 » alors que 3 kWh ont pu être livrés
// avant : crédible et faux. Rebaser `targetWh` serait pire — on perdrait l'obligation
// réelle, et l'écran ne pourrait plus dire ce qui reste dû.
if (!m_ecoMesureDepuis.contains(lc.id))
@ -708,7 +734,6 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
<< m_ecoBaseWh.value(lc.id) << "→" << sessionWh
<< " Wh) : valeur ignorée, avancement gelé.";
p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable);
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
return p; // le tronçon reste OUVERT sur sa dernière valeur saine
} else if (!lc.telemetry.sessionCumulative
&& sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) {
@ -729,6 +754,8 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
p.delivered = true;
p.measuredSince = m_ecoMesureDepuis.value(lc.id);
p.measuredGaps = m_ecoTrous.value(lc.id, 0);
p.purchased = true;
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
// Un recul INFIME laisse la base au-dessus de la valeur lue ; l'écrêtage évite un
// avancement négatif sans masquer un vrai recul, qui a déjà provoqué la recapture.
// Le livré de la période = les tronçons CLOS + celui en cours. Sans le premier terme, un
@ -2036,13 +2063,14 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// comme un avancement nul (LM-1009 C1).
if (n.progress.delivered)
progress.insert(QStringLiteral("deliveredWh"), n.progress.deliveredWh);
// L'ACHETÉ est publié dans TOUS les régimes : c'est notre propre commande, donc
// un fait connu sans mesure. Sans lui, « ce que ça coûte par jour » redeviendrait
// une estimation fabriquée à partir du taux.
progress.insert(QStringLiteral("purchasedWh"), n.progress.purchasedWh);
// L'ACHETÉ, même règle que le livré depuis le correctif c : compté sur l'énergie
// MESURÉE à la part éco de l'action appliquée, donc ABSENT sous `unmeasurable` —
// jamais 0, jamais la commande. Ce qu'on ne mesure pas, on ne le chiffre pas.
if (n.progress.purchased)
progress.insert(QStringLiteral("purchasedWh"), n.progress.purchasedWh);
// DEPUIS QUAND on mesure — présent seulement si on mesure. Ferme le piège de la
// bascule en cours de période : « 0,5 sur 4,0 » alors que 3 kWh ont pu être
// achetés avant. L'écran dit « mesuré depuis 09:14 », purchasedWh couvre l'avant.
// livrés avant. L'écran dit « mesuré depuis 09:14 ».
if (n.progress.measuredSince.isValid())
progress.insert(QStringLiteral("measuredSince"),
n.progress.measuredSince.toUTC().toString(Qt::ISODate));

View File

@ -627,14 +627,38 @@ public:
void noterCauseEco(const QString &loadId, const QString &cause) const;
/*!
* \brief Note les watts ACHETÉS pour l'obligation d'une charge, et cumule l'énergie.
* \param loadId Charge. \param achatW Puissance achetée ce cycle. \param now Temps de cycle.
* \note Le cumul est remis à zéro à la bascule de période (\rule{LM-1014-b}) : le laisser
* courir ferait porter au lendemain la dépense de la veille. Le premier cycle d'une
* période ne contribue rien — il n'a pas de durée derrière lui, et en inventer une serait
* fabriquer de l'énergie.
* \brief Cumule l'énergie ACHETÉE pour l'obligation d'une charge, sur l'intervalle écoulé
* depuis le cycle précédent.
* \param lc Contexte de la charge au cycle courant (\c telemetry.sessionWh et sa mesurabilité).
*
* \par La règle (correctif c, etm71)
* `achatWh += ΔE × partÉco`, où ΔE est l'énergie MESURÉE de la charge entre le cycle
* précédent et celui-ci (\c sessionWh : \c totalEnergyConsumed du \c meterThingId, ou session
* de la borne), et partÉco la part éco de l'action APPLIQUÉE au cycle précédent
* (\c m_actionsAppliquees, lue avant le dispatch de ce cycle-ci) :
* `min(1, Σ targetW des niveaux « eco » / estimatedPowerW appliqué)`, 0 si rien n'a été
* appliqué. Avant ce correctif, on intégrait la CIBLE éco, avant palier et verrou : un relais
* resté ouvert sous une cible de 300 W était compté acheté.
*
* \note Sans mesure d'énergie (\c !sessionMeasurable), rien n'est compté et la base est
* lâchée : au retour de la mesure, le premier cycle ne contribue rien. On ne fabrique pas
* l'énergie d'un intervalle qu'on n'a pas vu.
* \note Un recul de \c sessionWh ne contribue jamais. Au-delà du bruit, un accumulateur de
* session qui recule ouvre une nouvelle session (base reprise) ; un compteur de VIE qui
* recule est une anomalie, et la base saine est conservée — même règle qu'\c ecoProgress().
* \note Sous un plan d'optimiseur, les actions appliquées portent le niveau unique
* \c optimizer : la part éco vaut 0, et l'ombre rule-based qui appelle encore cette méthode
* ne compte plus rien.
* \note Appelée AVANT \c ecoProgress() du même cycle : l'avancement publié inclut l'intervalle
* qui vient de s'écouler. Le cumul est remis à zéro à la bascule de période
* (\rule{LM-1014-b}) ; la base est gardée, si bien que l'intervalle qui enjambe l'échéance
* va à la période close, et le suivant à la période neuve.
* \warning Un intervalle qui enjambe un mode dégradé L2 (aucun dispatch, donc
* \c m_actionsAppliquees inchangée) est attribué à la dernière action appliquée avant L2.
* Le repli L2 coupe les charges pilotées : l'énergie mesurée de l'intervalle y est donc
* nulle en pratique.
*/
void noterAchatEco(const QString &loadId, double achatW, const QDateTime &now) const;
void noterAchatEco(const LoadContext &lc) const;
/*!
* \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé.
@ -1147,8 +1171,9 @@ private:
//! loadId → action APPLIQUÉE AU CYCLE : ce que l'adaptateur a rendu au dispatch
//! (\c applyActionsToAdapters()), après écrêtage et verrous — pas ce que le plan demandait.
//! Vidée à chaque dispatch ; les délestages L4 et le repli L2 n'y entrent pas (ils ont leur
//! propre trace). CONSERVÉE SANS ÊTRE LUE : aucune décision n'en dépend aujourd'hui. Elle
//! servira au retour d'exécution vers l'optimiseur (brief 4) et à l'écart plan/appliqué.
//! propre trace). AUCUNE DÉCISION N'EN DÉPEND. Lue par la finalisation d'un plan d'optimiseur
//! (brief 4 T3) et, au cycle SUIVANT, par \c noterAchatEco() pour la part éco de l'énergie
//! mesurée (correctif c).
QHash<QString, LoadAction> m_actionsAppliquees;
//! Temps de cycle du dernier dispatch : date \c m_actionsAppliquees. Un cycle qui rend la
//! main avant le dispatch (repli L2) laisse la table et sa date telles quelles.
@ -1177,10 +1202,12 @@ private:
QHash<QString, int> m_derogationModeRendu;
mutable QHash<QString, QDateTime> m_divergenceDepuis;
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE.
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE :
//! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()).
mutable QHash<QString, double> m_ecoAchatWh;
//! loadId → instant du dernier cycle où un achat a été noté, pour intégrer la durée.
mutable QHash<QString, QDateTime> m_ecoDernierAchat;
//! loadId → \c sessionWh lue au dernier cycle où l'achat a été compté : origine du ΔE de
//! l'intervalle suivant. Absente tant que la mesure manque ; conservée à la bascule de période.
mutable QHash<QString, double> m_ecoAchatBaseWh;
//! loadId → instant où l'avancement est devenu MESURABLE dans la période courante.
mutable QHash<QString, QDateTime> m_ecoMesureDepuis;
//! loadId → énergie livrée (Wh) sur les TRONÇONS MESURÉS DÉJÀ CLOS de la période courante.

View File

@ -316,11 +316,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
if (!estUneBorne && margeRestante.borne)
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
// L'achat est noté à CHAQUE cycle, y compris nul : c'est le passage du temps qui intègre
// l'énergie. Et il est noté même si la charge porte par ailleurs une obligation — le
// boost achète pour de vrai, et ne pas l'y compter ferait racheter la même énergie deux
// fois quand la dérogation s'effacera.
m_arbitrator->noterAchatEco(lc.id, achatW, ctx.timestamp);
// L'achat est noté à CHAQUE cycle : c'est l'énergie MESURÉE de l'intervalle qui se
// compte, à la part éco de ce qui a été appliqué (correctif c). Et il est noté même si la
// charge porte par ailleurs une obligation — le boost achète pour de vrai, et ne pas l'y
// compter ferait racheter la même énergie deux fois quand la dérogation s'effacera.
m_arbitrator->noterAchatEco(lc);
if (achatW <= 0)
continue; // rien d'achetable ce cycle : pas de niveau plutôt qu'un zéro forgé
@ -365,6 +365,13 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// l'addition des deux dépasserait son plafond matériel.
if (sousDerogation.contains(lc.id))
continue;
// L'ACHAT de l'intervalle écoulé, compté à CHAQUE cycle et AVANT toute sortie de boucle :
// l'énergie mesurée depuis le cycle précédent a été produite par l'action appliquée
// alors, quoi que ce cycle-ci décide (clôture, indisponibilité, obligation tenue). Le
// sauter ferait verser cette énergie dans l'intervalle suivant, à une autre part éco.
// AVANT ecoProgress() : l'avancement publié ce cycle inclut l'intervalle qui vient de
// s'écouler, et à la bascule de période cet intervalle va à la période qui se clôt.
m_arbitrator->noterAchatEco(lc);
const EcoProgress avancement = m_arbitrator->ecoProgress(lc, ctx.timestamp);
const EcoTarget cible = computeEcoTarget(lc.needs, avancement,
lc.declared.maxPowerW, ctx.timestamp);
@ -440,11 +447,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
else
m_arbitrator->noterCauseEco(lc.id, QString()); // servie sans blocage
// L'ACHAT est noté à chaque cycle, y compris nul — c'est le passage du temps qui
// intègre l'énergie, et sauter les cycles sans achat ferait compter la durée d'un arrêt
// comme si l'on avait acheté pendant.
m_arbitrator->noterAchatEco(lc.id, achatW, ctx.timestamp);
if (achatW <= 0)
continue; // rien d'achetable ce cycle : pas de niveau éco plutôt qu'un zéro forgé

View File

@ -45,18 +45,36 @@ struct EcoProgress {
double targetWh = 0;
/*!
* \brief Énergie ACHETÉE au réseau pour cette obligation depuis l'ouverture de la période.
* \brief Vrai si \c purchasedWh a un sens. Faux sous \c unmeasurable — et le champ est alors
* ABSENT de la charge utile, jamais à 0, exactement comme \c deliveredWh.
*
* \par Pourquoi celui-ci est légitime là où \c deliveredWh ne l'est pas
* Ce qui est **acheté** est un fait que le moteur connaît **sans mesure** : c'est sa propre
* commande. Ce qui est **livré** est ce qu'il ne peut pas vérifier. La distinction qui
* interdit de publier un avancement non mesuré n'interdit donc pas de publier ce qu'on a
* dépensé — elle l'exige plutôt, sans quoi « ce que ça coûte par jour » redeviendrait une
* estimation fabriquée à partir du taux.
* \par Pourquoi l'acheté suit désormais la mesure (correctif c, etm71)
* L'acheté se comptait sur la CONSIGNE éco, avant quantification et verrou : une cible de
* 300 W sur un routeur dont le premier palier vaut 500 W laissait le relais OUVERT, et ces
* watts étaient comptés achetés toute la nuit. Il se compte maintenant sur l'énergie
* MESURÉE de la charge. Sans mesure, on ne sait pas ce qui a été acheté : on ne le dit pas.
*/
bool purchased = false;
/*!
* \brief Énergie ACHETÉE au réseau pour cette obligation depuis l'ouverture de la période (Wh).
* Sans objet si \c !purchased.
*
* \par Ce qui est compté
* À chaque cycle, l'énergie MESURÉE de la charge sur l'intervalle (\c sessionWh) multipliée
* par la part éco de l'action APPLIQUÉE au cycle précédent — celle qui a produit l'énergie de
* l'intervalle : `min(1, Σ targetW des niveaux « eco » / puissance appliquée)`. Une cible
* éco que le palier arrondit à zéro, un verrou qui retient le relais, une charge délestée par
* L4 ou commandée par un optimiseur (niveau unique \c optimizer) comptent donc 0. Voir
* \c EnergyArbitrator::noterAchatEco().
*
* \note **Cumul sur la fenêtre d'obligation** (\rule{LM-1014-b}) : remis à zéro à la
* bascule de période, comme la base d'avancement. Le laisser courir ferait porter au
* lendemain la dépense de la veille.
* lendemain la dépense de la veille. Il ne couvre que les tronçons MESURÉS de la période :
* \c measuredGaps dit s'il y en a eu d'autres.
* \note **Non borné par l'import du site.** Avec plusieurs charges qui tirent en même temps,
* l'attribution de l'import mesuré à l'une ou l'autre serait arbitraire : c'est la part éco
* de la COMMANDE qui répartit, pas le compteur racine.
*/
double purchasedWh = 0;
@ -71,8 +89,7 @@ struct EcoProgress {
*
* Rebaser \c targetWh serait pire : on perdrait l'obligation RÉELLE (l'utilisateur a demandé
* 4 kWh, pas 1), et l'écran ne pourrait plus dire ce qui reste dû. Ce champ prend la seconde
* sortie : l'écran dit « mesuré depuis 09:14 », et \c purchasedWh couvre la période d'avant.
* Les deux se répondent.
* sortie : l'écran dit « mesuré depuis 09:14 », et la cible reste entière.
*/
QDateTime measuredSince;

View File

@ -198,18 +198,21 @@ private:
{QStringLiteral("hasProgress"), n.hasProgress}};
if (n.hasProgress) {
const EcoProgress &p = n.progress;
niveau.insert(QStringLiteral("progress"), QVariantMap{
QVariantMap progres{
{QStringLiteral("regime"), p.regime},
{QStringLiteral("delivered"), p.delivered},
{QStringLiteral("deliveredWh"), p.deliveredWh},
{QStringLiteral("targetWh"), p.targetWh},
{QStringLiteral("purchasedWh"), p.purchasedWh},
{QStringLiteral("measuredSince"), date(p.measuredSince)},
{QStringLiteral("measuredGaps"), p.measuredGaps},
{QStringLiteral("closedMissed"), p.closedMissed},
{QStringLiteral("closedMissingWh"), p.closedMissingWh},
{QStringLiteral("closedKnown"), p.closedKnown},
{QStringLiteral("closedCause"), p.closedCause}});
{QStringLiteral("closedCause"), p.closedCause}};
// Comme la RPC : l'acheté n'existe que mesuré (correctif c). Absent sinon.
if (p.purchased)
progres.insert(QStringLiteral("purchasedWh"), p.purchasedWh);
niveau.insert(QStringLiteral("progress"), progres);
}
niveaux.append(niveau);
}

View File

@ -8236,6 +8236,185 @@ void Simulation::testTheDecisionDescribesTheMergedAction()
#endif
}
/*!
* \brief [Correctif c] L'acheté se compte sur l'énergie MESURÉE, à la part éco de l'APPLIQUÉ.
*
* \par Le défaut
* `purchasedWh` intégrait la CIBLE éco, avant palier et verrou. Sur un relay-router, le palier
* arrondit vers le bas : une cible de 300 W laisse le relais de 500 W ouvert, et ces watts
* étaient comptés achetés. Constaté au banc toute une nuit (ANALYSE_CORRECTIFS_MOTEUR.md, c).
*
* \par La règle
* `achatWh += ΔE × min(1, éco appliquée / puissance appliquée)`, ΔE lu sur le compteur de la
* charge, la part prise sur l'action appliquée au cycle PRÉCÉDENT. Sans mesure : absent.
*
* Chaque cas tourne trois cycles (A, B, C à 30 min) sur une charge neuve, et se lit en C :
* l'ancienne règle publiait l'achat d'un cycle avec un cycle de retard, si bien qu'une lecture
* en B ne l'aurait pas distinguée de la nouvelle. Routeur 500/1000/2000 W, compteur `meter`
* dont le test pose lui-même `totalEnergyConsumed` (puissance du compteur à 0 : le mock
* n'intègre rien de son côté).
*/
void Simulation::testPurchasedIsCountedOnTheMeasuredEnergy()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-achat.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid racineId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(racineId);
Thing *racine = tm->findConfiguredThing(racineId);
const QUuid compteurId = addMeter(27080);
Thing *compteur = tm->findConfiguredThing(compteurId);
const QUuid r500 = addPowerSwitch(500, 27081);
const QUuid r1000 = addPowerSwitch(1000, 27082);
const QUuid r2000 = addPowerSwitch(2000, 27083);
QVERIFY(racine && compteur && tm->findConfiguredThing(r500) && tm->findConfiguredThing(r1000)
&& tm->findConfiguredThing(r2000));
racine->setStateValue("connected", true);
compteur->setStateValue("connected", true);
compteur->setStateValue("currentPower", 0); // le mock n'intègre rien : le test pose
compteur->setStateValue("totalEnergyConsumed", 10.0);
// Une charge NEUVE par cas : son cumul, sa période et son action appliquée partent de rien.
// La précédente est d'abord RETIRÉE, et sa mise en sécurité (asynchrone) laissée aboutir :
// sans quoi la neuve relirait un relais encore fermé que l'ancienne éteint juste après.
auto poser = [&](const QString &id, int besoinWh, bool avecCompteur) {
const QString vide = injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", QVariantList()}}))
.toMap().value("params").toMap().value("energyError").toString();
if (vide != QLatin1String("EnergyErrorNoError"))
return vide;
QTest::qWait(100);
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", r500.toString()}, {"powerW", 500}},
QVariantMap{{"thingId", r1000.toString()}, {"powerW", 1000}},
QVariantMap{{"thingId", r2000.toString()}, {"powerW", 2000}}}},
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", besoinWh}}}};
if (avecCompteur)
m.insert("meterThingId", compteurId.toString());
const QString err = injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", QVariantList{m}}}))
.toMap().value("params").toMap().value("energyError").toString();
QTest::qWait(100);
return err;
};
auto cycle = [&](const QDateTime &t) {
arb->recordMeterUpdate(t);
arb->simulationCallUpdate(t);
QTest::qWait(50); // les écritures de relais sont asynchrones : on lit l'état POSÉ
};
auto ajouterWh = [&](double wh) {
compteur->setStateValue("totalEnergyConsumed",
compteur->stateValue("totalEnergyConsumed").toDouble() + wh / 1000.0);
};
auto niveau = [&](const QString &id, const QString &nom) {
for (const QVariant &n : telemetrieParId(arb->loadTelemetry()).value(id).value("levels").toList())
if (n.toMap().value("level").toString() == nom)
return n.toMap();
return QVariantMap();
};
auto progres = [&](const QString &id) { return niveau(id, "eco").value("progress").toMap(); };
auto ferme = [&](const QUuid &r) { return tm->findConfiguredThing(r)->stateValue("power").toBool(); };
auto appliqueW = [&]() {
return (ferme(r500) ? 500 : 0) + (ferme(r1000) ? 1000 : 0) + (ferme(r2000) ? 2000 : 0);
};
// 2 h avant l'échéance : la cadence éco vaut besoin / 2 h.
const QDateTime tA = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
const QDateTime tB = tA.addSecs(1800), tC = tA.addSecs(3600);
// (i) CIBLE 300 W, RELAIS OUVERT → 0. Le palier de 500 W ne s'enclenche pas ; rien ne passe.
racine->setStateValue("currentPower", 3000); // import franc : aucun confort
QCOMPARE(poser("achat-i", 600, true), QString("EnergyErrorNoError"));
cycle(tA);
QCOMPARE(qRound(niveau("achat-i", "eco").value("targetW").toDouble()), 300);
QCOMPARE(appliqueW(), 0);
cycle(tB);
cycle(tC);
QCOMPARE(progres("achat-i").value("regime").toString(), QString("measured"));
QVERIFY2(progres("achat-i").contains("purchasedWh"), "sous measured, l'acheté est publié");
QCOMPARE(progres("achat-i").value("purchasedWh").toDouble(), 0.0);
// (ii) ÉCO SEUL 500 W, COMPTEUR +250 Wh → 250.
QCOMPARE(poser("achat-ii", 1000, true), QString("EnergyErrorNoError"));
cycle(tA);
QCOMPARE(appliqueW(), 500);
ajouterWh(250);
cycle(tB);
cycle(tC);
QCOMPARE(qRound(progres("achat-ii").value("purchasedWh").toDouble()), 250);
QCOMPARE(qRound(progres("achat-ii").value("deliveredWh").toDouble()), 250);
// (iii) COMPTEUR FIGÉ, RELAIS FERMÉ → 0. La commande n'est pas une énergie.
QCOMPARE(poser("achat-iii", 1000, true), QString("EnergyErrorNoError"));
cycle(tA);
cycle(tB);
QVERIFY(appliqueW() > 0);
cycle(tC);
QCOMPARE(progres("achat-iii").value("purchasedWh").toDouble(), 0.0);
QCOMPARE(progres("achat-iii").value("deliveredWh").toDouble(), 0.0);
// (iv) ÉCO 500 + CONFORT 500, COMPTEUR +500 Wh → 250. La moitié seulement a été achetée.
racine->setStateValue("currentPower", -500); // 500 W exportés : le confort
QCOMPARE(poser("achat-iv", 1000, true), QString("EnergyErrorNoError"));
cycle(tA);
QCOMPARE(qRound(niveau("achat-iv", "eco").value("targetW").toDouble()), 500);
QCOMPARE(qRound(niveau("achat-iv", "comfort").value("targetW").toDouble()), 500);
QCOMPARE(appliqueW(), 1000);
racine->setStateValue("currentPower", 3000);
ajouterWh(500);
cycle(tB);
cycle(tC);
QCOMPARE(qRound(progres("achat-iv").value("purchasedWh").toDouble()), 250);
QCOMPARE(qRound(progres("achat-iv").value("deliveredWh").toDouble()), 500);
// (v) DÉROGATION : niveau « eco » au maximum, part 1 — l'énergie mesurée PENDANT la
// dérogation est achetée entière, et sur ce que le compteur a vu, pas sur 3 500 W × durée.
// La tranche d'AVANT (éco 500 + confort 500, part 0,5) garde sa propre part : un achat
// sauté pendant la dérogation la ferait recompter à la part de la dérogation.
racine->setStateValue("currentPower", -500);
QCOMPARE(poser("achat-v", 1000, true), QString("EnergyErrorNoError"));
cycle(tA); // période ouverte, éco + confort
QCOMPARE(appliqueW(), 1000);
QVERIFY(arb->poseDerogation("achat-v", 7200, 0, tA));
racine->setStateValue("currentPower", 3000);
ajouterWh(500); // 500 × 0,5 = 250 achetés
cycle(tB); // dérogation : 3 500 W appliqués
QCOMPARE(appliqueW(), 3500);
ajouterWh(250); // 250 × 1 = 250 achetés
cycle(tC);
QVERIFY(arb->leveDerogation("achat-v"));
cycle(tC.addSecs(1800)); // l'éco reprend et publie le cumul
QCOMPARE(qRound(progres("achat-v").value("purchasedWh").toDouble()), 500);
QCOMPARE(qRound(progres("achat-v").value("deliveredWh").toDouble()), 750);
// (vi) SANS MESURE D'ÉNERGIE → ABSENT. Jamais 0, jamais la commande.
QCOMPARE(poser("achat-vi", 1000, false), QString("EnergyErrorNoError"));
cycle(tA);
cycle(tB);
cycle(tC);
const QVariantMap sans = progres("achat-vi");
QCOMPARE(sans.value("regime").toString(), QString("unmeasurable"));
QVERIFY2(!sans.contains("purchasedWh"),
qUtf8Printable("sans mesure, purchasedWh doit être ABSENT : "
+ QJsonDocument::fromVariant(sans).toJson(QJsonDocument::Compact)));
QVERIFY(!sans.contains("deliveredWh"));
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
/*!
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
*

View File

@ -199,6 +199,8 @@ private slots:
void testEcoFloorMissedSaysWhatWasLostAndWhy();
//! Correctif a — le motif publié est celui de la part majoritaire de la consigne fusionnée.
void testTheDecisionDescribesTheMergedAction();
//! Correctif c — l'acheté se compte sur l'énergie mesurée, à la part éco de l'appliqué.
void testPurchasedIsCountedOnTheMeasuredEnergy();
void testCommandDivergenceIsPublishedNotDiagnosed();
void testProgressMeasurabilityIsDeclaredAtConfigTime();
void testThermalProbeFeedsTelemetry();

View File

@ -87,6 +87,7 @@ private slots:
void testTheTriggerNamesTheCause();
void testANewInvalidPlanReplacesTheCache();
void testConnectionLossDropsThePlan();
void testThePlanOfAnOptimizerBuysNothingEco();
private:
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
@ -633,5 +634,70 @@ void TestSocketScheduler::testConnectionLossDropsThePlan()
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
}
void TestSocketScheduler::testThePlanOfAnOptimizerBuysNothingEco()
{
// Correctif c, cas (vii) — sous un plan d'optimiseur, l'ombre rule-based tourne encore et
// calcule sa cible éco ; elle n'est pas exécutée. L'acheté se compte sur ce qui a été
// APPLIQUÉ, et une action d'optimiseur (niveau unique `optimizer`) n'a pas de part éco : rien
// n'est compté, même quand le compteur de la charge avance.
monter();
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid compteurId = addMeter(26664);
Thing *compteur = tm->findConfiguredThing(compteurId);
QVERIFY(compteur);
compteur->setStateValue("connected", true);
compteur->setStateValue("currentPower", 0); // le test pose lui-même l'énergie
compteur->setStateValue("totalEnergyConsumed", 10.0);
auto ajouterWh = [&](double wh) {
compteur->setStateValue("totalEnergyConsumed",
compteur->stateValue("totalEnergyConsumed").toDouble() + wh / 1000.0);
};
// L'ECS reçoit une obligation (2 000 Wh d'ici 07:00, soit 1 000 W à 05:00 : son relais
// entier) et un compteur d'énergie. Le routeur, sans obligation, ne sert que Q1.
QVariantList l;
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}},
{"meterThingId", compteurId.toString()},
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}}}});
l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})});
};
auto eco = [this]() {
for (const QVariant &n : telemetrie().value("ecs").value("levels").toList())
if (n.toMap().value("level").toString() == QLatin1String("eco"))
return n.toMap().value("progress").toMap();
return QVariantMap();
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(5, 0, 0));
cycle(t0, 2000); // rule-based : éco 1 000 W, relais fermé
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(eco().value("purchasedWh").toDouble(), 0.0);
cycle(t0.addSecs(60), 2000); // le plan de l'optimiseur s'applique
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(tm->findConfiguredThing(m_relais)->stateValue("power").toBool(), true);
// 250 Wh passent pendant que l'OPTIMISEUR commande ; puis il répond faux, et l'on revient
// aux règles — le seul moment où le niveau éco, donc l'acheté, se publie de nouveau.
ajouterWh(250);
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
cycle(t0.addSecs(120), 2000); // plan d'avant appliqué ; le nouveau, refusé
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
cycle(t0.addSecs(180), 2000);
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
const QVariantMap p = eco();
QCOMPARE(p.value("regime").toString(), QString("measured"));
QCOMPARE(qRound(p.value("deliveredWh").toDouble()), 250); // l'énergie est bien arrivée…
QVERIFY2(p.contains("purchasedWh"), "sous measured, l'acheté est publié");
QCOMPARE(p.value("purchasedWh").toDouble(), 0.0); // …et l'éco n'en a rien acheté
}
QTEST_MAIN(TestSocketScheduler)
#include "testsocketscheduler.moc"