Compare commits
No commits in common. "main" and "release/etm75" have entirely different histories.
main
...
release/et
@ -1,18 +1,3 @@
|
|||||||
powersync-energy-plugin-nymea (1.15.2+etm76) trixie; urgency=medium
|
|
||||||
|
|
||||||
* Borne au surplus : la consigne n'oscille plus sous un surplus constant.
|
|
||||||
L'écart commande/mesure est jugé au début du cycle, sur la commande en
|
|
||||||
vigueur et la mesure fraîche, daté à l'instant de la commande ; une montée
|
|
||||||
qui progresse est une latence, une descente jamais. Une marche suivie ne
|
|
||||||
suspend plus le recrédit ; un vrai refus reste vu au cycle suivant.
|
|
||||||
* La borne retient ce qu'elle tient, quel que soit l'émetteur (répartition,
|
|
||||||
échéance, protection L4) ; un arrêt refusé par le verrou de marche/arrêt
|
|
||||||
gèle son courant minimal (LOCK_MIN_ON) au lieu de le prêter. Le verrou
|
|
||||||
borne la somme éco + confort.
|
|
||||||
* command.divergent publie ce verdict (même cycle que RECREDIT_SUSPENDED).
|
|
||||||
|
|
||||||
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 25 Oct 2026 20:07:00 +0100
|
|
||||||
|
|
||||||
powersync-energy-plugin-nymea (1.15.2+etm75) trixie; urgency=medium
|
powersync-energy-plugin-nymea (1.15.2+etm75) trixie; urgency=medium
|
||||||
|
|
||||||
* Heures creuses : zones au prix le plus bas du tarif. En mode éco, la borne
|
* Heures creuses : zones au prix le plus bas du tarif. En mode éco, la borne
|
||||||
|
|||||||
@ -51,8 +51,8 @@ LoadTelemetry EvAdapter::telemetry() const
|
|||||||
|
|
||||||
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
||||||
{
|
{
|
||||||
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` sert à savoir
|
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` ne sert qu'à
|
||||||
// si l'instant tombe en heures creuses (lot e2), et si l'arrêt est verrouillé (P2, plus bas).
|
// savoir si l'instant tombe en heures creuses (lot e2).
|
||||||
LoadContext ctx;
|
LoadContext ctx;
|
||||||
ctx.id = m_charger->thing()->id().toString();
|
ctx.id = m_charger->thing()->id().toString();
|
||||||
ctx.adapter = QStringLiteral("evcharger");
|
ctx.adapter = QStringLiteral("evcharger");
|
||||||
@ -176,20 +176,9 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy();
|
ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy();
|
||||||
ctx.telemetry.sessionWh = m_charger->sessionEnergyWh();
|
ctx.telemetry.sessionWh = m_charger->sessionEnergyWh();
|
||||||
|
|
||||||
// Pas de plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
|
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond
|
||||||
// contexte).
|
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable.
|
||||||
ctx.telemetry.lockMaxPowerW = -1;
|
ctx.telemetry.lockMaxPowerW = -1;
|
||||||
// P2 — l'ARRÊT VERROUILLÉ engage le courant minimal, comme un relais sous minOn. Une borne en
|
|
||||||
// marche dont le verrou de marche/arrêt (300 s par défaut) n'est pas expiré ne s'arrêtera pas,
|
|
||||||
// quoi qu'on lui commande : elle tirera son minimum. Le déclarer fait GELER ces watts par le
|
|
||||||
// waterfall ; ne pas le déclarer les laissait prêter aux suivantes, et la coupure que le plan
|
|
||||||
// croyait faire se lisait ensuite comme un refus de la borne — c'est le moteur qui la tenait.
|
|
||||||
if (m_charger->chargingEnabled() && m_charger->chargingEnabledLocked(now)) {
|
|
||||||
const uint phases = m_commandeValide && m_commandePhases > 0 ? m_commandePhases
|
|
||||||
: m_charger->phaseCount();
|
|
||||||
ctx.telemetry.lockMinPowerW = m_charger->maxChargingCurrentMinValue() * 230.0
|
|
||||||
* qMax(1u, phases);
|
|
||||||
}
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,10 +287,18 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
|
|||||||
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
|
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
|
||||||
m_lastActionAt = now;
|
m_lastActionAt = now;
|
||||||
|
|
||||||
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Elle n'est plus posée
|
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de
|
||||||
// ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
|
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de
|
||||||
// proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
|
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est
|
||||||
// elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
|
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce
|
||||||
|
// qu'on a commandé et ce que la borne fait n'est pas CALCULABLE.
|
||||||
|
//
|
||||||
|
// On retient la commande ÉCRÊTÉE, pas la demande : c'est elle qui est partie au matériel,
|
||||||
|
// et c'est donc elle que la borne devrait refléter. Comparer la demande ferait voir un
|
||||||
|
// écart là où l'adaptateur a simplement fait son travail.
|
||||||
|
m_commandeA = enclencher ? clampedA : 0;
|
||||||
|
m_commandePhases = phases;
|
||||||
|
m_commandeValide = true;
|
||||||
|
|
||||||
LoadAction applied = action;
|
LoadAction applied = action;
|
||||||
applied.currentA = clampedA;
|
applied.currentA = clampedA;
|
||||||
|
|||||||
@ -46,9 +46,9 @@ public:
|
|||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
||||||
* \param now Temps de cycle (\c ctx.timestamp). Il sert à savoir s'il tombe en heures
|
* \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors
|
||||||
* creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
|
* waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses
|
||||||
* et si l'ARRÊT est verrouillé (voir la note P2).
|
* (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent.
|
||||||
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
||||||
* \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum
|
* \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum
|
||||||
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
|
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
|
||||||
@ -57,9 +57,6 @@ public:
|
|||||||
* \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par
|
* \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par
|
||||||
* \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la
|
* \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la
|
||||||
* borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée.
|
* borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée.
|
||||||
* \note **P2** — borne en marche dont le verrou de marche/arrêt n'a pas expiré :
|
|
||||||
* \c telemetry.lockMinPowerW = courant minimal × 230 × phases. Elle ne s'arrêtera pas ce
|
|
||||||
* cycle, et le waterfall gèle ce qu'elle tirera, comme pour un relais sous \c minOn.
|
|
||||||
*/
|
*/
|
||||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||||
|
|
||||||
@ -142,9 +139,8 @@ public:
|
|||||||
EvCharger *evCharger() const { return m_charger; }
|
EvCharger *evCharger() const { return m_charger; }
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Dernière commande tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
|
* \brief Dernière commande envoyée au matériel par cet adaptateur.
|
||||||
* PASSÉ ses verrous (\c noterCommandeTenue()).
|
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée.
|
||||||
* \param[out] ampere Courant tenu (A), 0 si la charge a été coupée.
|
|
||||||
* \param[out] phases Nombre de phases commandé.
|
* \param[out] phases Nombre de phases commandé.
|
||||||
* \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et
|
* \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et
|
||||||
* c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas.
|
* c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas.
|
||||||
@ -152,30 +148,9 @@ public:
|
|||||||
bool derniereCommande(uint &ere, uint &phases) const
|
bool derniereCommande(uint &ere, uint &phases) const
|
||||||
{ ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; }
|
{ ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; }
|
||||||
|
|
||||||
//! \brief Instant de la dernière commande, à l'horloge de l'émetteur (temps de cycle pour le
|
|
||||||
//! waterfall). \return Invalide tant qu'aucune commande n'est partie.
|
|
||||||
QDateTime derniereCommandeAt() const { return m_commandeAt; }
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Note la commande que la borne TIENT, quel qu'en soit l'émetteur (P2).
|
|
||||||
* \param active Marche après les verrous. \param courantA Courant après les verrous (A).
|
|
||||||
* \param phases Phases commandées. \param at Instant de la commande.
|
|
||||||
* \note Appelée par \c EnergyArbitrator::executeChargingAction(), le point unique par où
|
|
||||||
* passent le waterfall, le proxy et la protection L4 : posée par un seul d'entre eux, la
|
|
||||||
* mémoire se périmait dès qu'un autre écrivait.
|
|
||||||
*/
|
|
||||||
void noterCommandeTenue(bool active, uint courantA, uint phases, const QDateTime &at)
|
|
||||||
{
|
|
||||||
m_commandeA = active ? courantA : 0;
|
|
||||||
m_commandePhases = phases;
|
|
||||||
m_commandeValide = true;
|
|
||||||
m_commandeAt = at;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/*!
|
/*!
|
||||||
* \brief Dernière commande RÉELLEMENT tenue par le matériel — écrêtée et passée par les
|
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée.
|
||||||
* verrous de la borne, pas demandée.
|
|
||||||
*
|
*
|
||||||
* \par Pourquoi ce souvenir n'existait pas
|
* \par Pourquoi ce souvenir n'existait pas
|
||||||
* Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou
|
* Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou
|
||||||
@ -186,9 +161,6 @@ private:
|
|||||||
*
|
*
|
||||||
* \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel.
|
* \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel.
|
||||||
* Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail.
|
* Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail.
|
||||||
* \note **P2** — et ce qui a PASSÉ les verrous de marche/arrêt et de courant : un arrêt
|
|
||||||
* refusé par le verrou de 300 s laisse la borne en marche à son courant minimal, et c'est
|
|
||||||
* ce courant qui est retenu, pas 0.
|
|
||||||
*/
|
*/
|
||||||
uint m_commandeA = 0;
|
uint m_commandeA = 0;
|
||||||
uint m_commandePhases = 0;
|
uint m_commandePhases = 0;
|
||||||
@ -196,7 +168,6 @@ private:
|
|||||||
//! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle
|
//! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle
|
||||||
//! consomme, ce qui est le cas NORMAL du mode manuel.
|
//! consomme, ce qui est le cas NORMAL du mode manuel.
|
||||||
bool m_commandeValide = false;
|
bool m_commandeValide = false;
|
||||||
QDateTime m_commandeAt; //!< Instant de la dernière commande.
|
|
||||||
|
|
||||||
EvCharger *m_charger;
|
EvCharger *m_charger;
|
||||||
EnergyArbitrator *m_parent;
|
EnergyArbitrator *m_parent;
|
||||||
|
|||||||
@ -1093,17 +1093,6 @@ void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const Chargin
|
|||||||
// touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4).
|
// touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4).
|
||||||
SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime);
|
SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime);
|
||||||
|
|
||||||
// P2 — la borne retient ce qu'elle TIENT, quel que soit l'émetteur : c'est contre cette
|
|
||||||
// commande-là que sa mesure est jugée au cycle suivant. Le waterfall, le proxy et la
|
|
||||||
// protection L4 passent tous ici ; une coupure refusée par le verrou de 300 s y reste en
|
|
||||||
// marche à son courant, et une borne rabaissée par L4 y est à son nouveau courant.
|
|
||||||
if (EvAdapter *ev = m_adapters.value(evCharger->id().toString(), nullptr))
|
|
||||||
ev->noterCommandeTenue(activationPasse ? chargingAction.chargingEnabled() : avantActive,
|
|
||||||
courantPasse ? demandeA : avantA,
|
|
||||||
chargingAction.desiredPhaseCount() > 0
|
|
||||||
? chargingAction.desiredPhaseCount() : evCharger->phaseCount(),
|
|
||||||
currentDateTime);
|
|
||||||
|
|
||||||
if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA,
|
if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA,
|
||||||
activationPasse ? chargingAction.chargingEnabled() : avantActive,
|
activationPasse ? chargingAction.chargingEnabled() : avantActive,
|
||||||
courantPasse ? demandeA : avantA, currentDateTime))
|
courantPasse ? demandeA : avantA, currentDateTime))
|
||||||
@ -1808,19 +1797,26 @@ 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())
|
||||||
lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
|
return; // aucun écart en cours : rien à qualifier
|
||||||
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
|
||||||
@ -2388,7 +2384,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, now);
|
attachMeasurements(orphan, action.loadId);
|
||||||
loads.append(orphan);
|
loads.append(orphan);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -2511,7 +2507,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, now);
|
attachMeasurements(entry, action.loadId);
|
||||||
loads.append(entry);
|
loads.append(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2537,119 +2533,14 @@ 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 et arrondi à l'ampère s'y logent. Valeur de conception, à ajuster sur relevé.
|
//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent.
|
||||||
|
//! 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 négociation la plus lente de DESIGN_COMMANDE_PERDUE.md §9.1 (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.
|
|
||||||
// Une borne connaît l'instant de sa commande, quel qu'en soit l'émetteur (L4 écrit hors cycle).
|
|
||||||
QPair<double, QDateTime> &enVigueur = m_commandeEnVigueur[loadId];
|
|
||||||
if (!enVigueur.second.isValid()
|
|
||||||
|| qAbs(commandeW - enVigueur.first) > toleranceConcordance(enVigueur.first)) {
|
|
||||||
const EvAdapter *ev = m_adapters.value(loadId, nullptr);
|
|
||||||
const QDateTime instantBorne = ev ? ev->derniereCommandeAt() : QDateTime();
|
|
||||||
// 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);
|
|
||||||
if (instantBorne.isValid() && instantBorne <= now)
|
|
||||||
enVigueur = { commandeW, instantBorne };
|
|
||||||
else
|
|
||||||
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 QString &source, double mesureW) const
|
||||||
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.
|
||||||
@ -2662,34 +2553,58 @@ 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;
|
||||||
|
|
||||||
// Le VERDICT est celui du cycle — le jugement que le recrédit a lu, fait au début du cycle
|
const double ecart = qAbs(attenduW - mesureW);
|
||||||
// sur la commande en vigueur et la mesure fraîche. Il n'est PAS refait ici sur la consigne
|
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW);
|
||||||
// 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
|
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un
|
||||||
// celle qui vient de partir : récente, donc jamais divergente.
|
// déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence
|
||||||
const std::optional<JugementCommande> j = jugerCommande(loadId, now);
|
// entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait
|
||||||
const bool divergent = j && j->refus;
|
// 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";
|
||||||
|
}
|
||||||
|
|
||||||
QVariantMap commande;
|
QVariantMap commande;
|
||||||
commande.insert(QStringLiteral("expectedW"), *commandeeW);
|
commande.insert(QStringLiteral("expectedW"), attenduW);
|
||||||
commande.insert(QStringLiteral("observedW"), mesureW);
|
commande.insert(QStringLiteral("observedW"), mesureW);
|
||||||
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
|
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une
|
||||||
// des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
|
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La
|
||||||
// et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
|
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la
|
||||||
// la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
|
// même chose du même cycle.
|
||||||
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,
|
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
|
||||||
const QDateTime &now) const
|
|
||||||
{
|
{
|
||||||
if (!m_tm)
|
if (!m_tm)
|
||||||
return;
|
return;
|
||||||
@ -2715,7 +2630,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, now);
|
attachCommandDivergence(entry, loadId, source, powerW);
|
||||||
|
|
||||||
// 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
|
||||||
@ -2825,7 +2740,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, now);
|
attachMeasurements(entry, id);
|
||||||
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.
|
||||||
|
|||||||
@ -1176,9 +1176,8 @@ 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 QDateTime &now) const;
|
void attachMeasurements(QVariantMap &entry, const QString &loadId) 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 {
|
||||||
@ -1206,49 +1205,9 @@ 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.
|
||||||
@ -1315,15 +1274,6 @@ 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 |
|
||||||
@ -1340,8 +1290,7 @@ 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 QString &source, double mesureW) const;
|
||||||
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.
|
||||||
@ -1534,15 +1483,7 @@ 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;
|
||||||
|
|||||||
@ -613,13 +613,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
LoadContext lcConfort = lc;
|
LoadContext lcConfort = lc;
|
||||||
if (plafondPartage.contains(lc.id))
|
if (plafondPartage.contains(lc.id))
|
||||||
lcConfort.declared.minPowerW = 0;
|
lcConfort.declared.minPowerW = 0;
|
||||||
// Même règle pour la puissance ENGAGÉE par un verrou : elle borne la SOMME éco + confort,
|
|
||||||
// qui est la consigne. Relevée en entier sur la part confort, elle s'ajoutait à un achat
|
|
||||||
// éco qui la couvrait déjà : une borne tenue à 6 A par son verrou d'arrêt, servie à 6 A
|
|
||||||
// en éco, recevait 12 A.
|
|
||||||
if (niveauxEco.contains(lc.id))
|
|
||||||
lcConfort.telemetry.lockMinPowerW =
|
|
||||||
qMax(0.0, lc.telemetry.lockMinPowerW - niveauxEco.value(lc.id).targetW);
|
|
||||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
||||||
else
|
else
|
||||||
|
|||||||
Binary file not shown.
@ -9113,705 +9113,6 @@ void Simulation::testRecreditIsALoanAgainstObedience()
|
|||||||
#endif
|
#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.
|
|
||||||
// Date FIXE : l'écart est daté en temps de cycle, plus à l'horloge murale. Partir de
|
|
||||||
// l'horloge murale, comme le test de reproduction devait le faire, rendrait la preuve
|
|
||||||
// `decisions` irreproductible (les cycles y sont datés).
|
|
||||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 25), QTime(12, 0, 0));
|
|
||||||
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 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 :
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
// 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)} };
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Un VRAI refus reste vu, au cycle qui suit la commande — et la descente n'a pas d'excuse.
|
|
||||||
*
|
|
||||||
* Même rejeu que testRecreditSuspendedAfterAnObeyedStep(). Trois bornes qui n'obéissent pas :
|
|
||||||
* - **montée refusée** : la borne tient 7 A, la consigne passe à 13 A, le véhicule reste à 7 A —
|
|
||||||
* mesure figée. Refus au cycle qui suit la commande, c'est-à-dire au même cycle qu'avant le
|
|
||||||
* correctif ;
|
|
||||||
* - **montée qui progresse puis cale** : rampe vers 13 A, calée à 2 000 W. Tant qu'elle se
|
|
||||||
* rapproche, c'est une latence ; le cycle où elle cale, c'est un refus ;
|
|
||||||
* - **descente non suivie** : la production s'effondre, la consigne descend, la borne ne lâche
|
|
||||||
* que 500 W. Elle se « rapproche » de sa consigne, et c'est pourtant un refus, au cycle qui
|
|
||||||
* suit la commande de descente : la progression n'excuse jamais une descente.
|
|
||||||
*
|
|
||||||
* \note Les deux marches dont on lit le MOTIF tombent après les 300 s du verrou de marche/arrêt
|
|
||||||
* armé au démarrage. Avant, l'arrêt verrouillé engage le courant minimal (P2) et le motif
|
|
||||||
* publié est \c LOCK_MIN_ON, qui précède le refus ; le verdict, lui, est le même.
|
|
||||||
*/
|
|
||||||
void Simulation::testARealRefusalIsStillSeen()
|
|
||||||
{
|
|
||||||
#ifndef ETM_ARBITRATOR
|
|
||||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
|
||||||
#else
|
|
||||||
// ── 1. Montée refusée : 7 A suivis jusqu'à k=5, consigne de 13 A à k=5, figée à 7 A. ──
|
|
||||||
// La production retombe à k=6 : sans recrédit, le budget ne paie plus le plancher, et le
|
|
||||||
// motif qui l'explique est la suspension.
|
|
||||||
const QList<CycleBorne> figee = rejouerBorne("refus-montee",
|
|
||||||
[](int k, double commandeW, double tireW) { return k <= 5 ? commandeW : tireW; },
|
|
||||||
[](int k) { return k < 5 ? 1910.0 : (k == 5 ? 3300.0 : 2000.0); }, 7);
|
|
||||||
// ── 2. Montée qui progresse (la moitié de l'écart par cycle) puis cale à 2 000 W. ──────
|
|
||||||
const QList<CycleBorne> cale = rejouerBorne("refus-cale",
|
|
||||||
[](int, double commandeW, double tireW) {
|
|
||||||
const double rampe = commandeW > tireW ? tireW + (commandeW - tireW) * 0.5 : commandeW;
|
|
||||||
return qMin(rampe, 2000.0);
|
|
||||||
}, [](int) { return 3300.0; }, 5);
|
|
||||||
// ── 3. Descente non suivie : production 3 300 → 1 900 W à k=6, la borne ne lâche que 500 W.
|
|
||||||
const QList<CycleBorne> descente = rejouerBorne("refus-descente",
|
|
||||||
[](int k, double commandeW, double tireW) {
|
|
||||||
if (k <= 6)
|
|
||||||
return commandeW; // elle a obéi jusqu'à la commande de descente
|
|
||||||
return commandeW < tireW ? qMax(commandeW, tireW - 500.0) : commandeW;
|
|
||||||
}, [](int k) { return k >= 6 ? 1900.0 : 3300.0; }, 8);
|
|
||||||
|
|
||||||
QCOMPARE(figee.size(), 7);
|
|
||||||
QCOMPARE(cale.size(), 5);
|
|
||||||
QCOMPARE(descente.size(), 8);
|
|
||||||
|
|
||||||
// 1. 7 A suivis : aucune suspension. Commande de 13 A à k=5 ; à k=6 la borne tire toujours
|
|
||||||
// 1 610 W : refus AU CYCLE SUIVANT. Rien ne s'est rapproché, aucune latence ne l'excuse.
|
|
||||||
for (int k = 1; k <= 4; ++k) {
|
|
||||||
QCOMPARE(figee.at(k).consigneA, 7);
|
|
||||||
QVERIFY(figee.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
|
|
||||||
}
|
|
||||||
QCOMPARE(figee.at(5).consigneA, 13);
|
|
||||||
QCOMPARE(figee.at(6).mesureW, 1610);
|
|
||||||
QCOMPARE(figee.at(6).motif, QString(DecisionCode::RecreditSuspended));
|
|
||||||
QCOMPARE(figee.at(6).retenuW, 1610);
|
|
||||||
QCOMPARE(figee.at(6).recrediteW, 0);
|
|
||||||
QVERIFY(figee.at(6).divergent);
|
|
||||||
// Daté à l'instant de la commande (k=5), en temps de cycle : un cycle, 60 s.
|
|
||||||
QCOMPARE(figee.at(6).refusDepuisS, 60);
|
|
||||||
|
|
||||||
// 2. 0 → 1 495 W à k=1 (rapprochée de 1 495 W) : latence, recrédit rendu, pas divergent.
|
|
||||||
// 1 495 → 2 000 W à k=2 (rapprochée de 505 W) : latence encore. 2 000 W à k=3 : calée, refus.
|
|
||||||
QCOMPARE(cale.at(0).consigneA, 13);
|
|
||||||
QCOMPARE(cale.at(1).mesureW, 1495);
|
|
||||||
QCOMPARE(cale.at(1).recrediteW, 1495);
|
|
||||||
QVERIFY(!cale.at(1).divergent);
|
|
||||||
QCOMPARE(cale.at(2).mesureW, 2000);
|
|
||||||
QCOMPARE(cale.at(2).recrediteW, 2000);
|
|
||||||
QVERIFY(!cale.at(2).divergent);
|
|
||||||
QCOMPARE(cale.at(3).mesureW, 2000);
|
|
||||||
QCOMPARE(cale.at(3).recrediteW, 0);
|
|
||||||
QVERIFY(cale.at(3).divergent);
|
|
||||||
|
|
||||||
// 3. Jusqu'à k=5, 13 A suivis. À k=6 la production tombe : la consigne descend. À k=7 la
|
|
||||||
// borne n'a lâché que 500 W — elle s'est rapprochée de sa consigne, et c'est un refus,
|
|
||||||
// AU CYCLE QUI SUIT la commande de descente : jugé sur la latence de descente (7 s), sans
|
|
||||||
// l'excuse de la progression.
|
|
||||||
QCOMPARE(descente.at(5).consigneA, 13);
|
|
||||||
QVERIFY2(descente.at(6).consigneA < 13, "la production tombe : la consigne doit descendre");
|
|
||||||
QCOMPARE(descente.at(7).mesureW, 2490);
|
|
||||||
QCOMPARE(descente.at(7).motif, QString(DecisionCode::RecreditSuspended));
|
|
||||||
QCOMPARE(descente.at(7).retenuW, 2490);
|
|
||||||
QCOMPARE(descente.at(7).recrediteW, 0);
|
|
||||||
QVERIFY(descente.at(7).divergent);
|
|
||||||
QCOMPARE(descente.at(7).refusDepuisS, 60);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Relais et consignes continues sous compteur dédié : marche suivie, refus réel, et sans
|
|
||||||
* compteur rien ne change.
|
|
||||||
*
|
|
||||||
* Même défaut que la borne avant le correctif : l'écart comparait la consigne qui venait de
|
|
||||||
* partir à la mesure de la précédente, et le verdict dépendait de la phase du battement de
|
|
||||||
* publication. Jugé au début du cycle, il ne dépend plus que de la charge :
|
|
||||||
* - **marche suivie** (le compteur voit la consigne au cycle suivant) : jamais de refus, dans
|
|
||||||
* les deux sens ;
|
|
||||||
* - **refus réel** (le compteur reste à 0) : latence au cycle qui suit la commande — le défaut
|
|
||||||
* de 60 s n'est pas dépassé par un cycle de 60 s — puis refus au suivant ;
|
|
||||||
* - **sans compteur** : relais sec, rien ne mesure, l'écart est absent ; consigne continue, la
|
|
||||||
* mesure est celle de l'appareil, qui est aussi sa commande — l'écart est publié, jamais
|
|
||||||
* divergent. Exactement comme avant.
|
|
||||||
*
|
|
||||||
* \note Pour une consigne continue, la « commande » est la puissance que l'appareil annonce
|
|
||||||
* appliquer (\c puissanceCommandee()). Le modèle le fait annoncer la consigne qu'il a reçue.
|
|
||||||
*/
|
|
||||||
void Simulation::testWattLoadsFollowedOrRefusedUnderAMeter()
|
|
||||||
{
|
|
||||||
#ifndef ETM_ARBITRATOR
|
|
||||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
|
||||||
#else
|
|
||||||
constexpr double MaisonW = 300;
|
|
||||||
struct Pas {
|
|
||||||
int consigneW = 0;
|
|
||||||
int recrediteW = 0;
|
|
||||||
QString motif;
|
|
||||||
int refusDepuisS = -1;
|
|
||||||
int retenuW = 0;
|
|
||||||
bool commande = false; //!< objet `command` publié
|
|
||||||
bool divergent = false;
|
|
||||||
};
|
|
||||||
// mecanisme : "relais" (relais sec 1 000 W) ou "variable" (consigne continue 0-2 000 W).
|
|
||||||
auto rejouer = [&](const QString &mecanisme, bool avecCompteur, bool obeit,
|
|
||||||
const std::function<double(int)> &productionW, int cycles) -> QList<Pas> {
|
|
||||||
QList<Pas> trace;
|
|
||||||
const QString nom = mecanisme + (avecCompteur ? "-compteur" : "-seul") + (obeit ? "-suivie" : "-refus");
|
|
||||||
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(27320);
|
|
||||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
|
||||||
const QUuid compteurId = addMeter(27321);
|
|
||||||
Thing *meter = tm->findConfiguredThing(meterId);
|
|
||||||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
|
||||||
if (!arb || !meter || !compteur)
|
|
||||||
return trace;
|
|
||||||
meter->setStateValue("connected", true);
|
|
||||||
compteur->setStateValue("connected", true);
|
|
||||||
compteur->setStateValue("currentPower", 0);
|
|
||||||
|
|
||||||
QString loadId;
|
|
||||||
Thing *pilote = nullptr;
|
|
||||||
QVariantMap cfg;
|
|
||||||
if (mecanisme == QLatin1String("relais")) {
|
|
||||||
const QUuid r = addDryRelay(27322);
|
|
||||||
loadId = "ecs-relais";
|
|
||||||
cfg = {{"id", loadId}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
|
||||||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
|
||||||
{"relays", QVariantList() << QVariantMap{{"thingId", r.toString()}, {"powerW", 1000}}}};
|
|
||||||
} else {
|
|
||||||
const QUuid v = addEtmVariableLoad(27323);
|
|
||||||
pilote = tm->findConfiguredThing(v);
|
|
||||||
if (!pilote)
|
|
||||||
return trace;
|
|
||||||
pilote->setStateValue("currentPowerW", 0);
|
|
||||||
loadId = v.toString();
|
|
||||||
cfg = {{"id", loadId}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
|
||||||
{"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", 1}, {"enabled", true}};
|
|
||||||
}
|
|
||||||
if (avecCompteur)
|
|
||||||
cfg.insert("meterThingId", compteurId.toString());
|
|
||||||
LoadConfigStore *store = new LoadConfigStore(arb);
|
|
||||||
arb->setLoadConfigStore(store);
|
|
||||||
LoadConfigs cfgs;
|
|
||||||
cfgs.append(LoadConfig::fromMap(cfg));
|
|
||||||
QString err;
|
|
||||||
if (!store->setConfigs(cfgs, &err))
|
|
||||||
return trace;
|
|
||||||
|
|
||||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
|
||||||
double commandeW = 0;
|
|
||||||
for (int k = 0; k < cycles; ++k) {
|
|
||||||
// Suivie : ce qu'on a commandé au cycle précédent coule. Refusée : rien ne coule.
|
|
||||||
const double tireW = obeit ? commandeW : 0.0;
|
|
||||||
compteur->setStateValue("currentPower", tireW);
|
|
||||||
if (pilote)
|
|
||||||
pilote->setStateValue("currentPowerW", commandeW); // l'appareil annonce sa consigne
|
|
||||||
meter->setStateValue("currentPower", MaisonW + tireW - productionW(k));
|
|
||||||
arb->simulationCallUpdate(t0.addSecs(60 * k));
|
|
||||||
QCoreApplication::processEvents();
|
|
||||||
|
|
||||||
const QVariantMap snap = arb->loadTelemetry();
|
|
||||||
const QVariantMap e = telemetrieParId(snap).value(loadId);
|
|
||||||
const QVariantMap mech = e.value("mechanism").toMap();
|
|
||||||
commandeW = mech.value(mecanisme == QLatin1String("relais") ? "stageW" : "setpointW").toDouble();
|
|
||||||
const QVariantMap params = e.value("decision").toMap().value("params").toMap();
|
|
||||||
Pas p;
|
|
||||||
p.consigneW = qRound(commandeW);
|
|
||||||
p.recrediteW = qRound(snap.value("budget").toMap().value("recreditedW").toDouble());
|
|
||||||
p.motif = e.value("decision").toMap().value("code").toString();
|
|
||||||
p.refusDepuisS = params.value("refusedSinceS", -1).toInt();
|
|
||||||
p.retenuW = params.value("withheldW").toInt();
|
|
||||||
p.commande = e.contains("command");
|
|
||||||
p.divergent = e.value("command").toMap().value("divergent").toBool();
|
|
||||||
trace.append(p);
|
|
||||||
qInfo().noquote() << QStringLiteral("[%1] k=%2 consigne %3 W | recrédit %4 | %5 retenu %6 | divergent %7")
|
|
||||||
.arg(nom).arg(k).arg(p.consigneW).arg(p.recrediteW).arg(p.motif)
|
|
||||||
.arg(p.retenuW).arg(p.divergent ? "oui" : "non");
|
|
||||||
}
|
|
||||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
|
||||||
QFile::remove(cfgPath);
|
|
||||||
return trace;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Marche suivie : montée à k=0, descente à k=3, remontée à k=5.
|
|
||||||
auto paliers = [](int k) { return (k >= 3 && k < 5) ? 1000.0 : 1800.0; };
|
|
||||||
// Refus réel : de quoi commander au début, plus rien ensuite — le budget ne tient alors que
|
|
||||||
// par le recrédit, et sa suspension se lit dans le motif.
|
|
||||||
auto relaisRefus = [](int k) { return k < 2 ? 1800.0 : 1000.0; };
|
|
||||||
auto variableRefus = [](int k) { return k < 2 ? 2300.0 : 300.0; };
|
|
||||||
|
|
||||||
const QList<Pas> r1 = rejouer("relais", true, true, paliers, 7);
|
|
||||||
const QList<Pas> r2 = rejouer("relais", true, false, relaisRefus, 4);
|
|
||||||
const QList<Pas> r3 = rejouer("relais", false, false, relaisRefus, 4);
|
|
||||||
const QList<Pas> v1 = rejouer("variable", true, true, paliers, 7);
|
|
||||||
const QList<Pas> v2 = rejouer("variable", true, false, variableRefus, 4);
|
|
||||||
const QList<Pas> v3 = rejouer("variable", false, false, variableRefus, 4);
|
|
||||||
QCOMPARE(r1.size(), 7); QCOMPARE(r2.size(), 4); QCOMPARE(r3.size(), 4);
|
|
||||||
QCOMPARE(v1.size(), 7); QCOMPARE(v2.size(), 4); QCOMPARE(v3.size(), 4);
|
|
||||||
|
|
||||||
// ── Marche suivie : elle a bien eu lieu, dans les deux sens, et jamais de refus. ─────────
|
|
||||||
QCOMPARE(r1.at(0).consigneW, 1000);
|
|
||||||
QCOMPARE(r1.at(3).consigneW, 0);
|
|
||||||
QCOMPARE(r1.at(5).consigneW, 1000);
|
|
||||||
QVERIFY(v1.at(3).consigneW < v1.at(2).consigneW);
|
|
||||||
QVERIFY(v1.at(5).consigneW > v1.at(4).consigneW);
|
|
||||||
for (const QList<Pas> *t : {&r1, &v1}) {
|
|
||||||
for (int k = 1; k < t->size(); ++k) {
|
|
||||||
QVERIFY2(t->at(k).motif != QLatin1String(DecisionCode::RecreditSuspended),
|
|
||||||
qUtf8Printable(QStringLiteral("k=%1 : recrédit suspendu sur une charge qui obéit").arg(k)));
|
|
||||||
QVERIFY(t->at(k).commande);
|
|
||||||
QVERIFY2(!t->at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergent").arg(k)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refus réel : latence au cycle qui suit la commande (60 s ne dépasse pas le défaut de
|
|
||||||
// 60 s), refus au suivant — recrédit suspendu, et le motif le dit. ─────────────────────
|
|
||||||
for (const QList<Pas> *t : {&r2, &v2}) {
|
|
||||||
QVERIFY(t->at(0).consigneW > 0);
|
|
||||||
QVERIFY(t->at(1).recrediteW > 0);
|
|
||||||
QVERIFY(!t->at(1).divergent);
|
|
||||||
QCOMPARE(t->at(2).recrediteW, 0);
|
|
||||||
QVERIFY(t->at(2).divergent);
|
|
||||||
QCOMPARE(t->at(2).motif, QString(DecisionCode::RecreditSuspended));
|
|
||||||
QCOMPARE(t->at(2).retenuW, t->at(0).consigneW);
|
|
||||||
// Daté à l'instant de la commande (k=0) : deux cycles.
|
|
||||||
QCOMPARE(t->at(2).refusDepuisS, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sans compteur : rien ne change. Relais sec : rien ne mesure, aucun écart publié ;
|
|
||||||
// consigne continue : l'appareil mesure ce qu'il annonce, l'écart ne diverge jamais. ──
|
|
||||||
for (int k = 0; k < r3.size(); ++k) {
|
|
||||||
QVERIFY(!r3.at(k).commande);
|
|
||||||
QVERIFY(r3.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
|
|
||||||
}
|
|
||||||
for (int k = 1; k < v3.size(); ++k) {
|
|
||||||
QVERIFY(v3.at(k).commande);
|
|
||||||
QVERIFY(!v3.at(k).divergent);
|
|
||||||
QVERIFY(v3.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief SG-Ready sous compteur dédié : la divergence se PUBLIE, le recrédit n'en dépend pas.
|
|
||||||
*
|
|
||||||
* Le recrédit d'une PAC est la puissance DÉCLARÉE de son état (invariant 8), pas sa mesure : il
|
|
||||||
* ne consulte pas le refus, avant comme après le correctif. Ce qui change est ce que l'écart
|
|
||||||
* publié dit d'une PAC qui démarre :
|
|
||||||
* - **qui monte en puissance en plusieurs minutes** (1 000 W de plus par cycle) : en progression,
|
|
||||||
* donc une latence — jamais divergente ;
|
|
||||||
* - **qui ne démarre pas** (compteur figé à 0) : latence au cycle qui suit la commande (défaut de
|
|
||||||
* 60 s), divergente au suivant. Le recrédit, lui, reste de 3 000 W.
|
|
||||||
*/
|
|
||||||
void Simulation::testSgReadyDivergenceIsPublishedOnly()
|
|
||||||
{
|
|
||||||
#ifndef ETM_ARBITRATOR
|
|
||||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
|
||||||
#else
|
|
||||||
constexpr double MaisonW = 300;
|
|
||||||
struct Pas { int etat = 0; int recrediteW = 0; QString motif; bool divergent = false; bool commande = false; };
|
|
||||||
auto rejouer = [&](const QString &nom, const std::function<double(int)> &pacW, int cycles) -> QList<Pas> {
|
|
||||||
QList<Pas> 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(27330);
|
|
||||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
|
||||||
const QUuid compteurId = addMeter(27331);
|
|
||||||
const QUuid k1 = addPowerSwitch(0, 27332);
|
|
||||||
const QUuid k2 = addPowerSwitch(0, 27333);
|
|
||||||
Thing *meter = tm->findConfiguredThing(meterId);
|
|
||||||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
|
||||||
if (!arb || !meter || !compteur || !tm->findConfiguredThing(k1) || !tm->findConfiguredThing(k2))
|
|
||||||
return trace;
|
|
||||||
meter->setStateValue("connected", true);
|
|
||||||
compteur->setStateValue("connected", true);
|
|
||||||
compteur->setStateValue("currentPower", 0);
|
|
||||||
QVariantMap cfg = sgReadyConfigMap("pac", "PAC", k1, k2, 1500.0, 3000.0, 0, 1);
|
|
||||||
cfg.insert("meterThingId", compteurId.toString());
|
|
||||||
LoadConfigStore *store = new LoadConfigStore(arb);
|
|
||||||
arb->setLoadConfigStore(store);
|
|
||||||
LoadConfigs cfgs;
|
|
||||||
cfgs.append(LoadConfig::fromMap(cfg));
|
|
||||||
QString err;
|
|
||||||
if (!store->setConfigs(cfgs, &err))
|
|
||||||
return trace;
|
|
||||||
|
|
||||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
|
||||||
for (int k = 0; k < cycles; ++k) {
|
|
||||||
const double tireW = pacW(k);
|
|
||||||
compteur->setStateValue("currentPower", tireW);
|
|
||||||
meter->setStateValue("currentPower", MaisonW + tireW - 5000.0); // 4 700 W sans la PAC
|
|
||||||
arb->simulationCallUpdate(t0.addSecs(60 * k));
|
|
||||||
QCoreApplication::processEvents();
|
|
||||||
const QVariantMap snap = arb->loadTelemetry();
|
|
||||||
const QVariantMap e = telemetrieParId(snap).value("pac");
|
|
||||||
Pas p;
|
|
||||||
p.etat = e.value("mechanism").toMap().value("state").toInt();
|
|
||||||
p.recrediteW = qRound(snap.value("budget").toMap().value("recreditedW").toDouble());
|
|
||||||
p.motif = e.value("decision").toMap().value("code").toString();
|
|
||||||
p.commande = e.contains("command");
|
|
||||||
p.divergent = e.value("command").toMap().value("divergent").toBool();
|
|
||||||
trace.append(p);
|
|
||||||
qInfo().noquote() << QStringLiteral("[%1] k=%2 état %3 | PAC %4 W | recrédit %5 | %6 | divergent %7")
|
|
||||||
.arg(nom).arg(k).arg(p.etat).arg(qRound(tireW)).arg(p.recrediteW)
|
|
||||||
.arg(p.motif).arg(p.divergent ? "oui" : "non");
|
|
||||||
}
|
|
||||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
|
||||||
QFile::remove(cfgPath);
|
|
||||||
return trace;
|
|
||||||
};
|
|
||||||
|
|
||||||
const QList<Pas> monte = rejouer("pac-monte", [](int k) { return qMin(3000.0, 1000.0 * k); }, 5);
|
|
||||||
const QList<Pas> figee = rejouer("pac-figee", [](int) { return 0.0; }, 4);
|
|
||||||
QCOMPARE(monte.size(), 5);
|
|
||||||
QCOMPARE(figee.size(), 4);
|
|
||||||
|
|
||||||
for (const QList<Pas> *t : {&monte, &figee}) {
|
|
||||||
QCOMPARE(t->at(0).etat, 4); // 4 700 W de surplus : état forcé
|
|
||||||
for (int k = 1; k < t->size(); ++k) {
|
|
||||||
QCOMPARE(t->at(k).etat, 4);
|
|
||||||
QVERIFY(t->at(k).commande);
|
|
||||||
// Le recrédit est la puissance DÉCLARÉE de l'état 4, refus ou pas.
|
|
||||||
QCOMPARE(t->at(k).recrediteW, 3000);
|
|
||||||
QVERIFY(t->at(k).motif != QLatin1String(DecisionCode::RecreditSuspended));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Une PAC qui monte en minutes est en progression : jamais divergente.
|
|
||||||
for (int k = 1; k < monte.size(); ++k)
|
|
||||||
QVERIFY2(!monte.at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergente").arg(k)));
|
|
||||||
// Une PAC qui ne démarre pas : latence au cycle qui suit la commande, divergente ensuite.
|
|
||||||
QVERIFY(!figee.at(1).divergent);
|
|
||||||
QVERIFY(figee.at(2).divergent);
|
|
||||||
QVERIFY(figee.at(3).divergent);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief P2 — un ARRÊT VERROUILLÉ engage le courant minimal : gelé au budget, et aucun faux refus.
|
|
||||||
*
|
|
||||||
* Borne à 13 A, démarrée à k=0 : son verrou de marche/arrêt (300 s) court jusqu'à k=5. À k=2 la
|
|
||||||
* production s'effondre, le plan voudrait couper. Avant le correctif, la coupure était
|
|
||||||
* commandée, refusée par le verrou, et la borne restait à 6 A pendant qu'on attendait 0 W : un
|
|
||||||
* refus fabriqué par le moteur lui-même au cycle suivant, et 1 380 W prêtés à la charge suivante
|
|
||||||
* pendant que la borne les tirait.
|
|
||||||
*
|
|
||||||
* Avec P2, la borne DÉCLARE ce que le verrou engage (\c lockMinPowerW = 6 A × 230 V) : le
|
|
||||||
* waterfall gèle ces watts (\c LOCK_MIN_ON), la consigne de routeur derrière n'en reçoit rien, la
|
|
||||||
* borne retient les 6 A qu'elle tient, et aucun refus n'est lu. À l'expiration du verrou,
|
|
||||||
* l'arrêt passe. La somme avec la passe éco : testALockedStopDoesNotAddToTheEcoFloor().
|
|
||||||
*/
|
|
||||||
void Simulation::testLockedStopFreezesTheMinimumAndIsNoRefusal()
|
|
||||||
{
|
|
||||||
#ifndef ETM_ARBITRATOR
|
|
||||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
|
||||||
#else
|
|
||||||
constexpr double MaisonW = 300;
|
|
||||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-arret-verrouille.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());
|
|
||||||
QVERIFY(arb);
|
|
||||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
|
||||||
const QUuid meterId = addMeter(27340);
|
|
||||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
|
||||||
const QUuid borneId = addCharger("A", 16, 27341);
|
|
||||||
const QUuid routeurId = addEtmVariableLoad(27342);
|
|
||||||
Thing *meter = tm->findConfiguredThing(meterId);
|
|
||||||
Thing *borne = tm->findConfiguredThing(borneId);
|
|
||||||
Thing *routeur = tm->findConfiguredThing(routeurId);
|
|
||||||
QVERIFY(meter && borne && routeur);
|
|
||||||
meter->setStateValue("connected", true);
|
|
||||||
routeur->setStateValue("currentPowerW", 0);
|
|
||||||
armerBorne(borne);
|
|
||||||
|
|
||||||
QVariantMap info;
|
|
||||||
info.insert("evChargerId", borneId);
|
|
||||||
info.insert("chargingMode", "ChargingModeEco");
|
|
||||||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
|
||||||
|
|
||||||
// Amorce : la borne reçoit son entrée de configuration (rang 1) ; le routeur PV passe derrière.
|
|
||||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
|
||||||
meter->setStateValue("currentPower", MaisonW);
|
|
||||||
arb->simulationCallUpdate(t0.addSecs(-60));
|
|
||||||
QCoreApplication::processEvents();
|
|
||||||
QVariantList cfgs = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
|
|
||||||
.value("params").toMap().value("loadConfigs").toList();
|
|
||||||
int rangMax = 0;
|
|
||||||
for (const QVariant &v : cfgs)
|
|
||||||
rangMax = qMax(rangMax, v.toMap().value("priority").toInt());
|
|
||||||
cfgs << QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"},
|
|
||||||
{"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", rangMax + 1}, {"enabled", true}};
|
|
||||||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", cfgs}})).toMap()
|
|
||||||
.value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
|
|
||||||
|
|
||||||
struct Pas { int borneA = 0; QString motif; int appliqueW = -1; int alloueBorneW = 0; int alloueRouteurW = 0;
|
|
||||||
bool divergent = false; };
|
|
||||||
QList<Pas> trace;
|
|
||||||
double borneW = 0, routeurW = 0;
|
|
||||||
for (int k = 0; k < 7; ++k) {
|
|
||||||
const double productionW = k < 2 ? 3300.0 : 600.0;
|
|
||||||
borne->setStateValue("currentPower", qRound(borneW));
|
|
||||||
routeur->setStateValue("currentPowerW", routeurW);
|
|
||||||
meter->setStateValue("currentPower", qRound(MaisonW + borneW + routeurW - productionW));
|
|
||||||
arb->simulationCallUpdate(t0.addSecs(60 * k));
|
|
||||||
QCoreApplication::processEvents();
|
|
||||||
|
|
||||||
const bool enMarche = borne->stateValue("power").toBool();
|
|
||||||
const int courantA = borne->stateValue("maxChargingCurrent").toInt();
|
|
||||||
borneW = enMarche ? courantA * 230.0 : 0.0;
|
|
||||||
routeurW = routeur->stateValue("powerSetpoint").toDouble();
|
|
||||||
|
|
||||||
const QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
|
||||||
const QVariantMap b = tele.value(borneId.toString());
|
|
||||||
Pas p;
|
|
||||||
p.borneA = enMarche ? courantA : 0;
|
|
||||||
p.motif = b.value("decision").toMap().value("code").toString();
|
|
||||||
p.appliqueW = b.value("decision").toMap().value("params").toMap().value("appliedW", -1).toInt();
|
|
||||||
p.alloueBorneW = qRound(b.value("allocatedW").toDouble());
|
|
||||||
p.alloueRouteurW = qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble());
|
|
||||||
p.divergent = b.value("command").toMap().value("divergent").toBool();
|
|
||||||
trace.append(p);
|
|
||||||
qInfo().noquote() << QStringLiteral("[arrêt verrouillé] k=%1 borne %2 A | %3 appliqué %4 | alloué borne %5 routeur %6 | divergent %7")
|
|
||||||
.arg(k).arg(p.borneA).arg(p.motif).arg(p.appliqueW).arg(p.alloueBorneW)
|
|
||||||
.arg(p.alloueRouteurW).arg(p.divergent ? "oui" : "non");
|
|
||||||
}
|
|
||||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
|
||||||
QFile::remove(cfgPath);
|
|
||||||
|
|
||||||
QCOMPARE(trace.at(0).borneA, 13);
|
|
||||||
QCOMPARE(trace.at(1).borneA, 13);
|
|
||||||
// k=2..4 : l'arrêt est verrouillé (démarrage à k=0, 300 s). La borne déclare ses 1 380 W
|
|
||||||
// engagés : le waterfall les gèle, le motif le dit, la borne reste à 6 A.
|
|
||||||
for (int k = 2; k <= 4; ++k) {
|
|
||||||
QCOMPARE(trace.at(k).motif, QString(DecisionCode::LockMinOn));
|
|
||||||
QCOMPARE(trace.at(k).appliqueW, 1380);
|
|
||||||
QCOMPARE(trace.at(k).alloueBorneW, 1380);
|
|
||||||
QCOMPARE(trace.at(k).borneA, 6);
|
|
||||||
// Les watts gelés ne sont PAS prêtés : le routeur derrière n'en reçoit rien.
|
|
||||||
QCOMPARE(trace.at(k).alloueRouteurW, 0);
|
|
||||||
}
|
|
||||||
// k=5 : verrou expiré, l'arrêt passe.
|
|
||||||
QCOMPARE(trace.at(5).borneA, 0);
|
|
||||||
QCOMPARE(trace.at(6).borneA, 0);
|
|
||||||
// Et à aucun cycle la borne n'est lue comme refusant : c'est le moteur qui la tenait.
|
|
||||||
for (int k = 0; k < trace.size(); ++k) {
|
|
||||||
QVERIFY2(trace.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended),
|
|
||||||
qUtf8Printable(QStringLiteral("k=%1 : faux refus sous arrêt verrouillé").arg(k)));
|
|
||||||
QVERIFY2(!trace.at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergent").arg(k)));
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
|
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
|
||||||
*
|
*
|
||||||
@ -11738,36 +11039,6 @@ void Simulation::testWithoutOffPeakTheEngineIsEtm74()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief P2 — le verrou d'arrêt borne la SOMME éco + confort, il ne s'y ajoute pas.
|
|
||||||
*
|
|
||||||
* « Éco + minimum » sans heures creuses : le minimum est acheté au réseau (6 A) par la passe
|
|
||||||
* éco, pendant que l'arrêt est verrouillé. Relevé en entier sur la part confort, le minimum
|
|
||||||
* engagé s'ajoutait à l'achat qui le couvrait déjà, et la borne recevait 12 A. Trouvé par la
|
|
||||||
* preuve `decisions`, aucun test ne le voyait : testWithoutOffPeakTheEngineIsEtm74() compare les
|
|
||||||
* trames entre elles, pas le courant.
|
|
||||||
*/
|
|
||||||
void Simulation::testALockedStopDoesNotAddToTheEcoFloor()
|
|
||||||
{
|
|
||||||
#ifndef ETM_ARBITRATOR
|
|
||||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
|
||||||
#else
|
|
||||||
FuseauDeTest fuseau("Europe/Paris");
|
|
||||||
ScenarioHc s = preparerScenarioHc(QStringLiteral("arret-eco"), BatterieHc::Aucune, QVariantList());
|
|
||||||
QVERIFY(s.arb && s.borne);
|
|
||||||
s.borne->setStateValue("pluggedIn", true);
|
|
||||||
for (int m = 0; m <= 3; ++m) {
|
|
||||||
const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)),
|
|
||||||
600 + (m > 0 ? 1380 : 0));
|
|
||||||
const QVariantMap d = telemetrieParId(snap).value(s.borneId.toString())
|
|
||||||
.value("decision").toMap();
|
|
||||||
QCOMPARE(d.value("code").toString(), QString(DecisionCode::EcoFloorGrid));
|
|
||||||
QVERIFY(s.borne->stateValue("power").toBool());
|
|
||||||
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void Simulation::testABatteryWithoutControlIsReadAsBefore()
|
void Simulation::testABatteryWithoutControlIsReadAsBefore()
|
||||||
{
|
{
|
||||||
#ifndef ETM_ARBITRATOR
|
#ifndef ETM_ARBITRATOR
|
||||||
@ -12774,10 +12045,6 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
|
|||||||
QCOMPARE(e.value("after").toMap().value("powerW").toInt(), 0);
|
QCOMPARE(e.value("after").toMap().value("powerW").toInt(), 0);
|
||||||
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("chargingEnabled").toBool(), false);
|
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("chargingEnabled").toBool(), false);
|
||||||
QVERIFY(!e.contains("endedAt"));
|
QVERIFY(!e.contains("endedAt"));
|
||||||
// La protection écrit HORS du waterfall. La borne tient ce qu'elle lui a donné : ce n'est pas
|
|
||||||
// un refus. Jugée contre la dernière consigne du waterfall (16 A), elle se lisait refusante,
|
|
||||||
// et son recrédit était retiré.
|
|
||||||
QCOMPARE(entree().value("command").toMap().value("divergent").toBool(), false);
|
|
||||||
|
|
||||||
// 3. RETOUR — le four s'éteint. Le cycle reprend la borne : le motif se lève AU MÊME CYCLE.
|
// 3. RETOUR — le four s'éteint. Le cycle reprend la borne : le motif se lève AU MÊME CYCLE.
|
||||||
// L'épisode, lui, n'est clos qu'après un cycle complet sans protection — daté de la reprise.
|
// L'épisode, lui, n'est clos qu'après un cycle complet sans protection — daté de la reprise.
|
||||||
@ -12787,7 +12054,6 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
|
|||||||
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
|
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
|
||||||
QVERIFY(entree().contains("levels"));
|
QVERIFY(entree().contains("levels"));
|
||||||
QVERIFY(!journal().first().toMap().contains("endedAt"));
|
QVERIFY(!journal().first().toMap().contains("endedAt"));
|
||||||
QCOMPARE(entree().value("command").toMap().value("divergent").toBool(), false);
|
|
||||||
|
|
||||||
notifs.clear();
|
notifs.clear();
|
||||||
const int revisionAvant = snap.value("shedding").toMap().value("revision").toInt();
|
const int revisionAvant = snap.value("shedding").toMap().value("revision").toInt();
|
||||||
|
|||||||
@ -27,7 +27,6 @@
|
|||||||
|
|
||||||
#include "simulationbase.h"
|
#include "simulationbase.h"
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
class EnergyArbitrator;
|
class EnergyArbitrator;
|
||||||
@ -81,32 +80,6 @@ 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 scénario 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();
|
||||||
@ -267,18 +240,6 @@ private slots:
|
|||||||
void testPhaseVoltageDoesNotLeakBetweenPhases();
|
void testPhaseVoltageDoesNotLeakBetweenPhases();
|
||||||
void testMeterEnergyActuallyAccumulates();
|
void testMeterEnergyActuallyAccumulates();
|
||||||
void testRecreditIsALoanAgainstObedience();
|
void testRecreditIsALoanAgainstObedience();
|
||||||
//! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient.
|
|
||||||
void testRecreditSuspendedAfterAnObeyedStep();
|
|
||||||
//! Un vrai refus reste vu au cycle qui suit la commande ; la descente n'a pas d'excuse.
|
|
||||||
void testARealRefusalIsStillSeen();
|
|
||||||
//! Relais et consignes sous compteur dédié : marche suivie, refus réel, sans compteur.
|
|
||||||
void testWattLoadsFollowedOrRefusedUnderAMeter();
|
|
||||||
//! SG-Ready sous compteur dédié : la divergence se publie, le recrédit n'en dépend pas.
|
|
||||||
void testSgReadyDivergenceIsPublishedOnly();
|
|
||||||
//! P2 — un arrêt verrouillé engage le courant minimal : gelé au budget, aucun faux refus.
|
|
||||||
void testLockedStopFreezesTheMinimumAndIsNoRefusal();
|
|
||||||
//! P2 — le verrou d'arrêt borne la somme éco + confort, il ne s'y ajoute pas.
|
|
||||||
void testALockedStopDoesNotAddToTheEcoFloor();
|
|
||||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||||
void testDerogationTakesTheHeadButNotTheSafety();
|
void testDerogationTakesTheHeadButNotTheSafety();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user