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:
Patrick Schurig 2026-10-25 11:05:53 +01:00
parent 76bf482b21
commit 0aa357d5b6
3 changed files with 342 additions and 0 deletions

View File

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

View 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()}};
}

View 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;
};