Compare commits

...

2 Commits

Author SHA1 Message Date
Patrick Schurig
fa3c8d4755 feat(délestage 1/4): le plafond de soutirage et sa source — comportement inchangé
Étape 1 du design : le TYPE et sa résolution. Rien ne le consomme encore, et
c'est voulu — dans un lot de sécurité, ce qui coûte n'est pas d'écrire le
comportement, c'est de prouver qu'on n'a rien cassé en chemin.

DrawCap porte la source en PARAMÈTRE, jamais enfouie dans le nom d'une fonction :
Breaker (par phase, permanent), GridOperator (total, sur ordre — aucun émetteur,
le transport du §14a n'existe pas), EcoSelfImposed (total, le temps de la passe 1
— la passe n'existe pas non plus). Les deux sources sans émetteur sont déclarées
pour que la porte reste ouverte, pas parce qu'elle s'ouvre.

LE POINT TECHNIQUE : on ne compare pas les plafonds, on compare les MARGES. Un
plafond par phase donne sa marge sur la phase la plus chargée, un plafond total
la sienne sur le soutirage mesuré ; les deux sont des watts. Le plus petit mord,
et on retient sa source ET la phase qui borne — sans elle, un installateur devant
une maison triphasée déséquilibrée ne sait pas où mesurer.

La contre-épreuve de la conversion est éloquente : ramener un plafond par phase à
un « total équivalent » donne 9 300 W de marge là où il y en a 500. Dix-huit fois
trop, sur la couche qui décidera d'acheter. C'est exactement l'hypothèse
d'équilibre que LM-1006-1 interdit, et elle serait restée invisible jusqu'au jour
où elle coupe du courant.

Trois autres invariants, tous vérifiés échouant : aucun plafond déclaré rend une
marge SANS OBJET et non nulle — une marge nulle bornerait tout à 0 W et couperait
l'installation, c'est le « zéro forgé » appliqué à la sécurité ; une marge
NÉGATIVE conserve l'ampleur du dépassement, qui est ce que L4 doit délester ; et
le départage à marge égale est STABLE (Breaker > GridOperator > EcoSelfImposed),
sans quoi le motif publié changerait au gré de l'ordre du tableau, donc sans
qu'aucune décision n'ait changé.

Le design complet est dans docs/DESIGN_DELESTAGE.md, avec les trois décisions
qu'il demande — dont celle qui touche la sécurité : que fait le délestage d'une
charge sous verrou minOn, et la réponse diffère selon la source.

simulation 35/35, charging 17/17, loadmodel 21/21, spotmarket 7/7, amd64 0/0.
Comportement inchangé : aucune suite ne bouge d'une ligne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:32:52 +02:00
Patrick Schurig
0908943da8 docs(design): l'extension du délestage au waterfall — la précondition du §10
Écrit avant tout code de liaison, comme le workflow l'exige pour une couche de
sécurité. Il dit sous quelle forme, dans quel ordre, et nomme trois points à
trancher.

L'écart mesuré : verifyOverloadProtection() parcourt m_evChargers et rien
d'autre ; le waterfall ne connaît le plafond que par evPhaseAllowanceW(), et
uniquement pour les bornes. Les charges pilotées et la PAC n'ont aucun plafond —
invisible tant que la cascade ne dépense que du surplus, puisqu'on ne peut pas
dépasser ce qu'on n'achète pas. Le §10 change cela.

LE POINT TECHNIQUE DU LOT, et il n'est pas évident : on ne compare pas les
plafonds — ils n'ont pas la même forme — on compare LES MARGES qu'ils laissent.
Un plafond par phase donne sa marge sur la phase la plus chargée, un plafond
total la sienne sur le soutirage mesuré ; les deux sont des watts, donc
comparables. Le plus petit mord, et on retient sa source. Aucune conversion, donc
aucune hypothèse d'équilibre entre phases — et la marge est exactement ce que la
cascade doit connaître pour ne pas dépasser : le même nombre borne et explique.

Deux endroits, deux rôles, et ce n'est pas une redondance : la cascade empêche de
CRÉER le dépassement, la protection L4 rattrape ce qu'on n'a pas décidé — une
plaque de cuisson, un sèche-linge. L4 garde le dernier mot.

Quatre étapes dont DEUX À COMPORTEMENT CONSTANT (le type, puis la source Breaker
branchée à résultat identique au watt près). Dans un lot de sécurité, ce qui coûte
n'est pas d'écrire le comportement, c'est de prouver qu'on n'a rien cassé.

Trois décisions demandées, avec recommandation : la marge tient-elle compte du
commandé non encore mesuré (recommandé NON — une hypothèse fausse y coupe du
courant) ; que fait le délestage d'une charge sous minOn (recommandé : bypass pour
Breaker et GridOperator, PAS pour EcoSelfImposed — un plafond qu'on s'impose ne
justifie pas d'abîmer un compresseur, et c'est la première conséquence concrète
du fait que la source soit un paramètre) ; et ce que porte le motif DRAW_CAP.

