refactor(§10 D 2/2a): le niveau devient un objet du plan, et les motifs éco naissent

La frontière FABRIQUAIT le niveau à partir de l'action : elle ne pouvait donc
en produire qu'un, puisqu'il n'y avait qu'un endroit pour le former. Le plan
les porte désormais (`LoadAction::Level`), et la télémétrie les traduit au
lieu de les décider — une frontière qui invente une structure finit par
diverger de ce que le moteur a réellement décidé.

Comportement CONSTANT : un seul niveau « comfort » aujourd'hui, et les quatre
suites sont vertes avec charging identique à la référence ligne à ligne.

Le test qui exige un `levels[]` non vide a trouvé un site d'émission oublié
— SG-Ready passe par un autre chemin que les consignes en watts. D'où un
POINT UNIQUE de pose (`poserNiveauConfort`) : recopier la pose à chaque site
la ferait diverger au premier site ajouté, et l'invariant
Σ levels[].targetW == estimatedPowerW tomberait en silence, une charge sans
niveau publiant simplement une liste vide.

ECO_FLOOR_GRID rend lisible la TRAJECTOIRE, pas l'instant. La cadence de
l'étalement ne monte pas, elle fait un mur : 333 W à six heures de
l'échéance, le plafond dès seize minutes. Un client qui lit « 333 W achetés »
ne peut pas deviner qu'il paiera dix fois plus dans cinq heures si le soleil
ne vient pas. Le motif porte donc gridW, remainingWh, targetWh, remainingS,
le régime, et atMaximum — les deux derniers disant respectivement sur quoi on
achète et si l'étalement a cessé.

ECO_FLOOR_MET est INTERDIT sous `unmeasurable` (R6) : dire « tenue » suppose
de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et
continue de s'afficher comme tenue est pire qu'une échéance absente — elle
rassure.

LM-1014-b généralise les deux défauts de cumul de ces deux jours : toute
grandeur cumulée rapportée à une période doit dire ce qu'elle fait au
changement de période. La question à poser une fois, à l'écriture : que
vaut-elle à la seconde qui suit la bascule ? Ne pas choisir revient à
choisir « elle continue », presque toujours faux et toujours silencieux.

Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique,
spotmarket 32/32, doxygen 0 avertissement.

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-31 19:08:04 +02:00
parent e1fc723e15
commit ca7a501797
7 changed files with 186 additions and 41 deletions

View File

