diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index aa75f8b..1e26668 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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)); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index e3e7e36..ea81574 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 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 m_derogationModeRendu; mutable QHash 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 m_ecoAchatWh; - //! loadId → instant du dernier cycle où un achat a été noté, pour intégrer la durée. - mutable QHash 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 m_ecoAchatBaseWh; //! loadId → instant où l'avancement est devenu MESURABLE dans la période courante. mutable QHash m_ecoMesureDepuis; //! loadId → énergie livrée (Wh) sur les TRONÇONS MESURÉS DÉJÀ CLOS de la période courante. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index c24378a..5bc3854 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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é diff --git a/energyplugin/etm/types/ecoprogress.h b/energyplugin/etm/types/ecoprogress.h index 8bca8e4..650d6d3 100644 --- a/energyplugin/etm/types/ecoprogress.h +++ b/energyplugin/etm/types/ecoprogress.h @@ -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; diff --git a/tests/auto/decisions/decisionrecorder.cpp b/tests/auto/decisions/decisionrecorder.cpp index 3cf80a4..1a0315e 100644 --- a/tests/auto/decisions/decisionrecorder.cpp +++ b/tests/auto/decisions/decisionrecorder.cpp @@ -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); } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 10c9349..efde7d3 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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. * diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index eb02600..5330777 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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(); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 5400a5b..12c2c73 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -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"