From da6558fa8e4864632fad310a77c4a22fe72d2091 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:05:53 +0100 Subject: [PATCH] =?UTF-8?q?D=C3=A9lestage=20visible=20:=20la=20protection?= =?UTF-8?q?=20publie=20ce=20qu'elle=20fait=20(OVERLOAD=5FTHROTTLED,=20GetS?= =?UTF-8?q?heddingLog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Borne : executeChargingAction() devient virtuel (marque [ETM], corps amont inchangé). L'arbitre laisse partir la consigne, puis la lit. Une baisse ou une coupure de la protection de surcharge ouvre la tenue, et la borne publie OVERLOAD_THROTTLED {fromA, toA, cut, overshootW, limitA, o:phase}. La tenue se lève quand le cycle reprend la borne, ou quand la reprise amont lui rend son courant manuel. Les verrous de la borne sont lus : un geste refusé n'est pas publié comme un délestage. - Cascade : shedWaterfallForOverload() note chaque délestage au journal, avant et après lus sur runtimeView(). Rien d'autre ne change. - Télémétrie : bloc shedding {revision, open}, absent tant que rien n'a été délesté. Sa révision déclenche LoadTelemetryChanged à l'ouverture et à la fermeture d'un épisode. - RPC : NymeaEnergy.GetSheddingLog, même droit que GetLoadTelemetry (table des droits et compte sans droit mis à jour). Aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2). Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 261 ++++++++++++++++++ energyplugin/etm/energyarbitrator.h | 92 ++++++ energyplugin/etm/types/decisionreason.cpp | 16 +- energyplugin/etm/types/decisionreason.h | 23 ++ energyplugin/nymeaenergyjsonhandler.cpp | 45 +++ energyplugin/nymeaenergyjsonhandler.h | 11 + energyplugin/smartchargingmanager.h | 29 +- tests/auto/charging/testcharging.cpp | 8 +- .../auto/loadmodel/testdecisioncatalogue.cpp | 1 + 9 files changed, 481 insertions(+), 5 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 71e422b..0db272e 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include namespace { @@ -432,6 +433,10 @@ void EnergyArbitrator::rebuildLoadAdapters() void EnergyArbitrator::update(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; + // Un geste de protection fait PENDANT le cycle est publié par le cycle ; hors cycle + // (powerBalanceChanged), par executeChargingAction() lui-même. + m_dansCycle = true; + const auto finDuCycle = qScopeGuard([this]() { m_dansCycle = false; }); // Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner. // SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery // 5.planSpot 6.planSurplus 7.adjustEv @@ -559,6 +564,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410). buildTelemetry(plan, slot, currentDateTime); attacherStockage(m_telemetry, batterie); + // Lot délestage visible — APRÈS le dispatch et la fusion L4 : la tenue se juge sur ce qui a + // été exécuté. Publication seule, rien ici ne commande. + suivreDelestages(slot, currentDateTime); + attacherDelestage(m_telemetry); publishTelemetry(false); // Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7). @@ -809,6 +818,9 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) { m_shedActions.clear(); const DrawMargin marge = houseDrawMargin(); + // Lot délestage visible — noté pour le journal seulement : un plafond encore épuisé TIENT les + // charges délestées, quel que soit le motif que la cascade leur donne ensuite. + m_plafondEpuiseAuCycle = marge.borne && marge.watts <= 0; if (!marge.borne || marge.watts >= 0) return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester @@ -860,6 +872,11 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) LoadAction shed; shed.loadId = id; + // Lot délestage visible — l'état AVANT le geste, lu comme l'adaptateur le publie. Lecture + // seule (runtimeView est const) : le journal n'entre dans aucune des décisions qui suivent. + LoadRuntimeView vueAvant = adaptateur->runtimeView(now); + vueAvant.mechanism.insert(QStringLiteral("kind"), vueAvant.mechanismKind); + // DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait // redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est // pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413). @@ -935,6 +952,22 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) // REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera. m_shedActions.insert(id, publie); + // Le JOURNAL, après le geste : ce qui a été rendu, et le mécanisme tel qu'il est laissé. + // Aucun type de charge n'est nommé ici — l'adaptateur dit lui-même ce qu'il tient. + LoadRuntimeView vueApres = adaptateur->runtimeView(now); + vueApres.mechanism.insert(QStringLiteral("kind"), vueApres.mechanismKind); + SheddingAct geste; + geste.source = drawCapSourceCode(marge.source); + if (marge.phase >= 0 && marge.phase <= 2) + geste.phase = QString(QChar('A' + marge.phase)); + geste.overshootW = qMax(0.0, -marge.watts); + // Une coupure est une consigne en watts menée à zéro. Une PAC rendue à l'état 2 n'est + // pas coupée : elle chauffe sur son propre thermostat (ECS-413). + geste.cut = shed.kind == LoadAction::Setpoint && apresW <= 0; + geste.before = { engageW, vueAvant.mechanism }; + geste.after = { apresW, vueApres.mechanism }; + m_journalDelestage.tenir(id, now, geste); + qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id << "→" << renderFr(publie.reason, id) << "| rendu:" << rendu << "W | reste à délester:" << resteW << "W"; @@ -962,6 +995,227 @@ void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const } } +void EnergyArbitrator::suivreDelestages(const Slot &slot, const QDateTime &now) +{ + // Une charge de la cascade reste TENUE tant que le plafond la tient : il est encore épuisé + // au compteur, ou son motif est DRAW_CAP — délestée ce cycle (shed: true), ou la cascade + // refuse de la rallumer, l'autorisation étant prise par les charges servies avant elle + // (shed: false). C'est la suite que shedWaterfallForOverload() annonce lui-même : « le cycle + // suivant, la cascade ne les rallume pas non plus ». Le plafond épuisé compte à lui seul : + // une PAC rendue à l'état 2 publie ensuite son verrou, pas le plafond, et le dépassement dure. + QSet tenues; + for (const LoadAction &a : slot.actions) + if (a.reason.code == QLatin1String(DecisionCode::DrawCap)) + tenues.insert(a.loadId); + + for (const QString &id : m_journalDelestage.ouverts()) { + const bool borneVivante = internalEvChargers().contains(ThingId(id)); + if (borneVivante || m_bornesTenues.contains(id)) { + // Une borne : sa tenue suit ses consignes. Seule une borne disparue se relâche ici. + if (!borneVivante) { + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + } + continue; + } + if (tenues.contains(id) || m_plafondEpuiseAuCycle) + m_journalDelestage.prolonger(id); + else + m_journalDelestage.relacher(id, now); + } + if (m_journalDelestage.finDeCycle()) + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] journal — épisode(s) clos ; en cours :" + << (m_journalDelestage.ouverts().isEmpty() + ? QStringLiteral("aucun") + : m_journalDelestage.ouverts().join(QStringLiteral(", "))); +} + +void EnergyArbitrator::attacherDelestage(QVariantMap &snap) const +{ + if (snap.isEmpty()) + return; // aucun cycle encore : rien à compléter, et rien à inventer + // ABSENT tant que rien n'a été délesté depuis le démarrage : l'absence dit « aucun + // délestage », un bloc à zéro le dirait mal — et la télémétrie d'une maison qui n'a jamais + // délesté reste exactement celle d'avant ce lot. + const QVariantMap resume = m_journalDelestage.resume(); + if (resume.isEmpty()) + snap.remove(QStringLiteral("shedding")); + else + snap.insert(QStringLiteral("shedding"), resume); + + if (m_bornesTenues.isEmpty()) + return; + QVariantList loads = snap.value(QStringLiteral("loads")).toList(); + for (QVariant &v : loads) { + QVariantMap entry = v.toMap(); + const auto t = m_bornesTenues.constFind(entry.value(QStringLiteral("loadId")).toString()); + if (t == m_bornesTenues.constEnd()) + continue; + // Préséance L4 : l'action entière est celle de la protection, sans niveaux (R7 — les + // deux absents ensemble), et l'allocation publiée est ce qu'elle laisse à la borne. + entry.insert(QStringLiteral("decision"), motifTenue(*t).toMap()); + entry.remove(QStringLiteral("levels")); + entry.insert(QStringLiteral("allocatedW"), t->cut ? 0.0 : t->toA * 230.0 * t->phases); + v = entry; + } + snap.insert(QStringLiteral("loads"), loads); +} + +DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const +{ + QVariantMap params{{QStringLiteral("fromA"), t.fromA}, + {QStringLiteral("toA"), t.toA}, + {QStringLiteral("cut"), t.cut}, + {QStringLiteral("overshootW"), qRound(t.overshootW)}, + {QStringLiteral("limitA"), phasePowerLimit()}}; + // Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée, + // aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP. + if (!t.phase.isEmpty()) + params.insert(QStringLiteral("phase"), t.phase); + return DecisionReason(DecisionCode::OverloadThrottled, params); +} + +void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, + const QDateTime ¤tDateTime) +{ + // AVANT l'écriture : ce que la borne tenait, et ce que ses verrous laisseront passer. Les + // verrous sont lus ICI, avec le même instant que les setters amont : c'est ce qui permet de + // publier ce que la borne a RÉELLEMENT reçu, et non ce qui lui a été demandé. + const bool avantActive = evCharger->chargingEnabled(); + const uint avantA = evCharger->maxChargingCurrent(); + const bool courantPasse = chargingAction.force() || !evCharger->chargingCurrentLocked(currentDateTime); + const bool activationPasse = chargingAction.force() || !evCharger->chargingEnabledLocked(currentDateTime); + const uint demandeA = qBound(evCharger->maxChargingCurrentMinValue(), + static_cast(qMax(0, qRound(chargingAction.maxChargingCurrent()))), + evCharger->maxChargingCurrentMaxValue()); + + // La consigne part TELLE QUELLE, par le corps amont : la publication vient après, et ne + // touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4). + SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime); + + if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA, + activationPasse ? chargingAction.chargingEnabled() : avantActive, + courantPasse ? demandeA : avantA, currentDateTime)) + return; + // HORS CYCLE — powerBalanceChanged : aucun cycle ne publiera ce geste avant une minute. + // L'instantané publié est complété et republié tel quel : son timestamp reste celui du + // dernier cycle, le geste est daté par le journal. + if (!m_dansCycle && !m_telemetry.isEmpty()) { + attacherDelestage(m_telemetry); + publishTelemetry(false); + } +} + +bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur, + bool avantActive, uint avantA, bool apresActive, uint apresA, + const QDateTime &now) +{ + const QString id = evCharger->id().toString(); + + // L'émetteur INCONNU n'est émis que par un chemin : la boucle de verifyOverloadProtection() + // sur une borne monophasée dont la phase ne dépasse pas, qui reçoit une consigne par défaut. + // Ce n'est pas un geste de la protection au sens de son émetteur : il n'ouvre ni ne ferme rien. + if (emetteur == ChargingAction::ChargingActionIssuerUnknown) + return false; + + const bool tenueAvant = m_bornesTenues.contains(id); + + if (emetteur != ChargingAction::ChargingActionIssuerOverloadProtection) { + // Le cycle reprend la main : la protection ne tient plus la borne. + if (!tenueAvant) + return false; + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— le cycle reprend la main, la protection ne la tient plus."; + return true; + } + + // Rien n'a passé les verrous de la borne : la protection a demandé, la borne n'a rien reçu. + // Un refus se dit au moins aussi fort qu'un geste (règle 7-d) — mais il ne s'enregistre pas + // comme un délestage, qui n'a pas eu lieu. + if (apresActive == avantActive && apresA == avantA) { + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— geste de la protection REFUSÉ par les verrous de la " + "borne : elle garde" << (avantActive ? avantA : 0u) << "A."; + return false; + } + + const uint phases = qMax(1u, evCharger->phaseCount()); + const bool coupe = !apresActive; + const uint commandeA = apresA; + const uint versA = coupe ? 0u : commandeA; + + if (!coupe && avantActive && versA >= avantA) { + // verifyOverloadProtectionRecovery() rend le courant manuel : la tenue est levée. + if (!tenueAvant) + return false; + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— la protection lui rend" << versA << "A : tenue levée."; + return true; + } + if (!coupe && !avantActive && !tenueAvant) + return false; // une remise en route sans tenue en cours n'a rien à prolonger + + // Le dépassement que la protection a vu — relu sur le MÊME compteur, dans le même appel, et + // jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon. + QString phase; + double depassementW = 0; + if (RootMeter *compteur = internalRootMeter()) { + const double limiteW = 230.0 * phasePowerLimit(); + const QHash parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()}, + {QStringLiteral("B"), compteur->currentPowerPhaseB()}, + {QStringLiteral("C"), compteur->currentPowerPhaseC()}}; + if (evCharger->phaseCount() == 1) { + phase = chargerPhaseFor(evCharger); + depassementW = qMax(0.0, parPhase.value(phase) - limiteW); + } else { + for (double w : parPhase) + depassementW = qMax(depassementW, w - limiteW); + } + } + + BorneTenue &t = m_bornesTenues[id]; + if (!tenueAvant) { + // Un épisode encore ouvert — le cycle a repris la borne, la protection la reprend avant + // qu'un cycle complet ne soit passé : c'est le MÊME épisode, et « avant » est le sien. + const SheddingEvent *ouvert = m_journalDelestage.enCours(id); + t.fromA = ouvert ? ouvert->before.mechanism.value(QStringLiteral("currentA")).toUInt() + : (avantActive ? avantA : 0); + t.phase = ouvert ? ouvert->phase : phase; + t.overshootW = ouvert ? ouvert->overshootW : 0; + } + t.toA = versA; + t.cut = coupe; + t.phases = phases; + if (depassementW > t.overshootW) { + t.overshootW = depassementW; + t.phase = phase; + } + + auto etat = [phases](bool active, uint ampere) { + return SheddingState{ active ? ampere * 230.0 * phases : 0.0, + QVariantMap{{QStringLiteral("kind"), QStringLiteral("evcharger")}, + {QStringLiteral("chargingEnabled"), active}, + {QStringLiteral("currentA"), ampere}, + {QStringLiteral("phaseCount"), phases}} }; + }; + SheddingAct geste; + geste.source = drawCapSourceCode(DrawCapSource::Breaker); // la limite par phase du branchement + geste.phase = phase; + geste.overshootW = depassementW; + geste.cut = coupe; + geste.before = etat(avantActive, avantA); + geste.after = etat(!coupe, commandeA); + m_journalDelestage.tenir(id, now, geste); + + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << id << "→" + << renderFr(motifTenue(t), evCharger->name()); + return true; // le motif publié suit chaque geste, même quand le journal ne bouge pas +} + bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const { if (!ev) @@ -2517,6 +2771,8 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} }; attacherStockage(snap, repli); } + // La protection de surcharge tourne aussi sous L2 : ce qu'elle tient se publie ici aussi. + attacherDelestage(snap); m_telemetry = snap; } @@ -3506,6 +3762,11 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const // pris fin — ni pourquoi. if (b.contains(QStringLiteral("derogationEnded"))) return true; + // Lot délestage visible — l'OUVERTURE et la FERMETURE d'un épisode se notifient toujours, + // même quand rien d'autre n'a bougé : c'est l'alerte que l'app attend. + if (a.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision")) + != b.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision"))) + return true; // Lot e1 — la batterie : apparition du bloc, consigne tenue ou levée, CODE du motif. const QVariantMap sa = a.value(QStringLiteral("storage")).toMap(); const QVariantMap sb = b.value(QStringLiteral("storage")).toMap(); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index f960818..0c25850 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -11,6 +11,7 @@ // qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config. #include "types/loadconfig.h" #include "types/drawcap.h" +#include "types/sheddinglog.h" #include "types/derogation.h" #include "forecast/forecaststore.h" #include "forecast/forecastlearninglog.h" @@ -522,6 +523,23 @@ public: */ QVariantMap loadTelemetry() const { return m_telemetry; } + /*! + * \brief Le journal court des délestages — charge utile de \c NymeaEnergy.GetSheddingLog. + * + * \return \c revision, \c capacity et \c events : les \c capacity derniers épisodes de + * protection, du plus ancien au plus récent. Un épisode en cours n'a pas d'\c endedAt. + * + * \par Ce qui y entre + * Les deux couches de protection, et elles seules : la protection de surcharge de l'amont + * sur les bornes (\c executeChargingAction()), et le délestage du waterfall + * (\c shedWaterfallForOverload()) pour TOUTES les autres charges, sans liste de types. + * + * \note En mémoire seulement : perdu au redémarrage de nymead. La télémétrie en porte le + * résumé (\c shedding : \c revision et \c open), absent tant que le journal est vide — + * c'est lui qui notifie l'ouverture et la fermeture d'un épisode. + */ + QVariantMap sheddingLog() const { return m_journalDelestage.toMap(); } + /*! * \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul. * @@ -790,6 +808,41 @@ public: //! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu. void fusionnerDelestageDansSlot(Slot &slot) const; + /*! + * \brief Fin de cycle du journal des délestages : prolonge, relâche, ferme. + * \param slot Actions du cycle, délestage L4 fusionné. Une charge de la cascade reste tenue + * tant que son motif est \c DRAW_CAP — délestée ce cycle, ou que la cascade refuse de la + * rallumer sous le même plafond. + * \param now Temps de cycle. + * \note Les bornes ne passent pas par ici : leur tenue suit les consignes elles-mêmes + * (\c executeChargingAction()). Une borne qui a disparu est relâchée. + */ + void suivreDelestages(const Slot &slot, const QDateTime &now); + + /*! + * \brief Pose dans \p snap ce que la protection tient : le résumé du journal, et le motif + * \c OVERLOAD_THROTTLED des bornes tenues. + * \param[in,out] snap Instantané de télémétrie. Rien n'est ajouté à un instantané vide. + * \note Idempotent : rejouée hors cycle sur l'instantané publié, elle ne double rien. Une + * borne tenue perd ses niveaux, comme une charge délestée par L4 — l'action entière est + * celle de la protection, et \c allocatedW dit ce qu'elle laisse à la borne. + */ + void attacherDelestage(QVariantMap &snap) const; + + /*! + * \brief Classe une consigne de borne qui vient de partir, et note ce qu'elle dit de la protection. + * \param evCharger Borne. \param emetteur Émetteur de la consigne. + * \param avantActive La borne était-elle enclenchée avant la consigne ? + * \param avantA Son courant avant la consigne. + * \param apresActive Enclenchement que la borne a REÇU — l'ancien si son verrou a refusé. + * \param apresA Courant que la borne a REÇU, borné à ses limites — l'ancien si son verrou a refusé. + * \param now Temps de cycle. + * \return Vrai si la tenue ou le journal ont changé. + */ + bool noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur, + bool avantActive, uint avantA, bool apresActive, uint apresA, + const QDateTime &now); + /*! * \brief Marge de soutirage de la MAISON, toutes phases confondues. * @@ -937,6 +990,25 @@ protected: */ void update(const QDateTime ¤tDateTime) override; + /*! + * \brief Exécute une consigne de borne par le corps amont, puis PUBLIE un geste de protection. + * + * \param evCharger Borne commandée. \param chargingAction Consigne, transmise TELLE QUELLE. + * \param currentDateTime Temps de cycle. + * + * \par Ce qui est publié + * Une consigne émise par la protection de surcharge (\c ChargingActionIssuerOverloadProtection) + * qui baisse ou coupe la borne ouvre ou prolonge son épisode au journal, et la borne publie + * \c OVERLOAD_THROTTLED. La remise au minimum après coupure prolonge la tenue. La reprise au + * courant manuel, ou toute consigne d'un autre émetteur, la lève. + * + * \par Ce qui ne change pas + * La consigne part d'abord, inchangée ; la lecture vient après. Hors d'un cycle — la + * protection tourne aussi sur \c powerBalanceChanged — la télémétrie est republiée sur-le-champ. + */ + void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, + const QDateTime ¤tDateTime) override; + private: /*! * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. @@ -1358,6 +1430,26 @@ private: //! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges, //! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4. QHash m_shedActions; + //! Journal court des délestages (lot délestage visible). AUCUNE DÉCISION N'EN DÉPEND. + SheddingLog m_journalDelestage; + //! Ce que la protection de surcharge tient d'une borne : la base du motif OVERLOAD_THROTTLED. + struct BorneTenue { + uint fromA = 0; //!< Courant avant le premier geste. + uint toA = 0; //!< Courant tenu ; 0 si coupée. + uint phases = 1; //!< Phases de la borne, pour la puissance laissée. + bool cut = false; //!< Coupée par le dernier geste. + QString phase; //!< Phase jugée ; vide pour une borne triphasée. + double overshootW = 0; //!< Plus fort dépassement de la tenue. + }; + //! loadId de borne → tenue en cours. Vide dans le cas normal. + QHash m_bornesTenues; + //! Le motif OVERLOAD_THROTTLED d'une tenue — construit ICI, en un seul endroit. + DecisionReason motifTenue(const BorneTenue &t) const; + //! Vrai pendant update() : un geste de protection y est publié par le cycle lui-même. + bool m_dansCycle = false; + //! Le plafond de soutirage était-il épuisé au compteur, à l'évaluation L4 du cycle ? Lu par + //! le seul journal des délestages : une charge délestée reste tenue tant qu'il l'est. + bool m_plafondEpuiseAuCycle = false; //! 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 diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 6e9f90d..7bbd312 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -123,6 +123,20 @@ QString renderFr(const DecisionReason &reason, const QString &label) "%3 n'est pas servie") .arg(ou).arg(origine).arg(label); } + // La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne + // reçoit, puis pourquoi : le branchement, la phase, sa limite. + if (c == QLatin1String(DecisionCode::OverloadThrottled)) { + const QString pourquoi = p.contains(QStringLiteral("phase")) + ? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)") + .arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA")) + : QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA")); + const QString qui = label.isEmpty() ? QStringLiteral("Borne") : label; + if (p.value(QStringLiteral("cut")).toBool()) + return QStringLiteral("%1 coupée : %2, dépassement de %3 W") + .arg(qui, pourquoi).arg(w("overshootW")); + return QStringLiteral("%1 abaissée à %2 A : %3, dépassement de %4 W") + .arg(qui).arg(w("toA")).arg(pourquoi).arg(w("overshootW")); + } // La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable // là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va. auto delaiEco = [&p]() { @@ -433,7 +447,7 @@ QStringList DecisionCode::knownCodes() return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle, LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint, SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap, - EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, + OverloadThrottled, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive, DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, ForecastDateUnreadable, TariffRefused, LoadConfigRefused, diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 9a7de1c..edcfb76 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -176,6 +176,29 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT"; */ constexpr const char *DrawCap = "DRAW_CAP"; +/*! + * \brief La PROTECTION DE SURCHARGE de l'amont tient la borne : abaissée ou coupée. + * + * params : \c fromA (courant avant le premier geste), \c toA (courant tenu, 0 si coupée), + * \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue), + * \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque + * \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur + * la phase la plus chargée, sans qu'une phase soit à elle seule coupable). + * + * \par Ce qu'il publie, et ce qu'il ne décide pas + * \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi + * à l'ampère supérieur, et la coupe sous son plancher. Ce geste ne laissait ni motif, ni + * télémétrie. Ce code le PUBLIE, après coup : la protection reste celle de l'amont, à l'identique. + * + * \par Quand il tient, quand il se lève + * Il tient tant que la dernière commande appliquée à la borne vient de la protection — la baisse, + * la coupure, et la remise au courant minimal par \c verifyOverloadProtectionRecovery(). Il se lève + * quand le cycle commande à nouveau la borne, ou quand la reprise lui rend son courant manuel. + * + * \note Préséance : celle du délestage L4 — l'action entière est remplacée, sans niveaux. + */ +constexpr const char *OverloadThrottled = "OVERLOAD_THROTTLED"; + /*! * \brief L'obligation éco est servie en ACHETANT au réseau. * diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index dbba234..dfd5bde 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -30,6 +30,7 @@ #include #endif #include "etm/types/decisionreason.h" // hors ETM_ARBITRATOR aussi : SPOT_MARKET_NOT_IN_BUILD +#include "etm/types/sheddinglog.h" // hors ETM_ARBITRATOR aussi : GetSheddingLog rend un journal vide #include "types/charginginfo.h" #include "smartchargingmanager.h" #include "spotmarket/spotmarketmanager.h" @@ -331,6 +332,39 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns = loadTelemetrySchema(); registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeNone); + // [ETM] Journal court des délestages. Hors de la télémétrie, parce qu'il la doublerait : vingt + // épisodes portant chacun deux charges utiles de mécanisme, émis à chaque battement. La + // télémétrie n'en porte que le résumé (shedding.revision, shedding.open) — c'est lui qui + // notifie. Même droit que GetLoadTelemetry : c'est de la lecture d'état, la vitrine la lit. + params.clear(); returns.clear(); + description = "Get the short log of load-shedding episodes: the last 'capacity' episodes of " + "overload protection, oldest first, EV chargers and cascade loads alike. ONE " + "event per episode, not per cycle: 'endedAt' is omitted while the episode is " + "running. 'before' and 'after' carry the mechanism payload as the adapter " + "publishes it (stage for relays, setpoint, SG-Ready state, charger current); " + "'after' is the lowest state held during the episode. In memory only: lost " + "when nymead restarts. No own notification: GetLoadTelemetry carries " + "'shedding.revision', and LoadTelemetryChanged is emitted when an episode opens " + "or closes."; + QVariantMap etatItem; + etatItem.insert("powerW", enumValueName(Int)); + etatItem.insert("mechanism", enumValueName(Variant)); + QVariantMap evenementItem; + evenementItem.insert("id", enumValueName(Int)); + evenementItem.insert("loadId", enumValueName(String)); + evenementItem.insert("startedAt", enumValueName(String)); // ISO 8601 UTC + evenementItem.insert("o:endedAt", enumValueName(String)); // absent = en cours + evenementItem.insert("source", enumValueName(String)); // connection | gridOperator | selfImposed + evenementItem.insert("o:phase", enumValueName(String)); // A | B | C ; absent = aucune + evenementItem.insert("overshootW", enumValueName(Int)); + evenementItem.insert("cut", enumValueName(Bool)); + evenementItem.insert("before", etatItem); + evenementItem.insert("after", etatItem); + returns.insert("revision", enumValueName(Int)); + returns.insert("capacity", enumValueName(Int)); + returns.insert("events", QVariantList() << evenementItem); + registerMethod("GetSheddingLog", description, params, returns, Types::PermissionScopeNone); + // --- Prévision PV ------------------------------------------------------------------------ // // UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE @@ -1483,6 +1517,17 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms) return createReply(currentLoadTelemetry()); } +JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap ¶ms) +{ + Q_UNUSED(params) +#ifdef ETM_ARBITRATOR + if (EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager)) + return createReply(arb->sheddingLog()); +#endif + // Sans arbitre, aucun délestage ne peut être noté : le journal est vide, et le dit. + return createReply(SheddingLog().toMap()); +} + JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) { const QVariantMap succes{{"forecastPoints", QVariantList()}, diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index cf1e16d..f5b8748 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -119,6 +119,17 @@ public: * notification seule le laisse dans un silence ambigu (famille ECS-411). */ Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms); + + /*! + * \brief [ETM] Journal court des délestages : les derniers épisodes de protection. + * \param params Aucun. + * \return \c revision, \c capacity, \c events — un événement par ÉPISODE, borne et charges + * de la cascade confondues. Rendu tel que tenu en mémoire, jamais recalculé. + * \note Pas de notification propre : la télémétrie porte \c shedding.revision, et + * \c LoadTelemetryChanged part à chaque ouverture et à chaque fermeture. Même droit que + * \c GetLoadTelemetry. Perdu au redémarrage de nymead. + */ + Q_INVOKABLE JsonReply *GetSheddingLog(const QVariantMap ¶ms); //! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco). //! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel). //! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes. diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 8c90e9c..45c0cf3 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -131,8 +131,12 @@ signals: // 4. internalEvChargers(), internalChargingActions(), internalRootMeter(), // internalProcessInfo(), internalHasProcessInfo() // — accesseurs ETM neufs, documentés ci-dessous. -// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont +// 5. bestPhaseCountFor(), phasesForCount(), chargerPhaseFor() +// — passe-plats ETM vers trois règles amont // privées, documentés ci-dessous. +// 8. executeChargingAction() — virtual ajouté (le corps reste amont), pour +// que l'arbitre PUBLIE les gestes de la +// protection de surcharge. Documenté ci-dessous. // 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée // ci-dessous. Pas un réglage : jamais persistée. // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() @@ -154,7 +158,28 @@ protected slots: void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected protected: - void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected + /*! + * \brief [ETM] Exécute une consigne de borne — corps amont, inchangé. + * \param evCharger Borne commandée. \param chargingAction Consigne et émetteur. + * \param currentDateTime Temps de cycle, passé aux verrous de la borne. + * \note `private → protected`, puis `virtual` ajouté (lot délestage visible). C'est le seul + * point par où passent TOUS les gestes de la protection de surcharge — y compris ceux de + * \c verifyOverloadProtection() déclenchés par \c powerBalanceChanged, hors cycle. + * \c EnergyArbitrator le surcharge pour les PUBLIER : il appelle ce corps tel quel, puis + * lit ce qui a été commandé. Une surcharge qui modifierait la consigne avant de l'appeler + * ferait décider la publication à la place de la protection (règle 4). + */ + virtual void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected, virtual added + + /*! + * \brief [ETM] Phase de raccordement d'une borne monophasée, selon la protection de surcharge. + * \param evCharger Borne. \return \c "A", \c "B" ou \c "C". + * \note `private → protected`, même raison que \c bestPhaseCountFor : l'arbitre publie la + * phase sur laquelle la protection a jugé la borne. La redériver en ferait deux versions, + * et le motif nommerait une autre phase que celle qui a décidé. + */ + QString chargerPhaseFor(EvCharger *evCharger) const + { return chargerPhaseKey(evCharger); } /*! * \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné. diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index 33e1611..8747dd3 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -2202,6 +2202,7 @@ void TestCharging::testNymeaEnergyPermissionScopes() {"GetSpotMarketScoreEntries", none}, {"GetLoadConfig", none}, {"GetLoadTelemetry", none}, + {"GetSheddingLog", none}, // même droit que la télémétrie : la vitrine le lit {"GetEnergyRatios", none}, {"GetPvForecast", none}, {"GetForecastSite", none}, @@ -2322,12 +2323,15 @@ void TestCharging::testNymeaEnergyPermissionsEnforced() // Le transport du harnais EXIGE l'authentification. Sans elle, nymea ne vérifie aucun droit // et rien de ce qui suit ne prouverait quoi que ce soit. QVERIFY(lecteur.authentificationExigee); - for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"), - QStringLiteral("GetBatteryLevelConsideration")}) { + for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetSheddingLog"), + QStringLiteral("GetTariff"), QStringLiteral("GetBatteryLevelConsideration")}) { const QVariantMap r = appeler(lecteur, m); QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact))); } QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille + // Le journal des délestages : un compte sans aucun droit — celui de la vitrine — le lit en entier. + QCOMPARE(appeler(lecteur, "GetSheddingLog").value("params"), lireAdmin("GetSheddingLog")); + QVERIFY(appeler(lecteur, "GetSheddingLog").value("params").toMap().contains("events")); QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}}))); QCOMPARE(lireAdmin("GetTariff"), tarifAvant); QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}}))); diff --git a/tests/auto/loadmodel/testdecisioncatalogue.cpp b/tests/auto/loadmodel/testdecisioncatalogue.cpp index 2dd46ce..88096fa 100644 --- a/tests/auto/loadmodel/testdecisioncatalogue.cpp +++ b/tests/auto/loadmodel/testdecisioncatalogue.cpp @@ -53,6 +53,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes() for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, DecisionCode::LoadUnavailable, DecisionCode::DrawCap, + DecisionCode::OverloadThrottled, // la protection de surcharge de l'amont DecisionCode::PhaseLimit, DecisionCode::DerogationActive, DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet, DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)