Compare commits

...

11 Commits

Author SHA1 Message Date
Patrick Schurig
2d06de0bc1 debian : entrée de changelog 1.15.2+etm76
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 20:07:21 +01:00
Patrick Schurig
c75cc32470 fusion : fix/recredit-oscillation (écart commande/mesure jugé au début du cycle, borne qui retient ce qu'elle tient)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 20:07:08 +01:00
Patrick Schurig
0a88a0f97f commentaires : la marge de progression renvoie au document de conception
La justification de ProgressionMinW cite la négociation la plus lente
par son document (DESIGN_COMMANDE_PERDUE.md §9.1) plutôt que comme un
relevé. Dans les tests, « banc » désignait le scénario : mot remplacé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:58:17 +01:00
Patrick Schurig
f9f2e1e5c5 decisions : référence régénérée (verdict de l'écart commande/mesure)
Contre la référence d'etm75, 29 fonctions changent, toutes par le
verdict de l'écart commande/mesure ou ce qui en découle :
- 21 par `command.divergent` seul (marches suivies et premières
  commandes qui ne divergent plus ; mesures remises à la main par le
  harnais qui divergent) ;
- `run` : `expectedW` de la borne commandée par le proxy (mémoire de
  commande tous émetteurs) et 46 cycles de gel sous arrêt verrouillé ;
- 7 où la garde de refus mord enfin : datée à l'horloge murale, elle
  était inerte dans toute la suite de simulation.
Plus les 6 fonctions de test nouvelles. Détail dans le rapport du lot.

run-decisions.sh check : IDENTIQUE (24 415 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:52:02 +01:00
Patrick Schurig
c9e4a251ca test : le rejeu de borne part d'une date fixe
rejouerBorne() partait de l'horloge murale, comme le test de
reproduction devait le faire tant que l'écart était daté à l'horloge
murale. Le moteur n'en dépend plus, et l'enregistreur `decisions` date
les cycles : la référence n'était pas reproductible à la milliseconde
près. Mêmes chiffres de rejeu avec une date fixe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:40:11 +01:00
Patrick Schurig
e856c48084 test : un vrai refus reste vu, sur la borne et sur les autres charges
testARealRefusalIsStillSeen — borne figée sous une consigne à la hausse :
refus au cycle qui suit la commande, comme avant le correctif ; montée
qui progresse puis cale : latence tant qu'elle se rapproche, refus au
cycle où elle cale ; descente suivie de 500 W seulement : refus au cycle
suivant, la progression n'excuse jamais une descente.

testWattLoadsFollowedOrRefusedUnderAMeter — relais et consigne continue
sous compteur dédié : marche suivie dans les deux sens sans refus ; refus
réel vu au deuxième cycle (la latence par défaut de 60 s n'est pas
dépassée par un cycle de 60 s) ; sans compteur, rien ne change.

testSgReadyDivergenceIsPublishedOnly — sous compteur dédié, une PAC qui
monte en minutes n'est pas divergente, une PAC qui ne démarre pas l'est ;
son recrédit (puissance déclarée) n'en dépend pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:05 +01:00
Patrick Schurig
c4e517a8d9 borne : retenir ce que la borne tient, et geler l'arrêt verrouillé (P2)
La borne retenait la consigne VOULUE par son adaptateur, et seulement
quand le waterfall la commandait. Deux faux refus en sortaient :
- une coupure refusée par le verrou de marche/arrêt (300 s) laissait la
  borne à 6 A pendant qu'on attendait 0 W ;
- une borne rabaissée par la protection L4, ou commandée par le proxy
  (échéance, spot), restait jugée contre la dernière consigne du
  waterfall.

La mémoire de commande est posée par EnergyArbitrator::
executeChargingAction(), point unique de tous les émetteurs, avec ce qui
a PASSÉ les verrous et l'instant de la commande.

Pendant que l'arrêt est verrouillé, la borne déclare lockMinPowerW =
courant minimal × 230 × phases, comme un relais sous minOn : le
waterfall gèle ces watts (LOCK_MIN_ON) au lieu de les prêter.

Le verrou borne la SOMME éco + confort : la part confort n'est relevée
que de ce que la passe éco ne couvre pas déjà. Sans cela, une borne
servie à 6 A en éco sous arrêt verrouillé recevait 12 A (trouvé par la
preuve decisions, aucun test ne le voyait).

Tests : testLockedStopFreezesTheMinimumAndIsNoRefusal,
testALockedStopDoesNotAddToTheEcoFloor, et une garde L4 dans
testOverloadProtectionReasonOnTheCharger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:00 +01:00
Patrick Schurig
0dc2a1aeba 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>
2026-10-25 19:30:45 +01:00
Patrick Schurig
41ce86a634 test : une marche de consigne suivie fait suspendre le recrédit (reproduction)
Borne monophasée 6-16 A, 3 000 W de surplus constant. L'écart commande/mesure
est jugé après le dispatch, entre la consigne qui vient de partir et la mesure
de la précédente, puis consommé au cycle suivant par qualifierRefus() avant
d'être rejugé. Une marche suivie se lit donc comme un refus vieux d'un cycle,
au-delà des 30 s / 7 s de la borne : recrédit retenu, coupure (bloquée par le
verrou de 300 s, 6 A tirés pour 0 attendu), puis 7 A, puis 13 A, et ainsi de
suite. La borne tire 1 495 W en moyenne sur 3 000.

Trois rampes : consigne atteinte au pas suivant, 30 s / 7 s, 120 s. Les deux
premières donnent la même trace, cycle pour cycle.

Le test FIGE le défaut. Le correctif devra inverser les deux assertions
marquées « DÉFAUT ». Aucune ligne du moteur n'est modifiée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:46:18 +01:00
Patrick Schurig
9640ac73f2 debian : entrée de changelog 1.15.2+etm75
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 11:47:45 +01:00
Patrick Schurig
e449019390 Merge feature/hc-batterie dans release/etm75
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 11:47:34 +01:00
9 changed files with 1082 additions and 91 deletions

View File

@ -1,3 +1,38 @@
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
* Heures creuses : zones au prix le plus bas du tarif. En mode éco, la borne
attend les heures creuses (EV_WAITING_OFF_PEAK) et y charge à la pleine
puissance permise (EV_OFF_PEAK_FULL_POWER), sous la limite de phase
mesurée moins 1 A ; la somme éco + confort tient dans le plafond de la
borne. Sans tarif à heures creuses, aucune décision ne change.
* Batterie : la décharge est bloquée pendant que la voiture charge en heures
creuses (limite de décharge SunSpec, rafraîchie au tiers du minuteur,
levée inverse) ; une consigne refusée est réessayée en s'espaçant, la
levée jamais. Bloc storage dans la télémétrie.
* Délestage visible : la protection de surcharge publie ce qu'elle fait à la
borne (OVERLOAD_THROTTLED : abaissée ou coupée, phase, limite,
dépassement) ; journal court des 20 derniers épisodes de délestage, toutes
charges, par NymeaEnergy.GetSheddingLog (lecture sans droit, en mémoire) ;
télémétrie shedding (revision, open), notifiée à l'ouverture et à la
fermeture. La protection décide exactement comme avant.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 25 Oct 2026 13:00:00 +0100
powersync-energy-plugin-nymea (1.15.2+etm74) trixie; urgency=medium powersync-energy-plugin-nymea (1.15.2+etm74) trixie; urgency=medium
* Protocole optimiseur 1.3 : le moteur annonce 1.3. Forme normée de * Protocole optimiseur 1.3 : le moteur annonce 1.3. Forme normée de

View File

@ -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` ne sert qu'à // Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` sert à savoir
// savoir si l'instant tombe en heures creuses (lot e2). // si l'instant tombe en heures creuses (lot e2), et si l'arrêt est verrouillé (P2, plus bas).
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,9 +176,20 @@ 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 fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond // Pas de plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable. // contexte).
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;
} }
@ -287,18 +298,10 @@ 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. Les adaptateurs de // §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Elle n'est plus posée
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de // ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est // proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce // elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
// 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;

View File

@ -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). L'EV n'a pas de verrou de palier (hors * \param now Temps de cycle (\c ctx.timestamp). Il sert à savoir s'il tombe en heures
* waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses * creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
* (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent. * et si l'ARRÊT est verrouillé (voir la note P2).
* \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,6 +57,9 @@ 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;
@ -139,8 +142,9 @@ public:
EvCharger *evCharger() const { return m_charger; } EvCharger *evCharger() const { return m_charger; }
/*! /*!
* \brief Dernière commande envoyée au matériel par cet adaptateur. * \brief Dernière commande tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée. * PASSÉ ses verrous (\c noterCommandeTenue()).
* \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.
@ -148,9 +152,30 @@ public:
bool derniereCommande(uint &ampere, uint &phases) const bool derniereCommande(uint &ampere, 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 envoyée au matériel — écrêtée, pas demandée. * \brief Dernière commande RÉELLEMENT tenue par le matériel — écrêtée et passée par les
* 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
@ -161,6 +186,9 @@ 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;
@ -168,6 +196,7 @@ 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;

View File

@ -1093,6 +1093,17 @@ 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))
@ -1797,26 +1808,19 @@ void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const
void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const
{ {
// Le jugement est fait ICI, au début du cycle, et nulle part ailleurs : la commande en
// vigueur contre la mesure fraîche. Il était fait après le dispatch, contre la consigne qui
// venait de partir et la mesure de la précédente — toute marche suivie se lisait comme un
// refus vieux d'un cycle (60 s, au-delà des 30 s / 7 s d'une borne), et le recrédit d'une
// borne qui obéit était suspendu à chaque marche. La consigne oscillait sous un surplus
// constant.
const std::optional<JugementCommande> j = jugerCommande(lc.id, now);
if (!j)
return; // rien ne mesure, ou rien n'a été commandé : rien à qualifier
const QDateTime depuis = m_divergenceDepuis.value(lc.id); const QDateTime depuis = m_divergenceDepuis.value(lc.id);
if (!depuis.isValid()) if (depuis.isValid())
return; // aucun écart en cours : rien à qualifier lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
lc.telemetry.commandRefused = j->refus;
const int ecoule = static_cast<int>(depuis.secsTo(now));
lc.telemetry.refusedSinceS = qMax(0, ecoule);
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr);
if (!a)
a = m_adapters.value(lc.id, nullptr);
if (!a)
return;
// Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux —
// commander la baisse sans être suivi — est justement celui qui se constate le plus vite.
const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0;
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
} }
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
@ -2384,7 +2388,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
QVariantMap mech; QVariantMap mech;
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger")); mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
orphan.insert(QStringLiteral("mechanism"), mech); orphan.insert(QStringLiteral("mechanism"), mech);
attachMeasurements(orphan, action.loadId); attachMeasurements(orphan, action.loadId, now);
loads.append(orphan); loads.append(orphan);
continue; continue;
} }
@ -2507,7 +2511,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
mech.insert(QStringLiteral("kind"), v.mechanismKind); mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech); entry.insert(QStringLiteral("mechanism"), mech);
} }
attachMeasurements(entry, action.loadId); attachMeasurements(entry, action.loadId, now);
loads.append(entry); loads.append(entry);
} }
@ -2533,14 +2537,119 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
namespace { namespace {
//! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de //! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de
//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent. //! mesure et arrondi à l'ampère s'y logent. Valeur de conception, à ajuster sur relevé.
//! Valeur de conception, à ajuster sur relevé.
constexpr double DivergenceAbsolueW = 200.0; constexpr double DivergenceAbsolueW = 200.0;
constexpr double DivergenceRelative = 0.15; constexpr double DivergenceRelative = 0.15;
//! Rapprochement minimal, d'un cycle au suivant, pour qu'une mesure soit « en progression ».
//! La MÊME grandeur que la tolérance absolue, et c'est voulu : un mouvement que le moteur ne
//! distingue pas d'une concordance ne se distingue pas non plus du bruit. Une charge figée ne le
//! franchit jamais ; la 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 const QString &source, double mesureW,
const QDateTime &now) const
{ {
// RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`, // RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`,
// qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout. // qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout.
@ -2553,58 +2662,34 @@ void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString
const std::optional<double> commandeeW = puissanceCommandee(loadId); const std::optional<double> commandeeW = puissanceCommandee(loadId);
if (!commandeeW) if (!commandeeW)
return; return;
const double attenduW = *commandeeW;
const double ecart = qAbs(attenduW - mesureW); // Le VERDICT est celui du cycle — le jugement que le recrédit a lu, fait au début du cycle
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW); // sur la commande en vigueur et la mesure fraîche. Il n'est PAS refait ici sur la consigne
// qui vient de partir : c'était le défaut, une marche suivie se lisait comme un refus. Sans
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un // commande en vigueur au début du cycle (première commande), le jugement est fait ici, sur
// déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence // celle qui vient de partir : récente, donc jamais divergente.
// entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait const std::optional<JugementCommande> j = jugerCommande(loadId, now);
// indéfiniment parce que plus personne ne commandait. const bool divergent = j && j->refus;
// JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié.
//
// `divergent` et `since` ne vivaient que dans la charge utile de télémétrie et dans ce
// QHash en mémoire — donc nulle part. Rien ne comptait les épisodes : un redémarrage de
// nymead effaçait l'état, et le hash est PURGÉ dès que l'écart cesse, si bien qu'une
// divergence qui s'ouvre et se referme entre deux lectures ne laissait aucune trace. Même
// un observateur externe échantillonnant en continu aurait raté les épisodes courts, qui
// sont probablement les plus nombreux.
//
// Sans ces deux lignes, « attendre que les traces parlent » attend indéfiniment : le
// silence du journal était garanti par construction, pas par l'absence du phénomène.
if (!divergent) {
if (m_divergenceDepuis.contains(loadId)) {
const qint64 dureeS = m_divergenceDepuis.value(loadId).secsTo(QDateTime::currentDateTime());
qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId
<< "après" << dureeS << "s"
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W";
}
m_divergenceDepuis.remove(loadId);
} else if (!m_divergenceDepuis.contains(loadId)) {
const QDateTime debut = m_lastTelemetryPublish.isValid()
? m_lastTelemetryPublish : QDateTime::currentDateTime();
m_divergenceDepuis.insert(loadId, debut.toUTC()); // ancré à la capture
qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W,"
<< "écart" << (mesureW - attenduW) << "W";
}
QVariantMap commande; QVariantMap commande;
commande.insert(QStringLiteral("expectedW"), attenduW); commande.insert(QStringLiteral("expectedW"), *commandeeW);
commande.insert(QStringLiteral("observedW"), mesureW); commande.insert(QStringLiteral("observedW"), mesureW);
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une // Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La // des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la // et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
// même chose du même cycle. // la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
commande.insert(QStringLiteral("divergent"), divergent); commande.insert(QStringLiteral("divergent"), divergent);
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'une
// latence. C'est pourtant TOUTE la différence entre un fonctionnement normal et le cas
// relevé le 2026-08-31, où l'écart durait indéfiniment parce que plus personne ne commandait.
if (divergent) if (divergent)
commande.insert(QStringLiteral("since"), commande.insert(QStringLiteral("since"),
m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate)); m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate));
entry.insert(QStringLiteral("command"), commande); entry.insert(QStringLiteral("command"), commande);
} }
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId,
const QDateTime &now) const
{ {
if (!m_tm) if (!m_tm)
return; return;
@ -2630,7 +2715,7 @@ void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loa
mesure.insert(QStringLiteral("powerW"), powerW); mesure.insert(QStringLiteral("powerW"), powerW);
entry.insert(QStringLiteral("measurement"), mesure); entry.insert(QStringLiteral("measurement"), mesure);
attachCommandDivergence(entry, loadId, source, powerW); attachCommandDivergence(entry, loadId, source, powerW, now);
// Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa // Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne // raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
@ -2740,7 +2825,7 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
} }
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce // Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien. // que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
attachMeasurements(entry, id); attachMeasurements(entry, id, now);
loads.append(entry); loads.append(entry);
}; };
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare. // Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.

View File

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

View File

@ -613,6 +613,13 @@ 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

View File

@ -9113,6 +9113,705 @@ 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.
* *
@ -11039,6 +11738,36 @@ 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
@ -12045,6 +12774,10 @@ 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.
@ -12054,6 +12787,7 @@ 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();

View File

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