@ -1479,47 +1479,47 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// mixte est justement celui qui le rend indécidable, et deux endroits pour un fait // mixte est justement celui qui le rend indécidable, et deux endroits pour un fait
// divergent. Le motif « decision », lui, RESTE — parce qu'on saura dire de quel niveau // divergent. Le motif « decision », lui, RESTE — parce qu'on saura dire de quel niveau
// il parle (R7), là où un financement de charge ne pourrait rien dire de vrai. // il parle (R7), là où un financement de charge ne pourrait rien dire de vrai.
QVariantMap niveau; // §10 — les niveaux sont LUS DU PLAN, plus fabriqués ici. Les fabriquer à la frontière
niveau.insert(QStringLiteral("level"), QStringLiteral("comfort")); // ne pouvait donner qu'un seul niveau, puisqu'il n'y avait qu'un endroit pour le former ;
// R4 — le niveau publie ce qu'il a DÉCIDÉ. C'est la même valeur qu'allocatedW tant // et une frontière qui invente une structure finit par diverger de ce que le moteur a
// qu'il n'y a qu'un niveau ; elle s'en séparera quand il y en aura deux. // réellement décidé. Elle traduit désormais, elle ne décide plus.
niveau.insert(QStringLiteral("targetW"), action.estimatedPowerW); QVariantList niveaux;
niveau.insert(QStringLiteral("funding"), for (const LoadAction::Level &n : action.levels) {
action.funding == LoadAction::Grid ? QStringLiteral("grid") QVariantMap niveau;
: QStringLiteral("surplus")); niveau.insert(QStringLiteral("level"), n.level);
// R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de « funding », // R4 — le niveau publie ce qu'il a DÉCIDÉ. Σ targetW == allocatedW, exactement.
// qui dit d'où ils VIENNENT — les deux ne se dérivent pas l'un de l'autre, et sur un niveau.insert(QStringLiteral("targetW"), n.targetW);
// EV_GRID_START l'un vaut « grid » quand l'autre porte deux destinations. niveau.insert(QStringLiteral("funding"),
// n.funding == LoadAction::Grid ? QStringLiteral("grid")
// Publiée TOUJOURS, même à une seule destination et même à zéro : un « counts » vide : QStringLiteral("surplus"));
// satisferait l'identité en perdant la destination, sur le cas le plus fréquent. Un // R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de
// zéro AVEC sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait // « funding », qui dit d'où ils VIENNENT. Publiée TOUJOURS, même à une seule
// « elle n'existe pas », ce qui est une autre affirmation. // destination et même à zéro : un zéro AVEC sa destination dit « cette grandeur
QVariantMap counts; // existe et vaut 0 », l'absence dirait « elle n'existe pas ».
for (auto it = action.counts.constBegin(); it != action.counts.constEnd(); ++it) QVariantMap counts;
counts.insert(it.key(), it.value()); for (auto it = n.counts.constBegin(); it != n.counts.constEnd(); ++it)
niveau.insert(QStringLiteral("counts"), counts); counts.insert(it.key(), it.value());
niveau.insert(QStringLiteral("counts"), counts);
// R5 — LE RÉGIME D'AVANCEMENT, porté par le NIVEAU et non par la charge. Publié dès if (!n.reason.isEmpty()) {
// maintenant, alors qu'aucune décision ne s'y appuie encore : c'est délibéré, et c'est QVariantMap d;
// ce qui rendra R6 (`ECO_FLOOR_MET` interdit sous `unmeasurable`) testable le jour où le d.insert(QStringLiteral("code"), n.reason.code);
// motif naîtra. On sépare PUBLIER LE RÉGIME de DÉCIDER AVEC, exactement comme d.insert(QStringLiteral("params"), n.reason.params);
// `measurement` a été séparé de l'arbitrage. niveau.insert(QStringLiteral("decision"), d);
// }
// Absent quand aucune obligation n'est déclarée : une charge sans obligation éco ne // R5 — le RÉGIME d'avancement, porté par le niveau ÉCO et par lui seul.
// porte pas un avancement à zéro, elle n'en porte pas (R1). if (n.hasProgress) {
const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now), now); QVariantMap progress;
if (!avancement.regime.isEmpty()) { progress.insert(QStringLiteral("regime"), n.progress.regime);
QVariantMap progress; progress.insert(QStringLiteral("targetWh"), n.progress.targetWh);
progress.insert(QStringLiteral("regime"), avancement.regime); // ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas se lire
progress.insert(QStringLiteral("targetWh"), avancement.targetWh); // comme un avancement nul (LM-1009 C1).
// `deliveredWh` ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas if (n.progress.delivered)
// se lire comme un avancement nul (LM-1009 C1). C'est la moitié qui compte. progress.insert(QStringLiteral("deliveredWh"), n.progress.deliveredWh);
if (avancement.delivered) niveau.insert(QStringLiteral("progress"), progress);
progress.insert(QStringLiteral("deliveredWh"), avancement.deliveredWh); }
niveau.insert(QStringLiteral("progress"), progress); niveaux.append(niveau);
} }
entry.insert(QStringLiteral("levels"), QVariantList() << niveau); entry.insert(QStringLiteral("levels"), niveaux);
entry.insert(QStringLiteral("available"), adapter->telemetry().available); entry.insert(QStringLiteral("available"), adapter->telemetry().available);
// R7 — LE MOTIF HÉRITÉ DIT DE QUEL NIVEAU IL PARLE. `decision` reste au niveau de la // R7 — LE MOTIF HÉRITÉ DIT DE QUEL NIVEAU IL PARLE. `decision` reste au niveau de la
// charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif // charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif
@ -1530,8 +1530,13 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément // Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément
// pourquoi le champ s'ajoute MAINTENANT : quand elle se posera, les clients le liront // pourquoi le champ s'ajoute MAINTENANT : quand elle se posera, les clients le liront
// déjà. Voir R2, même raisonnement sur le coût de déplacement. // déjà. Voir R2, même raisonnement sur le coût de déplacement.
// Le niveau dont `decision` parle : le DERNIER parcouru. La règle est arbitraire mais
// elle doit être UNE, et publiée : sans elle chaque client réinventerait sa fusion, et
// deux écrans afficheraient deux motifs pour le même cycle. Le dernier parcouru est
// aussi celui qui a eu le dernier mot sur la consigne, donc celui qu'on explique.
QVariantMap decision = action.reason.toMap(); QVariantMap decision = action.reason.toMap();
decision.insert(QStringLiteral("level"), niveau.value(QStringLiteral("level"))); if (!action.levels.isEmpty())
decision.insert(QStringLiteral("level"), action.levels.last().level);
entry.insert(QStringLiteral("decision"), decision); entry.insert(QStringLiteral("decision"), decision);
const LoadRuntimeView v = adapter->runtimeView(now); const LoadRuntimeView v = adapter->runtimeView(now);

