feat(délestage §3): la cascade tient dans deux ressources, et DRAW_CAP naît

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. C'est ce qui change.

`SurplusContext::drawMargin` est une valeur de CYCLE, résolue une fois par
`houseDrawMargin()` et jamais relue : deux lectures à deux instants
feraient décider sur deux mondes. Chaque charge servie l'entame.

Le motif porte les quatre grandeurs gravées au §6.3 : la marge (SIGNÉE),
l'ampleur du dépassement, la source, et la phase qui borne. La phase n'est
publiée que s'il y en a une — un plafond total n'en a pas, et en annoncer
une enverrait mesurer au hasard. Précédence LM-1011 appliquée telle quelle :
DRAW_CAP seulement à autorisation NULLE, sinon BELOW_MIN_POWER.

Les bornes VE ne sont pas écrêtées par la marge de la maison, et ce n'est
pas un oubli : `evPhaseAllowanceW()` 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 la marge comme les autres.

LIMITE DE MODÈLE assumé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
pire phase est donc retenue. Deviner, dans une couche de sécurité, se paie
au disjoncteur. Le pessimisme est borné en pratique et coûte du rendement,
jamais de la sécurité ; le lever demande un masque déclaré, donc config,
interface et migration. Documenté dans la spec et le design, à trancher.

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éjà dépassée de 460 W.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-30 12:29:38 +02:00
parent 13d24e10df
commit b274989166
13 changed files with 412 additions and 5 deletions

View File

@ -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) |

View File

@ -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 <etm.schurig@gmail.com> 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

View File

@ -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.

View File

@ -600,6 +600,28 @@ QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi
return tirages;
}
DrawMargin EnergyArbitrator::houseDrawMargin() const
{
DrawMargin sansObjet;
RootMeter *meter = internalRootMeter();
if (!meter)
return sansObjet;
const QList<DrawCap> 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) {

View File

@ -344,6 +344,19 @@ public:
*/
QList<double> 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.
*

View File

@ -218,11 +218,21 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
});
QSet<QString> 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.

View File

@ -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;
/*!

View File

@ -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")

View File

@ -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) {}

View File

@ -6,6 +6,7 @@
#include <QList>
#include <QString>
#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<LoadContext> loads; //!< Une entrée par charge pilotable, tous types confondus.

View File

@ -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.)*

View File

@ -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<EnergyArbitrator *>(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<LoadConfigRelay>({ {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

View File

@ -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