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:
parent
41ce86a634
commit
0dc2a1aeba
@ -1797,26 +1797,19 @@ void EnergyArbitrator::alimenterAvancement(LoadContext &lc) 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);
|
||||
if (!depuis.isValid())
|
||||
return; // aucun écart en cours : rien à qualifier
|
||||
|
||||
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);
|
||||
if (depuis.isValid())
|
||||
lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
|
||||
lc.telemetry.commandRefused = j->refus;
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
|
||||
@ -2384,7 +2377,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
QVariantMap mech;
|
||||
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
|
||||
orphan.insert(QStringLiteral("mechanism"), mech);
|
||||
attachMeasurements(orphan, action.loadId);
|
||||
attachMeasurements(orphan, action.loadId, now);
|
||||
loads.append(orphan);
|
||||
continue;
|
||||
}
|
||||
@ -2507,7 +2500,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||||
entry.insert(QStringLiteral("mechanism"), mech);
|
||||
}
|
||||
attachMeasurements(entry, action.loadId);
|
||||
attachMeasurements(entry, action.loadId, now);
|
||||
loads.append(entry);
|
||||
}
|
||||
|
||||
@ -2533,14 +2526,113 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
|
||||
namespace {
|
||||
//! 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.
|
||||
//! Valeur de conception, à ajuster sur relevé.
|
||||
//! mesure et arrondi à l'ampère s'y logent. Valeur de conception, à ajuster sur relevé.
|
||||
constexpr double DivergenceAbsolueW = 200.0;
|
||||
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,
|
||||
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`,
|
||||
// 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);
|
||||
if (!commandeeW)
|
||||
return;
|
||||
const double attenduW = *commandeeW;
|
||||
|
||||
const double ecart = qAbs(attenduW - mesureW);
|
||||
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW);
|
||||
|
||||
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un
|
||||
// déphasage d'un cycle entre l'écriture et sa mesure. 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.
|
||||
// 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";
|
||||
}
|
||||
// Le VERDICT est celui du cycle — le jugement que le recrédit a lu, fait au début du cycle
|
||||
// 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
|
||||
// commande en vigueur au début du cycle (première commande), le jugement est fait ici, sur
|
||||
// celle qui vient de partir : récente, donc jamais divergente.
|
||||
const std::optional<JugementCommande> j = jugerCommande(loadId, now);
|
||||
const bool divergent = j && j->refus;
|
||||
|
||||
QVariantMap commande;
|
||||
commande.insert(QStringLiteral("expectedW"), attenduW);
|
||||
commande.insert(QStringLiteral("expectedW"), *commandeeW);
|
||||
commande.insert(QStringLiteral("observedW"), mesureW);
|
||||
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une
|
||||
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La
|
||||
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la
|
||||
// même chose du même cycle.
|
||||
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
|
||||
// des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
|
||||
// et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
|
||||
// la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
|
||||
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)
|
||||
commande.insert(QStringLiteral("since"),
|
||||
m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate));
|
||||
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)
|
||||
return;
|
||||
@ -2630,7 +2698,7 @@ void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loa
|
||||
mesure.insert(QStringLiteral("powerW"), powerW);
|
||||
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
|
||||
// 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
|
||||
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
|
||||
attachMeasurements(entry, id);
|
||||
attachMeasurements(entry, id, now);
|
||||
loads.append(entry);
|
||||
};
|
||||
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
|
||||
|
||||
@ -1176,8 +1176,9 @@ private:
|
||||
*
|
||||
* \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 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.
|
||||
struct MesureCharge {
|
||||
@ -1205,9 +1206,49 @@ private:
|
||||
* \c refusedSinceS. \param now Temps de cycle.
|
||||
* \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop
|
||||
* 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;
|
||||
|
||||
//! \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é.
|
||||
* \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 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
|
||||
* | | concordance | divergence |
|
||||
@ -1290,7 +1340,8 @@ private:
|
||||
* publie, il ne se diagnostique pas — la cause n'est pas dans les watts.
|
||||
*/
|
||||
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.
|
||||
@ -1483,7 +1534,15 @@ private:
|
||||
//! \brief Mode mémorisé rendu, publié avec l'événement de fin.
|
||||
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;
|
||||
//! 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 :
|
||||
//! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()).
|
||||
mutable QHash<QString, double> m_ecoAchatWh;
|
||||
|
||||
@ -9113,9 +9113,107 @@ void Simulation::testRecreditIsALoanAgainstObedience()
|
||||
#endif
|
||||
}
|
||||
|
||||
QList<Simulation::CycleBorne> Simulation::rejouerBorne(const QString &nom, const TirageBorne &tirage,
|
||||
const std::function<double(int)> &productionW,
|
||||
int cycles)
|
||||
{
|
||||
constexpr double MaisonW = 300;
|
||||
QList<CycleBorne> trace;
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-" + nom + ".json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
const QUuid meterId = addMeter(27310);
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
const QUuid borneId = addCharger("A", 16, 27311);
|
||||
const QUuid compteurId = addMeter(27312);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
Thing *borne = tm->findConfiguredThing(borneId);
|
||||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
||||
if (!arb || !meter || !borne || !compteur)
|
||||
return trace;
|
||||
meter->setStateValue("connected", true);
|
||||
compteur->setStateValue("connected", true);
|
||||
compteur->setStateValue("currentPower", 0);
|
||||
armerBorne(borne);
|
||||
|
||||
QVariantMap info;
|
||||
info.insert("evChargerId", borneId);
|
||||
info.insert("chargingMode", "ChargingModeEco");
|
||||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||||
|
||||
// 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.
|
||||
// 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();
|
||||
meter->setStateValue("currentPower", MaisonW);
|
||||
arb->simulationCallUpdate(t0.addSecs(-60));
|
||||
QCoreApplication::processEvents();
|
||||
QVariantList majs;
|
||||
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||||
.value("params").toMap().value("loadConfigs").toList()) {
|
||||
QVariantMap m = v.toMap();
|
||||
if (m.value("id").toString() == borneId.toString())
|
||||
m.insert("meterThingId", compteurId.toString());
|
||||
majs.append(m);
|
||||
}
|
||||
injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}}));
|
||||
|
||||
double tireW = 0;
|
||||
double commandeW = 0;
|
||||
for (int k = 0; k < cycles; ++k) {
|
||||
// La borne a eu un cycle pour répondre à la consigne précédente ; le compteur racine
|
||||
// voit ce qu'elle tire vraiment.
|
||||
tireW = tirage(k, commandeW, tireW);
|
||||
borne->setStateValue("currentPower", qRound(tireW));
|
||||
compteur->setStateValue("currentPower", qRound(tireW));
|
||||
meter->setStateValue("currentPower", qRound(MaisonW + tireW - productionW(k)));
|
||||
|
||||
arb->simulationCallUpdate(t0.addSecs(60 * k));
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
// Ce que la borne a REÇU, verrous compris — pas ce que le moteur a voulu.
|
||||
const bool enMarche = borne->stateValue("power").toBool();
|
||||
const int courantA = borne->stateValue("maxChargingCurrent").toInt();
|
||||
commandeW = enMarche ? courantA * 230.0 : 0.0;
|
||||
|
||||
const QVariantMap snap = arb->loadTelemetry();
|
||||
const QVariantMap e = telemetrieParId(snap).value(borneId.toString());
|
||||
const QVariantMap params = e.value("decision").toMap().value("params").toMap();
|
||||
const QVariantMap budget = snap.value("budget").toMap();
|
||||
const QVariantMap cmd = e.value("command").toMap();
|
||||
CycleBorne c;
|
||||
c.mesureW = qRound(tireW);
|
||||
c.motif = e.value("decision").toMap().value("code").toString();
|
||||
c.refusDepuisS = params.value("refusedSinceS", -1).toInt();
|
||||
c.retenuW = params.value("withheldW").toInt();
|
||||
c.surplusW = qRound(budget.value("surplusW").toDouble());
|
||||
c.recrediteW = qRound(budget.value("recreditedW").toDouble());
|
||||
c.consigneA = enMarche ? courantA : 0;
|
||||
c.consigneW = qRound(commandeW);
|
||||
c.attenduW = qRound(cmd.value("expectedW").toDouble());
|
||||
c.divergent = cmd.value("divergent").toBool();
|
||||
trace.append(c);
|
||||
qInfo().noquote() << QStringLiteral("[%1] k=%2 mesuré %3 W | surplus %4 recrédit %5"
|
||||
" | %6 retenu %7 | borne %8 A | attendu %9 W divergent %10")
|
||||
.arg(nom).arg(k).arg(c.mesureW)
|
||||
.arg(c.surplusW).arg(c.recrediteW).arg(c.motif).arg(c.retenuW)
|
||||
.arg(c.consigneA).arg(c.attenduW)
|
||||
.arg(c.divergent ? QStringLiteral("oui") : QStringLiteral("non"));
|
||||
}
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
return trace;
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief [Reproduction] Une borne qui SUIT une marche de consigne voit son recrédit suspendu au
|
||||
* cycle suivant, et la consigne oscille.
|
||||
* \brief Une borne qui SUIT une marche de consigne garde son recrédit, et la consigne tient.
|
||||
*
|
||||
* 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 :
|
||||
@ -9123,174 +9221,106 @@ void Simulation::testRecreditIsALoanAgainstObedience()
|
||||
* 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.
|
||||
* \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.
|
||||
*
|
||||
* \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.
|
||||
* \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
|
||||
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 int Cycles = 20;
|
||||
|
||||
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, en ligne droite
|
||||
// depuis ce qu'elle tirait quand la commande est partie. Le cycle dure 60 s : une montée de
|
||||
// 120 s est à mi-chemin au cycle suivant, arrivée au cycle d'après.
|
||||
auto rampe = [](int monteeS, int descenteS) -> TirageBorne {
|
||||
auto suivie = std::make_shared<double>(0); // commande en cours
|
||||
auto origine = std::make_shared<double>(0); // tirage quand elle est partie
|
||||
auto ecoule = std::make_shared<int>(0);
|
||||
return [=](int, double commandeW, double tireW) {
|
||||
if (commandeW != *suivie) {
|
||||
*suivie = commandeW;
|
||||
*origine = tireW;
|
||||
*ecoule = 0;
|
||||
}
|
||||
*ecoule += 60;
|
||||
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)} };
|
||||
|
||||
// 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);
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
const QUuid meterId = addMeter(27310);
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
const QUuid borneId = addCharger("A", 16, 27311);
|
||||
const QUuid compteurId = addMeter(27312);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
Thing *borne = tm->findConfiguredThing(borneId);
|
||||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
||||
if (!arb || !meter || !borne || !compteur)
|
||||
return trace;
|
||||
meter->setStateValue("connected", true);
|
||||
compteur->setStateValue("connected", true);
|
||||
compteur->setStateValue("currentPower", 0);
|
||||
armerBorne(borne);
|
||||
|
||||
QVariantMap info;
|
||||
info.insert("evChargerId", borneId);
|
||||
info.insert("chargingMode", "ChargingModeEco");
|
||||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||||
|
||||
// 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.
|
||||
// L'horloge murale sert de base : `since` y est daté (voir \note).
|
||||
const QDateTime t0 = QDateTime::currentDateTimeUtc();
|
||||
meter->setStateValue("currentPower", MaisonW);
|
||||
arb->simulationCallUpdate(t0.addSecs(-60));
|
||||
QCoreApplication::processEvents();
|
||||
QVariantList majs;
|
||||
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
||||
.value("params").toMap().value("loadConfigs").toList()) {
|
||||
QVariantMap m = v.toMap();
|
||||
if (m.value("id").toString() == borneId.toString())
|
||||
m.insert("meterThingId", compteurId.toString());
|
||||
majs.append(m);
|
||||
// Tout mesurer d'abord, affirmer ensuite : le journal porte les trois bilans même si le
|
||||
// premier cas échoue.
|
||||
for (const Rampe &r : rampes) {
|
||||
double tire = 0, export_ = 0, exportStable = 0;
|
||||
int suspensions = 0, changements = 0;
|
||||
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;
|
||||
}
|
||||
injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}}));
|
||||
qInfo().noquote() << QStringLiteral("[bilan %1] tirage moyen %2 W, export moyen %3 W "
|
||||
"(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);
|
||||
}
|
||||
|
||||
double tireW = 0;
|
||||
double commandeW = 0;
|
||||
for (int k = 0; k < Cycles; ++k) {
|
||||
// La borne a eu un cycle pour rejoindre la consigne précédente ; le compteur racine
|
||||
// voit ce qu'elle tire vraiment.
|
||||
const int delaiS = commandeW > tireW ? monteeS : descenteS;
|
||||
tireW = delaiS <= 60 ? commandeW : tireW + (commandeW - tireW) * 60.0 / delaiS;
|
||||
borne->setStateValue("currentPower", qRound(tireW));
|
||||
compteur->setStateValue("currentPower", qRound(tireW));
|
||||
meter->setStateValue("currentPower", qRound(MaisonW + tireW - ProductionW));
|
||||
|
||||
arb->simulationCallUpdate(t0.addSecs(60 * k));
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
// Ce que la borne a REÇU, verrous compris — pas ce que le moteur a voulu.
|
||||
const bool enMarche = borne->stateValue("power").toBool();
|
||||
const int courantA = borne->stateValue("maxChargingCurrent").toInt();
|
||||
commandeW = enMarche ? courantA * 230.0 : 0.0;
|
||||
|
||||
const QVariantMap snap = arb->loadTelemetry();
|
||||
const QVariantMap e = telemetrieParId(snap).value(borneId.toString());
|
||||
const QVariantMap params = e.value("decision").toMap().value("params").toMap();
|
||||
const QVariantMap budget = snap.value("budget").toMap();
|
||||
const QVariantMap cmd = e.value("command").toMap();
|
||||
Cycle c;
|
||||
c.mesureW = qRound(tireW);
|
||||
c.motif = e.value("decision").toMap().value("code").toString();
|
||||
c.refusDepuisS = params.value("refusedSinceS", -1).toInt();
|
||||
c.retenuW = params.value("withheldW").toInt();
|
||||
c.surplusW = qRound(budget.value("surplusW").toDouble());
|
||||
c.recrediteW = qRound(budget.value("recreditedW").toDouble());
|
||||
c.consigneA = enMarche ? courantA : 0;
|
||||
c.consigneW = qRound(commandeW);
|
||||
c.attenduW = qRound(cmd.value("expectedW").toDouble());
|
||||
c.divergent = cmd.value("divergent").toBool();
|
||||
trace.append(c);
|
||||
qInfo().noquote() << QStringLiteral("[marche %1/%2 s] k=%3 mesuré %4 W | surplus %5 recrédit %6"
|
||||
" | %7 retenu %8 | borne %9 A | attendu %10 W divergent %11")
|
||||
.arg(monteeS).arg(descenteS).arg(k).arg(c.mesureW)
|
||||
.arg(c.surplusW).arg(c.recrediteW).arg(c.motif).arg(c.retenuW)
|
||||
.arg(c.consigneA).arg(c.attenduW)
|
||||
.arg(c.divergent ? QStringLiteral("oui") : QStringLiteral("non"));
|
||||
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)));
|
||||
}
|
||||
return trace;
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// 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.
|
||||
// 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(latences.at(k).motif, instant.at(k).motif);
|
||||
QCOMPARE(latences.at(k).consigneA, instant.at(k).consigneA);
|
||||
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);
|
||||
}
|
||||
|
||||
// 2. DÉFAUT — le recrédit est suspendu à des cycles où la borne tire exactement ce qu'elle
|
||||
// a reçu au cycle précédent. Correct : aucune suspension, la borne a obéi partout.
|
||||
int suspenduesObeie = 0;
|
||||
for (int k = 1; k < Cycles; ++k) {
|
||||
if (instant.at(k).mesureW == instant.at(k - 1).consigneW
|
||||
&& instant.at(k).motif == QLatin1String(DecisionCode::RecreditSuspended)) {
|
||||
++suspenduesObeie;
|
||||
QCOMPARE(instant.at(k).retenuW, instant.at(k).mesureW); // toute sa conso retenue
|
||||
QCOMPARE(instant.at(k).recrediteW, 0);
|
||||
}
|
||||
}
|
||||
QVERIFY2(suspenduesObeie > 0, "DÉFAUT attendu : recrédit suspendu sur une borne qui a obéi");
|
||||
|
||||
// 3. DÉFAUT — la consigne ne se stabilise pas sous un surplus constant. Correct : 13 A dès
|
||||
// le premier cycle, puis plus aucun changement.
|
||||
QCOMPARE(instant.at(0).consigneA, 13);
|
||||
int changements = 0;
|
||||
for (int k = 1; k < Cycles; ++k)
|
||||
if (instant.at(k).consigneA != instant.at(k - 1).consigneA)
|
||||
++changements;
|
||||
QVERIFY2(changements >= Cycles / 3,
|
||||
qUtf8Printable(QStringLiteral("DÉFAUT attendu : consigne qui oscille, %1 changements")
|
||||
.arg(changements)));
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
|
||||
#include "simulationbase.h"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class EnergyArbitrator;
|
||||
@ -80,6 +81,32 @@ private:
|
||||
//! 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);
|
||||
|
||||
// ── 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:
|
||||
void run_data();
|
||||
void run();
|
||||
@ -240,7 +267,7 @@ private slots:
|
||||
void testPhaseVoltageDoesNotLeakBetweenPhases();
|
||||
void testMeterEnergyActuallyAccumulates();
|
||||
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 testARefusedStateIsDistinguishableFromAnApplied();
|
||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user