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:
parent
ff7c8011e2
commit
6481515b64
@ -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));
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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é
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user