diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index bf6f6f4..a49d7c3 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -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 \ diff --git a/energyplugin/etm/types/drawcap.cpp b/energyplugin/etm/types/drawcap.cpp new file mode 100644 index 0000000..dd33173 --- /dev/null +++ b/energyplugin/etm/types/drawcap.cpp @@ -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 &caps, + const QList &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(); +} diff --git a/energyplugin/etm/types/drawcap.h b/energyplugin/etm/types/drawcap.h new file mode 100644 index 0000000..1e59f64 --- /dev/null +++ b/energyplugin/etm/types/drawcap.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include + +/*! + * \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 &caps, + const QList &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); diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 8e607e3..754d67d 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -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 @@ -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")); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index d2a428f..56b87e7 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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