moteur : l'écart commande/mesure est jugé au début du cycle (P1)

L'écart était jugé APRÈS le dispatch, entre la consigne qui venait de
partir et la mesure de la précédente, puis consommé au cycle suivant
avant d'être rejugé. Toute marche de consigne suivie par la borne se
lisait donc comme un refus vieux d'un cycle (60 s, au-delà des 30 s /
7 s de la borne) : recrédit suspendu, coupure, et une boucle de trois
cycles sous un surplus constant (~1,5 kW exportés en moyenne).

jugerCommande() rend désormais UN jugement par cycle, au début du cycle,
lu par le recrédit et par la télémétrie :
- la commande EN VIGUEUR contre la mesure fraîche ; s'ils concordent
  (max(200 W, 15 %)), l'épisode se referme ;
- l'ouverture est datée à l'instant de la commande, en temps de cycle,
  et non plus à l'horloge murale de la dernière publication ;
- le sens est celui que la commande demande à la charge, et non plus
  deviné au niveau de la mesure ;
- en montée seulement, une mesure rapprochée de la commande de plus de
  200 W depuis le cycle précédent est une latence. La descente n'a
  jamais cette excuse.

command.divergent publie ce verdict ; expectedW et observedW restent
les nombres de la publication.

Le test de reproduction inverse ses assertions « DÉFAUT » : sur trois
rampes (instantanée, 30 s / 7 s, 120 s), 13 A tenus dès le premier
cycle, aucune suspension, export de 10 W dès que la borne a rejoint sa
consigne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 19:30:45 +01:00
parent 41ce86a634
commit 0dc2a1aeba
4 changed files with 405 additions and 221 deletions

View File