Et ce que le lot ne fait pas : aucun transport §14a, aucun changement du repli L2
— le plafond auto-imposé est respecté par construction dès lors que le repli ne
dépense pas, et l'exigence dure appartient au §14a, hors du moteur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 11:24:52 +02:00
6 changed files with 400 additions and 0 deletions

159
docs/DESIGN_DELESTAGE.md Normal file
View File

@ -0,0 +1,159 @@
# DESIGN — l'extension du délestage au waterfall
**Statut : conception, à valider avant tout code de liaison** (`AGENTS.md`, workflow obligatoire).
**Précondition de** : `§10` (LM-1006, couplage 1). Le §10 ne part pas sans elle.
**Contrainte de forme** : `specs/spec_loadmodel.md` **LM-1006-1**, écrite le 2026-08-27
*« pour que la porte reste ouverte »*. Elle se referme ici.
> **Ce document ne demande pas si le délestage doit s'étendre.** Il dit sous quelle forme, dans
> quel ordre, et nomme **ce qui demande une décision** — §6.
---
## 1. L'écart, mesuré
| | Aujourd'hui |
|---|---|
| **Qui est délesté** | `verifyOverloadProtection()` parcourt `m_evChargers` et **rien d'autre** |
| **Ce que le waterfall sait du plafond** | `evPhaseAllowanceW()` — et **uniquement pour les bornes**, comme plafond déclaré d'une borne |
| **Charges pilotées, PAC** | aucun plafond : leur `maxPowerW` vient de la configuration, la cascade n'est bornée que par le **surplus** |
Tant que la cascade ne dépense que du surplus, l'absence de plafond ne se voit pas : on ne peut
pas dépasser ce qu'on n'achète pas. **Le §10 change cela** — la passe éco décide d'acheter, et
plusieurs planchers qui s'ouvrent le même matin sans soleil s'additionnent sur le disjoncteur de
branchement.
---
## 2. Le mécanisme : UN plafond, TROIS sources
LM-1006-1 l'impose et c'est le cœur du lot : **la source est un paramètre, jamais une hypothèse
enfouie dans le nom de la fonction.**
```
struct DrawCap {
Source source; // Breaker | GridOperator | EcoSelfImposed
double perPhaseW; // < 0 = ce plafond ne borne pas par phase
double totalW; // < 0 = ce plafond ne borne pas au total
};
```
**Les deux formes coexistent et ne se convertissent JAMAIS** (LM-1006-1). Un plafond par phase
n'est pas un tiers d'un plafond total : une maison peut être à 20 A sur une phase et 2 A sur les
deux autres.
| Source | Forme | Durée | Origine |
|---|---|---|---|
| `Breaker` | **par phase** | permanent | `phasePowerLimit`, réglage d'installation |
| `GridOperator` | **total** | temporaire, sur ordre | §14a — *le transport n'existe pas* |
| `EcoSelfImposed` | **total** | le temps de la passe 1 | §10 — *la passe n'existe pas* |
> **Deux des trois sources n'ont pas encore d'émetteur, et c'est voulu.** Le mécanisme se conçoit
> pour trois parce que l'écrire pour une le rendrait irréversible : *« cela tient dans une
> signature aujourd'hui ; ce serait une refonte dans deux ans »* (LM-1006-1).
---
## 3. Comment on compare sans convertir
C'est le point technique du lot, et il n'est pas évident. On ne compare pas les **plafonds** —
ils n'ont pas la même forme. **On compare les MARGES qu'ils laissent.**
```
marge(cap) = combien de watts SUPPLÉMENTAIRES ce plafond autorise, ici et maintenant
```
- pour un plafond **par phase** : la marge est celle de la **phase la plus chargée**, ramenée en
watts — c'est déjà ce que fait `RootMeter::calculateAllowanceAmpere()` ;
- pour un plafond **total** : la marge est `totalW − soutirage mesuré`.
La marge est un **nombre de watts** dans les deux cas : elle se compare. **Le plafond qui laisse
la plus petite marge MORD**, et c'est sa `source` qu'on retient.
> **Ce que cela préserve** : aucune conversion, donc aucune hypothèse d'équilibre entre phases.
> Un plafond par phase reste évalué phase par phase jusqu'au bout ; il ne devient jamais un
> « total équivalent ».
>
> **Et ce que ça donne gratuitement** : la marge est exactement ce que la cascade doit connaître
> pour ne pas dépasser. Le même nombre sert à borner et à expliquer.
---
## 4. Où le plafond mord — deux endroits, deux rôles
Il faut les distinguer, sinon on écrit deux fois la même chose ou on en oublie une.
| | Quand | Ce qu'il fait |
|---|---|---|
| **Dans la cascade** (L3, décision) | à chaque cycle, **avant** de commander | borne l'allocation : on ne décide pas d'acheter au-delà de la marge |
| **Dans la protection** (L4, réaction) | sur `powerBalanceChanged`, **après** la mesure | déleste ce qui dépasse déjà, dans l'**ordre inverse du rang** |
**Les deux sont nécessaires, et ce n'est pas une redondance** : la première empêche de créer le
dépassement, la seconde rattrape ce qu'on n'a pas décidé — une plaque de cuisson, un
sèche-linge, tout ce que le HEMS ne pilote pas. La couche L4 garde le dernier mot
(`AGENTS.md`, MODÈLE DE SÉCURITÉ), et ce lot ne change pas cela.
> **L'ordre de coupe est l'inverse du rang** : la charge servie en dernier est délestée en
> premier. C'est le seul ordre qui respecte le classement de l'installateur — l'inverse
> reviendrait à le contredire au moment où il compte le plus.
---
## 5. Ordre de travail — quatre étapes, dont deux à comportement constant
**Étape 1 — le TYPE et sa résolution.** `DrawCap`, la marge, « le plus contraignant l'emporte et
on sait lequel ». Testable **isolément** (`tests/auto/loadmodel`, sans serveur nymea).
**Comportement inchangé** : rien ne le consomme encore.
**Étape 2 — la SOURCE `Breaker` branchée, à comportement CONSTANT.** `phasePowerLimit` devient un
`DrawCap{Breaker, perPhaseW}` et `evPhaseAllowanceW()` passe par la résolution. Le résultat doit
être **identique au watt près** — c'est ce qui rend l'étape vérifiable : les 46 tests de
`charging` ne doivent pas bouger d'une ligne.
**É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 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.
> **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
> comportement — c'est de prouver qu'on n'a rien cassé en chemin.
---
## 6. CE QUI DEMANDE UNE DÉCISION
**6.1 — La marge doit-elle tenir compte de ce que le HEMS a lui-même commandé et qui n'est pas
encore mesuré ?** C'est la correction A, transposée au soutirage. Sans elle, deux cycles
consécutifs peuvent décider d'acheter la même marge deux fois. Avec elle, on réintroduit un
terme de latence dans une couche de **sécurité**, où une hypothèse fausse coupe du courant.
**Recommandation : NON dans la cascade, OUI nulle part ailleurs** — la cascade borne sur la marge
MESURÉE, et l'écart d'un cycle est absorbé par la marge que le disjoncteur garde par
construction (`allowancePerPhase -= 1` existe déjà côté proxy). À trancher explicitement.
**6.2 — Que fait le délestage L4 d'une charge sous verrou `minOn` ?** Le repli L2 bypasse les
verrous (`force = true`) parce qu'un compteur muet est une panne. Un dépassement de plafond
est-il de la même gravité ? **Recommandation : OUI pour `Breaker` et `GridOperator`** — le
disjoncteur ne connaît pas le court-cycling, et un ordre du gestionnaire ne se négocie pas.
**NON pour `EcoSelfImposed`** : un plafond qu'on s'impose ne justifie pas d'abîmer un
compresseur. C'est la première conséquence concrète du fait que la source soit un paramètre.
**6.3 — Le motif `DRAW_CAP` naît à l'étape 3 : que porte-t-il exactement ?** R8 demande la
source. Faut-il aussi `capW`, `marginW`, et la **phase** qui borne quand la source est
`Breaker` ? **Recommandation : oui aux trois** — sans la phase, un installateur devant une maison
triphasée déséquilibrée ne sait pas où aller mesurer.
---
## 7. Ce que ce lot ne fait PAS
- **Il ne crée aucun transport §14a.** `GridOperator` sera une source qu'aucun émetteur ne
remplit ; c'est le but — la porte reste ouverte, elle ne s'ouvre pas.
- **Il ne touche pas au repli L2.** LM-1006-1, propriété 2 : le plafond **auto-imposé** est
respecté par construction dès lors que le repli **ne dépense pas**. L'exigence dure — tenir
une borne imposée par un tiers alors que le moteur ne décide plus — appartient au **§14a** et
vivra **hors du moteur**. Les confondre ferait hériter à ce lot d'une contrainte qui ne le
concerne pas.
- **Il ne déplace pas la frontière de sécurité.** L4 garde le dernier mot, L0 reste physique.