View File

@ -284,6 +284,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
la.reason = { DecisionCode::EvDeadline }; la.reason = { DecisionCode::EvDeadline };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté (échéance) la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté (échéance)
poserNiveauConfort(la);
return la; return la;
} }
@ -632,6 +633,21 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
return t; return t;
} }
void RuleBasedScheduler::poserNiveauConfort(LoadAction &la)
{
// UN point unique où le niveau se pose. Le recopier à chaque site d'émission le ferait
// diverger au premier site ajouté — et l'invariant Σ levels[].targetW == estimatedPowerW
// tomberait sans que rien ne le dise, puisqu'une charge sans niveau publie simplement une
// liste vide. Le test qui exige un niveau NON VIDE est ce qui a trouvé le site oublié.
LoadAction::Level confort;
confort.level = QStringLiteral("comfort");
confort.targetW = la.estimatedPowerW;
confort.funding = la.funding;
confort.counts = la.counts;
confort.reason = la.reason;
la.levels.append(confort);
}
LoadAction RuleBasedScheduler::emitSetpointAction(const SetpointTarget &t) const LoadAction RuleBasedScheduler::emitSetpointAction(const SetpointTarget &t) const
{ {
LoadAction la = t.action; LoadAction la = t.action;
@ -639,6 +655,12 @@ LoadAction RuleBasedScheduler::emitSetpointAction(const SetpointTarget &t) const
// motif : il n'y a rien à choisir, la cause est connue au moment où la décision s'arrête. // motif : il n'y a rien à choisir, la cause est connue au moment où la décision s'arrête.
if (t.motifAChoisir) if (t.motifAChoisir)
la.reason = choisirMotif(t.entrees); la.reason = choisirMotif(t.entrees);
// §10 — LE NIVEAU DEVIENT UN OBJET DU PLAN, au lieu d'être synthétisé à la frontière. Un
// seul niveau aujourd'hui, « comfort » : la passe unique d'aujourd'hui est celle qui sert le
// confort depuis le surplus. La passe éco est la nouveauté du lot, pas la passe existante
// rebaptisée — une charge sans obligation n'aura jamais d'entrée « eco » (R1).
poserNiveauConfort(la);
return la; return la;
} }
@ -725,5 +747,6 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0). // Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
remainingSurplusW = budgetW - la.estimatedPowerW; remainingSurplusW = budgetW - la.estimatedPowerW;
budget.allocatedW += la.estimatedPowerW; budget.allocatedW += la.estimatedPowerW;
poserNiveauConfort(la);
return la; return la;
} }

View File

