feat(§10 étape D, 1/2): l'étalement uniforme, mesuré avant d'être asserté

`computeEcoTarget()` convertit une obligation en énergie en une cible en
watts : cadence = reste / temps restant, recalculée à chaque cycle. Une
cadence figée au départ ne rattrape pas — après une heure sans surplus elle
reste à la cadence initiale et manque l'échéance en ayant l'air de
travailler.

Mesurer d'abord était nécessaire, pas rituel : aucun scénario existant ne
portait d'obligation éco, donc aucune référence. Les valeurs ont été
relevées par sonde avant toute assertion — et c'est le relevé qui a trouvé
un défaut que la lecture du code ne montrait pas.

LA BASCULE DE PÉRIODE (LM-1013-c). À l'instant de l'échéance quotidienne,
resolveDeadline() vise la prochaine occurrence : une nouvelle obligation
s'ouvre. Sans reprise de la base d'avancement, le lendemain hérite de
l'énergie de la veille et se croit tenu sans avoir rien reçu. La sonde
affichait 83 W et 86400 s restantes à 07:00 pile, ce qui n'avait aucun sens
tant qu'on ne cherchait pas la période. Contre-épreuve : 1800 Wh portés au
crédit du jour suivant.

Limite assumée et écrite : l'obligation précédente, si elle n'était pas
tenue, est abandonnée SANS motif dédié. Le dire demande un ECO_FLOOR_MISSED
que rien n'émet ; le journal le dit, l'écran non.

Le durcissement est relevé point par point — 333 W à six heures, 2 kW à une
heure, le plafond dès seize minutes — parce que c'est la courbe que le motif
devra rendre lisible, pas une valeur isolée. Point de non-retour nommé
plutôt que subi : un seuil se discute, une asymptote non.

R6 armée dans le calcul : sous `unmeasurable`, jamais « tenue ». On vise
l'obligation entière, le choix qui sert plutôt que celui qui suppose acquis.

Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la
référence ligne à ligne, 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 12:40:07 +02:00
parent b52e6d5ccc
commit e1fc723e15
10 changed files with 407 additions and 13 deletions

View File

@ -1,3 +1,34 @@
powersync-energy-plugin-nymea (1.15.2+etm41) trixie; urgency=medium
* §10 étape D (1/2) — L'ÉTALEMENT UNIFORME, mesuré avant d'être asserté. `computeEcoTarget()`
convertit une obligation en ÉNERGIE en une cible en WATTS : cadence = reste / temps restant,
recalculée à chaque cycle. « Au plus tard » serait optimal mais suppose une prévision, qui
n'existe pas dans ce modèle (LM-1013-b).
* MESURÉ D'ABORD, ET C'ÉTAIT NÉCESSAIRE. Aucun scénario existant ne portait d'obligation éco —
`LoadContext::needs` n'était rempli par aucun adaptateur — donc aucune référence. Les valeurs
ont été relevées par sonde avant toute assertion, et c'est le relevé qui a révélé un défaut
que la lecture du code ne montrait pas (voir ci-dessous).
* LE DURCISSEMENT, relevé point par point : 2 kWh restants font 333 W à six heures de
l'échéance, 2 kW à une heure, et le plafond dès SEIZE minutes. La fenêtre où l'achat devient
massif n'arrive pas progressivement, elle arrive d'un coup — c'est pourquoi le motif devra
publier la cadence ET le temps restant.
* POINT DE NON-RETOUR nommé : sous un seuil, on cesse d'étaler et on prend le maximum. Un seuil
se discute, une asymptote se subit.
* LM-1013-c — LA BASCULE DE PÉRIODE, trouvée en mesurant. À l'instant de l'échéance
quotidienne, `resolveDeadline()` vise la prochaine occurrence : une nouvelle obligation
s'ouvre. Sans reprise de la base d'avancement, LE LENDEMAIN HÉRITE DE L'ÉNERGIE DE LA VEILLE
et se croit tenu sans avoir rien reçu. Contre-épreuve : 1800 Wh portés au crédit du jour
suivant. Limite assumée : l'obligation non tenue est abandonnée sans motif dédié — le journal
le dit, l'écran non, faute d'un ECO_FLOOR_MISSED que rien n'émet.
* R6 armée dans le calcul : sous un régime `unmeasurable`, on ne conclut JAMAIS « tenue ». On
vise l'obligation entière — le choix qui sert plutôt que celui qui suppose acquis.
* L'heure quotidienne vise la PROCHAINE occurrence : lue à 23:00, une limite de 07:00 vise le
lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les nuits.
* Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 09:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm40) trixie; urgency=medium powersync-energy-plugin-nymea (1.15.2+etm40) trixie; urgency=medium
* §10 étape C — L'AVANCEMENT BRANCHÉ EN LECTURE SEULE, et le RÉGIME publié (R5). * §10 étape C — L'AVANCEMENT BRANCHÉ EN LECTURE SEULE, et le RÉGIME publié (R5).

