diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index ef967ca..5f086b6 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -5,6 +5,7 @@ HEADERS += \ $$PWD/types/surpluscontext.h \ $$PWD/types/plan.h \ $$PWD/types/drawcap.h \ + $$PWD/types/sheddinglog.h \ $$PWD/types/ecoprogress.h \ $$PWD/types/ecotarget.h \ $$PWD/types/loadconfig.h \ @@ -33,6 +34,7 @@ HEADERS += \ SOURCES += \ $$PWD/types/decisionreason.cpp \ $$PWD/types/drawcap.cpp \ + $$PWD/types/sheddinglog.cpp \ $$PWD/types/ecotarget.cpp \ $$PWD/types/loadconfig.cpp \ $$PWD/tariff/tariffconfig.cpp \ diff --git a/energyplugin/etm/types/sheddinglog.cpp b/energyplugin/etm/types/sheddinglog.cpp new file mode 100644 index 0000000..b62ccc8 --- /dev/null +++ b/energyplugin/etm/types/sheddinglog.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "sheddinglog.h" + +QVariantMap SheddingState::toMap() const +{ + return QVariantMap{{QStringLiteral("powerW"), qRound(powerW)}, + {QStringLiteral("mechanism"), mechanism}}; +} + +QVariantMap SheddingEvent::toMap() const +{ + QVariantMap m{{QStringLiteral("id"), id}, + {QStringLiteral("loadId"), loadId}, + {QStringLiteral("startedAt"), startedAt.toUTC().toString(Qt::ISODate)}, + {QStringLiteral("source"), source}, + {QStringLiteral("overshootW"), qRound(overshootW)}, + {QStringLiteral("cut"), cut}, + {QStringLiteral("before"), before.toMap()}, + {QStringLiteral("after"), after.toMap()}}; + // Absents quand ils n'existent pas : un épisode en cours n'a pas de fin, et un plafond total + // n'a pas de phase coupable. Une valeur par défaut dirait le contraire. + if (endedAt.isValid()) + m.insert(QStringLiteral("endedAt"), endedAt.toUTC().toString(Qt::ISODate)); + if (!phase.isEmpty()) + m.insert(QStringLiteral("phase"), phase); + return m; +} + +SheddingLog::SheddingLog(int capacity) + : m_capacity(qMax(1, capacity)) +{ +} + +SheddingEvent *SheddingLog::trouver(int id) +{ + for (SheddingEvent &e : m_evenements) + if (e.id == id) + return &e; + return nullptr; +} + +void SheddingLog::evincer() +{ + while (m_evenements.size() > m_capacity) { + // Le plus ancien épisode CLOS d'abord : un épisode en cours est ce que l'écran affiche + // en ce moment, l'évincer effacerait l'alerte pendant qu'elle a lieu. + int victime = 0; + for (int i = 0; i < m_evenements.size(); ++i) { + if (m_evenements.at(i).endedAt.isValid()) { + victime = i; + break; + } + } + // Le suivi n'est retiré que si l'évincé est en cours : un épisode CLOS de la même charge + // n'a plus de suivi, et retirer par loadId emporterait celui de l'épisode qui s'ouvre. + const SheddingEvent &evince = m_evenements.at(victime); + const auto suivi = m_ouverts.constFind(evince.loadId); + if (suivi != m_ouverts.constEnd() && suivi->id == evince.id) + m_ouverts.remove(evince.loadId); + m_evenements.removeAt(victime); + } +} + +void SheddingLog::tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act) +{ + auto it = m_ouverts.find(loadId); + SheddingEvent *e = it != m_ouverts.end() ? trouver(it->id) : nullptr; + if (!e) { + SheddingEvent n; + n.id = m_prochainId++; + n.loadId = loadId; + n.startedAt = now.toUTC(); // ancré à la capture (AGENTS, piège du fuseau) + n.source = act.source; + n.phase = act.phase; + n.overshootW = act.overshootW; + n.cut = act.cut; + n.before = act.before; + n.after = act.after; + m_evenements.append(n); + m_ouverts.insert(loadId, Ouvert{n.id, m_cycle, QDateTime()}); + ++m_revision; + evincer(); + return; + } + + it->dernierCycleTenu = m_cycle; + it->libreDepuis = QDateTime(); + + // Ce qui est PUBLIÉ peut changer : la révision ne bouge que dans ce cas. + const QVariantMap avant = e->toMap(); + if (act.overshootW > e->overshootW) { + e->overshootW = act.overshootW; + e->phase = act.phase; + e->source = act.source; + } + e->cut = e->cut || act.cut; + // « Après » garde le plus BAS : c'est ce que la protection a fait de plus fort. À égalité, + // le plus récent, qui dit l'état tenu. + if (act.after.powerW <= e->after.powerW) + e->after = act.after; + if (e->toMap() != avant) + ++m_revision; +} + +void SheddingLog::prolonger(const QString &loadId) +{ + auto it = m_ouverts.find(loadId); + if (it == m_ouverts.end()) + return; + it->dernierCycleTenu = m_cycle; + it->libreDepuis = QDateTime(); +} + +void SheddingLog::relacher(const QString &loadId, const QDateTime &now) +{ + auto it = m_ouverts.find(loadId); + if (it == m_ouverts.end() || it->libreDepuis.isValid()) + return; // la fin se date du PREMIER instant libre, pas du dernier + it->libreDepuis = now.toUTC(); +} + +bool SheddingLog::finDeCycle() +{ + bool ferme = false; + for (auto it = m_ouverts.begin(); it != m_ouverts.end();) { + // Libre, et pas tenue pendant le cycle qui s'achève : un cycle complet sans protection. + if (it->libreDepuis.isValid() && it->dernierCycleTenu < m_cycle) { + if (SheddingEvent *e = trouver(it->id)) + e->endedAt = it->libreDepuis; + it = m_ouverts.erase(it); + ferme = true; + } else { + ++it; + } + } + if (ferme) + ++m_revision; + ++m_cycle; + return ferme; +} + +const SheddingEvent *SheddingLog::enCours(const QString &loadId) const +{ + const auto it = m_ouverts.constFind(loadId); + if (it == m_ouverts.constEnd()) + return nullptr; + for (const SheddingEvent &e : m_evenements) + if (e.id == it->id) + return &e; + return nullptr; +} + +QStringList SheddingLog::ouverts() const +{ + QStringList ids = m_ouverts.keys(); + ids.sort(); + return ids; +} + +QVariantMap SheddingLog::toMap() const +{ + QVariantList evts; + for (const SheddingEvent &e : m_evenements) + evts.append(e.toMap()); + return QVariantMap{{QStringLiteral("revision"), m_revision}, + {QStringLiteral("capacity"), m_capacity}, + {QStringLiteral("events"), evts}}; +} + +QVariantMap SheddingLog::resume() const +{ + if (m_revision == 0) + return QVariantMap(); + return QVariantMap{{QStringLiteral("revision"), m_revision}, + {QStringLiteral("open"), ouverts()}}; +} diff --git a/energyplugin/etm/types/sheddinglog.h b/energyplugin/etm/types/sheddinglog.h new file mode 100644 index 0000000..cc71999 --- /dev/null +++ b/energyplugin/etm/types/sheddinglog.h @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include + +/*! + * \file sheddinglog.h + * \brief Journal COURT des délestages : un événement par ÉPISODE, en mémoire. + * + * \par Pourquoi il existe + * Un délestage ne vivait qu'un cycle dans la télémétrie (\c DRAW_CAP, \c shed: true), puis + * disparaissait. Une borne coupée la nuit par la protection de surcharge ne laissait aucune + * trace hors du journal système. Ce journal garde les N derniers épisodes, de quoi répondre + * le matin à « pourquoi ma borne s'est-elle arrêtée ? ». + * + * \par Ce qu'il ne fait pas + * Il ne décide rien. Il est alimenté par les deux couches de protection, après leur geste, + * et aucune décision ne le lit. Il est perdu au redémarrage de nymead : c'est accepté. + * + * \par Un épisode, pas un cycle + * Une charge tenue 20 minutes par la protection fait UNE entrée, avec un début et une fin. + * L'épisode s'ouvre au premier geste de protection, et se ferme à la fin du premier cycle + * COMPLET où la protection ne tient plus la charge. La fin est datée du premier instant où + * elle ne la tenait plus (\c relacher()). Un geste repris entre-temps prolonge l'épisode. + * + * \par Le mécanisme, sans liste de types + * « Avant » et « après » portent la charge utile de mécanisme TELLE QUE L'ADAPTATEUR LA + * PUBLIE (\c ILoadAdapter::runtimeView()) : un palier pour des relais, une consigne pour une + * charge continue, un état pour une PAC SG-Ready, un courant pour une borne. Un adaptateur + * ajouté demain est couvert sans rien changer ici. + */ + +//! \brief L'état d'une charge d'un côté du délestage. +struct SheddingState { + //! Puissance engagée, en watts — ce que la charge tenait (avant) ou tient (après). + double powerW = 0; + //! Charge utile de mécanisme, \c kind compris. Celle que \c GetLoadTelemetry publie. + QVariantMap mechanism; + + //! \return Forme sérialisée \c {powerW, mechanism}. + QVariantMap toMap() const; +}; + +//! \brief Ce qu'un geste de protection constate, au moment où il agit. +struct SheddingAct { + //! Source du plafond, code opaque : \c connection, \c gridOperator ou \c selfImposed. + QString source; + //! Phase qui dépasse (\c A, \c B, \c C), vide si aucune phase n'est coupable. + QString phase; + //! Dépassement mesuré au geste, en watts. + double overshootW = 0; + //! Vrai si le geste COUPE la charge. Un changement d'état SG-Ready n'est pas une coupure. + bool cut = false; + //! L'état avant le geste. + SheddingState before; + //! L'état laissé par le geste. + SheddingState after; +}; + +//! \brief Un épisode de délestage, tel que \c GetSheddingLog le publie. +struct SheddingEvent { + int id = 0; //!< Identifiant croissant, jamais réutilisé pendant la vie du processus. + QString loadId; //!< Charge délestée (identifiant de \c GetLoadConfig). + QDateTime startedAt; //!< Premier geste de l'épisode, en UTC. + QDateTime endedAt; //!< Fin de l'épisode, en UTC. Invalide = épisode en cours. + QString source; //!< Source du plafond au plus fort du dépassement. + QString phase; //!< Phase au plus fort du dépassement ; vide si aucune. + double overshootW = 0; //!< Le plus fort dépassement vu pendant l'épisode, en watts. + bool cut = false; //!< Vrai si la charge a été coupée au moins une fois. + SheddingState before; //!< L'état avant le premier geste. + SheddingState after; //!< L'état le plus bas tenu pendant l'épisode. + + //! \return Forme sérialisée. \c endedAt et \c phase sont absents quand ils n'existent pas. + QVariantMap toMap() const; +}; + +/*! + * \brief Journal circulaire des épisodes de délestage. + * + * \invariant Au plus \c capacity() événements. Le plus ancien épisode CLOS sort le premier ; + * un épisode en cours n'est évincé que si tous le sont. + * \invariant \c revision() change à chaque ouverture, fermeture, ou changement d'un contenu + * publié. Prolonger ou relâcher un épisode, sans le fermer, ne la change pas. + */ +class SheddingLog +{ +public: + //! Taille par défaut du journal. + static constexpr int DefaultCapacity = 20; + + //! \param capacity Nombre d'épisodes gardés, au moins 1. + explicit SheddingLog(int capacity = DefaultCapacity); + + /*! + * \brief Un geste de protection sur \p loadId : ouvre l'épisode, ou le prolonge. + * \param loadId Charge délestée. + * \param now Instant du geste. + * \param act Ce que le geste constate. + * \note Dans un épisode en cours, l'état « après » garde le plus bas, \c cut s'accumule, et + * le dépassement garde le plus fort, avec sa phase et sa source. + */ + void tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act); + + //! \brief La protection tient encore \p loadId ce cycle, sans nouveau geste. + //! \param loadId Charge visée. Sans épisode en cours, rien ne se passe. + void prolonger(const QString &loadId); + + //! \brief La protection ne tient plus \p loadId : la fin sera datée de \p now. + //! \param loadId Charge visée. \param now Premier instant libre. Sans épisode, rien. + void relacher(const QString &loadId, const QDateTime &now); + + /*! + * \brief Fin d'un cycle d'arbitrage : ferme les épisodes libres depuis un cycle complet. + * \return Vrai si un épisode s'est fermé. + */ + bool finDeCycle(); + + //! \param loadId Charge visée. \return Vrai si un épisode est en cours sur elle. + bool estOuvert(const QString &loadId) const { return m_ouverts.contains(loadId); } + //! \param loadId Charge visée. \return Son épisode en cours, ou nul s'il n'y en a pas. + const SheddingEvent *enCours(const QString &loadId) const; + //! \return Les charges dont un épisode est en cours, triées. + QStringList ouverts() const; + //! \return Les épisodes gardés, du plus ancien au plus récent. + const QList &evenements() const { return m_evenements; } + //! \return Le compteur de révision ; 0 tant que rien n'a été noté. + int revision() const { return m_revision; } + //! \return Le nombre maximal d'épisodes gardés. + int capacity() const { return m_capacity; } + + //! \return La charge utile de \c GetSheddingLog : \c revision, \c capacity, \c events. + QVariantMap toMap() const; + /*! + * \brief Le résumé publié dans la télémétrie : \c revision et \c open. + * \return Vide tant qu'aucun épisode n'a été noté depuis le démarrage : l'absence du bloc + * dit « aucun délestage », un bloc à zéro le dirait mal. + */ + QVariantMap resume() const; + +private: + struct Ouvert { + int id = 0; + int dernierCycleTenu = 0; + QDateTime libreDepuis; //!< Invalide tant que la protection tient la charge. + }; + + SheddingEvent *trouver(int id); + void evincer(); + + int m_capacity; + int m_revision = 0; + int m_prochainId = 1; + int m_cycle = 0; + QList m_evenements; + QHash m_ouverts; +};