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:
parent
0aa357d5b6
commit
da6558fa8e
@ -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 ¤tDateTime)
|
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||||
{
|
{
|
||||||
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 ¤tDateTime)
|
|||||||
// 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 ¤tDateTime)
|
||||||
|
{
|
||||||
|
// 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();
|
||||||
|
|||||||
@ -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 ¤tDateTime) override;
|
void update(const QDateTime ¤tDateTime) 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 ¤tDateTime) 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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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 ¶ms)
|
|||||||
return createReply(currentLoadTelemetry());
|
return createReply(currentLoadTelemetry());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap ¶ms)
|
||||||
|
{
|
||||||
|
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 ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
const QVariantMap succes{{"forecastPoints", QVariantList()},
|
const QVariantMap succes{{"forecastPoints", QVariantList()},
|
||||||
|
|||||||
@ -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 ¶ms);
|
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \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 ¶ms);
|
||||||
//! \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.
|
||||||
|
|||||||
@ -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 ¤tDateTime); // [ETM] private → protected
|
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [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 ¤tDateTime); // [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é.
|
||||||
|
|||||||
@ -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}})));
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user