View File

@ -452,7 +452,7 @@ namespace {
constexpr double BruitAvancementWh = 50.0; constexpr double BruitAvancementWh = 50.0;
} }
EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime &now) const
{ {
EcoProgress p; EcoProgress p;
p.targetWh = lc.needs.minEnergyWhPerDay; p.targetWh = lc.needs.minEnergyWhPerDay;
@ -461,9 +461,30 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const
// obligation éco n'aura jamais d'entrée « eco » — l'absence EST l'information (R1). // obligation éco n'aura jamais d'entrée « eco » — l'absence EST l'information (R1).
if (p.targetWh <= 0) { if (p.targetWh <= 0) {
m_ecoBaseWh.remove(lc.id); m_ecoBaseWh.remove(lc.id);
m_ecoBaseEcheance.remove(lc.id);
return p; // regime vide : l'appelant n'émet pas de niveau éco return p; // regime vide : l'appelant n'émet pas de niveau éco
} }
// BASCULE DE PÉRIODE — mesuré le 2026-08-31, et ça ne se voyait pas en lisant le code.
//
// À l'instant de l'échéance quotidienne, `resolveDeadline()` vise la PROCHAINE occurrence :
// une nouvelle période d'obligation s'ouvre. Si la base ne se reprenait pas là, le lendemain
// hériterait de l'énergie livrée la veille et se croirait tenu sans avoir rien reçu — un
// avancement crédible et faux, exactement ce que la base existe pour empêcher.
const QDateTime echeance = resolveDeadline(lc.needs, now);
if (echeance.isValid() && m_ecoBaseEcheance.value(lc.id) != echeance) {
if (m_ecoBaseEcheance.contains(lc.id)) {
// L'obligation précédente s'achève. Si elle n'était pas tenue, elle est abandonnée
// ici — SANS motif dédié à ce stade, et c'est une limite assumée : le dire demande
// un `ECO_FLOOR_MISSED` que rien n'émet encore. Le journal, lui, le dit.
qCInfo(dcNymeaEnergy()) << "[Arbitre] Obligation éco" << lc.id
<< "— nouvelle période (échéance" << echeance.toString()
<< "), base d'avancement reprise.";
}
m_ecoBaseEcheance.insert(lc.id, echeance);
m_ecoBaseWh.remove(lc.id);
}
// C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit. // C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit.
// `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un // `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un
// avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait. // avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait.
@ -1487,7 +1508,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// //
// Absent quand aucune obligation n'est déclarée : une charge sans obligation éco ne // 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). // porte pas un avancement à zéro, elle n'en porte pas (R1).
const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now)); const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now), now);
if (!avancement.regime.isEmpty()) { if (!avancement.regime.isEmpty()) {
QVariantMap progress; QVariantMap progress;
progress.insert(QStringLiteral("regime"), avancement.regime); progress.insert(QStringLiteral("regime"), avancement.regime);

View File

@ -11,6 +11,7 @@
#include "types/loadconfig.h" #include "types/loadconfig.h"
#include "types/drawcap.h" #include "types/drawcap.h"
#include "types/ecoprogress.h" #include "types/ecoprogress.h"
#include "types/ecotarget.h"
#include <QDateTime> #include <QDateTime>
#include <QVariantMap> #include <QVariantMap>
@ -348,7 +349,12 @@ public:
/*! /*!
* \brief [§10-C] Avancement d'une obligation éco, et le régime qui dit ce qu'il vaut. * \brief [§10-C] Avancement d'une obligation éco, et le régime qui dit ce qu'il vaut.
* *
* \param lc Charge du cycle. \return L'avancement — \c regime toujours renseigné. * \param lc Charge du cycle.
* \param now Temps de cycle — sert à résoudre l'échéance, donc à détecter la BASCULE de
* période : à l'instant de l'heure limite quotidienne, une nouvelle obligation s'ouvre et
* la base d'avancement se reprend (\rule{LM-1013-c}). Sans lui, le lendemain hériterait de
* l'énergie de la veille et se croirait tenu sans avoir rien reçu.
* \return L'avancement — \c regime toujours renseigné quand l'obligation existe.
* *
* \par La BASE, et les deux questions qu'elle pose * \par La BASE, et les deux questions qu'elle pose
* `sessionWh` est un ACCUMULATEUR de plugin, pas un compteur remis à zéro pour nous : côté * `sessionWh` est un ACCUMULATEUR de plugin, pas un compteur remis à zéro pour nous : côté
@ -371,7 +377,7 @@ public:
* \note Un recul infime est du bruit de lecture, pas une nouvelle session — même seuil de * \note Un recul infime est du bruit de lecture, pas une nouvelle session — même seuil de
* principe que le plugin. En dessous, la base suit la valeur sans rien conclure. * principe que le plugin. En dessous, la base suit la valeur sans rien conclure.
*/ */
EcoProgress ecoProgress(const LoadContext &lc) const; EcoProgress ecoProgress(const LoadContext &lc, const QDateTime &now) const;
/*! /*!
* \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé. * \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé.
@ -723,6 +729,10 @@ private:
//! différences d'avancement. Recapturée quand l'accumulateur de la borne recule (nouvelle //! différences d'avancement. Recapturée quand l'accumulateur de la borne recule (nouvelle
//! session), jamais au démarrage de nymead — voir \c ecoProgress(). //! session), jamais au démarrage de nymead — voir \c ecoProgress().
mutable QHash<QString, double> m_ecoBaseWh; mutable QHash<QString, double> m_ecoBaseWh;
//! loadId → échéance en vigueur quand la base a été prise. Quand elle BASCULE (heure limite
//! quotidienne franchie), la période d'obligation change : la base se reprend, sinon le
//! lendemain hérite de l'énergie de la veille et se croit déjà tenu.
mutable QHash<QString, QDateTime> m_ecoBaseEcheance;
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).

View File

@ -6,6 +6,7 @@ HEADERS += \
$$PWD/types/plan.h \ $$PWD/types/plan.h \
$$PWD/types/drawcap.h \ $$PWD/types/drawcap.h \
$$PWD/types/ecoprogress.h \ $$PWD/types/ecoprogress.h \
$$PWD/types/ecotarget.h \
$$PWD/types/loadconfig.h \ $$PWD/types/loadconfig.h \
$$PWD/config/loadconfigstore.h \ $$PWD/config/loadconfigstore.h \
$$PWD/adapters/iloadadapter.h \ $$PWD/adapters/iloadadapter.h \
@ -21,6 +22,7 @@ HEADERS += \
SOURCES += \ SOURCES += \
$$PWD/types/decisionreason.cpp \ $$PWD/types/decisionreason.cpp \
$$PWD/types/drawcap.cpp \ $$PWD/types/drawcap.cpp \
$$PWD/types/ecotarget.cpp \
$$PWD/types/loadconfig.cpp \ $$PWD/types/loadconfig.cpp \
$$PWD/config/loadconfigstore.cpp \ $$PWD/config/loadconfigstore.cpp \
$$PWD/ratios/energyratioscalculator.cpp \ $$PWD/ratios/energyratioscalculator.cpp \

View File

@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "ecotarget.h"
#include <QTimeZone>
QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now)
{
// L'échéance ABSOLUE l'emporte : elle a été posée pour une occasion précise, là où l'heure
// quotidienne est une habitude. Les mélanger ferait qu'un départ exceptionnel à 5 h serait
// écrasé par un réglage à 7 h.
if (needs.deadline.isValid())
return needs.deadline;
if (needs.dailyDeadline.isEmpty())
return QDateTime();
const QTime heure = QTime::fromString(needs.dailyDeadline, QStringLiteral("HH:mm"));
if (!heure.isValid())
return QDateTime();
// PROCHAINE occurrence, jamais la passée. Une heure limite de 07:00 lue à 23:00 vise le
// lendemain : la lire comme dépassée ferait franchir le point de non-retour toutes les
// nuits, et acheter à pleine puissance chaque soir sans qu'aucune échéance ne l'exige.
QDateTime echeance(now.date(), heure, now.timeZone());
if (echeance <= now)
echeance = echeance.addDays(1);
return echeance;
}
EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress,
double maxPowerW, const QDateTime &now)
{
EcoTarget t;
// Pas d'obligation en énergie : rien à étaler. La passe éco n'émettra pas de niveau pour
// cette charge — son absence dit « ne participe qu'au confort » (R1).
if (needs.minEnergyWhPerDay <= 0)
return t;
const QDateTime echeance = resolveDeadline(needs, now);
if (!echeance.isValid())
return t; // une obligation sans échéance n'a pas de cadence : elle n'est pas étalable
t.applicable = true;
t.remainingS = now.secsTo(echeance);
// TENUE — mais seulement si on MESURE. Sous un régime `unmeasurable`, l'avancement est
// inconnu : conclure « tenue » serait affirmer sur ce qu'on ne sait pas, et c'est exactement
// ce que C1 de LM-1009 interdit. Une échéance qui cesse silencieusement d'être mesurable est
// pire qu'une échéance absente, parce qu'elle continue de s'afficher comme si elle tenait.
if (progress.delivered) {
t.remainingWh = qMax(0.0, progress.targetWh - progress.deliveredWh);
t.tenue = t.remainingWh <= 0;
} else {
// Non mesurable : on ne sait pas ce qui a été livré. On vise l'obligation ENTIÈRE — le
// choix prudent, celui qui sert plutôt que celui qui suppose acquis.
t.remainingWh = needs.minEnergyWhPerDay;
t.tenue = false;
}
if (t.tenue)
return t; // watts = 0 : plus rien à acheter pour l'éco
// Échéance dépassée : il n'y a plus rien à étaler. On prend le maximum, ou l'obligation
// reste due sans jamais être servie.
if (t.remainingS <= 0) {
t.pointDeNonRetour = true;
t.watts = maxPowerW > 0 ? maxPowerW : t.remainingWh;
return t;
}
// POINT DE NON-RETOUR — le seuil est nommé plutôt que subi. En deçà, la cadence calculée
// dépasserait de toute façon ce que la charge sait prendre : on cesse d'étaler.
if (t.remainingS <= EcoPointDeNonRetourS) {
t.pointDeNonRetour = true;
t.watts = maxPowerW > 0 ? maxPowerW : t.remainingWh;
return t;
}
// ÉTALEMENT UNIFORME, recalculé à chaque cycle. Il se DURCIT mécaniquement à l'approche :
// 2 kWh restants font 2 kW à une heure de l'échéance et 12 kW à dix minutes. C'est pourquoi
// le motif publie la cadence ET le temps restant — sans eux, un client voit sa facture
// grimper à six heures du matin sans cause lisible, et cherche une panne.
const double heuresRestantes = t.remainingS / 3600.0;
t.watts = t.remainingWh / heuresRestantes;
// Écrêté au plafond physique : demander plus ne ferait qu'inventer un chiffre. Le retard
// qui en résulte sera rattrapé au cycle suivant, puisque la cadence se relit.
if (maxPowerW > 0)
t.watts = qMin(t.watts, maxPowerW);
return t;
}

View File

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include "loaddescriptor.h"
#include "ecoprogress.h"
/*!
* \file ecotarget.h
* \brief Convertit une obligation en ÉNERGIE en une cible en WATTS pour ce cycle.
*/
/*!
* \brief Cible éco d'un cycle, et de quoi la DIRE.
*
* \par Pourquoi chaque champ y est
* Le motif publié doit permettre à un client de comprendre une facture qui monte à six heures du
* matin. Les watts seuls ne le permettent pas : il faut l'obligation qu'ils servent, l'échéance
* qui les commande, et la cadence qui explique pourquoi ils augmentent.
*/
struct EcoTarget {
//! Puissance à tenir CE CYCLE pour rester dans les temps. 0 = rien à faire.
double watts = 0;
//! Énergie restant à livrer (Wh). 0 quand l'obligation est tenue.
double remainingWh = 0;
//! Temps restant avant l'échéance (s). Négatif = échéance dépassée.
qint64 remainingS = 0;
//! \brief L'obligation est TENUE. Ne vaut jamais vrai sous un régime \c unmeasurable :
//! on ne conclut pas « tenue » sur un avancement qu'on ne mesure pas (\rule{LM-1009} C1,
//! exigence R6).
bool tenue = false;
/*!
* \brief Le POINT DE NON-RETOUR est franchi : on prend tout ce qu'on peut.
*
* L'étalement uniforme se durcit mécaniquement à l'approche — 2 kWh restants font 2 kW à une
* heure de l'échéance et **12 kW à dix minutes**. Passé un seuil, la cadence calculée dépasse
* de toute façon ce que la charge sait prendre : on cesse d'étaler et on demande le maximum.
*
* **Le seuil est NOMMÉ plutôt que subi.** Sans lui, la cadence tend vers l'infini sans que
* rien ne décide, et le comportement se lit comme une dérive. Un seuil se discute.
*/
bool pointDeNonRetour = false;
//! Vrai si une obligation exploitable existe — sinon la passe éco n'émet rien pour cette
//! charge, et son niveau est ABSENT (R1) plutôt que présent à zéro.
bool applicable = false;
};
/*!
* \brief Résout l'instant d'échéance depuis les besoins déclarés.
* \param needs Besoins de la charge. \param now Temps de cycle.
* \return L'échéance absolue, ou un \c QDateTime invalide si aucune n'est déclarée.
* \note \c deadline (absolue) l'emporte sur \c dailyDeadline ; cette dernière se résout à sa
* PROCHAINE occurrence après \c now — une heure limite de 07:00 lue à 23:00 vise le lendemain,
* et la lire comme passée ferait franchir le point de non-retour toutes les nuits.
*/
QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now);
/*!
* \brief Cadence à tenir ce cycle pour honorer l'obligation — ÉTALEMENT UNIFORME.
*
* \param needs Besoins déclarés (l'obligation).
* \param progress Avancement et son régime.
* \param maxPowerW Plafond physique de la charge (0 = inconnu, aucun écrêtage).
* \param now Temps de cycle.
* \return La cible, avec de quoi la publier.
*
* \par Pourquoi l'étalement, et pas mieux
* « Au plus tard » serait optimal mais suppose une **prévision** de production ; la prévision
* n'existe pas dans ce modèle. Un étalement se lit, se teste, et ne prétend rien prévoir — même
* raison qui a fait de la cible de confort un comparateur plutôt qu'un dosage (\rule{LM-1008}).
*
* \par Recalculé à CHAQUE cycle, jamais figé
* Une cadence calculée une fois au branchement ne rattrape pas : après une heure sans surplus,
* elle reste à la cadence initiale et **manque l'échéance en ayant l'air de travailler**. Les
* deux termes du rapport bougent, donc le rapport se relit.
*/
EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress,
double maxPowerW, const QDateTime &now);
//! \brief Sous ce temps restant, on cesse d'étaler et on prend le maximum disponible.
//! Valeur de conception, à ajuster sur relevé : trop court, on rate l'échéance faute de
//! puissance ; trop long, on achète tôt ce que le soleil aurait pu payer.
constexpr qint64 EcoPointDeNonRetourS = 15 * 60;

