diff --git a/INTERFACE.md b/INTERFACE.md index 4346d2b..127601e 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -910,6 +910,7 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa | `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `o:state` | Budget **sous le premier palier atteignable** : 0 W, et le budget passe à la suivante. `state` nommé pour les mécanismes à états | | `EV_GRID_START` *(3g-2)* | `budgetW`, `floorW`, `gridW` | La borne **démarre en soutirant** : le surplus ne paie pas son plancher, mais `acquisitionTolerance` autorise l'appoint. `gridW = floorW − budgetW` est ce qui vient du réseau. Voir la note de comptabilité ci-dessous | | `PHASE_LIMIT` *(3g-2)* | `limitW`, `requiredW` | C'est la **limite de phase** qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil | +| `DRAW_CAP` *(délestage §3)* | `marginW`, `overshootW`, `source`, `o:phase` | L'**autorisation de soutirage** est épuisée — une seconde ressource, distincte du surplus. `source` est un code opaque : `connection` (le branchement), `gridOperator` (imposé), `selfImposed` (auto-imposé) ; ce qu'on a le droit d'en faire en dépend. `marginW` est **signé** et `overshootW` dit de combien on dépasse. `phase` (`A`/`B`/`C`) n'est présent que si le plafond est **par phase** : son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A ». **Précédence (LM-1011)** : tant qu'il reste de l'autorisation, même insuffisante, le motif est `BELOW_MIN_POWER` — `DRAW_CAP` est réservé à l'autorisation **nulle** | | `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) | | `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) | | `SG_RECOMMENDED` | `budgetW`, `estimatedW` | PAC en état 3 (recommandation) | diff --git a/debian-qt5/changelog b/debian-qt5/changelog index a9fd48b..5bfa05a 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,36 @@ +powersync-energy-plugin-nymea (1.15.2+etm35) trixie; urgency=medium + + * Délestage §3 — LA CASCADE TIENT DANS DEUX RESSOURCES. Le surplus dit ce qu'on peut dépenser + sans acheter ; l'autorisation de soutirage dit ce que le branchement supporte. Une charge + doit tenir dans les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde : une + charge de waterfall pouvait créer un dépassement que seule la protection L4 rattrapait, après + coup. C'est le changement de comportement du lot, et il ne touche QUE les charges non-EV. + * Motif `DRAW_CAP` (R8) — porte `marginW` (SIGNÉ), `overshootW`, `source` (code opaque : + connection | gridOperator | selfImposed) et `phase` (A/B/C, ABSENT si le plafond est un + total — son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A »). + Chacune commande un geste différent, et aucune ne se déduit des autres. + * PRÉCÉDENCE LM-1011, déjà gravée, appliquée telle quelle : `DRAW_CAP` uniquement quand + l'autorisation résiduelle est NULLE. S'il en reste, fût-ce moins que le plus petit palier, + c'est `BELOW_MIN_POWER` — « il y en a, mais pas au bon format ». Le soutirage hérite du + couple que le surplus avait déjà. + * Les bornes VE ne sont PAS écrêtées par la marge de la maison, et ce n'est pas un oubli : + leur plafond de cycle connaît LES PHASES QU'ELLES OCCUPENT. Leur appliquer en plus la pire + phase les priverait d'une phase saine parce qu'une autre sature. Entre deux bornes justes on + garde la mieux informée, pas la plus sévère. Elles décrémentent en revanche la marge, comme + toutes les autres : ne décompter que les charges qu'on borne offrirait aux suivantes une + marge déjà dépensée. + * LIMITE DE MODÈLE, ASSUMÉE ET DOCUMENTÉE (LM-1210-a) : `LoadDeclared::phases` est un NOMBRE, + pas un masque — le moteur ignore sur quelle phase une charge est câblée. La marge retenue est + donc celle de la PIRE phase. Le pessimisme est borné en pratique (une phase qui exporte a une + marge plus grande que le plafond et ne borne personne) et coûte du rendement, jamais de la + sécurité. Lever la limite demande un masque déclaré : config, interface, migration — hors lot. + * Suite complète : simulation 52/52, charging 48/48 IDENTIQUE à la référence ligne à ligne + (les bornes ne bougent pas, c'est l'invariant du lot), loadmodel 21/21, spotmarket 32/32, + doxygen 0 avertissement. Contre-épreuve : bornage débranché, la charge est servie 1000 W + alors que l'autorisation est dépassée de 460 W. + + -- Patrick Schurig Sun, 30 Aug 2026 21:15:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm34) trixie; urgency=medium * [upstream-fix] rootmeter — `calculateAllowanceAmpere()` lisait un AUTRE état que celui diff --git a/docs/DESIGN_DELESTAGE.md b/docs/DESIGN_DELESTAGE.md index fd5d02c..cd0bdcd 100644 --- a/docs/DESIGN_DELESTAGE.md +++ b/docs/DESIGN_DELESTAGE.md @@ -136,9 +136,41 @@ dix** (230 → 23) qui tranche : **7 échecs** dans `testEcoMode`, `testEcoModeP > l'ampère, pas du watt. « Identique au watt près » y est donc vérifié comme une identité de > **résultats**, pas de grandeurs intermédiaires — pour celles-ci, c'est l'inversion qui répond. -**Étape 3 — la cascade est bornée par la marge.** Le waterfall cesse d'allouer au-delà. C'est ici -que le comportement change pour les charges non-EV, et c'est ici que le motif `DRAW_CAP` naît -(**R8** : il publie sa source). +**Étape 3 — la cascade est bornée par la marge. ✅ FAITE (2026-08-30).** `SurplusContext` porte +une `drawMargin` de cycle, résolue une fois par `houseDrawMargin()`. La cascade la consomme +comme elle consomme le surplus, et `DRAW_CAP` naît avec sa source (**R8**), son ampleur et sa +phase. + +*Deux ressources, pas une.* Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation +de soutirage dit ce que le branchement supporte. Une charge doit tenir dans les deux. La +précédence suit `LM-1011`, déjà gravée : `DRAW_CAP` **uniquement** quand l'autorisation +résiduelle est nulle ; s'il en reste, fût-ce moins que le plus petit palier, c'est +`BELOW_MIN_POWER`. Le soutirage hérite exactement du couple que le surplus avait déjà. + +*Les bornes VE en sont exclues, et c'est délibéré.* Leur plafond de cycle est déjà écrêté par +`evPhaseAllowanceW()`, **qui connaît les phases qu'elles occupent**. Leur appliquer en plus la +marge de la maison — la pire phase — les priverait d'une phase saine parce qu'une autre sature. +Mieux vaut la borne mieux informée que la borne la plus sévère. La marge est en revanche +décrémentée par **toutes** les charges servies, bornes comprises : elles consomment la même +autorisation, et ne décompter que celles qu'on borne offrirait aux suivantes une marge déjà +dépensée. + +> ### ⚠️ Une limite du MODÈLE, pas de l'implantation — à trancher +> +> `LoadDeclared::phases` est un **nombre**, jamais un masque : le moteur sait qu'une charge est +> mono ou triphasée, **jamais sur quelle phase elle est câblée**. `houseDrawMargin()` retient +> donc la **pire phase**, seul choix défendable : une monophasée de phase inconnue peut tomber +> sur n'importe laquelle, et deviner, dans une couche de sécurité, se paie au disjoncteur. +> +> Le pessimisme est **borné en pratique** — une phase qui exporte a une marge *plus grande* que +> le plafond et ne borne personne ; seule une phase réellement proche de sa limite mord. Mais +> sur une triphasée franchement déséquilibrée, une charge câblée sur une phase saine sera +> refusée à cause d'une autre. **C'est une perte économique, jamais une perte de sécurité**, et +> elle est réversible. +> +> La levée demande un champ déclaré (masque de phases par charge) — donc config, interface app +> et migration. Cela dépasse ce lot. **Décision attendue** : vivre avec le pessimisme, ou ouvrir +> le champ. **É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. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 59b5eeb..b1d3f6e 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -600,6 +600,28 @@ QList EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi return tirages; } +DrawMargin EnergyArbitrator::houseDrawMargin() const +{ + DrawMargin sansObjet; + RootMeter *meter = internalRootMeter(); + if (!meter) + return sansObjet; + + const QList plafonds = drawCaps(); + if (plafonds.isEmpty()) + return sansObjet; + + // TOUTES les phases, et la plus contrainte l'emporte. C'est le seul choix défendable tant + // que le modèle ne connaît qu'un NOMBRE de phases par charge et jamais LESQUELLES : une + // monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur la pire. + // Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au + // disjoncteur. Le pessimisme ne mord d'ailleurs que là où il doit : une phase qui exporte + // a une marge PLUS GRANDE que le plafond, et ne borne personne. + return resolveDrawMargin(plafonds, + phaseDrawW(Electricity::PhaseNone, phasePowerLimit()), + meter->currentPower()); +} + DrawMargin EnergyArbitrator::evDrawMargin(EvCharger *ev, uint phaseCount) const { DrawMargin sansObjet; @@ -661,6 +683,9 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const SurplusContext ctx; ctx.timestamp = now; + // Marge de soutirage du cycle — résolue ICI, une fois, avec le reste des mesures. + ctx.drawMargin = houseDrawMargin(); + // --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) --- RootMeter *meter = internalRootMeter(); if (meter) { diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 3fbcd2f..a523c4c 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -344,6 +344,19 @@ public: */ QList phaseDrawW(Electricity::Phases masque, uint limiteA) const; + /*! + * \brief Marge de soutirage de la MAISON, toutes phases confondues. + * + * \return La marge de la phase la plus contrainte — \c borne à faux si aucun plafond. + * + * \note Toutes les phases sont évaluées, et la pire l'emporte. Le modèle ne connaît qu'un + * NOMBRE de phases par charge, jamais LESQUELLES : une monophasée de phase inconnue peut + * tomber sur n'importe laquelle. Supposer mieux serait deviner, dans une couche de + * sécurité. Le pessimisme ne mord que là où il doit — une phase qui exporte a une marge + * plus grande que le plafond et ne borne personne. + */ + DrawMargin houseDrawMargin() const; + /*! * \brief Marge de tirage vue par une borne, avec la source qui la borne. * diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index c84a69e..72c375e 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -218,11 +218,21 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) }); QSet waterfallEvIds; + // Étape 3 — l'autorisation de SOUTIRAGE, consommée le long de la cascade au même titre que + // le surplus. C'est une marge de CYCLE : elle est prise une fois dans le contexte, jamais + // relue, et chaque charge servie l'entame de ce qu'elle a été autorisée à prendre. + DrawMargin margeRestante = ctx.drawMargin; for (const LoadContext &lc : nonEvLoads) { if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else - slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta, draw)); // etmvariableload + relay-router + evcharger + slot.actions.append(buildSetpointAction(lc, remainingSurplusW, margeRestante, compta, draw)); // etmvariableload + relay-router + evcharger + + // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles + // consomment la même autorisation, qu'elles aient été bornées par elle ou non. Ne + // décompter que les charges qu'on borne offrirait aux suivantes une marge déjà dépensée. + if (margeRestante.borne && !slot.actions.isEmpty()) + margeRestante.watts -= slot.actions.last().estimatedPowerW; // 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le // redéduire plus tard depuis les motifs ou le financement dériverait du vrai au // premier motif ajouté (EV_GRID_START porte funding=Grid tout en venant du waterfall). @@ -266,6 +276,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev, LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, double &remainingSurplusW, + DrawMargin &margeRestante, PlanBudget &budget, PlanDraw &draw) const { // Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle @@ -411,6 +422,54 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, } } + // --- Étape 3 : la cascade ne CRÉE pas le dépassement ---------------------------------- + // + // Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de soutirage dit ce + // que le branchement supporte. Ce sont DEUX ressources, et une charge doit tenir dans les + // deux. Jusqu'ici seules les bornes étaient bornées par la seconde — d'où des charges de + // waterfall qui pouvaient créer une surcharge que seule la protection L4 rattrapait, après + // coup. + // + // Les BORNES en sont exclues, et ce n'est pas un oubli : leur plafond de cycle est déjà + // écrêté par `evPhaseAllowanceW()`, qui connaît LES PHASES QU'ELLES OCCUPENT. Leur + // appliquer en plus la marge de la maison — la pire phase — les priverait d'une phase + // saine parce qu'une autre sature. Mieux vaut la borne mieux informée que la borne la + // plus sévère. + bool capEpuise = false; + if (!estUneBorne && margeRestante.borne && setpointW > 0) { + if (margeRestante.watts <= 0) { + // \rule{LM-1011} — autorisation résiduelle NULLE : c'est DRAW_CAP, et rien d'autre. + setpointW = 0; + capEpuise = true; + sousPlancher = false; // ce n'est pas une affaire de format, il ne reste RIEN + } else if (setpointW > margeRestante.watts) { + setpointW = margeRestante.watts; + + // Fixed : retomber sur un palier ATTEIGNABLE. Écrêter à la marge donnerait une + // consigne que le mécanisme ne sait pas tenir, et il prendrait le palier au-dessus + // — c'est-à-dire exactement le dépassement qu'on vient de refuser. + if (!dynamic) { + int retenu = 0; + for (int l : levels) { + if (l <= setpointW) retenu = l; + else break; + } + setpointW = retenu; + } else if (lc.declared.minPowerW > 0 && setpointW < lc.declared.minPowerW) { + setpointW = 0; + } + + // \rule{LM-1011} — il RESTE de l'autorisation, simplement pas au bon format. Le + // motif est donc BELOW_MIN_POWER, jamais DRAW_CAP : dire « épuisée » enverrait + // revoir l'abonnement là où il faut lire une fiche technique. + if (setpointW <= 0) { + sousPlancher = true; + plancherW = dynamic ? lc.declared.minPowerW + : (levels.size() > 1 ? levels.at(1) : 0); + } + } + } + // ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ. // Aucune allocation neuve, mais sa consommation reste retranchée du budget : une // puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de @@ -509,6 +568,21 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, {{"socPercent", qRound(m_reserveSocPct)}, {"reservePercent", qRound(m_reserveSeuilPct)}, {"withheldW", qRound(m_reserveWithheldW + lc.telemetry.currentPowerW)}} }; + else if (capEpuise) { + // 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 }; + } else if (sousPlancher) // §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la // seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 6fbe6f5..c0cc17e 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -83,6 +83,12 @@ private: * * \param lc Charge \c etmvariableload du SurplusContext. * \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite. + * \param[in,out] margeRestante Autorisation de SOUTIRAGE restante (W), consommée le long + * de la cascade au même titre que le surplus. \c borne à faux = aucun plafond en vigueur, + * ce qui n'est PAS une marge nulle : sans plafond la cascade n'est pas bornée, à marge + * nulle elle s'arrête. Les bornes VE ne sont pas ÉCRÊTÉES par elle — leur plafond de + * cycle connaît déjà les phases qu'elles occupent — mais elles la DÉCRÉMENTENT + * (\rule{LM-1210}). * \param[in,out] budget Comptabilité PUBLIÉE : \c allocatedW et \c recreditedW sont * accumulés ici, dans la cascade même. Le recrédit y figure parce que sans lui * l'identité \c surplus + \c recrédit − \c alloué == \c restant serait fausse dès @@ -93,6 +99,7 @@ private: //! seul endroit de la cascade où le moteur sache partager une allocation entre le //! surplus et le réseau. LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW, + DrawMargin &margeRestante, PlanBudget &budget, PlanDraw &draw) const; /*! diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index dc9e63c..ac64415 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -98,6 +98,27 @@ QString renderFr(const DecisionReason &reason, const QString &label) .arg(label).arg(w("floorW")).arg(w("gridW")).arg(w("budgetW")); // 3g-2 — la limite de phase, pas le budget. Deux gestes opposés : revoir l'abonnement ou // délester la maison, contre attendre le soleil. + // L'autorisation de SOUTIRAGE, pas le surplus. La phrase nomme la source, parce que le + // geste attendu en dépend : réviser un plafond qu'on s'est donné n'est pas la même chose + // que changer d'abonnement, ni que délester la maison. + if (c == QLatin1String(DecisionCode::DrawCap)) { + const QString src = p.value(QStringLiteral("source")).toString(); + const QString origine = src == QLatin1String("connection") ? QStringLiteral("le branchement") + : src == QLatin1String("gridOperator") ? QStringLiteral("le gestionnaire de réseau") + : src == QLatin1String("selfImposed") ? QStringLiteral("un plafond auto-imposé") + : QStringLiteral("un plafond"); + const QString ou = p.contains(QStringLiteral("phase")) + ? QStringLiteral(" sur la phase %1").arg(p.value(QStringLiteral("phase")).toString()) + : QString(); + const int depassement = p.value(QStringLiteral("overshootW")).toInt(); + if (depassement > 0) + return QStringLiteral("Autorisation de soutirage épuisée%1 — %2 impose la limite, " + "dépassée de %3 W : %4 n'est pas servie") + .arg(ou).arg(origine).arg(depassement).arg(label); + return QStringLiteral("Autorisation de soutirage épuisée%1 — %2 impose la limite : " + "%3 n'est pas servie") + .arg(ou).arg(origine).arg(label); + } if (c == QLatin1String(DecisionCode::PhaseLimit)) return QStringLiteral("Limite de phase : %1 W disponibles pour %2, sous les %3 W qu'elle " "exige — aucun démarrage possible ce cycle") diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 9293a98..46bdfc2 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -145,6 +145,34 @@ constexpr const char *EvGridStart = "EV_GRID_START"; */ constexpr const char *PhaseLimit = "PHASE_LIMIT"; +/*! + * \brief L'autorisation de SOUTIRAGE est épuisée — la cascade s'arrête là. + * + * params : \c marginW (marge restante, ≤ 0 — négative, elle porte le dépassement), + * \c overshootW (ampleur du dépassement, 0 si l'on est juste à la limite), + * \c source (code opaque : \c connection | \c gridOperator | \c selfImposed), + * \c phase (code opaque \c A | \c B | \c C — **absent** si le plafond est un total). + * + * \par Ce qu'il dit, et ce qu'il ne dit pas + * Il ne parle **pas** de surplus. Le soutirage est une **seconde ressource**, avec son propre + * couple « il n'y en a plus » / « il y en a, mais pas au bon format » — exactement comme le + * surplus a \c SURPLUS_INSUFFICIENT et \c BELOW_MIN_POWER. \rule{LM-1011} tranche la + * précédence : tant qu'il RESTE de l'autorisation, même insuffisante pour le plus petit + * palier, le motif est \c BELOW_MIN_POWER. \c DRAW_CAP est réservé à l'autorisation **nulle**. + * + * \par Pourquoi la source y figure + * Ce qu'on a le droit de faire d'un plafond dépend de qui l'impose. Un plafond auto-imposé se + * révise ; le disjoncteur, non. Sans la source, un écran ne peut proposer ni le bon geste ni + * le bon avertissement — c'est l'exigence R8, et c'est aussi ce qui décide, en L4, si un + * verrou \c minOn peut être franchi. + * + * \par Pourquoi la phase y figure + * Sur une triphasée déséquilibrée, la maison peut exporter pendant qu'une phase sature. Sans + * la phase qui borne, l'installateur cherche un défaut global là où une seule branche est en + * cause, et le compteur d'ensemble lui donnera raison — à tort. + */ +constexpr const char *DrawCap = "DRAW_CAP"; + // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; @@ -183,7 +211,10 @@ struct DecisionReason /*! * \brief Construit un motif. * \param decisionCode Constante de \c DecisionCode (jamais une chaîne libre). - * \param decisionParams Paramètres du code, nombres et booléens uniquement. + * \param decisionParams Paramètres du code : nombres, booléens, et **codes opaques** + * (`source`, `phase`) — jamais de langue naturelle. Un code opaque se compare, une + * phrase se traduit : la charge utile RPC porte les premiers, `renderFr()` produit la + * seconde. Même doctrine que les clés de `counts`. */ DecisionReason(const char *decisionCode, const QVariantMap &decisionParams = QVariantMap()) : code(QString::fromLatin1(decisionCode)), params(decisionParams) {} diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 036d18f..90c3530 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -6,6 +6,7 @@ #include #include #include "loaddescriptor.h" +#include "drawcap.h" // Structures miroirs de OPTIMIZER_PROTOCOL.md §5 (noms de champs identiques). @@ -139,6 +140,20 @@ struct SurplusContext { QDateTime timestamp; SurplusSite site; //!< Paramètres fixes du site (puissance souscrite, limites par phase). SurplusMeter meter; //!< Mesures BRUTES du compteur principal (invariant 8). + + /*! + * \brief Marge de SOUTIRAGE de la maison, avec la source qui la borne. + * + * Valeur de CYCLE, résolue une fois par \c buildContext() et jamais relue ensuite : elle + * dépend de ce que la maison consomme à cet instant, et deux lectures à deux moments + * feraient décider sur deux mondes. + * + * \c borne à faux quand aucun plafond n'est en vigueur — ce qui n'est PAS la même chose + * qu'une marge nulle. Sans plafond la cascade n'est pas bornée ; à marge nulle elle + * s'arrête. Les confondre interdirait tout pilotage sur une installation sans limite + * réglée (\rule{LM-1013}). + */ + DrawMargin drawMargin; SurplusPv pv; //!< Production PV mesurée brute. SurplusBattery battery; //!< État du stockage ; \c present faux si absent. QList loads; //!< Une entrée par charge pilotable, tous types confondus. diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 96dc7e3..0d13f4f 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -850,6 +850,36 @@ se déduit. > La maquette **vaut comme contrat, pas comme feuille de route** : elle fixe la forme de ce qui > sera publié, pas une date. Et côté app, rien ne commence avant que `levels[]` soit en ligne. +**LM-1210 — la cascade tient dans DEUX ressources, et les bornes VE gardent la leur.** +*(Étape 3 du délestage, 2026-08-30.)* + +Le surplus dit ce qu'on peut dépenser sans acheter ; l'**autorisation de soutirage** dit ce que +le branchement supporte. Une charge doit tenir dans les deux. Jusqu'ici seules les bornes VE +étaient bornées par la seconde — une charge de waterfall pouvait donc créer un dépassement que +seule la protection L4 rattrapait, **après coup**. + +La marge est une valeur de **cycle** : `SurplusContext::drawMargin`, résolue une fois, jamais +relue. Chaque charge servie l'entame de ce qu'elle a été autorisée à prendre — **toutes**, bornes +comprises. Ne décompter que les charges qu'on borne offrirait aux suivantes une marge déjà +dépensée. + +**Les bornes VE ne sont pas bornées par la marge de la maison**, et ce n'est pas un oubli : leur +plafond de cycle est déjà écrêté par `evPhaseAllowanceW()`, **qui connaît les phases qu'elles +occupent**. Leur appliquer en plus la pire phase de la maison les priverait d'une phase saine +parce qu'une autre sature. Entre deux bornes justes, on garde **la mieux informée**, pas la plus +sévère. + +**LM-1210-a — la marge retenue est celle de la PIRE phase, faute de savoir laquelle.** +`LoadDeclared::phases` est un **nombre**, pas un masque : le moteur ignore sur quelle phase une +charge est câblée. Une monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur +la pire. Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au +disjoncteur. + +Le pessimisme est **borné en pratique** : une phase qui exporte a une marge *plus grande* que le +plafond et ne borne personne. Il ne mord que sur une triphasée réellement déséquilibrée — et il +coûte alors du **rendement, jamais de la sécurité**. Lever la limite demande un masque de phases +déclaré, donc config, interface et migration : hors de ce lot. + **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 6b7ca9f..dd7a4f2 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6189,6 +6189,130 @@ void Simulation::testPhaseAllowanceReadsTheStateItTests() Electricity::PhaseA | Electricity::PhaseB | Electricity::PhaseC, plafondA), 5.0); } +/*! + * \brief [Étape 3] L'autorisation de SOUTIRAGE borne la cascade, et le motif dit laquelle. + * + * \par Ce qui change + * La protection de surcharge ne bornait jusqu'ici que les bornes VE, via le plafond de cycle + * d'`evPhaseAllowanceW()`. Une charge de waterfall pouvait donc créer un dépassement que seule + * la protection L4 rattrapait — après coup. Le waterfall tient désormais compte des DEUX + * ressources : le surplus dit ce qu'on peut dépenser sans acheter, l'autorisation dit ce que + * le branchement supporte. + * + * \par La précédence, qui est le point subtil + * \rule{LM-1011} : `DRAW_CAP` est réservé à l'autorisation résiduelle **nulle**. S'il en reste, + * fût-ce moins que le plus petit palier, le motif est `BELOW_MIN_POWER` — « il y en a, mais pas + * au bon format ». Les deux phrases envoient l'installateur vers des gestes opposés : revoir un + * abonnement, ou relire une fiche technique. + * + * \par Pourquoi le contexte est bâti à la main + * La marge est un champ du \c SurplusContext. La poser directement épingle la RÈGLE plutôt que + * la chaîne qui produit la marge — celle-ci a ses propres tests + * (`testDrawMarginComparesHeadroomsNotCaps`, `testPhaseAllowanceReadsTheStateItTests`). Un test + * qui dépendrait des deux échouerait pour deux raisons sans dire laquelle. + */ +void Simulation::testDrawCapBoundsTheCascade() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testDrawCapBoundsTheCascade nécessite ETM_ARBITRATOR."); +#else + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-drawcap.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + ThingManager *tm = NymeaCore::instance()->thingManager(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + + QUuid rA = addPowerSwitch(1000, 26690); + QVERIFY(tm->findConfiguredThing(rA)); + + const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); + RuleBasedScheduler sched(arb); + + // Un relais de 1000 W : paliers {0, 1000}. Le cas *fixed* est le plus exigeant, parce que + // l'écrêtage à la marge doit RETOMBER sur un palier atteignable — sinon le mécanisme prend + // le palier au-dessus, c'est-à-dire exactement le dépassement qu'on vient de refuser. + RelayRouter *routeur = new RelayRouter(tm, "cap", "Charge sous plafond", + QList({ {rA.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb); + + auto decider = [&](double surplusW, const DrawMargin &marge) -> LoadAction { + SurplusContext ctx; + ctx.timestamp = t0; + ctx.meter.exportW = surplusW > 0 ? surplusW : 0.0; + ctx.meter.importW = surplusW < 0 ? -surplusW : 0.0; + ctx.drawMargin = marge; + ctx.loads.append(routeur->toLoadContext(t0)); + const Plan plan = sched.getPlan(ctx); + for (const LoadAction &a : plan.timeSlots.first().actions) + if (a.loadId == routeur->descriptor().id) + return a; + return LoadAction(); + }; + + DrawMargin aucun; // borne == false : personne ne borne + DrawMargin epuise; // autorisation NULLE, et même dépassée + epuise.borne = true; epuise.watts = -460; epuise.source = DrawCapSource::Breaker; epuise.phase = 0; + DrawMargin reste; // il RESTE 500 W, sous le palier de 1000 + reste.borne = true; reste.watts = 500; reste.source = DrawCapSource::Breaker; reste.phase = 1; + DrawMargin large; // 5000 W : le plafond existe et ne mord pas + large.borne = true; large.watts = 5000; large.source = DrawCapSource::Breaker; large.phase = 0; + DrawMargin total; // plafond TOTAL : aucune phase coupable + total.borne = true; total.watts = -300; total.source = DrawCapSource::GridOperator; total.phase = -1; + + // 1. CONTRE-ÉPREUVE — sans plafond, 5000 W de surplus servent le palier. C'est la ligne de + // base : si elle tombait, les suivantes ne prouveraient rien sur le plafond. + const LoadAction sansPlafond = decider(5000, aucun); + QCOMPARE(qRound(sansPlafond.estimatedPowerW), 1000); + QCOMPARE(sansPlafond.reason.code, QString(DecisionCode::SurplusSetpoint)); + + // 2. Un plafond LARGE ne change rien. Un plafond qui mord toujours serait un plafond qui + // ne borne pas : il coupe. + const LoadAction sousPlafondLarge = decider(5000, large); + QCOMPARE(qRound(sousPlafondLarge.estimatedPowerW), 1000); + QCOMPARE(sousPlafondLarge.reason.code, QString(DecisionCode::SurplusSetpoint)); + + // 3. Autorisation ÉPUISÉE : le surplus est pléthorique, et pourtant rien n'est servi. Le + // motif porte les quatre grandeurs gravées au §6.3 du design. + const LoadAction bloque = decider(5000, epuise); + QCOMPARE(qRound(bloque.estimatedPowerW), 0); + QCOMPARE(bloque.reason.code, QString(DecisionCode::DrawCap)); + QCOMPARE(bloque.reason.params.value("marginW").toInt(), -460); + QCOMPARE(bloque.reason.params.value("overshootW").toInt(), 460); + QCOMPARE(bloque.reason.params.value("source").toString(), QString("connection")); + QCOMPARE(bloque.reason.params.value("phase").toString(), QString("A")); + + // 4. \rule{LM-1011} — il RESTE 500 W d'autorisation, sous le palier de 1000. Ce n'est PAS + // DRAW_CAP : il y a de l'autorisation, elle n'est pas au bon format. + const LoadAction pasAuFormat = decider(5000, reste); + QCOMPARE(qRound(pasAuFormat.estimatedPowerW), 0); + QCOMPARE(pasAuFormat.reason.code, QString(DecisionCode::BelowMinPower)); + QVERIFY2(pasAuFormat.reason.code != QString(DecisionCode::DrawCap), + "autorisation résiduelle non nulle : LM-1011 impose BELOW_MIN_POWER"); + + // 5. Plafond TOTAL : aucune phase n'est coupable, et le motif n'en invente pas une. + // Publier « A » par défaut serait indiscernable d'une vraie phase A, et enverrait + // mesurer au hasard. + const LoadAction plafondTotal = decider(5000, total); + QCOMPARE(plafondTotal.reason.code, QString(DecisionCode::DrawCap)); + QCOMPARE(plafondTotal.reason.params.value("source").toString(), QString("gridOperator")); + QVERIFY2(!plafondTotal.reason.params.contains("phase"), + "un plafond total n'a pas de phase coupable — ne pas en publier une"); + + // 6. Le TEXTE publié nomme la ressource et la source. Règle 7-b : le motif nomme le + // mécanisme qui a produit l'issue. Dire « surplus » ici enverrait attendre le soleil + // devant 5000 W de surplus disponibles. + const QString texte = renderFr(bloque.reason, routeur->descriptor().label); + 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 fa35a06..9f1b6e1 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -192,6 +192,7 @@ private slots: // absents — en mode dégradé il n'y a pas de passe, donc ni l'un ni l'autre. void testLevelAbsenceAndZeroDifferInMeaning(); void testPhaseAllowanceReadsTheStateItTests(); + void testDrawCapBoundsTheCascade(); // ── §12 / LM-1209 ────────────────────────────────────────────────────── // Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant