Délestage visible : la protection publie ce qu'elle fait (OVERLOAD_THROTTLED, GetSheddingLog)

- 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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 11:05:53 +01:00
parent 0aa357d5b6
commit da6558fa8e
9 changed files with 481 additions and 5 deletions

View File

@ -31,6 +31,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QFileInfo> #include <QFileInfo>
#include <nymeasettings.h> #include <nymeasettings.h>
#include <QScopeGuard>
#include <cmath> #include <cmath>
namespace { namespace {
@ -432,6 +433,10 @@ void EnergyArbitrator::rebuildLoadAdapters()
void EnergyArbitrator::update(const QDateTime &currentDateTime) void EnergyArbitrator::update(const QDateTime &currentDateTime)
{ {
qCDebug(dcNymeaEnergy()) << "Updating smart charging"; 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. // Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery // SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
// 5.planSpot 6.planSurplus 7.adjustEv // 5.planSpot 6.planSurplus 7.adjustEv
@ -559,6 +564,10 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410). // confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
buildTelemetry(plan, slot, currentDateTime); buildTelemetry(plan, slot, currentDateTime);
attacherStockage(m_telemetry, batterie); 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); publishTelemetry(false);
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7). // 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(); m_shedActions.clear();
const DrawMargin marge = houseDrawMargin(); 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) if (!marge.borne || marge.watts >= 0)
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
@ -860,6 +872,11 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
LoadAction shed; LoadAction shed;
shed.loadId = id; 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 // 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 // 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). // 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. // REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
m_shedActions.insert(id, publie); 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 qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id
<< "→" << renderFr(publie.reason, id) << "→" << renderFr(publie.reason, id)
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W"; << "| 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<QString> 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 &currentDateTime)
{
// 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<uint>(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<QString, double> 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 bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
{ {
if (!ev) if (!ev)
@ -2517,6 +2771,8 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} }; repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} };
attacherStockage(snap, repli); attacherStockage(snap, repli);
} }
// La protection de surcharge tourne aussi sous L2 : ce qu'elle tient se publie ici aussi.
attacherDelestage(snap);
m_telemetry = snap; m_telemetry = snap;
} }
@ -3506,6 +3762,11 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
// pris fin — ni pourquoi. // pris fin — ni pourquoi.
if (b.contains(QStringLiteral("derogationEnded"))) if (b.contains(QStringLiteral("derogationEnded")))
return true; 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. // 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 sa = a.value(QStringLiteral("storage")).toMap();
const QVariantMap sb = b.value(QStringLiteral("storage")).toMap(); const QVariantMap sb = b.value(QStringLiteral("storage")).toMap();

View File

@ -11,6 +11,7 @@
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config. // qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
#include "types/loadconfig.h" #include "types/loadconfig.h"
#include "types/drawcap.h" #include "types/drawcap.h"
#include "types/sheddinglog.h"
#include "types/derogation.h" #include "types/derogation.h"
#include "forecast/forecaststore.h" #include "forecast/forecaststore.h"
#include "forecast/forecastlearninglog.h" #include "forecast/forecastlearninglog.h"
@ -522,6 +523,23 @@ public:
*/ */
QVariantMap loadTelemetry() const { return m_telemetry; } 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. * \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. //! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
void fusionnerDelestageDansSlot(Slot &slot) const; 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. * \brief Marge de soutirage de la MAISON, toutes phases confondues.
* *
@ -937,6 +990,25 @@ protected:
*/ */
void update(const QDateTime &currentDateTime) override; void update(const QDateTime &currentDateTime) 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 &currentDateTime) override;
private: private:
/*! /*!
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. * \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, //! 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. //! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4.
QHash<QString, LoadAction> m_shedActions; QHash<QString, LoadAction> 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<QString, BorneTenue> 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 //! 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. //! (\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 //! Vidée à chaque dispatch ; les délestages L4 et le repli L2 n'y entrent pas (ils ont leur

View File

@ -123,6 +123,20 @@ QString renderFr(const DecisionReason &reason, const QString &label)
"%3 n'est pas servie") "%3 n'est pas servie")
.arg(ou).arg(origine).arg(label); .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 // 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. // là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
auto delaiEco = [&p]() { auto delaiEco = [&p]() {
@ -433,7 +447,7 @@ QStringList DecisionCode::knownCodes()
return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle, return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle,
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint, LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap, SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, OverloadThrottled, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
DerogationActive, DerogationActive,
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
ForecastDateUnreadable, TariffRefused, LoadConfigRefused, ForecastDateUnreadable, TariffRefused, LoadConfigRefused,

View File

@ -176,6 +176,29 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT";
*/ */
constexpr const char *DrawCap = "DRAW_CAP"; 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. * \brief L'obligation éco est servie en ACHETANT au réseau.
* *