View File

@ -957,6 +957,41 @@ 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-1013-b — une obligation en ÉNERGIE devient une cible en WATTS par étalement uniforme.**
*(§10 étape D, tranché le 2026-08-31.)*
`cadence = reste / temps restant`, et rien de plus savant. « Au plus tard » serait optimal mais
suppose une **prévision** de production, qui n'existe pas dans ce modèle. Même raison qui a fait
de la cible de confort un comparateur plutôt qu'un dosage (`LM-1008`) : ce qui manque n'est pas
la formule, c'est le modèle qui la rendrait honnête.
**LM-1013-b-i — recalculé à CHAQUE cycle, jamais figé au départ.** Une cadence calculée une fois
au branchement ne rattrape pas : après une heure sans surplus, elle reste à la cadence initiale
et **manque l'échéance en ayant l'air de travailler**. Les deux termes du rapport bougent.
**LM-1013-b-ii — le durcissement est mécanique, et le motif doit le DIRE.** Relevé : 2 kWh
restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **le plafond dès
seize minutes**. La fenêtre où l'achat devient massif n'arrive pas progressivement, elle arrive
**d'un coup**. Le motif publie donc la cadence ET le temps restant — sans eux, un client voit sa
facture grimper à six heures du matin sans cause lisible et cherche une panne.
**LM-1013-b-iii — un POINT DE NON-RETOUR nommé.** Sous un seuil de temps restant, on cesse
d'étaler et on prend le maximum. Sans lui la cadence tend vers l'infini sans que rien ne décide :
**un seuil se discute, une asymptote se subit.**
**LM-1013-b-iv — l'heure quotidienne vise la PROCHAINE occurrence.** Lue à 23:00, une limite de
07:00 vise le lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les
nuits, et acheter à pleine puissance chaque soir sans qu'aucune échéance ne l'exige.
**LM-1013-c — la BASCULE DE PÉRIODE reprend la base d'avancement.** *(Trouvé en mesurant, et
invisible à la lecture du code.)* À l'instant de l'échéance quotidienne, une nouvelle période
d'obligation s'ouvre. Sans reprise de la base, **le lendemain hérite de l'énergie de la veille et
se croit tenu sans avoir rien reçu** — contre-épreuve : 1800 Wh portés au crédit du jour suivant.
> **Limite assumée** : l'obligation précédente, si elle n'était pas tenue, est abandonnée **sans
> motif dédié**. Le dire demanderait un `ECO_FLOOR_MISSED` que rien n'émet encore ; le journal le
> dit, l'écran non. À reprendre quand le catalogue s'ouvrira.
**LM-1011 — `BELOW_MIN_POWER` prime sur `DRAW_CAP` dès qu'il reste de l'autorisation.** **LM-1011 — `BELOW_MIN_POWER` prime sur `DRAW_CAP` dès qu'il reste de l'autorisation.**
*(Précédence tranchée par Patrick le 2026-08-29, sur une question de l'agent app. Écrite ICI, *(Précédence tranchée par Patrick le 2026-08-29, sur une question de l'agent app. Écrite ICI,
une fois, pour qu'aucun côté de la frontière ne l'établisse pour son compte.)* une fois, pour qu'aucun côté de la frontière ne l'établisse pour son compte.)*