@ -1797,26 +1797,19 @@ void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const
void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const
{ {
// Le jugement est fait ICI, au début du cycle, et nulle part ailleurs : la commande en
// vigueur contre la mesure fraîche. Il était fait après le dispatch, contre la consigne qui
// venait de partir et la mesure de la précédente — toute marche suivie se lisait comme un
// refus vieux d'un cycle (60 s, au-delà des 30 s / 7 s d'une borne), et le recrédit d'une
// borne qui obéit était suspendu à chaque marche. La consigne oscillait sous un surplus
// constant.
const std::optional<JugementCommande> j = jugerCommande(lc.id, now);
if (!j)
return; // rien ne mesure, ou rien n'a été commandé : rien à qualifier
const QDateTime depuis = m_divergenceDepuis.value(lc.id); const QDateTime depuis = m_divergenceDepuis.value(lc.id);
if (!depuis.isValid()) if (depuis.isValid())
return; // aucun écart en cours : rien à qualifier lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
lc.telemetry.commandRefused = j->refus;
const int ecoule = static_cast<int>(depuis.secsTo(now));
lc.telemetry.refusedSinceS = qMax(0, ecoule);
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr);
if (!a)
a = m_adapters.value(lc.id, nullptr);
if (!a)
return;
// Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux —
// commander la baisse sans être suivi — est justement celui qui se constate le plus vite.
const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0;
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
} }
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
@ -2384,7 +2377,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
QVariantMap mech; QVariantMap mech;
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger")); mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
orphan.insert(QStringLiteral("mechanism"), mech); orphan.insert(QStringLiteral("mechanism"), mech);
attachMeasurements(orphan, action.loadId); attachMeasurements(orphan, action.loadId, now);
loads.append(orphan); loads.append(orphan);
continue; continue;
} }
@ -2507,7 +2500,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
mech.insert(QStringLiteral("kind"), v.mechanismKind); mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech); entry.insert(QStringLiteral("mechanism"), mech);
} }
attachMeasurements(entry, action.loadId); attachMeasurements(entry, action.loadId, now);
loads.append(entry); loads.append(entry);
} }
@ -2533,14 +2526,113 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
namespace { namespace {
//! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de //! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de
//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent. //! mesure et arrondi à l'ampère s'y logent. Valeur de conception, à ajuster sur relevé.
//! Valeur de conception, à ajuster sur relevé.
constexpr double DivergenceAbsolueW = 200.0; constexpr double DivergenceAbsolueW = 200.0;
constexpr double DivergenceRelative = 0.15; constexpr double DivergenceRelative = 0.15;
//! Rapprochement minimal, d'un cycle au suivant, pour qu'une mesure soit « en progression ».
//! La MÊME grandeur que la tolérance absolue, et c'est voulu : un mouvement que le moteur ne
//! distingue pas d'une concordance ne se distingue pas non plus du bruit. Une charge figée ne le
//! franchit jamais ; la montée la plus lente relevée au banc (479 s pour ~3 kW, soit ~375 W par
//! cycle de 60 s) le franchit. Et l'excuse est BORNÉE par construction : chaque cycle excusé
//! rapproche la mesure d'au moins 200 W, donc un écart de E watts ne s'excuse jamais plus de
//! E / 200 cycles.
constexpr double ProgressionMinW = DivergenceAbsolueW;
double toleranceConcordance(double commandeW)
{
return qMax(DivergenceAbsolueW, DivergenceRelative * commandeW);
}
}
std::optional<EnergyArbitrator::JugementCommande>
EnergyArbitrator::jugerCommande(const QString &loadId, const QDateTime &now) const
{
// Un seul jugement par cycle : le recrédit (début du cycle) et la télémétrie (fin) lisent le
// MÊME. Deux comparaisons, à deux instants, diraient deux choses de la même charge.
const auto cache = m_jugementCommande.constFind(loadId);
if (cache != m_jugementCommande.constEnd() && cache->at == now)
return *cache;
// Le jugement du cycle PRÉCÉDENT — la référence de la progression.
const JugementCommande precedent = cache != m_jugementCommande.constEnd()
? *cache : JugementCommande();
ILoadAdapter *a = m_loadAdapters.value(loadId, nullptr);
if (!a)
a = m_adapters.value(loadId, nullptr);
const MesureCharge mc = mesurerCharge(loadId);
// Une charge jamais commandée n'est pas une charge commandée à zéro ; une charge que rien ne
// mesure n'est pas une charge qui concorde. Dans les deux cas : rien à juger, et l'épisode
// éventuel est oublié sans conclusion.
const std::optional<double> commandeeW = a ? puissanceCommandee(loadId) : std::nullopt;
if (!commandeeW || mc.source == QLatin1String("none")) {
m_jugementCommande.remove(loadId);
m_commandeEnVigueur.remove(loadId);
m_divergenceDepuis.remove(loadId);
return std::nullopt;
}
const double commandeW = *commandeeW;
// --- L'INSTANT DE LA COMMANDE, en temps de cycle ---------------------------------------
// Elle a changé depuis le dernier jugement : elle est partie au dernier dispatch, s'il a eu
// lieu depuis. Sinon (repli L2, protection L4 hors cycle) l'instant est inconnu, et on prend
// le plus récent possible — une latence accordée à tort retarde un verdict d'un cycle, un
// refus prononcé à tort suspend le recrédit d'une charge saine.
QPair<double, QDateTime> &enVigueur = m_commandeEnVigueur[loadId];
if (!enVigueur.second.isValid()
|| qAbs(commandeW - enVigueur.first) > toleranceConcordance(enVigueur.first)) {
// Le dispatch d'un cycle porte le MÊME temps que le jugement fait au début de ce cycle :
// il le suit, d'où le « au moins aussi récent ».
const bool parDispatch = m_actionsAppliqueesAt.isValid() && m_actionsAppliqueesAt <= now
&& (!precedent.at.isValid() || m_actionsAppliqueesAt >= precedent.at);
enVigueur = { commandeW, parDispatch ? m_actionsAppliqueesAt : now };
}
JugementCommande j;
j.at = now;
j.commandeW = commandeW;
j.mesureW = mc.powerW;
j.source = mc.source;
j.commandeDepuis = enVigueur.second;
const double ecartW = qAbs(commandeW - mc.powerW);
if (ecartW <= toleranceConcordance(commandeW)) {
// CONCORDANCE : l'épisode se referme, quelle que soit sa durée.
if (m_divergenceDepuis.contains(loadId))
qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId
<< "après" << m_divergenceDepuis.value(loadId).secsTo(now) << "s"
<< "— attendu" << commandeW << "W, mesuré" << mc.powerW << "W";
m_divergenceDepuis.remove(loadId);
m_jugementCommande.insert(loadId, j);
return j;
}
// JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié : sans ces lignes, un épisode
// qui s'ouvre et se referme entre deux lectures ne laisse aucune trace.
if (!m_divergenceDepuis.contains(loadId)) {
m_divergenceDepuis.insert(loadId, j.commandeDepuis); // daté à l'instant de la COMMANDE
qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId
<< "— attendu" << commandeW << "W, mesuré" << mc.powerW << "W,"
<< "écart" << (mc.powerW - commandeW) << "W";
}
// Le SENS est celui que la commande demande à la charge : monter si elle est au-dessus de la
// mesure, descendre sinon. Il était deviné au NIVEAU de la mesure (sous ou au-dessus de la
// moitié du plafond), si bien qu'une montée vers 13 A sur 16 était jugée en descente.
j.montee = commandeW > mc.powerW;
// PROGRESSION — en montée SEULEMENT, et sur le même instrument : deux sources ne se
// comparent pas. La descente n'a jamais cette excuse : commander la baisse sans être suivi est
// le refus qui coûte de l'argent, et il reste jugé sur la latence courte, au même cycle.
j.enProgression = j.montee && precedent.at.isValid() && precedent.source == mc.source
&& qAbs(commandeW - precedent.mesureW) - ecartW > ProgressionMinW;
const qint64 ecouleS = j.commandeDepuis.secsTo(now);
j.refus = ecouleS > a->commandSettleS(j.montee) && !j.enProgression;
m_jugementCommande.insert(loadId, j);
return j;
} }
void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId, void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId,
const QString &source, double mesureW) const const QString &source, double mesureW,
const QDateTime &now) const
{ {
// RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`, // RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`,
// qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout. // qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout.
@ -2553,58 +2645,34 @@ void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString
const std::optional<double> commandeeW = puissanceCommandee(loadId); const std::optional<double> commandeeW = puissanceCommandee(loadId);
if (!commandeeW) if (!commandeeW)
return; return;
const double attenduW = *commandeeW;
const double ecart = qAbs(attenduW - mesureW); // Le VERDICT est celui du cycle — le jugement que le recrédit a lu, fait au début du cycle
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW); // sur la commande en vigueur et la mesure fraîche. Il n'est PAS refait ici sur la consigne
// qui vient de partir : c'était le défaut, une marche suivie se lisait comme un refus. Sans
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un // commande en vigueur au début du cycle (première commande), le jugement est fait ici, sur
// déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence // celle qui vient de partir : récente, donc jamais divergente.
// entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait const std::optional<JugementCommande> j = jugerCommande(loadId, now);
// indéfiniment parce que plus personne ne commandait. const bool divergent = j && j->refus;
// JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié.
//
// `divergent` et `since` ne vivaient que dans la charge utile de télémétrie et dans ce
// QHash en mémoire — donc nulle part. Rien ne comptait les épisodes : un redémarrage de
// nymead effaçait l'état, et le hash est PURGÉ dès que l'écart cesse, si bien qu'une
// divergence qui s'ouvre et se referme entre deux lectures ne laissait aucune trace. Même
// un observateur externe échantillonnant en continu aurait raté les épisodes courts, qui
// sont probablement les plus nombreux.
//
// Sans ces deux lignes, « attendre que les traces parlent » attend indéfiniment : le
// silence du journal était garanti par construction, pas par l'absence du phénomène.
if (!divergent) {
if (m_divergenceDepuis.contains(loadId)) {
const qint64 dureeS = m_divergenceDepuis.value(loadId).secsTo(QDateTime::currentDateTime());
qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId
<< "après" << dureeS << "s"
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W";
}
m_divergenceDepuis.remove(loadId);
} else if (!m_divergenceDepuis.contains(loadId)) {
const QDateTime debut = m_lastTelemetryPublish.isValid()
? m_lastTelemetryPublish : QDateTime::currentDateTime();
m_divergenceDepuis.insert(loadId, debut.toUTC()); // ancré à la capture
qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W,"
<< "écart" << (mesureW - attenduW) << "W";
}
QVariantMap commande; QVariantMap commande;
commande.insert(QStringLiteral("expectedW"), attenduW); commande.insert(QStringLiteral("expectedW"), *commandeeW);
commande.insert(QStringLiteral("observedW"), mesureW); commande.insert(QStringLiteral("observedW"), mesureW);
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une // Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La // des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la // et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
// même chose du même cycle. // la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
commande.insert(QStringLiteral("divergent"), divergent); commande.insert(QStringLiteral("divergent"), divergent);
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'une
// latence. C'est pourtant TOUTE la différence entre un fonctionnement normal et le cas
// relevé le 2026-08-31, où l'écart durait indéfiniment parce que plus personne ne commandait.
if (divergent) if (divergent)
commande.insert(QStringLiteral("since"), commande.insert(QStringLiteral("since"),
m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate)); m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate));
entry.insert(QStringLiteral("command"), commande); entry.insert(QStringLiteral("command"), commande);
} }
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId,
const QDateTime &now) const
{ {
if (!m_tm) if (!m_tm)
return; return;
@ -2630,7 +2698,7 @@ void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loa
mesure.insert(QStringLiteral("powerW"), powerW); mesure.insert(QStringLiteral("powerW"), powerW);
entry.insert(QStringLiteral("measurement"), mesure); entry.insert(QStringLiteral("measurement"), mesure);
attachCommandDivergence(entry, loadId, source, powerW); attachCommandDivergence(entry, loadId, source, powerW, now);
// Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa // Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne // raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
@ -2740,7 +2808,7 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
} }
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce // Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien. // que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
attachMeasurements(entry, id); attachMeasurements(entry, id, now);
loads.append(entry); loads.append(entry);
}; };
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare. // Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.

