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
This commit is contained in:
Patrick Schurig 2026-08-30 11:32:52 +02:00
parent 0908943da8
commit fa3c8d4755
5 changed files with 241 additions and 0 deletions

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