View File

@ -6,6 +6,7 @@
#include "etm/energyarbitrator.h" #include "etm/energyarbitrator.h"
#include "etm/types/loadconfig.h" #include "etm/types/loadconfig.h"
#include "etm/types/drawcap.h" #include "etm/types/drawcap.h"
#include "etm/types/ecotarget.h"
#include "etm/config/loadconfigstore.h" #include "etm/config/loadconfigstore.h"
#include <QTest> #include <QTest>
@ -536,6 +537,90 @@ void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé"); QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé");
} }
/*!
* \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE.
*
* \par Ce que la mesure a montré avant que rien ne soit asserté
* L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne
* portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y
* avait donc **aucune référence** à laquelle se comparer : les valeurs ci-dessous ont été
* relevées d'abord, épinglées ensuite. C'est ce relevé qui a révélé la bascule de période, que
* la lecture du code ne montrait pas.
*
* \par Le durcissement est mécanique, et c'est pourquoi le motif doit le porter
* 2 kWh restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **7 kW —
* le plafond — dès seize minutes**. La fenêtre où l'achat devient massif n'arrive pas
* progressivement : elle arrive d'un coup. Un client qui voit sa facture grimper à six heures du
* matin sans cause lisible cherchera une panne.
*/
void TestLoadModel::testEcoSpreadHardensAndSaysSo()
{
LoadNeeds needs;
needs.minEnergyWhPerDay = 2000;
needs.dailyDeadline = "07:00";
EcoProgress p;
p.delivered = true;
p.deliveredWh = 0;
p.targetWh = 2000;
const QDate j(2026, 8, 31);
auto cible = [&](int h, int m) {
return computeEcoTarget(needs, p, 7000, QDateTime(j, QTime(h, m), QTimeZone::utc()));
};
// 1. LE DURCISSEMENT, relevé point par point. C'est la courbe que le motif doit rendre
// lisible — pas une valeur isolée, une progression.
QCOMPARE(qRound(cible(1, 0).watts), 333); // 6 h avant
QCOMPARE(qRound(cible(5, 0).watts), 1000); // 2 h avant
QCOMPARE(qRound(cible(6, 0).watts), 2000); // 1 h avant
QCOMPARE(qRound(cible(6, 30).watts), 4000); // 30 min
QCOMPARE(qRound(cible(6, 44).watts), 7000); // 16 min — déjà au plafond physique
// 2. Le POINT DE NON-RETOUR est franchi sous le seuil, et il se DIT. Sans lui, la cadence
// tend vers l'infini sans que rien ne décide : un seuil se discute, une asymptote se subit.
QVERIFY2(!cible(6, 44).pointDeNonRetour, "à 16 min le seuil n'est pas encore franchi");
QVERIFY2(cible(6, 50).pointDeNonRetour, "à 10 min il doit l'être");
QCOMPARE(qRound(cible(6, 50).watts), 7000);
// 3. La cadence est RECALCULÉE, jamais figée : un rattrapage après une heure sans surplus
// doit monter. Figée au départ, elle manquerait l'échéance en ayant l'air de travailler.
p.deliveredWh = 0;
const double aSixHeures = cible(1, 0).watts;
const double aDeuxHeures = cible(5, 0).watts;
QVERIFY2(aDeuxHeures > aSixHeures * 2.5,
"sans rien livré, la cadence doit avoir monté d'elle-même");
// 4. Ce qui est DÉJÀ LIVRÉ allège la cadence — l'étalement porte sur le RESTE.
p.deliveredWh = 1500;
QCOMPARE(qRound(cible(5, 0).watts), 250); // 500 Wh en 2 h
QCOMPARE(qRound(cible(5, 0).remainingWh), 500);
// 5. Obligation TENUE : plus rien à acheter pour l'éco.
p.deliveredWh = 2000;
QVERIFY2(cible(5, 0).tenue, "2000 sur 2000 : l'obligation est tenue");
QCOMPARE(qRound(cible(5, 0).watts), 0);
// 6. R6 — SOUS `unmeasurable`, ON NE CONCLUT JAMAIS « TENUE ». On ne sait pas ce qui a été
// livré ; l'affirmer serait affirmer sur ce qu'on ignore. On vise l'obligation entière,
// qui est le choix qui SERT plutôt que celui qui suppose acquis.
EcoProgress inconnu;
inconnu.delivered = false;
inconnu.targetWh = 2000;
const EcoTarget ti = computeEcoTarget(needs, inconnu, 7000,
QDateTime(j, QTime(5, 0), QTimeZone::utc()));
QVERIFY2(!ti.tenue, "jamais « tenue » sur un avancement qu'on ne mesure pas (LM-1009 C1)");
QCOMPARE(qRound(ti.remainingWh), 2000);
// 7. L'heure quotidienne lue le SOIR vise le LENDEMAIN. La lire comme dépassée ferait
// franchir le point de non-retour toutes les nuits, et acheter à pleine puissance chaque
// soir sans qu'aucune échéance ne l'exige.
const EcoTarget soir = computeEcoTarget(needs, p, 7000,
QDateTime(j, QTime(23, 0), QTimeZone::utc()));
QVERIFY2(!soir.pointDeNonRetour, "23:00 vers 07:00 : huit heures, pas un retard");
QCOMPARE(soir.remainingS, qint64(8 * 3600));
}
void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps() void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
{ {
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se // [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se

View File

@ -79,6 +79,7 @@ private slots:
// ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── // ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait
//! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale. //! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale.
void testEcoSpreadHardensAndSaysSo();
void testDrawMarginComparesHeadroomsNotCaps(); void testDrawMarginComparesHeadroomsNotCaps();
}; };