View File

@ -4,6 +4,7 @@ HEADERS += \
$$PWD/types/loaddescriptor.h \
$$PWD/types/surpluscontext.h \
$$PWD/types/plan.h \
$$PWD/types/drawcap.h \
$$PWD/types/loadconfig.h \
$$PWD/config/loadconfigstore.h \
$$PWD/adapters/iloadadapter.h \
@ -18,6 +19,7 @@ HEADERS += \
SOURCES += \
$$PWD/types/decisionreason.cpp \
$$PWD/types/drawcap.cpp \
$$PWD/types/loadconfig.cpp \
$$PWD/config/loadconfigstore.cpp \
$$PWD/ratios/energyratioscalculator.cpp \

View File

@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "drawcap.h"
namespace {
//! Rang de contrainte d'une source, pour départager DEUX MARGES ÉGALES. Plus haut = plus
//! contraignant. Ce n'est pas une hiérarchie de sévérité — c'est un départage STABLE : sans
//! lui, le motif publié dépendrait de l'ordre du tableau de plafonds, et changerait sans
//! qu'aucune décision n'ait changé.
int rangDeContrainte(DrawCapSource s)
{
switch (s) {
case DrawCapSource::Breaker: return 3; // physique : rien ne passe outre
case DrawCapSource::GridOperator: return 2; // imposé : ne se négocie pas
case DrawCapSource::EcoSelfImposed: return 1; // auto-imposé : se révise
}
return 0;
}
}
DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
const QList<double> &perPhaseW,
double totalW)
{
DrawMargin retenue;
if (caps.isEmpty())
return retenue; // borne == false : personne ne borne, la marge est SANS OBJET
auto retenir = [&retenue](double marge, DrawCapSource src, int phase) {
// Plus petite marge, puis départage par rang de contrainte à marge égale.
if (!retenue.borne || marge < retenue.watts
|| (qFuzzyCompare(marge + 1.0, retenue.watts + 1.0)
&& rangDeContrainte(src) > rangDeContrainte(retenue.source))) {
retenue.watts = marge;
retenue.source = src;
retenue.phase = phase;
retenue.borne = true;
}
};
for (const DrawCap &cap : caps) {
// Forme PAR PHASE : la marge est celle de la phase la plus chargée. On l'évalue phase
// par phase jusqu'au bout — la ramener à un « total équivalent » supposerait un
// équilibre que rien ne garantit (LM-1006-1).
if (cap.bornePhase()) {
for (int i = 0; i < perPhaseW.size(); ++i)
retenir(cap.perPhaseW - perPhaseW.at(i), cap.source, i);
}
// Forme TOTALE : la marge est ce qui reste sous le plafond au point de livraison.
// phase = -1 — il n'y en a pas, et prétendre le contraire enverrait mesurer au hasard.
if (cap.borneTotal())
retenir(cap.totalW - totalW, cap.source, -1);
}
return retenue;
}
QString drawCapSourceCode(DrawCapSource s)
{
switch (s) {
case DrawCapSource::Breaker: return QStringLiteral("connection");
case DrawCapSource::GridOperator: return QStringLiteral("gridOperator");
case DrawCapSource::EcoSelfImposed: return QStringLiteral("selfImposed");
}
return QString();
}