View File

@ -30,6 +30,7 @@
#include <QTimeZone> #include <QTimeZone>
#endif #endif
#include "etm/types/decisionreason.h" // hors ETM_ARBITRATOR aussi : SPOT_MARKET_NOT_IN_BUILD #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 "types/charginginfo.h"
#include "smartchargingmanager.h" #include "smartchargingmanager.h"
#include "spotmarket/spotmarketmanager.h" #include "spotmarket/spotmarketmanager.h"
@ -331,6 +332,39 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
returns = loadTelemetrySchema(); returns = loadTelemetrySchema();
registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeNone); 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 ------------------------------------------------------------------------ // --- Prévision PV ------------------------------------------------------------------------
// //
// UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE // 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 &params)
return createReply(currentLoadTelemetry()); return createReply(currentLoadTelemetry());
} }
JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap &params)
{
Q_UNUSED(params)
#ifdef ETM_ARBITRATOR
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(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 &params) JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap &params)
{ {
const QVariantMap succes{{"forecastPoints", QVariantList()}, const QVariantMap succes{{"forecastPoints", QVariantList()},

View File

@ -119,6 +119,17 @@ public:
* notification seule le laisse dans un silence ambigu (famille ECS-411). * notification seule le laisse dans un silence ambigu (famille ECS-411).
*/ */
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap &params); Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap &params);
/*!
* \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 &params);
//! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco). //! \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). //! \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. //! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes.

View File

@ -131,8 +131,12 @@ signals:
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(), // 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
// internalProcessInfo(), internalHasProcessInfo() // internalProcessInfo(), internalHasProcessInfo()
// — accesseurs ETM neufs, documentés ci-dessous. // — 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. // 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 // 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
// ci-dessous. Pas un réglage : jamais persistée. // ci-dessous. Pas un réglage : jamais persistée.
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
@ -154,7 +158,28 @@ protected slots:
void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime); // [ETM] private → protected void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime); // [ETM] private → protected
protected: protected:
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime); // [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 &currentDateTime); // [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é. * \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné.

View File

@ -2202,6 +2202,7 @@ void TestCharging::testNymeaEnergyPermissionScopes()
{"GetSpotMarketScoreEntries", none}, {"GetSpotMarketScoreEntries", none},
{"GetLoadConfig", none}, {"GetLoadConfig", none},
{"GetLoadTelemetry", none}, {"GetLoadTelemetry", none},
{"GetSheddingLog", none}, // même droit que la télémétrie : la vitrine le lit
{"GetEnergyRatios", none}, {"GetEnergyRatios", none},
{"GetPvForecast", none}, {"GetPvForecast", none},
{"GetForecastSite", 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 // 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. // et rien de ce qui suit ne prouverait quoi que ce soit.
QVERIFY(lecteur.authentificationExigee); QVERIFY(lecteur.authentificationExigee);
for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"), for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetSheddingLog"),
QStringLiteral("GetBatteryLevelConsideration")}) { QStringLiteral("GetTariff"), QStringLiteral("GetBatteryLevelConsideration")}) {
const QVariantMap r = appeler(lecteur, m); const QVariantMap r = appeler(lecteur, m);
QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact))); QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact)));
} }
QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille 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()}}))); QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}})));
QCOMPARE(lireAdmin("GetTariff"), tarifAvant); QCOMPARE(lireAdmin("GetTariff"), tarifAvant);
QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}}))); QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}})));

View File

@ -53,6 +53,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady,
DecisionCode::LoadUnavailable, DecisionCode::DrawCap, DecisionCode::LoadUnavailable, DecisionCode::DrawCap,
DecisionCode::OverloadThrottled, // la protection de surcharge de l'amont
DecisionCode::PhaseLimit, DecisionCode::DerogationActive, DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet, DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9) DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)