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
// 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.
QVariantMap niveau;
niveau.insert(QStringLiteral("level"), QStringLiteral("comfort"));
// R4 — le niveau publie ce qu'il a DÉCIDÉ. C'est la même valeur qu'allocatedW tant
// qu'il n'y a qu'un niveau ; elle s'en séparera quand il y en aura deux.
niveau.insert(QStringLiteral("targetW"), action.estimatedPowerW);
niveau.insert(QStringLiteral("funding"),
action.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
// R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de « funding »,
// qui dit d'où ils VIENNENT — les deux ne se dérivent pas l'un de l'autre, et sur un
// EV_GRID_START l'un vaut « grid » quand l'autre porte deux destinations.
//
// Publiée TOUJOURS, même à une seule destination et même à zéro : un « counts » vide
// satisferait l'identité en perdant la destination, sur le cas le plus fréquent. Un
// zéro AVEC sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait
// « elle n'existe pas », ce qui est une autre affirmation.
QVariantMap counts;
for (auto it = action.counts.constBegin(); it != action.counts.constEnd(); ++it)
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
// maintenant, alors qu'aucune décision ne s'y appuie encore : c'est délibéré, et c'est
// ce qui rendra R6 (`ECO_FLOOR_MET` interdit sous `unmeasurable`) testable le jour où le
// motif naîtra. On sépare PUBLIER LE RÉGIME de DÉCIDER AVEC, exactement comme
// `measurement` a été séparé de l'arbitrage.
//
// Absent quand aucune obligation n'est déclarée : une charge sans obligation éco ne
// porte pas un avancement à zéro, elle n'en porte pas (R1).
const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now), now);
if (!avancement.regime.isEmpty()) {
QVariantMap progress;
progress.insert(QStringLiteral("regime"), avancement.regime);
progress.insert(QStringLiteral("targetWh"), avancement.targetWh);
// `deliveredWh` ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas
// se lire comme un avancement nul (LM-1009 C1). C'est la moitié qui compte.
if (avancement.delivered)
progress.insert(QStringLiteral("deliveredWh"), avancement.deliveredWh);
niveau.insert(QStringLiteral("progress"), progress);
// §10 — les niveaux sont LUS DU PLAN, plus fabriqués ici. Les fabriquer à la frontière
// ne pouvait donner qu'un seul niveau, puisqu'il n'y avait qu'un endroit pour le former ;
// et une frontière qui invente une structure finit par diverger de ce que le moteur a
// réellement décidé. Elle traduit désormais, elle ne décide plus.
QVariantList niveaux;
for (const LoadAction::Level &n : action.levels) {
QVariantMap niveau;
niveau.insert(QStringLiteral("level"), n.level);
// R4 — le niveau publie ce qu'il a DÉCIDÉ. Σ targetW == allocatedW, exactement.
niveau.insert(QStringLiteral("targetW"), n.targetW);
niveau.insert(QStringLiteral("funding"),
n.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
// R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de
// « funding », qui dit d'où ils VIENNENT. Publiée TOUJOURS, même à une seule
// destination et même à zéro : un zéro AVEC sa destination dit « cette grandeur
// existe et vaut 0 », l'absence dirait « elle n'existe pas ».
QVariantMap counts;
for (auto it = n.counts.constBegin(); it != n.counts.constEnd(); ++it)
counts.insert(it.key(), it.value());
niveau.insert(QStringLiteral("counts"), counts);
if (!n.reason.isEmpty()) {
QVariantMap d;
d.insert(QStringLiteral("code"), n.reason.code);
d.insert(QStringLiteral("params"), n.reason.params);
niveau.insert(QStringLiteral("decision"), d);
}
// R5 — le RÉGIME d'avancement, porté par le niveau ÉCO et par lui seul.
if (n.hasProgress) {
QVariantMap progress;
progress.insert(QStringLiteral("regime"), n.progress.regime);
progress.insert(QStringLiteral("targetWh"), n.progress.targetWh);
// ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas se lire
// comme un avancement nul (LM-1009 C1).
if (n.progress.delivered)
progress.insert(QStringLiteral("deliveredWh"), n.progress.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);
// 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
@ -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
// 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.
// 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();
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);
const LoadRuntimeView v = adapter->runtimeView(now);

View File

@ -284,6 +284,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
la.reason = { DecisionCode::EvDeadline };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté (échéance)
poserNiveauConfort(la);
return la;
}
@ -632,6 +633,21 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
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 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.
if (t.motifAChoisir)
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;
}
@ -725,5 +747,6 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
remainingSurplusW = budgetW - la.estimatedPowerW;
budget.allocatedW += la.estimatedPowerW;
poserNiveauConfort(la);
return la;
}

View File

@ -167,6 +167,15 @@ private:
*/
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.
*

View File

@ -119,6 +119,24 @@ QString renderFr(const DecisionReason &reason, const QString &label)
"%3 n'est pas servie")
.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))
return QStringLiteral("Limite de phase : %1 W disponibles pour %2, sous les %3 W qu'elle "
"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";
/*!
* \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) ---------------------------------------------------------
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";

View File

@ -5,6 +5,7 @@
#include <QString>
#include <QMap>
#include "decisionreason.h"
#include "ecoprogress.h"
//! \brief Clés de \c LoadAction::counts — identifiants OPAQUES du contrat R3.
//!
@ -99,6 +100,32 @@ struct LoadAction {
*/
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).
*

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
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.**
*(§10 étape D, tranché le 2026-08-31.)*