View File

@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QString>
#include <QList>
/*!
* \file drawcap.h
* \brief Le PLAFOND DE SOUTIRAGE et sa source — \c specs/spec_loadmodel.md LM-1006-1.
*
* \par Pourquoi ce type existe
* Le délestage n'est pas « la protection de surcharge » : c'est **l'application d'un plafond
* de soutirage, quelle qu'en soit la source**. Trois sources, un seul mécanisme — et la
* source est un **paramètre**, jamais une hypothèse enfouie dans le nom d'une fonction.
* « Cela tient dans une signature aujourd'hui ; ce serait une refonte dans deux ans. »
*
* \par Deux formes qui ne se convertissent JAMAIS
* Un plafond **par phase** n'est pas le tiers d'un plafond **total** : une maison peut tirer
* 20 A sur une phase et 2 A sur les deux autres. Convertir supposerait un équilibre que rien
* ne garantit — et l'hypothèse serait invisible jusqu'au jour où elle coupe du courant.
*/
//! \brief D'où vient un plafond de soutirage. La source décide du MOTIF publié et du GESTE
//! qu'il appelle (LM-1006-1, propriété 1) — trois sources, trois phrases, trois gestes.
enum class DrawCapSource {
//! Disjoncteur de branchement — physique, permanent, **par phase**. Le geste : délester la
//! maison, ou revoir l'abonnement.
Breaker,
//! §14a EnWG — externe, **sur ordre** du gestionnaire de réseau, temporaire, **total**. Le
//! geste : se renseigner auprès du fournisseur ; cela se subit, cela ne s'attaque pas.
//! \note Aucun émetteur ne le remplit aujourd'hui — le transport du §14a n'existe pas. La
//! source est déclarée pour que la porte reste ouverte, pas parce qu'elle s'ouvre.
GridOperator,
//! §10 — celui qu'on **s'impose** en décidant de soutirer pour un plancher éco. Total, le
//! temps de la passe 1. Le geste : revoir le réglage éco.
//! \note Aucun émetteur non plus : la passe éco n'existe pas.
EcoSelfImposed
};
/*!
* \brief Un plafond de soutirage, dans la forme où sa source l'exprime.
*
* \invariant Une des deux formes AU MOINS est renseignée ; une valeur **négative** signifie
* « ce plafond ne borne pas de cette façon », jamais « zéro watt autorisé ». Zéro est une
* valeur licite et voudrait dire *interdiction totale de soutirer*.
*/
struct DrawCap {
DrawCapSource source = DrawCapSource::Breaker;
//! Plafond **par phase**, en watts. Négatif = ce plafond ne borne pas par phase.
double perPhaseW = -1;
//! Plafond **total** au point de livraison, en watts. Négatif = ne borne pas au total.
double totalW = -1;
bool bornePhase() const { return perPhaseW >= 0; }
bool borneTotal() const { return totalW >= 0; }
};
/*!
* \brief Ce qu'un plafond autorise ENCORE, ici et maintenant — et lequel a mordu.
*
* \par Comparer des marges, jamais des plafonds
* C'est le point technique de LM-1006-1 : deux plafonds de formes différentes ne se comparent
* pas, mais les **marges** qu'ils laissent sont des watts dans les deux cas. Le plafond qui
* laisse la plus petite marge **mord**, et c'est sa source qu'on retient. Aucune conversion,
* donc aucune hypothèse d'équilibre entre phases.
*/
struct DrawMargin {
//! Watts de soutirage SUPPLÉMENTAIRE autorisés. Peut être **négatif** : le plafond est déjà
//! dépassé, et l'ampleur du dépassement est ce qu'il faut délester.
double watts = 0;
//! La source du plafond qui a mordu — celle qui appartient au motif publié (R8).
DrawCapSource source = DrawCapSource::Breaker;
//! Index de la phase qui borne (0=A, 1=B, 2=C), ou **-1** si c'est un plafond TOTAL qui
//! mord. Sans elle, un installateur devant une maison triphasée déséquilibrée ne sait pas
//! où aller mesurer.
int phase = -1;
//! Faux si AUCUN plafond n'était déclaré — la marge est alors sans objet, et le distinguer
//! d'une marge nulle est ce qui empêche un « 0 W autorisé » inventé de couper des charges.
bool borne = false;
};
/*!
* \brief Résout la marge à partir de plusieurs plafonds et de la mesure du cycle.
*
* \param caps Plafonds en vigueur, toutes sources confondues. Vide = rien ne borne.
* \param perPhaseW Soutirage MESURÉ par phase (A, B, C), en watts. Négatif = export.
* \param totalW Soutirage MESURÉ total, en watts. Négatif = export.
* \return La plus petite marge, avec la source qui l'a produite. \c borne faux si \p caps est
* vide — auquel cas \c watts n'a aucun sens et ne doit borner personne.
*
* \note **Le plus CONTRAIGNANT l'emporte, et on sait lequel.** C'est l'exigence 1 de LM-1006-1,
* et elle est la raison d'être de cette fonction : un client bridé doit lire « gestionnaire de
* réseau » et non « protection de surcharge ». Deux causes, deux gestes — l'une se subit et se
* renseigne, l'autre s'attaque en délestant ou en revoyant l'abonnement.
* \note **Égalité : la source la plus CONTRAIGNANTE par nature l'emporte**, dans l'ordre
* \c Breaker > \c GridOperator > \c EcoSelfImposed. Non par prudence, mais parce qu'un
* arbitrage stable vaut mieux qu'un arbitrage juste-mais-instable : à marges égales, le motif
* publié ne doit pas dépendre de l'ordre du tableau.
*/
DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
const QList<double> &perPhaseW,
double totalW);
//! \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);