@ -167,6 +167,15 @@ private:
*/ */
LoadAction emitSetpointAction(const SetpointTarget &t) const; LoadAction emitSetpointAction(const SetpointTarget &t) const;
/*!
* \brief Pose le niveau « comfort » sur une action — POINT UNIQUE.
* \param[in,out] la Action émise ; son niveau est ajouté depuis ses propres grandeurs.
* \note Recopier cette pose à chaque site d'émission la ferait diverger au premier site
* ajouté, et l'invariant Σ \c levels[].targetW == \c estimatedPowerW tomberait en
* silence — une charge sans niveau publie simplement une liste vide.
*/
static void poserNiveauConfort(LoadAction &la);
/*! /*!
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus. * \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
* *

View File

@ -119,6 +119,24 @@ QString renderFr(const DecisionReason &reason, const QString &label)
"%3 n'est pas servie") "%3 n'est pas servie")
.arg(ou).arg(origine).arg(label); .arg(ou).arg(origine).arg(label);
} }
// La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable
// là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
if (c == QLatin1String(DecisionCode::EcoFloorGrid)) {
const int restantS = p.value(QStringLiteral("remainingS")).toInt();
const QString delai = restantS >= 3600
? QStringLiteral("%1 h").arg(restantS / 3600)
: QStringLiteral("%1 min").arg(qMax(1, restantS / 60));
if (p.value(QStringLiteral("atMaximum")).toBool())
return QStringLiteral("Obligation de %1 : %2 W ACHETÉS au maximum — il reste %3 Wh "
"à livrer et %4, l'étalement ne suffit plus")
.arg(label).arg(w("gridW")).arg(w("remainingWh")).arg(delai);
return QStringLiteral("Obligation de %1 : %2 W achetés au réseau — %3 Wh restent à "
"livrer en %4 (cadence recalculée à chaque cycle)")
.arg(label).arg(w("gridW")).arg(w("remainingWh")).arg(delai);
}
if (c == QLatin1String(DecisionCode::EcoFloorMet))
return QStringLiteral("Obligation de %1 tenue : %2 Wh livrés sur %3 — plus rien à acheter")
.arg(label).arg(w("deliveredWh")).arg(w("targetWh"));
if (c == QLatin1String(DecisionCode::PhaseLimit)) if (c == QLatin1String(DecisionCode::PhaseLimit))
return QStringLiteral("Limite de phase : %1 W disponibles pour %2, sous les %3 W qu'elle " return QStringLiteral("Limite de phase : %1 W disponibles pour %2, sous les %3 W qu'elle "
"exige — aucun démarrage possible ce cycle") "exige — aucun démarrage possible ce cycle")

View File

@ -173,6 +173,45 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT";
*/ */
constexpr const char *DrawCap = "DRAW_CAP"; constexpr const char *DrawCap = "DRAW_CAP";
/*!
* \brief L'obligation éco est servie en ACHETANT au réseau.
*
* params : \c gridW (acheté maintenant), \c remainingWh (reste à livrer),
* \c targetWh (l'obligation), \c remainingS (temps jusqu'à l'échéance),
* \c regime (code opaque : \c measured | \c estimated | \c unmeasurable),
* \c atMaximum (booléen — le point de non-retour est franchi, on prend tout ce qu'on peut).
*
* \par Ce motif est lu le matin où la facture monte
* C'est sa seule raison d'être, et elle commande sa forme. **Les watts de l'instant ne suffisent
* pas** : la cadence de l'étalement se durcit mécaniquement, et pas doucement — 2 kWh restants
* font 333 W à six heures de l'échéance et **le plafond dès seize minutes**. Ce n'est pas une
* montée, c'est un mur.
*
* Un client qui lit « 333 W achetés » ne peut pas deviner qu'il paiera dix fois plus dans cinq
* heures si le soleil ne vient pas. Avec le **reste à livrer** et le **temps restant**, il le
* peut : les deux ensemble donnent la trajectoire, et la trajectoire est ce qui se comprend.
* Sans elle, l'écran affiche un achat sans cause lisible — au pire moment pour ça.
*
* \par Pourquoi le régime y figure
* Un avancement mesuré et un avancement estimé ne se distinguent par aucune de leurs valeurs
* (C2 de \rule{LM-1009}). Acheter sur un avancement qu'on ne mesure pas est légitime — c'est le
* choix prudent — mais ça ne se lit pas comme acheter sur une mesure, et ça ne se corrige pas
* pareil.
*/
constexpr const char *EcoFloorGrid = "ECO_FLOOR_GRID";
/*!
* \brief L'obligation éco est TENUE — plus rien à acheter pour elle ce cycle.
*
* params : \c deliveredWh, \c targetWh.
*
* \warning **INTERDIT sous un régime \c unmeasurable** (exigence R6, C1 de \rule{LM-1009}).
* Dire « tenue » suppose de savoir ce qui a été livré ; sous un avancement non mesuré, c'est
* affirmer sur ce qu'on ignore. Une échéance qui cesse silencieusement d'être mesurable et
* continue de s'afficher comme tenue est **pire qu'une échéance absente** — elle rassure.
*/
constexpr const char *EcoFloorMet = "ECO_FLOOR_MET";
// --- SG-Ready (PAC) --------------------------------------------------------- // --- SG-Ready (PAC) ---------------------------------------------------------
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";

View File

