Patrick Schurig 16feb61149 docs(design): §10 ouvert — éco/confort avec le délestage, et la prépa du banc V2C
Deux livrables, aucun code.

DESIGN_10.md — le §10 sous la contrainte des deux notes.

Le délestage part AVEC pour une raison arithmétique, pas de calendrier : la
passe éco n'est pas bornée par le surplus mais par une autorisation de
soutirage, et cet objet n'existe pas encore. Le lot doit donc le créer.

LM-1006-1 devient une structure : DrawCap {source, totalW, perPhaseA, until} et
DrawBudget qui publie `binding` — QUI a mordu. Le piège technique est nommé et
tranché : les trois sources ne parlent pas la même langue (le disjoncteur est
par phase, le §14a et l'éco sont des totaux), un plafond total ne protège pas
d'une surcharge monophasée, un plafond par phase n'exprime pas le §14a. La
proposition est de porter les deux et de ne JAMAIS convertir — c'est la
conversion qui perdrait l'information.

LM-1003 impose la vraie refonte : les passes calculent des cibles, une seule
commande sort par charge et par cycle, arrondie une fois au mécanisme. Cela
scinde buildSetpointAction(), qui mélange aujourd'hui décision, écrêtage,
comptabilité et motif. Deux comptabilités distinctes, PlanBudget (d'où vient
l'énergie) et PlanDraw (combien on a décidé d'acheter) — les fondre rendrait
faux l'invariant publié de la première.

Cinq points à trancher, dont trois ne sont pas techniques. Le premier est le
plus lourd : la réserve batterie annule le budget de SURPLUS, mais la passe éco
dépense une autorisation de SOUTIRAGE. Si la réserve l'annule aussi, un plancher
éco ne sera jamais atteint batterie basse ; si elle ne l'annule pas, la maison
achète pendant qu'elle manque. Recommandation donnée, décision d'exploitation.

L'ordre de travail met en tête les deux étapes qui NE CHANGENT RIEN — publier le
plafond sans effet, puis la refonte décider/émettre à comportement constant,
prouvée par l'invariance des 16 scénarios enregistrés. Si un seul point de
contrôle bouge, c'est qu'une décision a changé, et il faut savoir laquelle.

PREPA_BANC_20260828.md — deux blocages silencieux trouvés la veille.

1. La ThingClass trydan ne déclare PAS phaseCount, et aucun registre Modbus V2C
   ne le porte. EvCharger::phaseCount() retombe sur 1 : sur une Trydan
   triphasée, le plancher publié vaudrait 1 380 W au lieu de 4 140, et la
   conversion W→A donnerait une consigne 3× trop haute. C'est exactement la
   famille de défaut que 3g-2 vient de fermer, rouverte côté plugin. Correctif
   proposé : saisie à l'appairage, comme la voiture simulée le fait déjà.
2. sessionEnergy n'est délibérément pas exposée — le brief du plugin l'interdit,
   evcc ayant retiré son ChargeRater pour cette borne (firmware peu fiable).
   Donc « sessionEnergy sur matériel réel » n'est PAS franchissable demain. En
   échange, demain permet la validation qui lèverait l'interdit, et exerce pour
   de vrai la condition C1 de LM-1009 sur du matériel.

Plus : le plugin V2C n'est pas installé sur le banc, la Skoda n'a aucune
intégration (donc voiture manuelle, avec les quatre réglages à saisir), et
l'IP de test .127 est libre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 15:06:37 +02:00

18 KiB
Raw Blame History

DESIGN — §10 : éco / confort, et le délestage dans le même lot

Statut : conception, à valider avant tout code (AGENTS.md, workflow obligatoire). Entrée : specs/spec_loadmodel.md §10 (LM-1000 → LM-1009) et §12. Écrit après relecture de rulebasedscheduler.cpp (getPlan, buildSetpointAction, buildSgReadyStateAction), smartchargingmanager.cpp (verifyOverloadProtection), et des deux notes qui contraignent la forme : LM-1006-1 (le plafond de soutirage) et LM-1009 (les deux régimes d'échéance).

Ce document ne demande pas si le §10 doit se faire. Il dit sous quelle forme, et nomme les cinq points qui demandent une décision — §8. Trois d'entre eux ne sont pas techniques.


1. Pourquoi le délestage part AVEC, et pas après

C'est la précondition écrite en §13 de la spec, et elle tient pour une raison arithmétique simple : la passe éco n'est pas bornée par le surplus (LM-1005). Elle est bornée par une autorisation de soutirage. Or aujourd'hui, la seule borne de soutirage qui existe dans le moteur — phasePowerLimit — ne déleste que les bornes de recharge (verifyOverloadProtection, boucle sur m_evChargers).

Livrer les planchers éco sans le délestage général donnerait donc : un matin sans soleil, chaque charge se sert au réseau pour tenir son obligation, personne ne les additionne, et l'installation disjoncte. Ce n'est pas une dégradation de confort — c'est une coupure, chez un client.

Conséquence de conception, pas de calendrier : la passe éco a besoin d'un objet « plafond » qui n'existe pas encore. Le §10 doit donc le créer. C'est le §2.


2. Le plafond de soutirage devient un OBJET (LM-1006-1)

2.1 Ce qu'il remplace

Aujourd'hui, la limite de raccordement est consultée à deux endroits, sous deux formes, sans jamais être nommée :

  • RootMeter::calculateAllowanceAmpere(phases, phasePowerLimit) — la place libre par phase, interrogée par le proxy puis, depuis 3g-2, par EnergyArbitrator::evPhaseAllowanceW() ;
  • verifyOverloadProtection() — la réaction temps réel, qui déleste les bornes.

Aucun des deux ne se demande d'où vient le plafond, parce qu'il n'y en a qu'un. Le §14a en ajoutera un second, le §10 lui-même un troisième.

2.2 La forme proposée

/*! Un plafond de soutirage, et ce qui l'a produit. */
struct DrawCap {
    enum Source { Connection, GridOperator, EcoSelfImposed };

    Source  source      = Connection;
    double  totalW      = -1;   //!< Plafond au POINT DE LIVRAISON. < 0 = pas de plafond total.
    QList<double> perPhaseA;    //!< Plafond PAR PHASE. Vide = pas de plafond par phase.
    QDateTime until;            //!< Fin de validité ; invalide = permanent.
};

/*! Le plafond EFFECTIF du cycle, et celui qui a mordu. */
struct DrawBudget {
    QList<DrawCap> declared;    //!< Tous les plafonds actifs, quelle que soit leur source.
    double  effectiveW   = -1;  //!< Le plus contraignant, ramené en watts.
    DrawCap::Source binding = DrawCap::Connection;  //!< QUI a mordu — publié, pas déduit.
    bool    perPhaseBound = false;  //!< Vrai si c'est une phase qui borne, pas le total.
};

binding est le champ qui justifie tout l'objet. Sans lui, un client bridé lit « protection de surcharge » là où la cause est le gestionnaire de réseau — deux causes, deux gestes (LM-1006-1, propriété 1).

2.3 Le piège : par phase, ou au total ?

Les trois sources ne parlent pas la même langue, et c'est le seul vrai problème technique du §2.

Source Forme naturelle Peut-on la convertir ?
Disjoncteur de branchement par phase (A) pas en un total : 3 × 32 A ne vaut pas 22 kW répartissables
§14a EnWG total (W au point de livraison) pas en par-phase : le gestionnaire ne dit rien de la répartition
Plancher éco (§10) total (W qu'on s'autorise) idem

Un plafond total ne protège pas d'une surcharge monophasée : 4 kW tirés entièrement sur L1 passent un plafond total de 6 kW et font disjoncter un 16 A. Et un plafond par phase ne sait pas exprimer le §14a.

Décision de conception proposée : DrawBudget porte les deux, les évalue toutes les deux, et binding dit laquelle a mordu la première. Aucune conversion, jamais — c'est la conversion qui perdrait l'information. effectiveW n'est qu'une commodité d'affichage, dérivée, et perPhaseBound dit s'il faut la lire avec précaution.

2.4 Où il se calcule

Une fois par cycle, dans buildContext(), à côté de ctx.meter et ctx.battery — c'est déjà l'endroit qui lit le compteur et connaît phasePowerLimit. Il devient un champ de SurplusContext :

struct SurplusContext {
    // … existant …
    DrawBudget drawCap;   //!< [§10] Plafond de soutirage du cycle, toutes sources confondues.
};

Publié en télémétrie au même titre que budget — c'est la contrepartie de binding : un client qui ne voit pas le plafond ne peut pas expliquer ce qu'il affiche.


3. Les deux passes, et ce qui borne chacune

LM-1002 fixe la structure : deux passes sur la même liste, triée une seule fois par rang. Ce qui change entre elles n'est pas l'ordre, c'est la borne.

                    ┌────────────── passe 1 : ÉCO ──────────────┐
liste triée par     │ borne : DrawBudget (autorisation de       │
rang (une seule)    │         SOUTIRAGE — pas le surplus)       │
   rang 1 ─────────▶│ sert : le plancher `needs` non atteint    │
   rang 2 ─────────▶│ produit : une CIBLE, pas une commande     │
   rang 3 ─────────▶└───────────────────────────────────────────┘
        │
        │           ┌──────────── passe 2 : CONFORT ────────────┐
        └──────────▶│ borne : le RELIQUAT de surplus            │
   (même ordre)     │ sert : la cible de confort si demandeuse  │
                    │ produit : une CIBLE, pas une commande     │
                    └───────────────────────────────────────────┘
                                        │
                            somme par charge, arrondie UNE fois
                                        │
                                   UNE commande

Ce que la passe 1 dépense n'est pas ce que la passe 2 dépense. C'est le point que le code actuel ne sait pas exprimer : getPlan() n'a qu'un compteur, remainingSurplusW, et buildSetpointAction() le décrémente et émet l'action dans le même geste (rulebasedscheduler.cpp:207-212). Le §10 doit séparer les deux — voir §4.

Deux comptabilités, donc, et elles ne se mélangent pas :

struct PlanBudget {          // existant, inchangé dans sa sémantique
    double surplusW, evReservedW, allocatedW, recreditedW, remainingW;
};
struct PlanDraw {            // [§10] ce que la passe 1 a décidé d'ACHETER
    double authorisedW = 0;  //!< Ce que le DrawBudget laissait prendre.
    double committedW  = 0;  //!< Ce que la passe 1 a réellement engagé au réseau.
    double remainingW  = 0;  //!< Reliquat d'autorisation, non dépensé.
};

Pourquoi deux structures et pas un champ de plus : PlanBudget répond « d'où vient l'énergie servie » ; PlanDraw répond « combien a-t-on décidé d'acheter ». Les fondre rendrait l'invariant publié de PlanBudget (surplus + recrédit − alloué == restant) faux ou incompréhensible, et c'est lui qui rend la charge utile vérifiable par un client.


4. Une seule commande par charge et par cycle (LM-1003)

C'est la contrainte qui impose la refonte structurelle, et elle est petite à énoncer : les passes calculent, elles ne commandent pas.

Découpage proposé de getPlan() :

// 1. contexte, budget de surplus, plafond de soutirage  (inchangé + DrawBudget)
// 2. tri UNE fois                                        (inchangé)
QHash<QString, LoadTarget> cibles;   // loadId → { ecoW, confortW, motifs… }

// 3. passe 1 — éco, bornée par ctx.drawCap
for (const LoadContext &lc : chargesTriees)
    cibles[lc.id].eco = planifierEco(lc, drawRestant, planDraw);

// 4. passe 2 — confort, bornée par le reliquat de surplus
for (const LoadContext &lc : chargesTriees)
    cibles[lc.id].confort = planifierConfort(lc, surplusRestant, planBudget);

// 5. UNE émission par charge : somme, arrondi au mécanisme, motif
for (const LoadContext &lc : chargesTriees)
    slot.actions.append(emettre(lc, cibles.value(lc.id)));

L'arrondi au mécanisme se fait à l'étape 5, une seule fois. C'est ce qui empêche le défaut que LM-1003 nomme : un relais commuté en passe 1 puis recommuté en passe 2, soit du court-cycling fabriqué par l'arbitre lui-même.

Le recrédit s'attribue à la première passe où la charge apparaît (LM-1004) — donc dans planifierEco() si elle a un plancher, dans planifierConfort() sinon. Un champ recreditAttribue dans LoadTarget le rend vérifiable plutôt que discipliné.

Ce que cette refonte casse, et qu'il faut prévoir : buildSetpointAction() et buildSgReadyStateAction() mélangent aujourd'hui décision, écrêtage de verrou, comptabilité et motif. Ils doivent se scinder en « décider une cible » et « émettre ». C'est le gros du travail du lot, et c'est du remaniement, pas de l'ajout. Les tests de motif existants (testDecisionReasonRendering, testEcsBudgetUnderLock, testEcsSurplusPV) sont le filet : ils portent sur le TEXTE publié, donc ils survivront au remaniement ou le condamneront.


5. Le délestage : ce qu'il coupe, et dans quel ordre

Le plafond borne le PLAN, il ne le corrige pas après coup. C'est la leçon de 3g-2 : un plafond appliqué après l'allocation laisse le waterfall croire qu'il a servi.

  • En passe 1, chaque charge servie décrémente planDraw.remainingW. Quand l'autorisation est épuisée, les charges suivantes reçoivent 0 et le motif nomme la source (§6).
  • L'ordre de délestage est l'inverse de l'ordre de service : le rang décide qui est servi en premier, donc qui est coupé en dernier. Aucun second classement — c'est LM-1002 appliqué à l'envers, et c'est la seule lecture qui ne surprendra pas l'installateur.
  • L4 ne bouge pas. verifyOverloadProtection() garde le dernier mot en temps réel, sur la mesure, après le dispatch. Le §10 lui ajoute des charges à délester ; il ne lui retire pas son rôle et ne s'y substitue pas. Sa position dans update() reste INTOUCHABLE (AGENTS, modèle de sécurité).

6. Les motifs — ce que le §10 ajoute au catalogue

Le catalogue est fermé et testé sur le texte ; toute clé ajoutée demande une ligne de rendu et une entrée de clôture (testDecisionReasonRendering).

Code params Quand
DRAW_CAP capW, source, requiredW, o:phase Le plafond de soutirage refuse, et source dit lequel : raccordement, gestionnaire de réseau, ou auto-imposé. phase présent si c'est une phase qui borne.
ECO_FLOOR_GRID deliveredWh, targetWh, gridW Une obligation éco servie au réseau. gridW chiffre l'achat, comme EV_GRID_START le fait déjà.
ECO_FLOOR_MET deliveredWh, targetWh L'obligation est atteinte — et elle ne peut se dire atteinte que sur le régime ÉNERGIE (LM-1009).
COMFORT_SKIPPED o:sensorValue, o:setpoint La charge n'est plus demandeuse de son bonus (comparateur de LM-1008). Distinct de « pas de budget ».

Et un champ, pas un code : le régime de l'avancement (mesuré / estimé / non mesurable), publié sur chaque charge portant une obligation. C'est la condition C2 de LM-1009, et elle ne peut pas vivre dans un code : les trois régimes coexistent dans le même cycle, sur des charges différentes.


7. Ce que LM-1009 impose à l'obligation éco

Trois décisions déjà prises, rappelées ici parce qu'elles ferment des choix de conception :

  • D1 — l'avancement se mesure et s'affiche en énergie livrée. La passe 1 compare donc des Wh à des Wh, jamais un pourcentage.
  • D2 — obligation par SESSION d'abord. Pour une borne, la session est le branchement, et la source est sessionEnergy. Pour une charge thermique, la « session » est le cycle quotidien — et c'est là que l'analogie s'arrête : minEnergyWhPerDay reste hors de ce lot, faute d'accumulation inter-sessions (§11).
  • C1 — une charge dont l'énergie livrée n'est pas mesurable dégrade visiblement. Le cas est réel dès demain : la V2C Trydan ne publie pas sessionEnergy (docs/PREPA_BANC_20260828.md §2.2). Le §10 doit donc prévoir ce régime dès l'écriture, pas comme un cas dégradé ajouté après.

Conséquence directe : une obligation éco sur une charge non mesurable est déclarative, pas vérifiable. Elle peut être servie ; elle ne peut jamais être annoncée tenue. C'est la même frontière que LM-1009 trace pour l'échéance VE.


8. LES CINQ POINTS À TRANCHER

8.1 — La réserve batterie annule-t-elle aussi la passe ÉCO ? (le plus important)

LM-1203-b dit : sous la réserve, le budget est annulé. Mais ce budget est un budget de surplus, et la passe 1 ne dépense pas de surplus — elle dépense une autorisation de soutirage.

  • Si la réserve annule aussi la passe 1 : une installation dont la batterie est basse n'atteindra jamais son plancher éco. Un ballon qui n'a pas vu 50 °C ne les verra pas, parce que la batterie de la maison est à 15 %.
  • Si elle ne l'annule pas : la maison achète au réseau pour un chauffe-eau pendant que sa batterie est basse — ce qui peut être exactement ce qu'on veut (l'obligation prime) ou une absurdité (on achète pendant qu'on manque).

Ma recommandation : la réserve N'annule PAS la passe 1. La réserve protège le surplus ; une obligation d'énergie sur échéance est d'une autre nature, et si elle cède à la réserve, elle cesse d'être une obligation. Mais c'est une décision d'exploitation, pas de code.

8.2 — Le plafond §14a est-il par phase, au total, ou les deux ? Le §2.3 propose de porter les deux sans jamais convertir. Reste à confirmer que c'est bien ainsi que le gestionnaire de réseau l'exprimera — et le transport du §14a n'existe pas encore, donc la réponse peut attendre à condition que la forme ne se ferme pas maintenant.

8.3 — Le repli L2 doit-il TENIR le plafond ? LM-1006-1, propriété 2 : le plafond survit à la panne du HEMS, et ce sera une obligation légale pour le §14a. Or le repli L2 actuel est conçu pour ne rien initier (SAFETY.md §L2), pas pour continuer à faire respecter une borne imposée par un tiers. Les deux ne sont pas la même chose. Ma recommandation : en mode dégradé, le plan cesse, mais une allocation statique respectant le dernier plafond connu est maintenue. C'est un changement de nature du repli, et il mérite d'être décidé, pas déduit.

8.4 — La cible de confort : comparateur seul, ou dosage ? LM-1008 tranche déjà : comparateur (sonde < consigne → demandeuse), le dosage attend le modèle thermique du §11. À confirmer que ça reste vrai pour ce lot, parce que c'est ce qui le rend faisable sans historique.

8.5 — Que fait l'app des deux passes ? LM-1006, couplage 2 : un plancher éco de rang 4 passe devant un confort de rang 1, et c'est contre-intuitif pour l'installateur qui vient de poser son ordre. Sans écran qui montre les deux passes, le premier retour terrain sera « l'ordre n'est pas respecté ». Ce n'est pas du travail moteur, mais c'est une condition de livraison du lot.


9. Ce qui n'est PAS dans ce lot

  • minEnergyWhPerDay — obligation quotidienne, exige l'accumulation inter-sessions (§11). Le lot livre l'obligation par session.
  • Le transport du §14a — aucun canal n'existe. Le lot livre la forme du plafond, pas sa source externe.
  • Le prix, l'heure, le signal tarifaire (LM-1005) — le rule-based franchit la frontière de l'achat grossièrement : « plancher non atteint à 22 h → chauffer ». Une règle d'horloge. L'optimisation appartient au MILP, donc à l'optimiseur socket, donc à 3d.
  • Le dosage en Wh du confort — voir 8.4.
  • La conversion degrés → Wh — couche domaine, jamais l'arbitre (LM-1008).

10. Ordre de travail proposé

Étape Contenu Éprouvable
10-a DrawCap / DrawBudget, calculés dans buildContext(), publiés, sans effet sur les décisions oui — télémétrie, aucun changement de comportement
10-b Délestage : le plafond borne le plan ; motif DRAW_CAP avec sa source ; l'ordre de coupe est l'inverse du rang oui — simulation + banc
10-c Refonte décider/émettre (§4) : deux passes, une commande, recrédit unique — à comportement CONSTANT, passe 1 vide oui — les 16 scénarios run doivent rester identiques au bit près
10-d Passe 1 réellement servie : obligation par session, régimes de LM-1009, motifs éco oui — banc
10-e Passe 2 confort : comparateur de sonde oui — banc, avec la sonde du §11

10-a et 10-c sont les deux étapes qui ne changent rien, et ce sont les plus importantes : elles rendent le reste petit. 10-c en particulier doit être prouvée par l'invariance des scénarios enregistrés — si la refonte décider/émettre change un seul point de contrôle, c'est qu'elle a changé une décision, et il faut savoir laquelle avant d'aller plus loin.