diff --git a/debian-qt5/changelog b/debian-qt5/changelog index e2d5301..6827da7 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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. diff --git a/docs/DESIGN_DELESTAGE.md b/docs/DESIGN_DELESTAGE.md index 84623a0..a973594 100644 --- a/docs/DESIGN_DELESTAGE.md +++ b/docs/DESIGN_DELESTAGE.md @@ -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 diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index b1d3f6e..18979d5 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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 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); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index a523c4c..49769b8 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -344,6 +344,34 @@ public: */ QList 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 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 m_shedActions; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 72c375e..7115fd3 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 diff --git a/energyplugin/etm/types/drawcap.cpp b/energyplugin/etm/types/drawcap.cpp index dd33173..17db774 100644 --- a/energyplugin/etm/types/drawcap.cpp +++ b/energyplugin/etm/types/drawcap.cpp @@ -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; +} diff --git a/energyplugin/etm/types/drawcap.h b/energyplugin/etm/types/drawcap.h index 72b5363..a9232af 100644 --- a/energyplugin/etm/types/drawcap.h +++ b/energyplugin/etm/types/drawcap.h @@ -3,6 +3,7 @@ #pragma once #include +#include #include /*! @@ -109,3 +110,24 @@ DrawMargin resolveDrawMargin(const QList &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); + diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index e434e37..ec0f5e5 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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.)* diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 65c1927..fe3a8a4 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 330e3c9..1c8d577 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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