From 5d67dc943d2ea429d1c5c5dd8b90b1a074eb2796 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 9 Jun 2026 21:25:22 +0200 Subject: [PATCH] =?UTF-8?q?[3c-3-fix]=20waterfall=20ECS=20:=20surplus=20ne?= =?UTF-8?q?t=20sign=C3=A9=20+=20clamp=20lock-aware=20(protection=20compres?= =?UTF-8?q?seur)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ (exportW - importW). Régime export inchangé. Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) : l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle), le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les charges suivantes (puissance verrouillée = engagée non-coupable). Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ; lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution). Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge" documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning. Co-Authored-By: Claude Opus 4.8 (1M context) --- energyplugin/etm/adapters/ecsrelayadapter.cpp | 48 +++++++++++-------- energyplugin/etm/adapters/ecsrelayadapter.h | 24 ++++++++-- energyplugin/etm/adapters/evadapter.cpp | 8 ++-- energyplugin/etm/adapters/evadapter.h | 4 +- energyplugin/etm/adapters/iloadadapter.h | 15 +++++- energyplugin/etm/energyarbitrator.cpp | 16 +++---- energyplugin/etm/energyarbitrator.h | 4 +- .../etm/scheduler/rulebasedscheduler.cpp | 31 +++++++++--- energyplugin/etm/types/surpluscontext.h | 5 ++ 9 files changed, 106 insertions(+), 49 deletions(-) diff --git a/energyplugin/etm/adapters/ecsrelayadapter.cpp b/energyplugin/etm/adapters/ecsrelayadapter.cpp index 85fb5b0..7b1b8da 100644 --- a/energyplugin/etm/adapters/ecsrelayadapter.cpp +++ b/energyplugin/etm/adapters/ecsrelayadapter.cpp @@ -84,7 +84,7 @@ LoadTelemetry EcsRelayAdapter::telemetry() const return t; } -LoadContext EcsRelayAdapter::toLoadContext() const +LoadContext EcsRelayAdapter::toLoadContext(const QDateTime &now) const { LoadContext ctx; ctx.id = m_id; @@ -98,10 +98,13 @@ LoadContext EcsRelayAdapter::toLoadContext() const ctx.telemetry.currentPowerW = tel.currentPowerW; ctx.telemetry.stage = m_currentStage; ctx.telemetry.lastSwitch = m_lastSwitch; + // Fenêtre de verrou évaluée au temps de cycle : le scheduler y borne son choix + // (plancher = puissance engagée non-coupable sous minOn ; plafond = redémarrage minOff). + lockWindow(now, ctx.telemetry.minStage, ctx.telemetry.maxStage); return ctx; } -LoadAction EcsRelayAdapter::applyAction(const LoadAction &action) +LoadAction EcsRelayAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::Stage) return action; @@ -117,8 +120,9 @@ LoadAction EcsRelayAdapter::applyAction(const LoadAction &action) if (newStage == m_currentStage) return action; // Aucun changement → idempotent - // Verrous anti-rebond — bypassés si force == true (L2 watchdog) - if (!action.force && lockActive(newStage)) { + // Verrous anti-rebond évalués au temps de cycle (même fenêtre que le scheduler) — + // bypassés si force == true (L2 watchdog). + if (!action.force && lockActive(newStage, now)) { qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label << "— verrou anti-rebond actif, stage" << newStage << "ignoré."; return action; @@ -133,8 +137,8 @@ LoadAction EcsRelayAdapter::applyAction(const LoadAction &action) applyRelayStage(newStage); m_currentStage = newStage; - m_lastSwitch = QDateTime::currentDateTime(); - m_lastActionAt = m_lastSwitch; + m_lastSwitch = now; // estampille au temps de cycle (cohérent avec la fenêtre de verrou) + m_lastActionAt = now; LoadAction applied = action; applied.stage = newStage; @@ -144,24 +148,26 @@ LoadAction EcsRelayAdapter::applyAction(const LoadAction &action) // ---- privé --------------------------------------------------------------- -bool EcsRelayAdapter::lockActive(int newStage) const +void EcsRelayAdapter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const { - if (!m_lastSwitch.isValid()) - return false; + const int topStage = m_stages.size() - 1; + const bool valid = m_lastSwitch.isValid(); + const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0; - const int elapsed = static_cast(m_lastSwitch.secsTo(QDateTime::currentDateTime())); + // Plancher : si ON et minOn non écoulé → interdit de descendre sous le palier courant + // (puissance engagée non-coupable, protection compresseur / anti court-cycling). + minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0; - if (newStage > m_currentStage) { - // Passage à un palier supérieur : minOffS si l'on quitte l'état OFF (stage 0→n) - // ou simplement le délai de stabilisation - if (m_currentStage == 0 && elapsed < m_minOffS) - return true; - } else { - // Réduction de palier : minOnS depuis le dernier ON - if (m_currentStage > 0 && elapsed < m_minOnS) - return true; - } - return false; + // Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer. + maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage; +} + +bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const +{ + // MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident. + int minStage, maxStage; + lockWindow(now, minStage, maxStage); + return newStage < minStage || newStage > maxStage; } void EcsRelayAdapter::applyRelayStage(int stage) diff --git a/energyplugin/etm/adapters/ecsrelayadapter.h b/energyplugin/etm/adapters/ecsrelayadapter.h index e3eb6ec..e5d9610 100644 --- a/energyplugin/etm/adapters/ecsrelayadapter.h +++ b/energyplugin/etm/adapters/ecsrelayadapter.h @@ -70,28 +70,42 @@ public: /*! * \brief Construit l'entrée loads[] §5 du SurplusContext. - * \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW, lastSwitch). + * \param now Temps de cycle (\c ctx.timestamp) — source unique pour minStage/maxStage. + * \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW, + * lastSwitch, et la fenêtre de verrou \c minStage/maxStage évaluée à \p now). */ - LoadContext toLoadContext() const override; + LoadContext toLoadContext(const QDateTime &now) const override; /*! * \brief Applique un changement de palier sur les relais. * * \param action LoadAction de kind Stage. Autres kinds : retour sans effet. + * \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(), + * utilisée pour l'évaluation des verrous et l'estampille \c m_lastSwitch. * \return L'action après écrêtage (stage borné à [0, stages.size()-1]). * * \invariant Si \c action.reason est vide → retour sans effet (log warning). * \invariant Si verrous anti-rebond actifs ET \c action.force == false → retour sans effet (log). * \invariant Si \c action.force == true → bypass verrous (L2 watchdog uniquement). - * \invariant Toute modification de stage met à jour \c m_lastSwitch. + * \invariant Toute modification de stage met à jour \c m_lastSwitch (= \p now). */ - LoadAction applyAction(const LoadAction &action) override; + LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; /*! \brief Stage courant (0 = off). */ int currentStage() const { return m_currentStage; } private: - bool lockActive(int newStage) const; + /*! + * \brief Fenêtre de paliers autorisée à l'instant \p now par les verrous minOn/minOff. + * \param now Temps de cycle. + * \param[out] minStage Plancher : palier courant si minOn non écoulé (puissance engagée + * non-coupable), sinon 0. + * \param[out] maxStage Plafond : 0 si à l'arrêt et minOff non écoulé (redémarrage interdit), + * sinon le palier le plus haut. + */ + void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const; + + bool lockActive(int newStage, const QDateTime &now) const; void applyRelayStage(int stage); ThingManager *m_thingManager; diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 1cde634..1b36a69 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -43,8 +43,9 @@ LoadTelemetry EvAdapter::telemetry() const return t; } -LoadContext EvAdapter::toLoadContext() const +LoadContext EvAdapter::toLoadContext(const QDateTime &now) const { + Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici. LoadContext ctx; ctx.id = m_charger->thing()->id().toString(); ctx.adapter = QStringLiteral("evcharger"); @@ -58,7 +59,7 @@ LoadContext EvAdapter::toLoadContext() const return ctx; } -LoadAction EvAdapter::applyAction(const LoadAction &action) +LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::Setpoint) return action; @@ -83,8 +84,7 @@ LoadAction EvAdapter::applyAction(const LoadAction &action) : ChargingAction::ChargingActionIssuerTimeRequirement; ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false); - const QDateTime now = QDateTime::currentDateTimeUtc(); - m_parent->doExecuteChargingAction(m_charger, ca, now); + m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable) m_lastActionAt = now; LoadAction applied = action; diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index d3e0f18..53e5831 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -48,7 +48,7 @@ public: * \brief Construit l'entrée loads[] §5 du SurplusContext. * \return LoadContext incluant declared, limits, needs et télémétrie EV. */ - LoadContext toLoadContext() const override; + LoadContext toLoadContext(const QDateTime &now) const override; /*! * \brief Applique une consigne Setpoint sur la borne VE. @@ -65,7 +65,7 @@ public: * \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction. * \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger. */ - LoadAction applyAction(const LoadAction &action) override; + LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; /*! \brief Borne VE sous-jacente (lecture). */ EvCharger *evCharger() const { return m_charger; } diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index 3a4bb25..93a421a 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -27,6 +27,12 @@ struct LoadTelemetry { * (second filet après l'écrêtage de l'arbitre). * \invariant applyAction() avec \c reason vide doit être rejetée silencieusement. * \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant). + * \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un + * adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le + * \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction(). + * JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le + * scheduler, qui rend impossible toute divergence décision/exécution et qui rend la + * logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery). */ class ILoadAdapter { public: @@ -47,9 +53,12 @@ public: /*! * \brief Construit l'entrée §5 loads[] pour le SurplusContext. + * \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des + * verrous (minStage/maxStage) — JAMAIS \c QDateTime::currentDateTime() côté adaptateur, + * afin que décision (scheduler) et exécution (applyAction) partagent le même temps. * \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique. */ - virtual LoadContext toLoadContext() const = 0; + virtual LoadContext toLoadContext(const QDateTime &now) const = 0; /*! * \brief Applique l'action et retourne ce qui a réellement été envoyé au matériel. @@ -57,8 +66,10 @@ public: * L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet. * * \param action Action à appliquer. Doit avoir \c reason non vide. + * \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(), + * pour que l'évaluation des verrous coïncide avec celle vue par le scheduler. * \return L'action après écrêtage matériel (peut différer de l'entrée). * \note Retour silencieux sans effet si \c action.reason est vide. */ - virtual LoadAction applyAction(const LoadAction &action) = 0; + virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0; }; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 6d991ba..74ee561 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -135,8 +135,8 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) } // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) - applyActionsToAdapters(slot); // ECS (kind==Stage) → m_ecsAdapters - adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g + applyActionsToAdapters(slot, currentDateTime); // ECS (kind==Stage) → m_ecsAdapters + adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g } SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const @@ -160,13 +160,13 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const // SurplusPv : interface inverter — déféré (remplissage prévu en 3d) // SurplusBattery : déféré 3f - // --- loads[] : EV adapters --- + // --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous) for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) - ctx.loads.append(it.value()->toLoadContext()); + ctx.loads.append(it.value()->toLoadContext(now)); // --- loads[] : ECS relay adapters --- for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it) - ctx.loads.append(it.value()->toLoadContext()); + ctx.loads.append(it.value()->toLoadContext(now)); return ctx; } @@ -186,7 +186,7 @@ void EnergyArbitrator::syncAdapters() } } -void EnergyArbitrator::applyActionsToAdapters(const Slot &slot) +void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now) { for (const LoadAction &action : slot.actions) { // EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g. @@ -199,7 +199,7 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot) continue; } // L'adaptateur applique, écrête et verrouille (anti-rebond) — il ne décide pas. - adapter->applyAction(action); + adapter->applyAction(action, now); } } @@ -236,7 +236,7 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now) la.stage = 0; la.force = true; la.reason = reason; - adapter->applyAction(la); + adapter->applyAction(la, now); // force=true → bypass verrous ; now = temps de cycle } // EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index a0c21b9..48081c5 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -134,8 +134,10 @@ private: * L'adaptateur écrête/verrouille lui-même (anti-rebond) et ignore toute action sans * \c reason ou de kind non supporté — aucune décision ici (règle 2). * \param slot Créneau courant retourné par le scheduler. + * \param now Temps de cycle (\c ctx.timestamp) — transmis aux adaptateurs pour une + * évaluation des verrous cohérente avec celle vue par le scheduler. */ - void applyActionsToAdapters(const Slot &slot); + void applyActionsToAdapters(const Slot &slot, const QDateTime &now); /*! * \brief Tick du watchdog L2 (SAFETY.md §L2) — piloté par \c m_meterWatchdog (QTimer 30 s). diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 669a991..cbe3d9c 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -91,7 +91,10 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) const double measuredW = ev ? ev->currentPower() : 0.0; evReservedW += qMax(0.0, la.estimatedPowerW - measuredW); } - double remainingSurplusW = qMax(0.0, ctx.meter.exportW - evReservedW); + // Surplus net SIGNÉ : exportW − importW = −puissance compteur. Négatif en import → + // l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien + // transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.) + double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW; // Charges ECS triées par priorité ASCENDANTE : rang 1 = premier servi // (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang, pas un poids). @@ -171,22 +174,38 @@ LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc, const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW; // Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction - // (Q_ASSERT EcsRelayAdapter) → bestStage ≥ 0 toujours, donc reason toujours définie. + // (Q_ASSERT EcsRelayAdapter). const QList &stages = lc.declared.stages; - int bestStage = 0; + const int topStage = stages.size() - 1; + int budgetStage = 0; for (int i = 0; i < stages.size(); ++i) { if (stages.at(i) <= budgetChargeW) - bestStage = i; + budgetStage = i; } + // Verrou (minOn/minOff) : le palier choisi est borné par la fenêtre déclarée par + // l'adaptateur (calculée au MÊME ctx.timestamp). Une charge verrouillée ON garde son + // palier (puissance engagée non-coupable) ; à l'arrêt sous minOff elle ne redémarre pas. + const int lo = qBound(0, lc.telemetry.minStage, topStage); + const int hi = qBound(lo, lc.telemetry.maxStage, topStage); + const int bestStage = qBound(lo, budgetStage, hi); + LoadAction la; la.loadId = lc.id; la.kind = LoadAction::Stage; la.funding = LoadAction::Surplus; la.stage = bestStage; - la.estimatedPowerW = bestStage < stages.size() ? stages.at(bestStage) : 0; + la.estimatedPowerW = stages.at(bestStage); - if (bestStage > 0) + if (bestStage > budgetStage) + // Maintenu au-dessus du budget : protection compresseur (minOn non écoulé). + la.reason = QStringLiteral("Verrou minOn — %1 maintenu palier %2 (%3 W, surplus %4 W)") + .arg(lc.label).arg(bestStage).arg(stages.at(bestStage)).arg(qRound(budgetChargeW)); + else if (bestStage < budgetStage) + // Empêché de monter/redémarrer par minOff malgré le surplus. + la.reason = QStringLiteral("Verrou minOff — %1 maintenu palier %2 (redémarrage trop tôt)") + .arg(lc.label).arg(bestStage); + else if (bestStage > 0) la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)") .arg(qRound(budgetChargeW)).arg(lc.label) .arg(bestStage).arg(stages.at(bestStage)); diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index c5c75c7..5f630ad 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -61,6 +61,11 @@ struct LoadContextTelemetry { double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh). // --- relay-stages --- int stage = 0; + //! Fenêtre de paliers autorisée MAINTENANT par les verrous (calculée au temps de cycle). + //! Le scheduler y borne son choix : minStage = plancher (verrou minOn, puissance + //! engagée non-coupable) ; maxStage = plafond (verrou minOff, redémarrage interdit). + int minStage = 0; + int maxStage = 0; // --- sg-ready --- int state = 0; // --- battery / electricvehicle ---