View File

@ -1176,8 +1176,9 @@ private:
* *
* \param entry Entrée de charge en cours de construction, modifiée en place. * \param entry Entrée de charge en cours de construction, modifiée en place.
* \param loadId Identifiant de la charge, clé dans le magasin de configuration. * \param loadId Identifiant de la charge, clé dans le magasin de configuration.
* \param now Temps de cycle — celui du jugement commande/mesure publié.
*/ */
void attachMeasurements(QVariantMap &entry, const QString &loadId) const; void attachMeasurements(QVariantMap &entry, const QString &loadId, const QDateTime &now) const;
//! \brief La source de mesure d'une charge et sa valeur — la règle UNIQUE de \c measurement. //! \brief La source de mesure d'une charge et sa valeur — la règle UNIQUE de \c measurement.
struct MesureCharge { struct MesureCharge {
@ -1205,9 +1206,49 @@ private:
* \c refusedSinceS. \param now Temps de cycle. * \c refusedSinceS. \param now Temps de cycle.
* \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop * \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop
* court pour une borne, trop long pour un relais. * court pour une borne, trop long pour un relais.
* \note Le jugement est celui de \c jugerCommande(), fait AVANT le plan : la commande en
* vigueur contre la mesure fraîche.
*/ */
void qualifierRefus(LoadContext &lc, const QDateTime &now) const; void qualifierRefus(LoadContext &lc, const QDateTime &now) const;
//! \brief Le jugement commande/mesure d'une charge, pour UN cycle (\c jugerCommande()).
struct JugementCommande {
QDateTime at; //!< Temps du cycle qui l'a produit.
double commandeW = 0; //!< La commande EN VIGUEUR pendant la mesure.
double mesureW = 0; //!< La mesure fraîche.
QString source; //!< Source de la mesure (\c meter | \c device).
QDateTime commandeDepuis; //!< Instant de la commande, en temps de cycle.
bool montee = false;//!< La commande demande à la charge de MONTER.
bool enProgression = false; //!< Rapprochée de la commande depuis le cycle précédent.
bool refus = false;//!< Écart au-delà de la latence, sans progression.
};
/*!
* \brief Juge l'écart entre la commande EN VIGUEUR et la mesure FRAÎCHE d'une charge.
* \param loadId Charge. \param now Temps de cycle.
* \return Le jugement ; \c std::nullopt si rien ne mesure, si rien n'a été commandé, ou si
* la charge n'a pas d'adaptateur.
*
* \par Pourquoi au DÉBUT du cycle
* Jugé après le dispatch, l'écart comparait la consigne qui venait de partir à la mesure de
* la précédente : toute marche suivie se lisait comme un refus vieux d'un cycle, et le
* recrédit d'une borne qui obéit était suspendu à chaque marche. Au début du cycle, la
* commande jugée est celle que la charge avait reçue quand la mesure a été prise.
*
* \par Latence, progression, refus
* - écart ≤ \c max(200 W, 15 %) : concordance, l'épisode se referme ;
* - sinon, un écart RÉCENT est une latence : temps depuis la commande ≤
* \c ILoadAdapter::commandSettleS ;
* - en MONTÉE seulement, une mesure qui s'est rapprochée de la commande de plus de 200 W
* depuis le cycle précédent est une latence (voiture qui négocie, PAC qui démarre). La
* DESCENTE n'a jamais cette excuse : c'est le refus qui coûte de l'argent ;
* - au-delà : refus.
*
* Un seul jugement par cycle, mis en cache : le recrédit et la télémétrie lisent le même.
*/
std::optional<JugementCommande> jugerCommande(const QString &loadId,
const QDateTime &now) const;
/*! /*!
* \brief Alimente l'avancement d'une charge NON-VE depuis son compteur désigné. * \brief Alimente l'avancement d'une charge NON-VE depuis son compteur désigné.
* \param[in,out] lc Charge du cycle. * \param[in,out] lc Charge du cycle.
@ -1274,6 +1315,15 @@ private:
* *
* \param[in,out] entry Entrée de télémétrie de la charge. * \param[in,out] entry Entrée de télémétrie de la charge.
* \param loadId Charge. \param source Source de mesure déjà résolue. \param mesureW Sa valeur. * \param loadId Charge. \param source Source de mesure déjà résolue. \param mesureW Sa valeur.
* \param now Temps de cycle.
*
* \par Le verdict est celui du cycle, pas une seconde comparaison
* \c expectedW (la commande qui vient de partir) et \c observedW (la mesure publiée) sont les
* deux nombres de la publication ; \c divergent est le verdict de \c jugerCommande(), fait au
* début du cycle sur la commande en vigueur — celui-là même qui suspend le recrédit. Un écart
* RÉCENT, ou en progression vers une commande à la hausse, est une latence : \c divergent
* reste faux. Sans commande en vigueur au début du cycle (première commande), on juge celle
* qui vient de partir : récente, donc jamais divergente.
* *
* \par L'écart ne vaut pas la même chose selon la source, dans les DEUX sens * \par L'écart ne vaut pas la même chose selon la source, dans les DEUX sens
* | | concordance | divergence | * | | concordance | divergence |
@ -1290,7 +1340,8 @@ private:
* publie, il ne se diagnostique pas — la cause n'est pas dans les watts. * publie, il ne se diagnostique pas — la cause n'est pas dans les watts.
*/ */
void attachCommandDivergence(QVariantMap &entry, const QString &loadId, void attachCommandDivergence(QVariantMap &entry, const QString &loadId,
const QString &source, double mesureW) const; const QString &source, double mesureW,
const QDateTime &now) const;
/*! /*!
* \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée. * \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée.
@ -1483,7 +1534,15 @@ private:
//! \brief Mode mémorisé rendu, publié avec l'événement de fin. //! \brief Mode mémorisé rendu, publié avec l'événement de fin.
QHash<QString, int> m_derogationModeRendu; QHash<QString, int> m_derogationModeRendu;
//! loadId → ouverture de l'épisode de divergence, datée à l'instant de la COMMANDE (temps de
//! cycle). Vidé dès que la mesure rejoint la commande.
mutable QHash<QString, QDateTime> m_divergenceDepuis; mutable QHash<QString, QDateTime> m_divergenceDepuis;
//! loadId → la commande en vigueur et l'instant où elle a pris effet (temps de cycle). Une
//! variation sous la tolérance de concordance n'est pas une nouvelle commande.
mutable QHash<QString, QPair<double, QDateTime>> m_commandeEnVigueur;
//! loadId → dernier jugement (\c jugerCommande()). Celui du cycle courant est relu par la
//! télémétrie ; celui du cycle précédent sert de référence à la PROGRESSION.
mutable QHash<QString, JugementCommande> m_jugementCommande;
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE : //! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE :
//! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()). //! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()).
mutable QHash<QString, double> m_ecoAchatWh; mutable QHash<QString, double> m_ecoAchatWh;

View File

@ -9113,59 +9113,15 @@ void Simulation::testRecreditIsALoanAgainstObedience()
#endif #endif
} }
/*! QList<Simulation::CycleBorne> Simulation::rejouerBorne(const QString &nom, const TirageBorne &tirage,
* \brief [Reproduction] Une borne qui SUIT une marche de consigne voit son recrédit suspendu au const std::function<double(int)> &productionW,
* cycle suivant, et la consigne oscille. int cycles)
*
* Borne monophasée 6-16 A, production 3 300 W, maison 300 W, aucune autre charge : 3 000 W de
* surplus constant, que 13 A servent entièrement. Trois rampes de borne, mesure lue au cycle :
* consigne atteinte au pas suivant, latences déclarées par l'EvAdapter (30 s / 7 s), montée
* lente (120 s). La mesure qui sert à l'écart vient d'un compteur dédié que le test pose : la
* mesure propre du mock recopie sa consigne dès la commande, et ne montrerait aucun écart.
*
* \par Le mécanisme
* L'écart est jugé dans attachCommandDivergence(), APRÈS le dispatch : il compare la consigne
* qui vient de partir à la mesure de la précédente. Une marche de plus de 15 % s'y lit comme
* une divergence, et elle n'est réévaluée qu'au cycle suivant, après le plan. qualifierRefus()
* la trouve donc ouverte depuis un cycle — 60 s, au-delà des deux latences — et suspend le
* recrédit d'une borne qui a obéi.
*
* \warning Ce test FIGE le comportement de 1.15.2+etm75, défaut compris. Le correctif devra
* inverser les assertions marquées « DÉFAUT ».
*
* \note `refusedSinceS` n'est pas comparé : `since` est daté à l'horloge murale
* (m_lastTelemetryPublish) et comparé au temps de cycle, injecté ici. Seul le verdict compte,
* et il ne dépend que de « au moins un cycle », qui dépasse les deux seuils.
*/
void Simulation::testRecreditSuspendedAfterAnObeyedStep()
{ {
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-marche.json";
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
constexpr double ProductionW = 3300;
constexpr double MaisonW = 300; constexpr double MaisonW = 300;
constexpr int Cycles = 20; QList<CycleBorne> trace;
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-" + nom + ".json";
struct Cycle {
int mesureW = 0; //!< ce que la borne tire au début du cycle
QString motif;
int refusDepuisS = -1;
int retenuW = 0;
int surplusW = 0;
int recrediteW = 0;
int consigneA = 0; //!< ce que la borne a reçu ; 0 = coupée
int consigneW = 0;
int attenduW = 0;
bool divergent = false;
};
// monteeS / descenteS : temps que met la borne à rejoindre sa consigne. Le cycle dure 60 s.
auto rejouer = [&](int monteeS, int descenteS) -> QList<Cycle> {
QList<Cycle> trace;
QFile::remove(cfgPath); QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase(); cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase(); initTestCase();
@ -9192,7 +9148,9 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep()
// Cycle d'amorce SANS surplus : la borne reçoit son entrée de configuration et reste // Cycle d'amorce SANS surplus : la borne reçoit son entrée de configuration et reste
// coupée. On lui désigne ensuite son compteur, avant la première marche. // coupée. On lui désigne ensuite son compteur, avant la première marche.
// L'horloge murale sert de base : `since` y est daté (voir \note). // L'horloge murale sert de base. Le moteur corrigé n'en dépend plus (l'écart est daté en
// temps de cycle), mais celui d'avant y datait `since` : la garder rend le même rejeu
// comparable sur les deux.
const QDateTime t0 = QDateTime::currentDateTimeUtc(); const QDateTime t0 = QDateTime::currentDateTimeUtc();
meter->setStateValue("currentPower", MaisonW); meter->setStateValue("currentPower", MaisonW);
arb->simulationCallUpdate(t0.addSecs(-60)); arb->simulationCallUpdate(t0.addSecs(-60));
@ -9209,14 +9167,13 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep()
double tireW = 0; double tireW = 0;
double commandeW = 0; double commandeW = 0;
for (int k = 0; k < Cycles; ++k) { for (int k = 0; k < cycles; ++k) {
// La borne a eu un cycle pour rejoindre la consigne précédente ; le compteur racine // La borne a eu un cycle pour répondre à la consigne précédente ; le compteur racine
// voit ce qu'elle tire vraiment. // voit ce qu'elle tire vraiment.
const int delaiS = commandeW > tireW ? monteeS : descenteS; tireW = tirage(k, commandeW, tireW);
tireW = delaiS <= 60 ? commandeW : tireW + (commandeW - tireW) * 60.0 / delaiS;
borne->setStateValue("currentPower", qRound(tireW)); borne->setStateValue("currentPower", qRound(tireW));
compteur->setStateValue("currentPower", qRound(tireW)); compteur->setStateValue("currentPower", qRound(tireW));
meter->setStateValue("currentPower", qRound(MaisonW + tireW - ProductionW)); meter->setStateValue("currentPower", qRound(MaisonW + tireW - productionW(k)));
arb->simulationCallUpdate(t0.addSecs(60 * k)); arb->simulationCallUpdate(t0.addSecs(60 * k));
QCoreApplication::processEvents(); QCoreApplication::processEvents();
@ -9231,7 +9188,7 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep()
const QVariantMap params = e.value("decision").toMap().value("params").toMap(); const QVariantMap params = e.value("decision").toMap().value("params").toMap();
const QVariantMap budget = snap.value("budget").toMap(); const QVariantMap budget = snap.value("budget").toMap();
const QVariantMap cmd = e.value("command").toMap(); const QVariantMap cmd = e.value("command").toMap();
Cycle c; CycleBorne c;
c.mesureW = qRound(tireW); c.mesureW = qRound(tireW);
c.motif = e.value("decision").toMap().value("code").toString(); c.motif = e.value("decision").toMap().value("code").toString();
c.refusDepuisS = params.value("refusedSinceS", -1).toInt(); c.refusDepuisS = params.value("refusedSinceS", -1).toInt();
@ -9243,54 +9200,127 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep()
c.attenduW = qRound(cmd.value("expectedW").toDouble()); c.attenduW = qRound(cmd.value("expectedW").toDouble());
c.divergent = cmd.value("divergent").toBool(); c.divergent = cmd.value("divergent").toBool();
trace.append(c); trace.append(c);
qInfo().noquote() << QStringLiteral("[marche %1/%2 s] k=%3 mesuré %4 W | surplus %5 recrédit %6" qInfo().noquote() << QStringLiteral("[%1] k=%2 mesuré %3 W | surplus %4 recrédit %5"
" | %7 retenu %8 | borne %9 A | attendu %10 W divergent %11") " | %6 retenu %7 | borne %8 A | attendu %9 W divergent %10")
.arg(monteeS).arg(descenteS).arg(k).arg(c.mesureW) .arg(nom).arg(k).arg(c.mesureW)
.arg(c.surplusW).arg(c.recrediteW).arg(c.motif).arg(c.retenuW) .arg(c.surplusW).arg(c.recrediteW).arg(c.motif).arg(c.retenuW)
.arg(c.consigneA).arg(c.attenduW) .arg(c.consigneA).arg(c.attenduW)
.arg(c.divergent ? QStringLiteral("oui") : QStringLiteral("non")); .arg(c.divergent ? QStringLiteral("oui") : QStringLiteral("non"));
} }
return trace;
};
const QList<Cycle> instant = rejouer(0, 0);
QCOMPARE(instant.size(), Cycles);
const QList<Cycle> latences = rejouer(30, 7);
QCOMPARE(latences.size(), Cycles);
const QList<Cycle> lente = rejouer(120, 7);
QCOMPARE(lente.size(), Cycles);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
return trace;
}
// 1. Échantillonné à 60 s, une borne qui rejoint sa consigne en 30 s ou en 7 s ne se distingue /*!
// pas d'une borne instantanée : les deux traces sont identiques, cycle pour cycle. * \brief Une borne qui SUIT une marche de consigne garde son recrédit, et la consigne tient.
for (int k = 0; k < Cycles; ++k) { *
QCOMPARE(latences.at(k).motif, instant.at(k).motif); * Borne monophasée 6-16 A, production 3 300 W, maison 300 W, aucune autre charge : 3 000 W de
QCOMPARE(latences.at(k).consigneA, instant.at(k).consigneA); * surplus constant, que 13 A servent entièrement. Trois rampes de borne, mesure lue au cycle :
} * consigne atteinte au pas suivant, latences déclarées par l'EvAdapter (30 s / 7 s), montée
* lente (120 s). La mesure qui sert à l'écart vient d'un compteur dédié que le test pose : la
* mesure propre du mock recopie sa consigne dès la commande, et ne montrerait aucun écart.
*
* \par Le défaut que ce test a reproduit (1.15.2+etm75)
* L'écart était jugé APRÈS le dispatch : la consigne qui venait de partir contre la mesure de la
* précédente. Toute marche de plus de 15 % s'y lisait comme une divergence, que qualifierRefus()
* trouvait au cycle suivant ouverte depuis 60 s — au-delà des deux latences — et le recrédit d'une
* borne qui avait obéi était suspendu. Puis le plan coupait, l'arrêt était refusé par le verrou de
* 300 s, la borne restait à 6 A pendant qu'on attendait 0 W : boucle 13 → 6 → 7 → 13 A, ~1,5 kW
* exportés en moyenne. Les assertions « DÉFAUT » du commit de reproduction sont inversées ici.
*
* \par Ce qui le corrige
* Le jugement est fait au DÉBUT du cycle, contre la commande en vigueur et la mesure fraîche
* (P1) ; une montée qui se rapproche de sa consigne est une latence, pas un refus ; la borne
* retient ce qui a passé ses verrous (P2).
*/
void Simulation::testRecreditSuspendedAfterAnObeyedStep()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
constexpr double ProductionW = 3300;
constexpr double MaisonW = 300;
constexpr int Cycles = 20;
// 2. DÉFAUT — le recrédit est suspendu à des cycles où la borne tire exactement ce qu'elle // monteeS / descenteS : temps que met la borne à rejoindre sa consigne, en ligne droite
// a reçu au cycle précédent. Correct : aucune suspension, la borne a obéi partout. // depuis ce qu'elle tirait quand la commande est partie. Le cycle dure 60 s : une montée de
int suspenduesObeie = 0; // 120 s est à mi-chemin au cycle suivant, arrivée au cycle d'après.
for (int k = 1; k < Cycles; ++k) { auto rampe = [](int monteeS, int descenteS) -> TirageBorne {
if (instant.at(k).mesureW == instant.at(k - 1).consigneW auto suivie = std::make_shared<double>(0); // commande en cours
&& instant.at(k).motif == QLatin1String(DecisionCode::RecreditSuspended)) { auto origine = std::make_shared<double>(0); // tirage quand elle est partie
++suspenduesObeie; auto ecoule = std::make_shared<int>(0);
QCOMPARE(instant.at(k).retenuW, instant.at(k).mesureW); // toute sa conso retenue return [=](int, double commandeW, double tireW) {
QCOMPARE(instant.at(k).recrediteW, 0); if (commandeW != *suivie) {
*suivie = commandeW;
*origine = tireW;
*ecoule = 0;
} }
} *ecoule += 60;
QVERIFY2(suspenduesObeie > 0, "DÉFAUT attendu : recrédit suspendu sur une borne qui a obéi"); const int delaiS = commandeW > *origine ? monteeS : descenteS;
if (delaiS <= *ecoule)
return commandeW;
return *origine + (commandeW - *origine) * *ecoule / static_cast<double>(delaiS);
};
};
auto production = [](int) { return ProductionW; };
struct Rampe { QString nom; QList<CycleBorne> trace; };
QList<Rampe> rampes = { {"marche 0/0 s", rejouerBorne("marche-0", rampe(0, 0), production, Cycles)},
{"marche 30/7 s", rejouerBorne("marche-30", rampe(30, 7), production, Cycles)},
{"marche 120/7 s", rejouerBorne("marche-120", rampe(120, 7), production, Cycles)} };
// 3. DÉFAUT — la consigne ne se stabilise pas sous un surplus constant. Correct : 13 A dès // Tout mesurer d'abord, affirmer ensuite : le journal porte les trois bilans même si le
// le premier cycle, puis plus aucun changement. // premier cas échoue.
QCOMPARE(instant.at(0).consigneA, 13); for (const Rampe &r : rampes) {
int changements = 0; double tire = 0, export_ = 0, exportStable = 0;
for (int k = 1; k < Cycles; ++k) int suspensions = 0, changements = 0;
if (instant.at(k).consigneA != instant.at(k - 1).consigneA) for (int k = 0; k < r.trace.size(); ++k) {
const CycleBorne &c = r.trace.at(k);
tire += c.mesureW;
export_ += ProductionW - MaisonW - c.mesureW;
if (k >= 2)
exportStable += ProductionW - MaisonW - c.mesureW;
if (c.motif == QLatin1String(DecisionCode::RecreditSuspended))
++suspensions;
if (k > 0 && c.consigneA != r.trace.at(k - 1).consigneA)
++changements; ++changements;
QVERIFY2(changements >= Cycles / 3, }
qUtf8Printable(QStringLiteral("DÉFAUT attendu : consigne qui oscille, %1 changements") qInfo().noquote() << QStringLiteral("[bilan %1] tirage moyen %2 W, export moyen %3 W "
.arg(changements))); "(dès k=2 : %4 W), suspensions %5, changements de consigne %6")
.arg(r.nom).arg(qRound(tire / Cycles)).arg(qRound(export_ / Cycles))
.arg(qRound(exportStable / (Cycles - 2))).arg(suspensions).arg(changements);
}
for (const Rampe &r : rampes) {
QCOMPARE(r.trace.size(), Cycles);
// 1. La consigne qui sert le surplus (13 A) est posée dès le premier cycle, et TIENT.
for (int k = 0; k < Cycles; ++k)
QVERIFY2(r.trace.at(k).consigneA == 13,
qUtf8Printable(QStringLiteral("%1 : k=%2, borne à %3 A — la consigne doit tenir à 13 A")
.arg(r.nom).arg(k).arg(r.trace.at(k).consigneA)));
for (int k = 1; k < Cycles; ++k) {
const CycleBorne &c = r.trace.at(k);
// 2. Aucune suspension : la borne a obéi partout, rampe lente comprise.
QVERIFY2(c.motif != QLatin1String(DecisionCode::RecreditSuspended),
qUtf8Printable(QStringLiteral("%1 : k=%2, recrédit suspendu sur une borne qui obéit")
.arg(r.nom).arg(k)));
// 3. Le recrédit est RENDU : toute la conso de la borne revient au budget.
QCOMPARE(c.recrediteW, c.mesureW);
// 4. Un écart qui se résorbe n'est pas publié comme une divergence.
QVERIFY2(!c.divergent, qUtf8Printable(QStringLiteral("%1 : k=%2, divergent").arg(r.nom).arg(k)));
}
// 5. Dès que la borne a rejoint sa consigne, l'export est le reste de l'arrondi à
// l'ampère (10 W), et non plus ~1,5 kW.
for (int k = 2; k < Cycles; ++k)
QCOMPARE(qRound(ProductionW - MaisonW - r.trace.at(k).mesureW), 10);
}
// 6. Échantillonné à 60 s, une borne qui rejoint sa consigne en 30 s ou en 7 s ne se
// distingue pas d'une borne instantanée : les deux traces sont identiques, cycle pour cycle.
for (int k = 0; k < Cycles; ++k) {
QCOMPARE(rampes.at(1).trace.at(k).motif, rampes.at(0).trace.at(k).motif);
QCOMPARE(rampes.at(1).trace.at(k).consigneA, rampes.at(0).trace.at(k).consigneA);
}
#endif #endif
} }