View File

@ -5,6 +5,7 @@
#include "etm/adapters/relayrouter.h"
#include "etm/energyarbitrator.h"
#include "etm/types/loadconfig.h"
#include "etm/types/drawcap.h"
#include "etm/config/loadconfigstore.h"
#include <QTest>
@ -534,3 +535,63 @@ void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
hors.setRankOrigin(QStringLiteral("installateur"));
QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé");
}
void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
{
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se
// convertissent pas : une maison peut tirer 20 A sur une phase et 2 A sur les deux autres,
// et « un tiers du total » supposerait un équilibre que rien ne garantit. Ce qui se compare,
// ce sont les MARGES — des watts dans les deux cas.
// ---- Aucun plafond : la marge est SANS OBJET, pas nulle -------------------------------
// La distinction n'est pas cosmétique : une marge nulle bornerait tout à 0 W et couperait
// l'installation. C'est le « zéro forgé » du contrat, appliqué à une couche de sécurité.
const DrawMargin rien = resolveDrawMargin({}, {0, 0, 0}, 0);
QVERIFY2(!rien.borne, "aucun plafond déclaré doit rendre une marge SANS OBJET");
// ---- Une seule source, par phase : c'est la phase la PLUS CHARGÉE qui décide -----------
const DrawCap disjoncteur{DrawCapSource::Breaker, 5000, -1};
DrawMargin m = resolveDrawMargin({disjoncteur}, {1000, 4500, 200}, 5700);
QVERIFY(m.borne);
QCOMPARE(qRound(m.watts), 500); // 5000 − 4500, la phase B
QCOMPARE(m.source, DrawCapSource::Breaker);
QCOMPARE(m.phase, 1); // et on DIT laquelle
// ---- Deux formes en concurrence : la plus petite marge mord ---------------------------
// 3000 W restants au total contre 500 W sur la phase B : c'est la phase qui borne, alors
// même que son plafond (5000) est plus BAS que le total (8700). Comparer les plafonds
// aurait donné la réponse inverse.
const DrawCap gestionnaire{DrawCapSource::GridOperator, -1, 8700};
m = resolveDrawMargin({gestionnaire, disjoncteur}, {1000, 4500, 200}, 5700);
QCOMPARE(qRound(m.watts), 500);
QCOMPARE(m.source, DrawCapSource::Breaker);
// …et l'inverse, sur la même mesure : un ordre du gestionnaire plus serré prend la main, et
// le motif publié doit alors dire « gestionnaire de réseau », jamais « surcharge ».
const DrawCap ordre{DrawCapSource::GridOperator, -1, 6000};
m = resolveDrawMargin({disjoncteur, ordre}, {1000, 4500, 200}, 5700);
QCOMPARE(qRound(m.watts), 300); // 6000 − 5700
QCOMPARE(m.source, DrawCapSource::GridOperator);
QCOMPARE(m.phase, -1); // un plafond total n'a pas de phase
// ---- Marge NÉGATIVE : le plafond est déjà dépassé, et de combien ----------------------
// C'est ce que la couche L4 doit délester. Écrêter à 0 perdrait l'ampleur du dépassement.
m = resolveDrawMargin({disjoncteur}, {1000, 6200, 200}, 7400);
QCOMPARE(qRound(m.watts), -1200);
QCOMPARE(m.phase, 1);
// ---- Marges ÉGALES : le départage est STABLE, pas dépendant de l'ordre du tableau -----
// Sans règle, le motif publié changerait au gré de l'ordre d'insertion — donc sans qu'aucune
// décision n'ait changé, ce qui est exactement ce qu'un motif ne doit jamais faire.
const DrawCap eco{DrawCapSource::EcoSelfImposed, -1, 6000};
const DrawCap ordre2{DrawCapSource::GridOperator, -1, 6000};
const DrawMargin a = resolveDrawMargin({eco, ordre2}, {2000, 2000, 2000}, 5700);
const DrawMargin b = resolveDrawMargin({ordre2, eco}, {2000, 2000, 2000}, 5700);
QCOMPARE(a.source, b.source);
QCOMPARE(a.source, DrawCapSource::GridOperator); // imposé > auto-imposé
// ---- Les codes franchissent la frontière RPC, et ils sont fermés ----------------------
QCOMPARE(drawCapSourceCode(DrawCapSource::Breaker), QStringLiteral("connection"));
QCOMPARE(drawCapSourceCode(DrawCapSource::GridOperator), QStringLiteral("gridOperator"));
QCOMPARE(drawCapSourceCode(DrawCapSource::EcoSelfImposed), QStringLiteral("selfImposed"));
}

View File

@ -75,6 +75,11 @@ private slots:
//! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un
//! client ne peut pas fabriquer un "auto" (LM-1209-c).
void testRankOriginFollowsTheRankNotTheWrite();
// ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait
//! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale.
void testDrawMarginComparesHeadroomsNotCaps();
};
#endif // TESTLOADMODEL_H