@ -5,6 +5,7 @@
#include <QString> #include <QString>
#include <QMap> #include <QMap>
#include "decisionreason.h" #include "decisionreason.h"
#include "ecoprogress.h"
//! \brief Clés de \c LoadAction::counts — identifiants OPAQUES du contrat R3. //! \brief Clés de \c LoadAction::counts — identifiants OPAQUES du contrat R3.
//! //!
@ -99,6 +100,32 @@ struct LoadAction {
*/ */
QMap<QString, double> counts; QMap<QString, double> counts;
/*!
* \brief Une PASSE réellement parcourue pour cette charge, avec ce qu'elle a décidé.
*
* \par Pourquoi le niveau est un objet et non un champ de plus
* Au §10 la même charge est financée au réseau pour son éco et au surplus pour son confort
* **dans le même cycle** : un \c funding par charge devient indécidable, et un \c counts par
* charge perd le partage. C'est l'exigence **R2**, et c'est la seule des huit à être un
* changement de FORME d'un champ existant.
*
* \invariant Σ \c levels[].targetW == \c estimatedPowerW. Une charge qui n'a pas
* d'obligation éco ne porte **pas** d'entrée « eco » à zéro : elle n'en porte pas. L'absence
* est l'information (R1), et un zéro forgé la détruirait.
*/
struct Level {
QString level; //!< \c eco | \c comfort — code opaque.
double targetW = 0; //!< Ce que CE niveau a décidé.
Funding funding = Surplus; //!< L'origine des watts de ce niveau.
QMap<QString, double> counts; //!< Ventilation : Σ counts == targetW.
DecisionReason reason; //!< Le motif DE CE NIVEAU (R7).
EcoProgress progress; //!< Avancement — niveau éco uniquement.
bool hasProgress = false;//!< Vrai si \c progress a un sens.
};
//! \brief Les passes réellement parcourues, dans l'ordre. Jamais vide pour une action émise.
QList<Level> levels;
/*! /*!
* \brief Forçage sécurité — bypasse les verrous anti-rebond (minOn/minOff). * \brief Forçage sécurité — bypasse les verrous anti-rebond (minOn/minOff).
* *

View File

@ -957,6 +957,30 @@ sépare **publier le régime** de **décider avec**, exactement comme `measureme
l'arbitrage. C'est ce qui rendra **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable` — testable l'arbitrage. C'est ce qui rendra **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable` — testable
le jour où le motif naîtra, au lieu d'un invariant posé sur un code que rien n'émet. le jour où le motif naîtra, au lieu d'un invariant posé sur un code que rien n'émet.
**LM-1014-b — toute grandeur CUMULÉE rapportée à une période doit dire ce qu'elle fait au
CHANGEMENT de période.** *(Généralisée le 2026-08-31, après deux occurrences en deux jours.)*
| Grandeur | Ce qui n'était pas dit | Ce que ça produisait |
|---|---|---|
| `sessionEnergy` | qu'elle porte un **offset** des incréments d'avant la dernière RAZ de la borne | toute obligation crue tenue de 7,2 kWh au premier cycle |
| l'obligation quotidienne | que la base d'avancement **ne se reprenait pas** à l'échéance | le lendemain héritant de l'énergie de la veille, tenu sans rien avoir reçu |
Ce sont deux cas de la même famille, et aucun des deux ne se voyait en lisant le code : les deux
produisent un nombre **crédible et faux**, que rien ne signale — pas une erreur, pas une absence,
un chiffre plausible au mauvais endroit.
> **La question à poser à chaque cumul, une fois, à l'écriture :**
> **« Que vaut-il à la seconde qui suit la bascule ? »**
>
> Trois réponses possibles, et il faut choisir explicitement : il **repart de zéro**, il
> **continue**, ou il est **repris sur une base**. Ne pas choisir revient à choisir « il
> continue » — qui est presque toujours faux pour une grandeur périodique, et toujours
> silencieux.
Le corollaire de test s'écrit tout seul : **un cumul se teste de part et d'autre de sa bascule**,
jamais seulement en régime établi. Les deux défauts ci-dessus ont survécu à des suites vertes
parce qu'aucun scénario ne traversait la frontière.
**LM-1013-b — une obligation en ÉNERGIE devient une cible en WATTS par étalement uniforme.** **LM-1013-b — une obligation en ÉNERGIE devient une cible en WATTS par étalement uniforme.**
*(§10 étape D, tranché le 2026-08-31.)* *(§10 étape D, tranché le 2026-08-31.)*