feat(délestage §4): L4 déleste le waterfall, et dit ce qu'il a franchi
La protection de surcharge ne connaissait que les bornes VE : une PAC ou un routeur pouvaient tenir un dépassement sans que rien ne les redescende. Elle déleste désormais les charges du waterfall, de la dernière servie à la première — le même ordre total que la cascade, lu à l'envers, pour que délester ne puisse pas contredire l'ordre dans lequel on a servi. Délester n'est pas toujours retirer des watts. Une PAC ne se module pas : on la fait redescendre d'ÉTAT, vers 2 et jamais 1. C'est le cas que le bornage de l'étape 3 ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne. Le franchissement de verrou se CONSTATE : on tente d'abord sans forcer, et `minOnBypassed` n'est marqué que si le verrou a réellement refusé. Le déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé et permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait inaperçu au milieu des fausses alertes. Ce constat n'était pas possible avant ECS-415. On ne force que pour un plafond SUBI. Comportement observé, non postulé : la contre-épreuve « forçage interdit » — qui est le comportement d'un plafond EcoSelfImposed — laisse la PAC en état 4. Un seul commandeur par organe : les charges délestées sont revendiquées pour le cycle et le dispatch les saute, sinon la consigne du plan rallumerait ce que la sécurité vient de couper. La fenêtre d'exécution est étroite — après syncAdapters(), avant le dispatch — et les deux placements écartés sont consignés au design §6bis parce qu'ils se représenteront. Ce lot a été repris après un arrêt : deux versions antérieures du test passaient sans rien prouver, leurs prémisses étant supposées. Les trois cycles sont désormais MESURÉS puis assertés, l'armement à froid compris. Suite complète : simulation 54/54, charging 48/48 identique à la référence ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
1171fded3a
commit
b3c63d1a7b
@ -1,3 +1,41 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm37) trixie; urgency=medium
|
||||
|
||||
* Délestage §4 — LA PROTECTION DE SURCHARGE NE SE LIMITE PLUS AUX BORNES. Une PAC ou un
|
||||
routeur pouvaient tenir un dépassement sans que rien ne les redescende ; L4 déleste
|
||||
désormais les charges du waterfall, de la DERNIÈRE SERVIE à la première — le même ordre
|
||||
total que la cascade, lu à l'envers (LM-1211).
|
||||
* DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait
|
||||
redescendre d'ÉTAT, vers 2 et jamais 1 (ECS-413). C'est exactement le cas que le bornage de
|
||||
la cascade ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne.
|
||||
* LE FRANCHISSEMENT DE VERROU SE CONSTATE, IL NE SE PRÉSUME PAS (LM-1211-b). Le délestage
|
||||
tente d'abord SANS forcer et ne marque `minOnBypassed` que si le verrou a réellement refusé.
|
||||
Le déduire d'un verrou simplement ACTIF serait faux : un verrou peut être armé et permettre
|
||||
malgré tout le geste demandé. Le marqueur annoncerait un court-cycle qui n'a pas eu lieu, et
|
||||
le premier compresseur réellement maltraité passerait inaperçu parmi les fausses alertes.
|
||||
* Ce constat n'était pas possible avant ECS-415 : tant qu'un refus renvoyait l'action
|
||||
DEMANDÉE, il se lisait comme un succès. Le correctif du contrat était la précondition.
|
||||
* ON NE FORCE QUE POUR UN PLAFOND SUBI (LM-1211-c). Breaker et GridOperator autorisent le
|
||||
franchissement, EcoSelfImposed jamais — on renonce au plancher éco plutôt qu'au matériel.
|
||||
Comportement OBSERVÉ et non postulé : forçage interdit, la PAC de test reste en état 4.
|
||||
* UN SEUL COMMANDEUR PAR ORGANE (LM-1211-a). Les charges délestées sont revendiquées pour le
|
||||
cycle et le dispatch les saute ; sans cela la consigne du plan rallumerait ce que la
|
||||
sécurité vient de couper. Même mécanisme que m_waterfallEvIds pour les bornes.
|
||||
* FENÊTRE D'EXÉCUTION ÉTROITE, et les deux autres placements sont consignés dans
|
||||
DESIGN_DELESTAGE §6bis parce qu'ils se représenteront : APRÈS le dispatch on lit un état
|
||||
périmé (aucune boucle d'événements dans un même update()), et AVANT syncAdapters()
|
||||
l'écriture part avec l'adaptateur reconstruit (ECS-412). La bonne fenêtre est entre les deux.
|
||||
* Non couvert, et le rester tant qu'aucun émetteur n'existe : GridOperator et EcoSelfImposed
|
||||
n'ont pas de source. La branche qui les distingue est écrite et son effet est démontré par
|
||||
contre-épreuve, mais aucun test de bout en bout ne peut les exercer — en écrire un poserait
|
||||
un invariant sur un code que rien n'émet. Ils viendront avec le §14a.
|
||||
* Suite complète : simulation 54/54, charging 48/48 IDENTIQUE à la référence ligne à ligne
|
||||
(les bornes restent au chemin amont), loadmodel 21/21, spotmarket 32/32, doxygen 0
|
||||
avertissement. Deux contre-épreuves distinctes : délestage débranché, la PAC reste à 4 ;
|
||||
forçage interdit, elle reste à 4 aussi — et c'est le comportement voulu d'un plafond
|
||||
auto-imposé.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 00:20:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm36) trixie; urgency=medium
|
||||
|
||||
* ECS-415 — UN REFUS DOIT SE DISTINGUER D'UNE APPLICATION, par la valeur de retour.
|
||||
|
||||
@ -178,8 +178,42 @@ dépensée.
|
||||
> Le jour venu, `houseDrawMargin()` retiendra la phase de la charge au lieu de la pire — un seul
|
||||
> appel change, parce que l'étape 2 compare des marges et non des plafonds.
|
||||
|
||||
**Étape 4 — la protection L4 déleste le waterfall.** `verifyOverloadProtection()` cesse d'être
|
||||
réservée aux bornes ; ordre inverse du rang, `force = true`, verrous bypassés comme le repli L2.
|
||||
**Étape 4 — la protection L4 déleste le waterfall. ✅ FAITE (2026-08-30).** La protection ne se
|
||||
limite plus aux bornes VE : `shedWaterfallForOverload()` déleste les charges du waterfall en
|
||||
**ordre inverse du rang**, et le motif `DRAW_CAP` porte alors `shed`, `shedW` et le
|
||||
franchissement de verrou.
|
||||
|
||||
*Où elle s'exécute, et pourquoi cette fenêtre est étroite.* Après `syncAdapters()`, avant tout
|
||||
dispatch. Les deux autres placements ont été essayés et échouent — voir **§6bis**, qui les
|
||||
consigne. Les charges délestées sont **revendiquées** : le dispatch les saute, un seul commandeur
|
||||
par organe (règle absolue 1), exactement comme `m_waterfallEvIds` protège les bornes.
|
||||
|
||||
*Le franchissement se CONSTATE.* Le délestage tente d'abord **sans forcer** ; il ne pose
|
||||
`minOnBypassed` que si le verrou a réellement refusé. Le déduire d'un verrou simplement *actif*
|
||||
serait faux — un verrou peut être armé et permettre malgré tout le geste demandé. Le marqueur
|
||||
annoncerait alors un court-cycle qui n'a pas eu lieu, et le premier compresseur réellement
|
||||
maltraité passerait inaperçu au milieu des fausses alertes.
|
||||
|
||||
*Ce constat n'était pas possible avant.* Tant que `applyAction()` renvoyait, sur refus, l'action
|
||||
**demandée** (\rule{ECS-415}), un refus se lisait comme un succès : le délestage n'a jamais
|
||||
forcé, et publiait 3 kW rendus par une PAC restée en état 4. La correction du contrat était donc
|
||||
la précondition, et non un détour.
|
||||
|
||||
*Délester n'est pas toujours retirer des watts.* Une PAC ne se module pas : on la fait
|
||||
redescendre d'**état**, vers 2 (ECS-413), jamais 1. C'est le cas où la décision 6.2 mord, et le
|
||||
test le montre dans les deux sens — forçage autorisé, la PAC redescend ; forçage interdit (le
|
||||
comportement d'un plafond `EcoSelfImposed`), **elle reste à 4**. On renonce au plancher plutôt
|
||||
qu'au matériel, et ce n'est pas une intention : c'est un comportement observé.
|
||||
|
||||
> **Ce qui n'est PAS couvert, et le rester tant qu'il n'y a pas d'émetteur.** `GridOperator` et
|
||||
> `EcoSelfImposed` n'ont aucune source qui les produise. La branche qui les distingue est écrite
|
||||
> et son effet est démontré par la contre-épreuve ci-dessus, mais **aucun test de bout en bout ne
|
||||
> peut les exercer** — en écrire un poserait un invariant sur un code que rien n'émet, c'est-à-dire
|
||||
> un test toujours vert. Ils viendront avec le §14a.
|
||||
>
|
||||
> **SG-Ready décrémente la marge sans être borné par elle** au niveau de la cascade (étape 3) :
|
||||
> son choix est un état, pas des watts. C'est précisément pourquoi le délestage était nécessaire
|
||||
> — et c'est désormais lui qui couvre le cas.
|
||||
|
||||
> **Les deux premières ne changent rien et rendent les deux suivantes petites.** C'est la même
|
||||
> stratégie que le §10 : ce qui coûte, dans un lot de sécurité, n'est pas d'écrire le
|
||||
|
||||
@ -363,6 +363,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
verifyOverloadProtection(currentDateTime);
|
||||
verifyOverloadProtectionRecovery(currentDateTime);
|
||||
|
||||
|
||||
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
|
||||
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
|
||||
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
|
||||
@ -375,12 +376,36 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
// compteur muet, ce que SAFETY.md §L2 interdit. Le repli est posé à la transition par
|
||||
// applyDegradedMode() ; c'est lui qui fait autorité tant que le compteur est muet.
|
||||
m_waterfallEvIds.clear();
|
||||
// SAFETY.md §L2 : la planification est suspendue, la SÉCURITÉ ne l'est pas. Le repli a
|
||||
// posé des consignes à la transition ; si elles ne suffisent pas à tenir le plafond,
|
||||
// c'est encore à L4 de trancher. Aucune télémétrie n'est publiée dans cet état, d'où
|
||||
// le slot local — il ne sert qu'à recevoir les actions, personne ne le lira.
|
||||
return;
|
||||
}
|
||||
|
||||
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
||||
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
||||
syncAdapters();
|
||||
|
||||
// L4 étendu au waterfall — ÉVALUÉ ICI : après syncAdapters(), avant toute écriture du cycle.
|
||||
//
|
||||
// Deux placements ont été essayés et écartés, chacun pour une raison distincte. APRÈS le
|
||||
// dispatch semblait plus juste (« le dernier mot »), et c'était un
|
||||
// piège : dans un même update() aucune boucle d'événements ne tourne, si bien qu'un
|
||||
// adaptateur qui vient d'être commandé rapporte encore son état PRÉCÉDENT. Le délestage
|
||||
// demandait alors un état que l'adaptateur croyait déjà tenir, se le voyait accorder sans
|
||||
// rien faire, et publiait des watts rendus que le matériel n'avait pas rendus — une PAC
|
||||
// restée en état 4 pendant qu'on annonçait 3 kW délestés. Avant tout dispatch, l'état lu
|
||||
// est POSÉ, et ce qu'on constate est vrai. AVANT syncAdapters() échouait autrement : la
|
||||
// reconstruction incrémentale (ECS-412) remplace l'adaptateur, et l'écriture en cours
|
||||
// partait avec lui — les contacts restaient fermés pendant qu'on publiait un délestage.
|
||||
//
|
||||
// Ce que le plan ferait de ces charges est neutralisé autrement : L4 les REVENDIQUE, et le
|
||||
// dispatch les saute — un seul commandeur par organe, exactement le mécanisme qui protège
|
||||
// déjà les bornes du waterfall (m_waterfallEvIds). Le cycle suivant, la cascade ne les
|
||||
// rallume pas non plus : la marge est encore négative, et l'étape 3 la borne.
|
||||
shedWaterfallForOverload(currentDateTime);
|
||||
|
||||
SurplusContext ctx = buildContext(currentDateTime);
|
||||
Plan plan = m_scheduler->getPlan(ctx);
|
||||
Slot slot = plan.slotCovering(currentDateTime);
|
||||
@ -409,6 +434,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
||||
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
|
||||
|
||||
// Les charges délestées par L4 portent le motif du DÉLESTAGE, pas celui du plan : la
|
||||
// charge utile doit dire ce qui a été exécuté, et le plan ne les a pas commandées.
|
||||
fusionnerDelestageDansSlot(slot);
|
||||
|
||||
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
|
||||
// (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas
|
||||
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
||||
@ -417,6 +446,163 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
publishTelemetry(false);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
||||
{
|
||||
m_shedActions.clear();
|
||||
const DrawMargin marge = houseDrawMargin();
|
||||
if (!marge.borne || marge.watts >= 0)
|
||||
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
|
||||
|
||||
double resteW = -marge.watts; // ce qu'il faut RETIRER à la maison, en watts
|
||||
|
||||
// §6.2 — le franchissement des verrous dépend de QUI impose le plafond, et de rien d'autre.
|
||||
// Le disjoncteur ne connaît pas le court-cycling ; un ordre du gestionnaire de réseau ne se
|
||||
// négocie pas. Un plafond qu'on s'impose, si : on renonce au plancher éco plutôt qu'au
|
||||
// matériel.
|
||||
const bool franchitLesVerrous = marge.source == DrawCapSource::Breaker
|
||||
|| marge.source == DrawCapSource::GridOperator;
|
||||
|
||||
// Ordre INVERSE du rang : la dernière servie est la première délestée. C'est le même ordre
|
||||
// total que la cascade, lu à l'envers — donc la même règle de départage, pour que délester
|
||||
// ne puisse pas contredire l'ordre dans lequel on a servi.
|
||||
QList<QString> ids = m_loadAdapters.keys();
|
||||
std::sort(ids.begin(), ids.end(), [this](const QString &a, const QString &b) {
|
||||
const int ra = m_builtFrom.value(a).priority();
|
||||
const int rb = m_builtFrom.value(b).priority();
|
||||
if (ra != rb)
|
||||
return ra > rb;
|
||||
return a > b;
|
||||
});
|
||||
|
||||
for (const QString &id : ids) {
|
||||
if (resteW <= 0)
|
||||
break;
|
||||
|
||||
ILoadAdapter *adaptateur = m_loadAdapters.value(id);
|
||||
if (!adaptateur)
|
||||
continue;
|
||||
|
||||
const LoadTelemetry t = adaptateur->telemetry();
|
||||
// Une charge indisponible ne se déleste pas : on ne sait plus la commander, et
|
||||
// prétendre l'avoir coupée ferait croire le dépassement résorbé (ECS-410).
|
||||
if (!t.available)
|
||||
continue;
|
||||
|
||||
// La puissance engagée, lue sur un état POSÉ. Aucune écriture n'a encore eu lieu ce
|
||||
// cycle, donc l'inflation d'ECS-414 — « tant que des écritures sont en vol, annoncer
|
||||
// la plus haute des puissances possibles » — ne s'applique pas ici. C'est la seconde
|
||||
// raison d'évaluer L4 avant le dispatch, et non la moindre : ailleurs dans le cycle,
|
||||
// ce nombre est délibérément majorant, ce qui est juste pour un budget et faux pour
|
||||
// un délestage.
|
||||
const double engageW = t.currentPowerW;
|
||||
if (engageW <= 0)
|
||||
continue; // rien d'engagé : il n'y a rien à retirer
|
||||
|
||||
LoadAction shed;
|
||||
shed.loadId = id;
|
||||
|
||||
// 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
|
||||
// pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413).
|
||||
// C'est ici que le verrou minStateHold s'oppose au délestage, et donc ici que le
|
||||
// franchissement prend tout son sens : sans lui, une PAC à 3 kW en état 4 tiendrait
|
||||
// 900 secondes de plus, pendant que le disjoncteur, lui, ne patiente pas.
|
||||
double cibleW = 0;
|
||||
if (adaptateur->runtimeView(now).mechanismKind == QStringLiteral("sgReady")) {
|
||||
shed.kind = LoadAction::State;
|
||||
shed.state = 2; // ECS-413 : l'état sûr d'une PAC est 2, jamais 1
|
||||
} else {
|
||||
cibleW = qMax(0.0, engageW - resteW);
|
||||
shed.kind = LoadAction::Setpoint;
|
||||
shed.powerW = cibleW;
|
||||
shed.estimatedPowerW = cibleW;
|
||||
shed.chargingEnabled = cibleW > 0;
|
||||
}
|
||||
|
||||
// Motif POSÉ AVANT l'application : les adaptateurs refusent une action sans motif, et
|
||||
// c'est voulu. Les chiffres réalisés le corrigeront juste après.
|
||||
shed.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, true, 0, false) };
|
||||
|
||||
// LE FRANCHISSEMENT SE CONSTATE, IL NE SE PRÉSUME PAS. On tente D'ABORD sans forcer :
|
||||
// si le verrou laisse passer, il n'y a rien à annoncer. C'est seulement quand il
|
||||
// refuse que le forçage a lieu — et alors le marqueur dit une chose vérifiée.
|
||||
//
|
||||
// Déduire le franchissement d'un verrou simplement ACTIF serait faux : un verrou peut
|
||||
// être armé et permettre malgré tout le geste demandé (une descente vers l'état sûr,
|
||||
// une réduction déjà dans sa fenêtre). Le marqueur annoncerait alors un court-cycle
|
||||
// qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait
|
||||
// inaperçu au milieu des fausses alertes.
|
||||
// La CIBLE est-elle atteinte ? La question ne se lit pas au même endroit selon le
|
||||
// geste, et s'y tromper fait mentir tout le reste. Une action State REFUSÉE revient
|
||||
// avec l'état courant et un `estimatedPowerW` que l'adaptateur ne renseigne pas —
|
||||
// le lire donnerait « 0 W, donc cible atteinte », c'est-à-dire l'exact contraire.
|
||||
auto cibleAtteinte = [&](const LoadAction &resultat) {
|
||||
if (shed.kind == LoadAction::State)
|
||||
return resultat.state == shed.state;
|
||||
return resultat.estimatedPowerW <= cibleW + 0.5;
|
||||
};
|
||||
|
||||
LoadAction essai = shed;
|
||||
essai.force = false;
|
||||
LoadAction applique = adaptateur->applyAction(essai, now);
|
||||
bool minOnFranchi = false;
|
||||
|
||||
if (!cibleAtteinte(applique)) {
|
||||
// Le verrou a refusé. §6.2 — on ne force que pour un plafond SUBI.
|
||||
if (!franchitLesVerrous)
|
||||
continue; // plafond auto-imposé : on renonce au plancher, pas au matériel
|
||||
shed.force = true;
|
||||
applique = adaptateur->applyAction(shed, now);
|
||||
minOnFranchi = cibleAtteinte(applique);
|
||||
}
|
||||
|
||||
// Ce qui a RÉELLEMENT été rendu — lu, là encore, à l'endroit qui correspond au geste.
|
||||
const double apresW = shed.kind == LoadAction::State
|
||||
? adaptateur->toLoadContext(now).declared.estimatedPowerW.value(applique.state, 0.0)
|
||||
: applique.estimatedPowerW;
|
||||
const double rendu = qMax(0.0, engageW - apresW);
|
||||
resteW -= rendu;
|
||||
|
||||
// Le motif publié porte ce qui a été RÉELLEMENT rendu, pas ce qu'on espérait rendre.
|
||||
// Un verrou peut avoir écrêté le geste ; l'annoncer entier ferait chercher ailleurs la
|
||||
// cause d'un dépassement qui persiste.
|
||||
if (rendu <= 0)
|
||||
continue; // rien n'a bougé : ne pas publier un délestage qui n'a pas eu lieu
|
||||
|
||||
LoadAction publie = applique;
|
||||
publie.reason = { DecisionCode::DrawCap,
|
||||
drawCapReasonParams(marge, true, rendu, minOnFranchi) };
|
||||
|
||||
// REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
|
||||
m_shedActions.insert(id, publie);
|
||||
|
||||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id
|
||||
<< "→" << renderFr(publie.reason, id)
|
||||
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W";
|
||||
}
|
||||
|
||||
if (resteW > 0)
|
||||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Délestage L4 INCOMPLET —" << resteW
|
||||
<< "W restent au-dessus du plafond après avoir délesté tout "
|
||||
"ce qui pouvait l'être. La cause est hors du waterfall.";
|
||||
}
|
||||
|
||||
void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
|
||||
{
|
||||
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) {
|
||||
bool remplace = false;
|
||||
for (int i = 0; i < slot.actions.size(); ++i) {
|
||||
if (slot.actions.at(i).loadId == it.key()) {
|
||||
slot.actions[i] = it.value();
|
||||
remplace = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!remplace)
|
||||
slot.actions.append(it.value());
|
||||
}
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||||
{
|
||||
if (!ev)
|
||||
@ -952,6 +1138,11 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
|
||||
//
|
||||
// Seules les bornes que le waterfall a commandées passent ici : celles restées au
|
||||
// proxy (échéance, spot market) gardent leur chemin, allowance de compteur comprise.
|
||||
// Délestée par L4 ce cycle : un seul commandeur par organe. Réappliquer la consigne
|
||||
// du plan rallumerait ce que la sécurité vient de couper, et c'est la règle absolue 1.
|
||||
if (m_shedActions.contains(action.loadId))
|
||||
continue;
|
||||
|
||||
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
|
||||
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) {
|
||||
adapter->applyAction(action, now);
|
||||
|
||||
@ -344,6 +344,34 @@ public:
|
||||
*/
|
||||
QList<double> phaseDrawW(Electricity::Phases masque, uint limiteA) const;
|
||||
|
||||
/*!
|
||||
* \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé.
|
||||
*
|
||||
* \param now Temps de cycle.
|
||||
* \note Évaluée AVANT tout dispatch du cycle : dans un même \c update() aucune boucle
|
||||
* d'événements ne tourne, donc un adaptateur qui vient d'être commandé rapporte encore
|
||||
* son état précédent. Les charges délestées sont REVENDIQUÉES (\c m_shedActions) et le
|
||||
* dispatch les saute — un seul commandeur par organe.
|
||||
*
|
||||
* \par Ce que cette méthode ajoute
|
||||
* La protection de surcharge amont ne connaît que les bornes VE. Une PAC ou un routeur
|
||||
* pouvaient donc tenir un dépassement sans que rien ne les redescende. Les bornes restent
|
||||
* traitées par l'amont — les délester ici aussi en ferait deux commandeurs.
|
||||
*
|
||||
* \par Ordre et verrous
|
||||
* Ordre INVERSE du rang : la dernière servie est la première délestée. Les verrous ne sont
|
||||
* franchis (\c LoadAction::force) que pour \c Breaker et \c GridOperator ; jamais pour
|
||||
* \c EcoSelfImposed, où l'on renonce au plancher plutôt qu'au matériel (\rule{LM-1211}).
|
||||
* Un franchissement est TOUJOURS dit dans le motif.
|
||||
*
|
||||
*/
|
||||
void shedWaterfallForOverload(const QDateTime &now);
|
||||
|
||||
//! \brief Replace dans \c slot les décisions des charges délestées par L4.
|
||||
//! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE :
|
||||
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
|
||||
void fusionnerDelestageDansSlot(Slot &slot) const;
|
||||
|
||||
/*!
|
||||
* \brief Marge de soutirage de la MAISON, toutes phases confondues.
|
||||
*
|
||||
@ -659,6 +687,9 @@ private:
|
||||
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
|
||||
//! (ECS-412) : on ne reconstruit que si le matériel a changé.
|
||||
QHash<QString, LoadConfig> m_builtFrom;
|
||||
//! 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.
|
||||
QHash<QString, LoadAction> m_shedActions;
|
||||
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
|
||||
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).
|
||||
|
||||
|
||||
@ -572,16 +572,12 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
// L'autorisation de soutirage est épuisée. Le motif porte la marge, la source,
|
||||
// l'ampleur du dépassement et la phase qui borne — chacune commande un geste
|
||||
// différent, et aucune ne se déduit des autres.
|
||||
QVariantMap params{{"marginW", qRound(margeRestante.watts)},
|
||||
{"overshootW", qRound(qMax(0.0, -margeRestante.watts))},
|
||||
{"source", drawCapSourceCode(margeRestante.source)}};
|
||||
// La phase n'est publiée QUE lorsqu'il y en a une. Un plafond total n'en a pas, et en
|
||||
// annoncer une enverrait mesurer au hasard — l'absence dit « il n'y a pas de phase
|
||||
// coupable », ce qu'un « A » par défaut aurait rendu indiscernable d'une vraie phase A.
|
||||
if (margeRestante.phase >= 0 && margeRestante.phase <= 2)
|
||||
params.insert(QStringLiteral("phase"),
|
||||
QString(QChar('A' + margeRestante.phase)));
|
||||
la.reason = { DecisionCode::DrawCap, params };
|
||||
// shed = FAUX : ici la cascade REFUSE d'allouer, elle ne retire rien à personne. Le
|
||||
// même code de motif porte les deux gestes, et le booléen les distingue — le déduire
|
||||
// de la présence de `shedW` marcherait aujourd'hui et casserait à la première clé
|
||||
// ajoutée.
|
||||
la.reason = { DecisionCode::DrawCap,
|
||||
drawCapReasonParams(margeRestante, false, 0, false) };
|
||||
}
|
||||
else if (sousPlancher)
|
||||
// §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la
|
||||
|
||||
@ -64,3 +64,24 @@ QString drawCapSourceCode(DrawCapSource s)
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW,
|
||||
bool minOnBypassed)
|
||||
{
|
||||
QVariantMap params{{QStringLiteral("marginW"), qRound(marge.watts)},
|
||||
{QStringLiteral("overshootW"), qRound(qMax(0.0, -marge.watts))},
|
||||
{QStringLiteral("source"), drawCapSourceCode(marge.source)},
|
||||
{QStringLiteral("shed"), shed}};
|
||||
if (shed)
|
||||
params.insert(QStringLiteral("shedW"), qRound(shedW));
|
||||
// La phase n'est publiée QUE lorsqu'il y en a une : un plafond total n'en a pas, et en
|
||||
// annoncer une enverrait mesurer au hasard.
|
||||
if (marge.phase >= 0 && marge.phase <= 2)
|
||||
params.insert(QStringLiteral("phase"), QString(QChar('A' + marge.phase)));
|
||||
// §6.2 — UN VERROU FRANCHI SE DIT. En silence, c'est un compresseur qui court-cycle sans
|
||||
// que personne ne sache pourquoi : la trace montrerait une charge coupée avant son minOn,
|
||||
// et rien n'expliquerait l'écart à la règle, qui passerait pour un défaut du verrou.
|
||||
if (minOnBypassed)
|
||||
params.insert(QStringLiteral("minOnBypassed"), true);
|
||||
return params;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
#include <QList>
|
||||
|
||||
/*!
|
||||
@ -109,3 +110,24 @@ DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
|
||||
//! \brief Code de la source, tel qu'il franchit la frontière RPC. Énumération FERMÉE.
|
||||
//! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
|
||||
QString drawCapSourceCode(DrawCapSource s);
|
||||
|
||||
/*!
|
||||
* \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit.
|
||||
*
|
||||
* \param marge Marge résolue : sa source, sa phase et son SIGNE y passent tels quels.
|
||||
* \param shed Vrai si le motif décrit un DÉLESTAGE (L4), faux s'il décrit un refus d'allouer
|
||||
* (L3). Toujours publié : un client teste un booléen présent, jamais une absence.
|
||||
* \param shedW Watts réellement rendus par la charge — 0 hors délestage.
|
||||
* \param minOnBypassed Vrai si un verrou a été franchi pour obtenir ce geste.
|
||||
* \return La table de paramètres. \c phase et \c minOnBypassed n'y figurent que s'ils ont un
|
||||
* sens : leur absence AFFIRME quelque chose (« aucune phase coupable », « aucun verrou
|
||||
* franchi »), là où une valeur par défaut le dirait mal.
|
||||
*
|
||||
* \par Pourquoi un seul constructeur
|
||||
* Deux couches émettent ce motif — la cascade qui refuse d'allouer, la protection qui déleste.
|
||||
* Deux constructions parallèles divergeraient à la première clé ajoutée, et un client verrait
|
||||
* le même code porter deux formes selon la couche qui l'a produit.
|
||||
*/
|
||||
QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW,
|
||||
bool minOnBypassed);
|
||||
|
||||
|
||||
@ -892,6 +892,37 @@ déclaré, donc config, interface et migration : hors de ce lot.
|
||||
> qu'aucune règle ne bouge. C'est précisément ce que l'étape 2 a préparé en comparant des
|
||||
> **marges** et non des plafonds.
|
||||
|
||||
**LM-1211 — le délestage L4 s'étend au waterfall, en ordre inverse du rang, et dit ce qu'il a
|
||||
franchi.** *(Étape 4 du délestage, 2026-08-30.)*
|
||||
|
||||
La protection de surcharge ne connaissait que les bornes VE : une PAC ou un routeur pouvaient
|
||||
tenir un dépassement sans que rien ne les redescende. Elle déleste désormais les charges du
|
||||
waterfall, **de la dernière servie à la première** — le même ordre total que la cascade, lu à
|
||||
l'envers, pour que délester ne puisse pas contredire l'ordre dans lequel on a servi.
|
||||
|
||||
**LM-1211-a — un seul commandeur par organe.** Les charges délestées sont **revendiquées** pour
|
||||
le cycle : le dispatch les saute. Sans cela, la consigne du plan rallumerait ce que la sécurité
|
||||
vient de couper — règle absolue 1, appliquée ici comme `m_waterfallEvIds` l'applique aux bornes.
|
||||
|
||||
**LM-1211-b — le franchissement de verrou se CONSTATE, il ne se présume pas.** Le délestage tente
|
||||
d'abord **sans forcer**, et ne marque `minOnBypassed` que si le verrou a réellement refusé.
|
||||
Déduire le franchissement d'un verrou simplement *actif* serait faux : un verrou peut être armé
|
||||
et permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle qui n'a
|
||||
pas eu lieu, et le premier compresseur réellement maltraité passerait inaperçu au milieu des
|
||||
fausses alertes.
|
||||
|
||||
> Ce constat n'était pas possible avant `ECS-415`. Tant qu'un refus renvoyait l'action
|
||||
> **demandée**, il se lisait comme un succès : le délestage ne forçait jamais et publiait des
|
||||
> watts que personne n'avait rendus.
|
||||
|
||||
**LM-1211-c — on ne force que pour un plafond SUBI.** `Breaker` et `GridOperator` autorisent le
|
||||
franchissement ; `EcoSelfImposed` **jamais** — on renonce au plancher éco plutôt qu'au matériel.
|
||||
Comportement observé, non postulé : forçage interdit, la PAC de test **reste en état 4**.
|
||||
|
||||
**LM-1211-d — délester n'est pas toujours retirer des watts.** Une PAC ne se module pas : on la
|
||||
fait redescendre d'**état**, vers 2 et jamais 1 (`ECS-413`). C'est le cas que le bornage de la
|
||||
cascade (`LM-1210`) ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne.
|
||||
|
||||
**LM-1011 — `BELOW_MIN_POWER` prime sur `DRAW_CAP` dès qu'il reste de l'autorisation.**
|
||||
*(Précédence tranchée par Patrick le 2026-08-29, sur une question de l'agent app. Écrite ICI,
|
||||
une fois, pour qu'aucun côté de la frontière ne l'établisse pour son compte.)*
|
||||
|
||||
@ -6412,6 +6412,161 @@ void Simulation::testARefusedStateIsDistinguishableFromAnApplied()
|
||||
#endif
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief [Étape 4] L4 déleste une PAC en la faisant CHANGER D'ÉTAT, et dit qu'il a forcé.
|
||||
*
|
||||
* \par Le premier cas où délester ne veut pas dire retirer des watts
|
||||
* Une PAC ne se module pas. Le bornage de la cascade (\rule{LM-1210}) ne peut rien contre une
|
||||
* PAC déjà en état 4 : il empêche d'ALLOUER au-delà du plafond, il ne retire rien à personne.
|
||||
* Seul le délestage peut la faire redescendre — et c'est un changement d'ÉTAT.
|
||||
*
|
||||
* \par C'est aussi le cas où le franchissement de verrou prend son sens
|
||||
* `minStateHold` est le verrou qui s'oppose à cette descente. Le plan le constate et publie
|
||||
* `LOCK_MIN_STATE_HOLD` ; le disjoncteur, lui, n'attend pas 300 secondes. L4 franchit, et **le
|
||||
* dit** — un verrou franchi en silence, c'est un compresseur qui court-cycle sans que personne
|
||||
* ne sache pourquoi.
|
||||
*
|
||||
* \par Les trois cycles sont tous NÉCESSAIRES, et chacun a été mesuré avant d'être asserté
|
||||
* Deux versions antérieures de ce test passaient **sans rien prouver**, parce que leurs
|
||||
* prémisses étaient supposées. Elles sont donc vérifiées ici, une par une :
|
||||
* 1. l'armement à froid (ECS-412) **gèle la PAC à l'état 2** — on ne peut pas monter à 4 au
|
||||
* premier cycle, et croire le contraire faisait tester une PAC qui n'a jamais chauffé ;
|
||||
* 2. une fois le verrou expiré, le surplus la porte en état 4 et **réarme** le verrou ;
|
||||
* 3. c'est seulement là que le dépassement rencontre un verrou actif.
|
||||
*/
|
||||
void Simulation::testL4ShedsAPacAndSaysItForcedTheLock()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-shed.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid k1 = addPowerSwitch(0, 26694);
|
||||
QUuid k2 = addPowerSwitch(0, 26695);
|
||||
Thing *relais1 = tm->findConfiguredThing(k1);
|
||||
Thing *relais2 = tm->findConfiguredThing(k2);
|
||||
QVERIFY(relais1 && relais2);
|
||||
|
||||
LoadConfigStore *store = new LoadConfigStore(arb);
|
||||
arb->setLoadConfigStore(store);
|
||||
|
||||
const int minStateHoldS = 300;
|
||||
LoadConfigs cfgs;
|
||||
cfgs.append(LoadConfig::fromMap(QVariantMap{
|
||||
{"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||||
{"priority", 2}, {"enabled", true}, {"domain", "hvac"},
|
||||
{"sgReady", QVariantMap{
|
||||
{"minStateHoldS", minStateHoldS},
|
||||
{"states", QVariantList()
|
||||
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
|
||||
{"estimatedPowerW", 0.0}}
|
||||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
|
||||
{"estimatedPowerW", 0.0}}
|
||||
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
|
||||
{"estimatedPowerW", 800.0}}
|
||||
<< QVariantMap{{"state", 4},
|
||||
{"relays", QVariantList() << k1.toString() << k2.toString()},
|
||||
{"estimatedPowerW", 3000.0}}}}}}));
|
||||
QString err;
|
||||
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8());
|
||||
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit",
|
||||
QVariantMap({{"phasePowerLimit", 25}})).toMap()
|
||||
.value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
|
||||
auto cycle = [&](const QDateTime &at) {
|
||||
arb->simulationCallUpdate(at);
|
||||
QTest::qWait(50);
|
||||
QCoreApplication::processEvents();
|
||||
return telemetrieParId(arb->loadTelemetry()).value("pac-delestee");
|
||||
};
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
|
||||
meter->setStateValue("currentPhaseA", 0);
|
||||
meter->setStateValue("currentPhaseB", 0);
|
||||
meter->setStateValue("currentPhaseC", 0);
|
||||
meter->setStateValue("currentPower", -6000);
|
||||
QVariantMap pac = cycle(t0);
|
||||
// PRÉMISSE 1 — l'armement à froid gèle la PAC à l'état 2. Le surplus est pléthorique et
|
||||
// n'y change rien : c'est ECS-412, et c'est ce que l'app verra après chaque redémarrage.
|
||||
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2);
|
||||
QCOMPARE(pac.value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::LockMinStateHold));
|
||||
|
||||
const QDateTime t1 = t0.addSecs(3600); // largement au-delà de l'armement à froid
|
||||
pac = cycle(t1);
|
||||
// PRÉMISSE 2 — verrou expiré, le surplus porte la PAC en état 4, et le verrou SE RÉARME.
|
||||
// Sans une PAC réellement à 3 kW, le délestage n'aurait rien à retirer.
|
||||
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 4);
|
||||
QCOMPARE(pac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced));
|
||||
QCOMPARE(relais1->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relais2->stateValue("power").toBool(), true);
|
||||
QCOMPARE(pac.value("lock").toMap().value("remainingS").toInt(), minStateHoldS);
|
||||
|
||||
const QDateTime t2 = t1.addSecs(10);
|
||||
meter->setStateValue("currentPhaseA", 30);
|
||||
meter->setStateValue("currentPower", 6900);
|
||||
pac = cycle(t2);
|
||||
|
||||
// 1. LA PAC EST REDESCENDUE, et seul L4 pouvait le faire : à t2 le verrou court encore.
|
||||
QCOMPARE(pac.value("mechanism").toMap().value("state").toInt(), 2);
|
||||
QCOMPARE(relais1->stateValue("power").toBool(), false);
|
||||
QCOMPARE(relais2->stateValue("power").toBool(), false);
|
||||
|
||||
// 2. Le motif publié est celui du DÉLESTAGE, pas celui du plan. La charge utile doit dire
|
||||
// ce qui a été exécuté : le plan n'a pas commandé cette charge — L4 l'a REVENDIQUÉE et
|
||||
// le dispatch l'a sautée. Publier LOCK_MIN_STATE_HOLD montrerait une PAC « maintenue »
|
||||
// que le matériel vient justement de couper.
|
||||
const QVariantMap decision = pac.value("decision").toMap();
|
||||
QCOMPARE(decision.value("code").toString(), QString(DecisionCode::DrawCap));
|
||||
|
||||
const QVariantMap p = decision.value("params").toMap();
|
||||
QCOMPARE(p.value("shed").toBool(), true);
|
||||
QCOMPARE(p.value("source").toString(), QString("connection"));
|
||||
QCOMPARE(p.value("phase").toString(), QString("A"));
|
||||
QCOMPARE(p.value("overshootW").toInt(), 1150);
|
||||
QCOMPARE(p.value("marginW").toInt(), -1150);
|
||||
// Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté.
|
||||
QCOMPARE(p.value("shedW").toInt(), 3000);
|
||||
|
||||
// 3. LE FRANCHISSEMENT SE DIT, et il a été CONSTATÉ, jamais présumé : le délestage tente
|
||||
// d'abord SANS forcer et ne pose ce marqueur que si le verrou a réellement refusé.
|
||||
// Le déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé et
|
||||
// permettre malgré tout le geste demandé. Le marqueur annoncerait alors un court-cycle
|
||||
// qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait
|
||||
// inaperçu au milieu des fausses alertes.
|
||||
//
|
||||
// Ce constat n'est possible que depuis \rule{ECS-415} : tant que le refus renvoyait
|
||||
// l'action DEMANDÉE, il se lisait comme un succès, et le délestage n'a jamais forcé —
|
||||
// il publiait 3 kW rendus par une PAC restée en état 4.
|
||||
QCOMPARE(p.value("minOnBypassed").toBool(), true);
|
||||
|
||||
// 4. Le TEXTE nomme la ressource et la source, jamais le surplus : ici il n'y en a pas, et
|
||||
// accuser le surplus enverrait attendre le soleil devant un disjoncteur qui sature.
|
||||
DecisionReason motif(DecisionCode::DrawCap, p);
|
||||
const QString texte = renderFr(motif, "PAC");
|
||||
QVERIFY2(texte.contains("soutirage", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||||
QVERIFY2(texte.contains("branchement", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||||
QVERIFY2(!texte.contains("surplus", Qt::CaseInsensitive), qUtf8Printable("motif: " + texte));
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testCountsSumToTarget()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
|
||||
@ -194,6 +194,7 @@ private slots:
|
||||
void testPhaseAllowanceReadsTheStateItTests();
|
||||
void testDrawCapBoundsTheCascade();
|
||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
|
||||
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
|
||||
// Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user