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:
parent
a8f8734fc2
commit
8d035b0c1a
@ -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
|
||||
|
||||
* §10 étape C — L'AVANCEMENT BRANCHÉ EN LECTURE SEULE, et le RÉGIME publié (R5).
|
||||
|
||||
@ -452,7 +452,7 @@ namespace {
|
||||
constexpr double BruitAvancementWh = 50.0;
|
||||
}
|
||||
|
||||
EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const
|
||||
EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime &now) const
|
||||
{
|
||||
EcoProgress p;
|
||||
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).
|
||||
if (p.targetWh <= 0) {
|
||||
m_ecoBaseWh.remove(lc.id);
|
||||
m_ecoBaseEcheance.remove(lc.id);
|
||||
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.
|
||||
// `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.
|
||||
@ -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
|
||||
// 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()) {
|
||||
QVariantMap progress;
|
||||
progress.insert(QStringLiteral("regime"), avancement.regime);
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
#include "types/loadconfig.h"
|
||||
#include "types/drawcap.h"
|
||||
#include "types/ecoprogress.h"
|
||||
#include "types/ecotarget.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#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.
|
||||
*
|
||||
* \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
|
||||
* `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
|
||||
* 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é.
|
||||
@ -723,6 +729,10 @@ private:
|
||||
//! différences d'avancement. Recapturée quand l'accumulateur de la borne recule (nouvelle
|
||||
//! session), jamais au démarrage de nymead — voir \c ecoProgress().
|
||||
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).
|
||||
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ HEADERS += \
|
||||
$$PWD/types/plan.h \
|
||||
$$PWD/types/drawcap.h \
|
||||
$$PWD/types/ecoprogress.h \
|
||||
$$PWD/types/ecotarget.h \
|
||||
$$PWD/types/loadconfig.h \
|
||||
$$PWD/config/loadconfigstore.h \
|
||||
$$PWD/adapters/iloadadapter.h \
|
||||
@ -21,6 +22,7 @@ HEADERS += \
|
||||
SOURCES += \
|
||||
$$PWD/types/decisionreason.cpp \
|
||||
$$PWD/types/drawcap.cpp \
|
||||
$$PWD/types/ecotarget.cpp \
|
||||
$$PWD/types/loadconfig.cpp \
|
||||
$$PWD/config/loadconfigstore.cpp \
|
||||
$$PWD/ratios/energyratioscalculator.cpp \
|
||||
|
||||
95
energyplugin/etm/types/ecotarget.cpp
Normal file
95
energyplugin/etm/types/ecotarget.cpp
Normal 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;
|
||||
}
|
||||
89
energyplugin/etm/types/ecotarget.h
Normal file
89
energyplugin/etm/types/ecotarget.h
Normal 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;
|
||||
@ -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
|
||||
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.**
|
||||
*(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.)*
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
#include "etm/energyarbitrator.h"
|
||||
#include "etm/types/loadconfig.h"
|
||||
#include "etm/types/drawcap.h"
|
||||
#include "etm/types/ecotarget.h"
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
|
||||
#include <QTest>
|
||||
@ -536,6 +537,90 @@ void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
|
||||
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()
|
||||
{
|
||||
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se
|
||||
|
||||
@ -79,6 +79,7 @@ private slots:
|
||||
// ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
|
||||
//! 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.
|
||||
void testEcoSpreadHardensAndSaysSo();
|
||||
void testDrawMarginComparesHeadroomsNotCaps();
|
||||
};
|
||||
|
||||
|
||||
@ -6718,6 +6718,7 @@ void Simulation::testEcoProgressBaseAndRegime()
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0));
|
||||
LoadContext lc;
|
||||
lc.id = "borne-eco";
|
||||
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.
|
||||
LoadContext sansObligation = lc;
|
||||
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");
|
||||
|
||||
// 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET.
|
||||
@ -6735,7 +6736,7 @@ void Simulation::testEcoProgressBaseAndRegime()
|
||||
LoadContext nonMesurable = lc;
|
||||
nonMesurable.telemetry.sessionMeasurable = false;
|
||||
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.delivered, false);
|
||||
QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh),
|
||||
@ -6746,7 +6747,7 @@ void Simulation::testEcoProgressBaseAndRegime()
|
||||
LoadContext mesuree = lc;
|
||||
mesuree.telemetry.sessionMeasurable = true;
|
||||
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.delivered, true);
|
||||
QCOMPARE(qRound(ouverture.deliveredWh), 0);
|
||||
@ -6754,29 +6755,53 @@ void Simulation::testEcoProgressBaseAndRegime()
|
||||
|
||||
// …et l'avancement suit la DIFFÉRENCE, pas la valeur lue.
|
||||
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
|
||||
// session. La base est recapturée — sans quoi la différence serait NÉGATIVE.
|
||||
mesuree.telemetry.sessionWh = 120;
|
||||
QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0);
|
||||
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
|
||||
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
|
||||
// pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur.
|
||||
mesuree.telemetry.sessionWh = 610;
|
||||
const EcoProgress bruit = arb->ecoProgress(mesuree);
|
||||
const EcoProgress bruit = arb->ecoProgress(mesuree, t0);
|
||||
QCOMPARE(qRound(bruit.deliveredWh), 490);
|
||||
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,
|
||||
// et n'hérite pas de l'avancement d'une obligation qui n'existe plus.
|
||||
LoadContext retiree = mesuree;
|
||||
retiree.needs.minEnergyWhPerDay = 0;
|
||||
arb->ecoProgress(retiree);
|
||||
arb->ecoProgress(retiree, t0);
|
||||
mesuree.telemetry.sessionWh = 5000;
|
||||
QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0);
|
||||
QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user