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/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 \
|
||||
|
||||
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