View File

@ -6718,6 +6718,7 @@ void Simulation::testEcoProgressBaseAndRegime()
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager()); EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb); QVERIFY(arb);
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0));
LoadContext lc; LoadContext lc;
lc.id = "borne-eco"; lc.id = "borne-eco";
lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour
@ -6726,7 +6727,7 @@ void Simulation::testEcoProgressBaseAndRegime()
// charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information. // charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information.
LoadContext sansObligation = lc; LoadContext sansObligation = lc;
sansObligation.needs.minEnergyWhPerDay = 0; sansObligation.needs.minEnergyWhPerDay = 0;
QVERIFY2(arb->ecoProgress(sansObligation).regime.isEmpty(), QVERIFY2(arb->ecoProgress(sansObligation, t0).regime.isEmpty(),
"sans obligation, aucun régime : le niveau éco n'existe pas"); "sans obligation, aucun régime : le niveau éco n'existe pas");
// 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET. // 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET.
@ -6735,7 +6736,7 @@ void Simulation::testEcoProgressBaseAndRegime()
LoadContext nonMesurable = lc; LoadContext nonMesurable = lc;
nonMesurable.telemetry.sessionMeasurable = false; nonMesurable.telemetry.sessionMeasurable = false;
nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION
const EcoProgress sansMesure = arb->ecoProgress(nonMesurable); const EcoProgress sansMesure = arb->ecoProgress(nonMesurable, t0);
QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable)); QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable));
QCOMPARE(sansMesure.delivered, false); QCOMPARE(sansMesure.delivered, false);
QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh), QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh),
@ -6746,7 +6747,7 @@ void Simulation::testEcoProgressBaseAndRegime()
LoadContext mesuree = lc; LoadContext mesuree = lc;
mesuree.telemetry.sessionMeasurable = true; mesuree.telemetry.sessionMeasurable = true;
mesuree.telemetry.sessionWh = 7204; mesuree.telemetry.sessionWh = 7204;
const EcoProgress ouverture = arb->ecoProgress(mesuree); const EcoProgress ouverture = arb->ecoProgress(mesuree, t0);
QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured)); QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured));
QCOMPARE(ouverture.delivered, true); QCOMPARE(ouverture.delivered, true);
QCOMPARE(qRound(ouverture.deliveredWh), 0); QCOMPARE(qRound(ouverture.deliveredWh), 0);
@ -6754,29 +6755,53 @@ void Simulation::testEcoProgressBaseAndRegime()
// …et l'avancement suit la DIFFÉRENCE, pas la valeur lue. // …et l'avancement suit la DIFFÉRENCE, pas la valeur lue.
mesuree.telemetry.sessionWh = 9204; mesuree.telemetry.sessionWh = 9204;
QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 2000); QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 2000);
// 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle // 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle
// session. La base est recapturée — sans quoi la différence serait NÉGATIVE. // session. La base est recapturée — sans quoi la différence serait NÉGATIVE.
mesuree.telemetry.sessionWh = 120; mesuree.telemetry.sessionWh = 120;
QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0); QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
mesuree.telemetry.sessionWh = 620; mesuree.telemetry.sessionWh = 620;
QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 500); QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 500);
// 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge // 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge
// pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur. // pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur.
mesuree.telemetry.sessionWh = 610; mesuree.telemetry.sessionWh = 610;
const EcoProgress bruit = arb->ecoProgress(mesuree); const EcoProgress bruit = arb->ecoProgress(mesuree, t0);
QCOMPARE(qRound(bruit.deliveredWh), 490); QCOMPARE(qRound(bruit.deliveredWh), 490);
QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif"); QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif");
// 7. BASCULE DE PÉRIODE — le cas que la sonde a révélé, et qu'aucune lecture du code
// n'aurait montré. À l'instant de l'échéance quotidienne, une NOUVELLE période s'ouvre.
// Sans reprise de la base ici, le lendemain hériterait de l'énergie de la veille et se
// croirait tenu sans avoir rien reçu.
LoadContext quotidienne;
quotidienne.id = "borne-quotidienne";
quotidienne.needs.minEnergyWhPerDay = 2000;
quotidienne.needs.dailyDeadline = "07:00";
quotidienne.telemetry.sessionMeasurable = true;
const QDateTime veille = utcDateTime(QDate(2026, 8, 31), QTime(6, 0, 0)); // vise 07:00 J
const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); // vise 07:00 J+1
quotidienne.telemetry.sessionWh = 1000;
QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 0); // base prise ici
quotidienne.telemetry.sessionWh = 2800;
QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 1800);
// L'échéance a basculé : la période est neuve, l'avancement repart de zéro — l'énergie de
// la veille n'est pas portée au crédit du jour suivant.
QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 0);
quotidienne.telemetry.sessionWh = 3300;
QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 500);
// 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf, // 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf,
// et n'hérite pas de l'avancement d'une obligation qui n'existe plus. // et n'hérite pas de l'avancement d'une obligation qui n'existe plus.
LoadContext retiree = mesuree; LoadContext retiree = mesuree;
retiree.needs.minEnergyWhPerDay = 0; retiree.needs.minEnergyWhPerDay = 0;
arb->ecoProgress(retiree); arb->ecoProgress(retiree, t0);
mesuree.telemetry.sessionWh = 5000; mesuree.telemetry.sessionWh = 5000;
QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0); QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
#endif #endif
} }