diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 23c8ae1..c6a8193 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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); diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 54b6344..d524130 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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; } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 32e3d51..64c5b31 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -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. * diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index ac64415..e9235c0 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -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") diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 46bdfc2..92df5c6 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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"; diff --git a/energyplugin/etm/types/loadaction.h b/energyplugin/etm/types/loadaction.h index 08f2ed8..da6ba19 100644 --- a/energyplugin/etm/types/loadaction.h +++ b/energyplugin/etm/types/loadaction.h @@ -5,6 +5,7 @@ #include #include #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 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 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 levels; + /*! * \brief Forçage sécurité — bypasse les verrous anti-rebond (minOn/minOff). * diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index b2863d1..9b2316a 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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.)*