View File

@ -27,6 +27,7 @@
#include "simulationbase.h" #include "simulationbase.h"
#include <functional>
#include <memory> #include <memory>
class EnergyArbitrator; class EnergyArbitrator;
@ -80,6 +81,32 @@ private:
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée. //! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW); QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
// ── Recrédit — une borne mesurée par un compteur dédié, cycle par cycle ──────────────────
//! Ce que la télémétrie dit de la borne à un cycle, et ce qu'elle a reçu.
struct CycleBorne {
int mesureW = 0; //!< ce que la borne tire au début du cycle
QString motif;
int refusDepuisS = -1;
int retenuW = 0;
int surplusW = 0;
int recrediteW = 0;
int consigneA = 0; //!< ce que la borne a reçu ; 0 = coupée
int consigneW = 0;
int attenduW = 0;
bool divergent = false;
};
//! Tirage de la borne au cycle \p k, depuis la commande reçue au cycle précédent et le
//! tirage précédent (W). C'est le modèle de la borne : rampe, refus, figeage.
using TirageBorne = std::function<double(int k, double commandeW, double tireW)>;
/*!
* \brief Rejoue \p cycles cycles de 60 s d'une borne monophasée 6-16 A, maison à 300 W.
* \param nom Suffixe du fichier de configuration. \param tirage Modèle de la borne.
* \param productionW Production au cycle k (W). \param cycles Nombre de cycles.
* \return La trace, cycle par cycle ; vide si le banc n'a pas pu être posé.
*/
QList<CycleBorne> rejouerBorne(const QString &nom, const TirageBorne &tirage,
const std::function<double(int)> &productionW, int cycles);
private slots: private slots:
void run_data(); void run_data();
void run(); void run();
@ -240,7 +267,7 @@ private slots:
void testPhaseVoltageDoesNotLeakBetweenPhases(); void testPhaseVoltageDoesNotLeakBetweenPhases();
void testMeterEnergyActuallyAccumulates(); void testMeterEnergyActuallyAccumulates();
void testRecreditIsALoanAgainstObedience(); void testRecreditIsALoanAgainstObedience();
//! Reproduction — une marche de consigne suivie fait suspendre le recrédit au cycle suivant. //! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient.
void testRecreditSuspendedAfterAnObeyedStep(); void testRecreditSuspendedAfterAnObeyedStep();
void testARefusedStateIsDistinguishableFromAnApplied(); void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock(); void testL4ShedsAPacAndSaysItForcedTheLock();