Délestage visible : journal court des délestages (SheddingLog)
Un événement par ÉPISODE, en mémoire : il s'ouvre au premier geste de protection et se ferme à la fin du premier cycle complet où la protection ne tient plus la charge, daté du premier instant libre. « Avant » et « après » portent la charge utile de mécanisme de l'adaptateur, sans liste de types. Vingt épisodes ; le plus ancien épisode clos sort le premier. Aucune décision ne le lit, et il est perdu au redémarrage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
76bf482b21
commit
0aa357d5b6
@ -5,6 +5,7 @@ HEADERS += \
|
|||||||
$$PWD/types/surpluscontext.h \
|
$$PWD/types/surpluscontext.h \
|
||||||
$$PWD/types/plan.h \
|
$$PWD/types/plan.h \
|
||||||
$$PWD/types/drawcap.h \
|
$$PWD/types/drawcap.h \
|
||||||
|
$$PWD/types/sheddinglog.h \
|
||||||
$$PWD/types/ecoprogress.h \
|
$$PWD/types/ecoprogress.h \
|
||||||
$$PWD/types/ecotarget.h \
|
$$PWD/types/ecotarget.h \
|
||||||
$$PWD/types/loadconfig.h \
|
$$PWD/types/loadconfig.h \
|
||||||
@ -33,6 +34,7 @@ HEADERS += \
|
|||||||
SOURCES += \
|
SOURCES += \
|
||||||
$$PWD/types/decisionreason.cpp \
|
$$PWD/types/decisionreason.cpp \
|
||||||
$$PWD/types/drawcap.cpp \
|
$$PWD/types/drawcap.cpp \
|
||||||
|
$$PWD/types/sheddinglog.cpp \
|
||||||
$$PWD/types/ecotarget.cpp \
|
$$PWD/types/ecotarget.cpp \
|
||||||
$$PWD/types/loadconfig.cpp \
|
$$PWD/types/loadconfig.cpp \
|
||||||
$$PWD/tariff/tariffconfig.cpp \
|
$$PWD/tariff/tariffconfig.cpp \
|
||||||
|
|||||||
178
energyplugin/etm/types/sheddinglog.cpp
Normal file
178
energyplugin/etm/types/sheddinglog.cpp
Normal file
@ -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()}};
|
||||||
|
}
|
||||||
162
energyplugin/etm/types/sheddinglog.h
Normal file
162
energyplugin/etm/types/sheddinglog.h
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \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<SheddingEvent> &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<SheddingEvent> m_evenements;
|
||||||
|
QHash<QString, Ouvert> m_ouverts;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user