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:
parent
e1fc723e15
commit
ca7a501797
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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).
|
||||
*
|
||||
|
||||
@ -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.)*
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user