Compare commits
25 Commits
feature/hc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ca21cfea | ||
|
|
dff1e6f9ff | ||
|
|
bb21e199ae | ||
|
|
afdfaae4aa | ||
|
|
1e4ec45e12 | ||
|
|
70cdaed481 | ||
|
|
3084b885d8 | ||
|
|
b0547806d8 | ||
|
|
8eb2f7b00b | ||
|
|
3dfb74dcb3 | ||
|
|
5ab5213aad | ||
|
|
440a45f526 | ||
|
|
cf0e65abc7 | ||
|
|
f20b08a3a2 | ||
|
|
2d06de0bc1 | ||
|
|
c75cc32470 | ||
|
|
0a88a0f97f | ||
|
|
f9f2e1e5c5 | ||
|
|
c9e4a251ca | ||
|
|
e856c48084 | ||
|
|
c4e517a8d9 | ||
|
|
0dc2a1aeba | ||
|
|
41ce86a634 | ||
|
|
9640ac73f2 | ||
|
|
e449019390 |
@ -1,3 +1,58 @@
|
|||||||
|
powersync-energy-plugin-nymea (1.15.2+etm77) trixie; urgency=medium
|
||||||
|
|
||||||
|
* Délestage : marge de sécurité réglable sur la limite de phase (10 % par
|
||||||
|
défaut, 0 à 25 %, phaseSafetyMarginPercent) ; le moteur vise la limite
|
||||||
|
effective (effectivePhaseLimit) pour le plafond, l'allocation, la
|
||||||
|
protection et la limite transmise à l'optimiseur.
|
||||||
|
* Une borne triphasée qui ne peut pas basculer n'est plus comptée en
|
||||||
|
monophasé après un délestage (allocation et consigne justes).
|
||||||
|
* Les courants par phase sont rendus cohérents avec le total du compteur
|
||||||
|
(relevés, jamais abaissés) : la protection ne réagit plus en retard.
|
||||||
|
* Sous un optimiseur, une hausse de consigne est bornée par ce que le
|
||||||
|
soutirage permet ; une consigne écrêtée publie DRAW_CAP.
|
||||||
|
* Une borne qui ne publie pas ses courants par phase (V2C Trydan) rend son
|
||||||
|
tirage mesuré à la phase que le moteur lui suppose : la consigne ne
|
||||||
|
bascule plus sous la limite de phase, en heures creuses comme de jour.
|
||||||
|
* Hors heures creuses aussi, la somme éco + confort d'une borne tient dans
|
||||||
|
son plafond de cycle.
|
||||||
|
|
||||||
|
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 27 Oct 2026 17:17:43 +0100
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@ -25,12 +25,18 @@ rien sur les cas qu'il ne sait pas produire.
|
|||||||
| `charger` | ✅ | ✅ | ❌ |
|
| `charger` | ✅ | ✅ | ❌ |
|
||||||
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
|
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
|
||||||
| `simpleCharger` | ❌ | ❌ | ❌ |
|
| `simpleCharger` | ❌ | ❌ | ❌ |
|
||||||
|
| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ |
|
||||||
| `powerSwitch` | ✅ | ❌ | — |
|
| `powerSwitch` | ✅ | ❌ | — |
|
||||||
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
|
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
|
||||||
| `energyStorage` | ✅ | ❌ | — |
|
| `energyStorage` | ✅ | ❌ | — |
|
||||||
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
|
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
|
||||||
| `car`, `notification` | ❌ | ❌ | — |
|
| `car`, `notification` | ❌ | ❌ | — |
|
||||||
|
|
||||||
|
`chargerUnmetered` mesure sa puissance mais publie ses courants sous `currentL1..3`, comme le
|
||||||
|
plugin V2C Trydan, et jamais sous `currentPhaseX` : `EvCharger::meteredPhases()` y est vide. Les
|
||||||
|
autres bornes du mock publiaient toutes leurs phases, si bien qu'une borne **non mesurée par
|
||||||
|
phase** — le cas d'un V2C Trydan — était introuvable par la suite.
|
||||||
|
|
||||||
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
|
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
|
||||||
sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` /
|
sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` /
|
||||||
`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.
|
`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 &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 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;
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
#include "scheduler/optimizerstatus.h"
|
#include "scheduler/optimizerstatus.h"
|
||||||
#include "scheduler/publishedplan.h"
|
#include "scheduler/publishedplan.h"
|
||||||
#include "scheduler/optimizertrace.h"
|
#include "scheduler/optimizertrace.h"
|
||||||
|
#include "scheduler/optimizerplan.h"
|
||||||
#include "types/decisionreason.h"
|
#include "types/decisionreason.h"
|
||||||
#include "types/surpluscontext.h"
|
#include "types/surpluscontext.h"
|
||||||
#include "types/plan.h"
|
#include "types/plan.h"
|
||||||
@ -532,6 +533,12 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
|
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
|
||||||
m_waterfallEvIds = plan.waterfallEvIds;
|
m_waterfallEvIds = plan.waterfallEvIds;
|
||||||
|
|
||||||
|
// Lot délestage triphasé — la marge de soutirage s'oppose aussi aux consignes d'un OPTIMISEUR,
|
||||||
|
// avant qu'elles partent : ce que la protection a retiré ne revient pas tant que le compteur
|
||||||
|
// ne lui en laisse pas la place. Le rule-based, lui, est déjà borné dans sa cascade.
|
||||||
|
if (plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur))
|
||||||
|
bornerOptimiseurAuSoutirage(slot, ctx);
|
||||||
|
|
||||||
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
||||||
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
||||||
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
|
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
|
||||||
@ -979,6 +986,82 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
|||||||
"ce qui pouvait l'être. La cause est hors du waterfall.";
|
"ce qui pouvait l'être. La cause est hors du waterfall.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void EnergyArbitrator::bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx)
|
||||||
|
{
|
||||||
|
const DrawMargin marge = ctx.drawMargin;
|
||||||
|
if (!marge.borne)
|
||||||
|
return; // aucun plafond en vigueur : rien à opposer
|
||||||
|
|
||||||
|
QHash<QString, const LoadContext *> charges;
|
||||||
|
for (const LoadContext &lc : ctx.loads)
|
||||||
|
charges.insert(lc.id, &lc);
|
||||||
|
|
||||||
|
double libreParPhaseW = qMax(0.0, marge.watts);
|
||||||
|
for (LoadAction &a : slot.actions) {
|
||||||
|
if (!OptimizerPlan::estActionOptimiseur(a) || m_shedActions.contains(a.loadId))
|
||||||
|
continue;
|
||||||
|
const LoadContext *lc = charges.value(a.loadId, nullptr);
|
||||||
|
if (!lc || !lc->telemetry.available)
|
||||||
|
continue;
|
||||||
|
const bool borne = lc->adapter == QLatin1String("evcharger");
|
||||||
|
const int phases = qMax(1, lc->declared.phases);
|
||||||
|
|
||||||
|
// Ce que la charge TIENT déjà, et qu'on ne lui retire pas ici.
|
||||||
|
double engageW = qMax(0.0, lc->telemetry.currentPowerW);
|
||||||
|
if (borne)
|
||||||
|
if (const std::optional<double> commandee = puissanceCommandee(a.loadId))
|
||||||
|
engageW = qMax(0.0, *commandee);
|
||||||
|
|
||||||
|
// Ce que l'action poserait, écrêtée au matériel comme l'adaptateur le fera. Pour une borne,
|
||||||
|
// le matériel est son courant maximal sur ses phases : `declared.maxPowerW` est l'enveloppe
|
||||||
|
// de la cascade, que l'EvAdapter ne réapplique pas.
|
||||||
|
const double materielW = borne ? lc->declared.maxA * 230.0 * phases : lc->declared.maxPowerW;
|
||||||
|
double demandeW = 0;
|
||||||
|
if (a.kind == LoadAction::Setpoint)
|
||||||
|
demandeW = materielW > 0 ? qMin(a.powerW, materielW) : a.powerW;
|
||||||
|
else if (a.kind == LoadAction::State)
|
||||||
|
demandeW = lc->declared.estimatedPowerW.value(a.state, 0.0);
|
||||||
|
else
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const double plafondW = engageW + libreParPhaseW * phases;
|
||||||
|
if (demandeW <= plafondW + 0.5) {
|
||||||
|
libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, demandeW - engageW) / phases);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double accordeW = plafondW;
|
||||||
|
if (a.kind == LoadAction::Setpoint) {
|
||||||
|
if (borne) // l'adaptateur arrondit au plus proche : on lui donne un ampère entier
|
||||||
|
accordeW = std::floor(plafondW / (230.0 * phases) + 1e-6) * 230.0 * phases;
|
||||||
|
a.powerW = accordeW;
|
||||||
|
a.estimatedPowerW = accordeW;
|
||||||
|
} else {
|
||||||
|
// L'état le plus haut qui tient sous le plafond. Les états 1 et 2 valent 0 W : c'est 2
|
||||||
|
// qui est retenu, jamais 1 (ECS-413).
|
||||||
|
int etat = 2;
|
||||||
|
for (int e = 3; e <= 4; ++e)
|
||||||
|
if (lc->declared.estimatedPowerW.value(e, 0.0) <= plafondW + 0.5)
|
||||||
|
etat = e;
|
||||||
|
a.state = etat;
|
||||||
|
accordeW = lc->declared.estimatedPowerW.value(etat, 0.0);
|
||||||
|
}
|
||||||
|
libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, accordeW - engageW) / phases);
|
||||||
|
|
||||||
|
// DÉFAUT 5 — le motif est celui du mécanisme qui a produit l'issue, pas celui que
|
||||||
|
// l'optimiseur avait donné à sa demande. Le niveau unique `optimizer` reste : c'est
|
||||||
|
// l'optimiseur qui a décidé, la sécurité qui a borné.
|
||||||
|
a.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, false, 0, false) };
|
||||||
|
a.clamped = true;
|
||||||
|
if (!a.levels.isEmpty())
|
||||||
|
a.levels.first().reason = a.reason;
|
||||||
|
qCInfo(dcNymeaEnergy()).noquote()
|
||||||
|
<< "[Arbitre] Garde de soutirage" << a.loadId << "— l'optimiseur demandait"
|
||||||
|
<< qRound(demandeW) << "W, la charge tient" << qRound(engageW) << "W, accordé"
|
||||||
|
<< qRound(accordeW) << "W |" << renderFr(a.reason, lc->label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
|
void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
|
||||||
{
|
{
|
||||||
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) {
|
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) {
|
||||||
@ -1068,6 +1151,11 @@ DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const
|
|||||||
{QStringLiteral("cut"), t.cut},
|
{QStringLiteral("cut"), t.cut},
|
||||||
{QStringLiteral("overshootW"), qRound(t.overshootW)},
|
{QStringLiteral("overshootW"), qRound(t.overshootW)},
|
||||||
{QStringLiteral("limitA"), phasePowerLimit()}};
|
{QStringLiteral("limitA"), phasePowerLimit()}};
|
||||||
|
// Lot délestage triphasé — le SEUIL où la protection agit, sous la limite de la marge de
|
||||||
|
// sécurité. Absent quand la marge est nulle : seuil et limite se confondent alors, et la
|
||||||
|
// charge utile reste celle d'avant ce lot.
|
||||||
|
if (phaseSafetyMarginPercent() > 0)
|
||||||
|
params.insert(QStringLiteral("thresholdA"), qRound(effectivePhaseLimit() * 10) / 10.0);
|
||||||
// Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée,
|
// Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée,
|
||||||
// aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP.
|
// aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP.
|
||||||
if (!t.phase.isEmpty())
|
if (!t.phase.isEmpty())
|
||||||
@ -1093,6 +1181,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))
|
||||||
@ -1163,11 +1262,11 @@ bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::Cha
|
|||||||
// jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon.
|
// jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon.
|
||||||
QString phase;
|
QString phase;
|
||||||
double depassementW = 0;
|
double depassementW = 0;
|
||||||
if (RootMeter *compteur = internalRootMeter()) {
|
if (internalRootMeter()) {
|
||||||
const double limiteW = 230.0 * phasePowerLimit();
|
// Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total,
|
||||||
const QHash<QString, double> parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()},
|
// limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé.
|
||||||
{QStringLiteral("B"), compteur->currentPowerPhaseB()},
|
const double limiteW = 230.0 * effectivePhaseLimit();
|
||||||
{QStringLiteral("C"), compteur->currentPowerPhaseC()}};
|
const QHash<QString, double> parPhase = phaseConsumptionW();
|
||||||
if (evCharger->phaseCount() == 1) {
|
if (evCharger->phaseCount() == 1) {
|
||||||
phase = chargerPhaseFor(evCharger);
|
phase = chargerPhaseFor(evCharger);
|
||||||
depassementW = qMax(0.0, parPhase.value(phase) - limiteW);
|
depassementW = qMax(0.0, parPhase.value(phase) - limiteW);
|
||||||
@ -1367,7 +1466,10 @@ QList<DrawCap> EnergyArbitrator::drawCaps() const
|
|||||||
if (limiteA > 0) {
|
if (limiteA > 0) {
|
||||||
DrawCap disjoncteur;
|
DrawCap disjoncteur;
|
||||||
disjoncteur.source = DrawCapSource::Breaker;
|
disjoncteur.source = DrawCapSource::Breaker;
|
||||||
disjoncteur.perPhaseW = limiteA * 230.0;
|
// Lot délestage triphasé (décision du 2026-10-27) — le plafond est le SEUIL, la limite
|
||||||
|
// moins la marge de sécurité : 27 A pour 30 A à 10 %. La cascade alloue sous lui, et la
|
||||||
|
// protection agit au-dessus (SmartChargingManager::verifyOverloadProtection()).
|
||||||
|
disjoncteur.perPhaseW = effectivePhaseLimit() * 230.0;
|
||||||
plafonds.append(disjoncteur);
|
plafonds.append(disjoncteur);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1390,17 +1492,22 @@ QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi
|
|||||||
const QList<Electricity::Phase> toutes =
|
const QList<Electricity::Phase> toutes =
|
||||||
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
|
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
|
||||||
|
|
||||||
for (Electricity::Phase p : toutes) {
|
// Tirage de chaque phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
|
||||||
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
|
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
|
||||||
|
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
|
||||||
|
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
|
||||||
|
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
|
||||||
|
QList<double> lus;
|
||||||
|
for (Electricity::Phase p : toutes)
|
||||||
|
lus.append((limiteA - meter->calculateAllowanceAmpere(p, limiteA)) * 230.0);
|
||||||
|
// Lot délestage triphasé — les TROIS phases d'abord, rendues cohérentes avec le total, et le
|
||||||
|
// masque ensuite : des phases en retard sur le total ont retardé la protection de 1 min 45
|
||||||
|
// (relevé du 2026-10-27). Voir tiragesCoherentsAvecLeTotal(), qui ne fait que relever.
|
||||||
|
lus = tiragesCoherentsAvecLeTotal(lus, meter->currentPower());
|
||||||
|
for (int i = 0; i < toutes.size(); ++i) {
|
||||||
|
if (masque != Electricity::PhaseNone && !masque.testFlag(toutes.at(i)))
|
||||||
continue;
|
continue;
|
||||||
|
tirages.append(lus.at(i));
|
||||||
// Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
|
|
||||||
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
|
|
||||||
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
|
|
||||||
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
|
|
||||||
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
|
|
||||||
const double margeA = meter->calculateAllowanceAmpere(p, limiteA);
|
|
||||||
tirages.append((limiteA - margeA) * 230.0);
|
|
||||||
}
|
}
|
||||||
return tirages;
|
return tirages;
|
||||||
}
|
}
|
||||||
@ -1474,7 +1581,20 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
|
|||||||
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
|
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
|
||||||
// l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre
|
// l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre
|
||||||
// n'est jamais négatif ; ce qu'il faut retirer à la maison, si.
|
// n'est jamais négatif ; ce qu'il faut retirer à la maison, si.
|
||||||
return qMax(0.0, marge.watts) * phaseCount;
|
const double libreW = qMax(0.0, marge.watts) * phaseCount;
|
||||||
|
|
||||||
|
// Lot délestage V2C (question 5 du lot délestage triphasé) — HORS DÉPASSEMENT, la place libre
|
||||||
|
// seule fait alterner une borne qui tient une bonne part de sa phase : pleine, la place tombe ;
|
||||||
|
// au plancher, elle remonte (16, 9, 16, 9 A sous 25 A et 10 % de marge, maison à 4 A). Le
|
||||||
|
// plafond qui lui rend son tirage, moins 1 A, se pose en un cycle — c'est la règle des heures
|
||||||
|
// creuses (lot e2, D8). EN DÉPASSEMENT, la place libre reste la règle, et la raison de sécurité
|
||||||
|
// ci-dessus avec elle : on ne rend rien à une borne sur une phase au-dessus du seuil.
|
||||||
|
// Le MAXIMUM des deux garde la règle d'avant quand le tirage n'est pas attribuable (le plafond
|
||||||
|
// rendu vaut alors la place libre moins 1 A) ou quand la borne tire moins d'un ampère.
|
||||||
|
const PlafondRendu rendu = evCeilingGivingBackDraw(ev, phaseCount);
|
||||||
|
if (!rendu.attribuable || rendu.depasse || rendu.watts < 0)
|
||||||
|
return libreW;
|
||||||
|
return qMax(libreW, rendu.watts);
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@ -1486,38 +1606,90 @@ constexpr double MargeHeuresCreusesA = 1.0;
|
|||||||
|
|
||||||
double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const
|
double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const
|
||||||
{
|
{
|
||||||
|
return evCeilingGivingBackDraw(ev, phaseCount).watts;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnergyArbitrator::TirageBorne EnergyArbitrator::evAttributableDraw(EvCharger *ev, uint phaseCount) const
|
||||||
|
{
|
||||||
|
TirageBorne tirage;
|
||||||
|
if (!ev || phaseCount == 0)
|
||||||
|
return tirage;
|
||||||
|
|
||||||
|
// Ce que la borne tire, MESURÉ. Pas la consigne : une voiture qui prend 8 A d'une borne
|
||||||
|
// commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 ouvrirait 8 A qu'elle peut
|
||||||
|
// prendre d'un coup. Une borne sans mesure ne rend rien.
|
||||||
|
if (ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull())
|
||||||
|
return tirage;
|
||||||
|
|
||||||
|
const Electricity::Phases mesurees = ev->meteredPhases();
|
||||||
|
if (mesurees != Electricity::PhaseNone) {
|
||||||
|
// Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas —
|
||||||
|
// et la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule
|
||||||
|
// (phases mesurées ≠ phases visées), on ne rend donc rien.
|
||||||
|
if (Electricity::getPhaseCount(mesurees) != phaseCount)
|
||||||
|
return tirage;
|
||||||
|
tirage.phases = mesurees;
|
||||||
|
} else {
|
||||||
|
// Lot délestage V2C — une borne qui ne publie PAS ses courants par phase (le V2C Trydan
|
||||||
|
// publie `currentL1..3`, que l'interface ne connaît pas). meteredPhases() est vide, et
|
||||||
|
// jusqu'ici rien n'était rendu : le plafond lisait la borne comme une charge de la maison
|
||||||
|
// sur sa phase, et la consigne alternait chaque nuit (16, 6, 13, 6 A sous 25 A).
|
||||||
|
//
|
||||||
|
// Ses phases sont celles que le moteur lui suppose DÉJÀ — phasesForCount(), la phase A en
|
||||||
|
// monophasé : c'est le masque de ce plafond, celui de L4 (chargerPhaseKey()) et celui de
|
||||||
|
// l'amont (prepareInformation()). Attribuer son tirage ailleurs ferait deux hypothèses.
|
||||||
|
//
|
||||||
|
// Seulement quand c'est sûr :
|
||||||
|
// - une borne qui publie des états par phase, tous nuls alors qu'elle tire, a une mesure
|
||||||
|
// qui se contredit — elle n'est pas « non mesurée » ;
|
||||||
|
// - elle doit annoncer autant de phases que le cycle en vise. Une triphasée qui charge
|
||||||
|
// une voiture monophasée (le cycle vise 1, elle annonce 3), ou une borne en cours de
|
||||||
|
// bascule, tire sur des phases que rien ne dit ;
|
||||||
|
// - une ou trois phases : la monophasée sur sa phase, la triphasée un tiers par phase.
|
||||||
|
// En biphasé, le masque A+B est une convention de l'amont, pas une observation : rien
|
||||||
|
// n'est rendu (décision du 2026-10-27 : mono et tri seulement).
|
||||||
|
for (const char *etat : {"currentPhaseA", "currentPhaseB", "currentPhaseC",
|
||||||
|
"currentPowerPhaseA", "currentPowerPhaseB", "currentPowerPhaseC"}) {
|
||||||
|
if (ev->thing()->hasState(etat))
|
||||||
|
return tirage;
|
||||||
|
}
|
||||||
|
if ((phaseCount != 1 && phaseCount != 3) || ev->phaseCount() != phaseCount)
|
||||||
|
return tirage;
|
||||||
|
tirage.phases = const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
||||||
|
}
|
||||||
|
tirage.watts = qMax(0.0, ev->currentPower());
|
||||||
|
return tirage;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnergyArbitrator::PlafondRendu EnergyArbitrator::evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const
|
||||||
|
{
|
||||||
|
PlafondRendu plafond;
|
||||||
RootMeter *meter = internalRootMeter();
|
RootMeter *meter = internalRootMeter();
|
||||||
if (!ev || phaseCount == 0 || !meter)
|
if (!ev || phaseCount == 0 || !meter)
|
||||||
return -1;
|
return plafond;
|
||||||
|
|
||||||
const QList<DrawCap> plafonds = drawCaps();
|
const QList<DrawCap> plafonds = drawCaps();
|
||||||
if (plafonds.isEmpty())
|
if (plafonds.isEmpty())
|
||||||
return -1;
|
return plafond;
|
||||||
|
|
||||||
// Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui
|
const TirageBorne tirage = evAttributableDraw(ev, phaseCount);
|
||||||
// prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16
|
plafond.attribuable = tirage.phases != Electricity::PhaseNone;
|
||||||
// ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien.
|
const Electricity::Phases masque = plafond.attribuable
|
||||||
const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull();
|
? tirage.phases : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
||||||
const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone;
|
const double tireW = plafond.attribuable ? tirage.watts : 0.0;
|
||||||
// Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et
|
|
||||||
// la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases
|
|
||||||
// mesurées ≠ phases visées), on ne rend donc rien.
|
|
||||||
const bool attribuable = tirees != Electricity::PhaseNone
|
|
||||||
&& Electricity::getPhaseCount(tirees) == phaseCount;
|
|
||||||
const Electricity::Phases masque = attribuable
|
|
||||||
? tirees : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
|
||||||
const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0;
|
|
||||||
|
|
||||||
// Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui
|
// Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui
|
||||||
// annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la
|
// annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la
|
||||||
// limite. Au mieux, une phase vaut sa limite.
|
// limite. Au mieux, une phase vaut sa limite.
|
||||||
QList<double> sansLaBorne = phaseDrawW(masque, phasePowerLimit());
|
const QList<double> avecLaBorne = phaseDrawW(masque, phasePowerLimit());
|
||||||
|
plafond.depasse = resolveDrawMargin(plafonds, avecLaBorne, meter->currentPower()).watts < 0;
|
||||||
|
QList<double> sansLaBorne = avecLaBorne;
|
||||||
for (double &w : sansLaBorne)
|
for (double &w : sansLaBorne)
|
||||||
w = qMax(0.0, w - tireW / phaseCount);
|
w = qMax(0.0, w - tireW / phaseCount);
|
||||||
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
|
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
|
||||||
qMax(0.0, meter->currentPower() - tireW));
|
qMax(0.0, meter->currentPower() - tireW));
|
||||||
if (!marge.borne)
|
if (!marge.borne)
|
||||||
return -1;
|
return plafond;
|
||||||
|
|
||||||
// Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche,
|
// Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche,
|
||||||
// et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié.
|
// et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié.
|
||||||
@ -1527,7 +1699,8 @@ double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) con
|
|||||||
// vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à
|
// vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à
|
||||||
// sortir du dépassement ou jusqu'au plancher, où L4 décide.
|
// sortir du dépassement ou jusqu'au plancher, où L4 décide.
|
||||||
const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA);
|
const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA);
|
||||||
return qMax(0.0, parPhaseA) * 230.0 * phaseCount;
|
plafond.watts = qMax(0.0, parPhaseA) * 230.0 * phaseCount;
|
||||||
|
return plafond;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
||||||
@ -1797,26 +1970,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 +2550,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 +2673,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 +2699,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 +2824,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 +2877,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 +2987,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.
|
||||||
@ -3562,8 +3809,11 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex
|
|||||||
// producteur ? » de l'apprentissage (observerPourApprentissage()).
|
// producteur ? » de l'apprentissage (observerPourApprentissage()).
|
||||||
if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty())
|
if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty())
|
||||||
fil.pvCurrentW = qAbs(m_em->currentPowerProduction());
|
fil.pvCurrentW = qAbs(m_em->currentPowerProduction());
|
||||||
|
// Lot délestage triphasé — l'optimiseur reçoit le SEUIL que le moteur fera respecter (limite
|
||||||
|
// moins la marge de sécurité), pas la limite : un plan bâti sur la limite serait écrêté à
|
||||||
|
// chaque cycle par la garde de soutirage.
|
||||||
if (phasePowerLimit() > 0)
|
if (phasePowerLimit() > 0)
|
||||||
fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())};
|
fil.phaseLimitA = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()};
|
||||||
if (ctx.battery.present)
|
if (ctx.battery.present)
|
||||||
fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0);
|
fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0);
|
||||||
if (m_loadConfigStore)
|
if (m_loadConfigStore)
|
||||||
|
|||||||
@ -803,6 +803,39 @@ public:
|
|||||||
*/
|
*/
|
||||||
void shedWaterfallForOverload(const QDateTime &now);
|
void shedWaterfallForOverload(const QDateTime &now);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Garde de soutirage : une consigne d'OPTIMISEUR ne fait monter une charge que de la
|
||||||
|
* marge que le compteur laisse sous le seuil (lot délestage triphasé, défauts 4 et 5).
|
||||||
|
*
|
||||||
|
* \param slot Créneau du cycle, modifié en place AVANT le dispatch. Seules les actions
|
||||||
|
* d'optimiseur (niveau unique \c optimizer) sont lues ; celles que L4 revendique ce cycle
|
||||||
|
* (\c m_shedActions) sont laissées au délestage.
|
||||||
|
* \param ctx Contexte du cycle : \c drawMargin (seuil, phase la plus chargée, signée) et
|
||||||
|
* la télémétrie des charges.
|
||||||
|
*
|
||||||
|
* \par Pourquoi
|
||||||
|
* La marge de soutirage bornait la cascade du rule-based, et RIEN d'autre : un optimiseur qui
|
||||||
|
* demande le maximum voyait ses consignes écrêtées au matériel seulement. Relevé le 2026-10-27 :
|
||||||
|
* la protection déleste, le compteur repasse juste sous la limite, le délestage se relâche,
|
||||||
|
* l'optimiseur redemande 3 kW, et la surcharge reprend une minute. La règle de sécurité était
|
||||||
|
* celle du disjoncteur ; elle devient celle du moteur, quel que soit le décideur.
|
||||||
|
*
|
||||||
|
* \par Contrat
|
||||||
|
* - Seule une HAUSSE est bornée, jamais une baisse : réduire est le geste de la protection, et
|
||||||
|
* la garde ne réduit rien sous ce que la charge tient déjà. Sous un compteur en dépassement
|
||||||
|
* (marge négative), la charge garde ce qu'elle tient.
|
||||||
|
* - Plafond = engagé + marge libre × phases de la charge. L'engagé d'une borne est sa dernière
|
||||||
|
* commande TENUE (la protection l'a peut-être rabaissée dans ce même cycle) ; sinon sa mesure.
|
||||||
|
* Celui des autres charges est leur télémétrie.
|
||||||
|
* - La marge est la pire phase, et chaque hausse servie la consomme pour les suivantes, dans
|
||||||
|
* l'ordre du créneau.
|
||||||
|
* - Une consigne écrêtée porte \c DRAW_CAP (le mécanisme qui a produit l'issue, règle 7-b) et
|
||||||
|
* \c clamped ; une borne est écrêtée à l'ampère entier inférieur, une PAC à l'état le plus haut
|
||||||
|
* qui tient (jamais 1).
|
||||||
|
* - Sans plafond en vigueur (limite non réglée), rien n'est borné.
|
||||||
|
*/
|
||||||
|
void bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx);
|
||||||
|
|
||||||
//! \brief Replace dans \c slot les décisions des charges délestées par L4.
|
//! \brief Replace dans \c slot les décisions des charges délestées par L4.
|
||||||
//! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE :
|
//! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE :
|
||||||
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
|
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
|
||||||
@ -884,12 +917,17 @@ public:
|
|||||||
* waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
|
* waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
|
||||||
* chose qui rattraperait serait \c verifyOverloadProtection() **après coup**.
|
* chose qui rattraperait serait \c verifyOverloadProtection() **après coup**.
|
||||||
*
|
*
|
||||||
* \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà.
|
* \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la
|
||||||
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
|
* borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la
|
||||||
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
|
* marge est négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
|
||||||
* consommation lui rendrait exactement la puissance qui cause le dépassement
|
* consommation lui rendrait exactement la puissance qui cause le dépassement
|
||||||
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
|
* (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser).
|
||||||
* fausse : le code ne l'a jamais fait.
|
* \note HORS DÉPASSEMENT (lot délestage V2C, question 5), le plafond vaut au moins celui qui
|
||||||
|
* rend à la borne son tirage attribuable, moins 1 A (\c evCeilingGivingBackDraw()). Sans
|
||||||
|
* lui, une borne qui tient une bonne part de sa phase alternait : pleine, la place libre
|
||||||
|
* tombait ; au plancher, elle remontait (16, 9, 16, 9 A sous 25 A et 10 % de marge). Le
|
||||||
|
* maximum des deux garde la règle d'avant quand rien n'est attribuable, ou quand la borne
|
||||||
|
* tire moins d'un ampère.
|
||||||
* \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet
|
* \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet
|
||||||
* instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
|
* instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
|
||||||
*/
|
*/
|
||||||
@ -912,9 +950,11 @@ public:
|
|||||||
*
|
*
|
||||||
* \par Contrat
|
* \par Contrat
|
||||||
* - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais
|
* - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais
|
||||||
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et
|
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est
|
||||||
* seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans
|
* sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou,
|
||||||
* mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même.
|
* pour une borne qui ne publie pas ses phases, celles que le moteur lui suppose (lot
|
||||||
|
* délestage V2C). Sinon — borne sans mesure, à l'arrêt, ou qui bascule — rien n'est rendu,
|
||||||
|
* et la marge s'applique quand même.
|
||||||
* - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire
|
* - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire
|
||||||
* sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur
|
* sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur
|
||||||
* voit ne se fabrique donc pas de place au-delà de la limite.
|
* voit ne se fabrique donc pas de place au-delà de la limite.
|
||||||
@ -927,7 +967,8 @@ public:
|
|||||||
* mange jamais la marge.
|
* mange jamais la marge.
|
||||||
*
|
*
|
||||||
* \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là,
|
* \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là,
|
||||||
* \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas.
|
* \c evPhaseAllowanceW() reste la règle ; depuis le lot délestage V2C, elle rend elle aussi
|
||||||
|
* le tirage hors dépassement.
|
||||||
*/
|
*/
|
||||||
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
|
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
|
||||||
|
|
||||||
@ -1010,6 +1051,53 @@ protected:
|
|||||||
const QDateTime ¤tDateTime) override;
|
const QDateTime ¤tDateTime) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
//! Lot délestage V2C — ce qu'une borne tire, et sur quelles phases. \c phases vaut
|
||||||
|
//! \c PhaseNone quand l'attribution n'est pas sûre : rien n'est alors rendu.
|
||||||
|
struct TirageBorne {
|
||||||
|
Electricity::Phases phases = Electricity::PhaseNone;
|
||||||
|
double watts = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Lot délestage V2C — le tirage MESURÉ d'une borne, attribué à ses phases quand c'est sûr.
|
||||||
|
*
|
||||||
|
* \param ev Borne. \param phaseCount Nombre de phases VISÉ par le cycle.
|
||||||
|
* \return Les phases et la puissance (\c currentPower de la borne, jamais sa consigne) ; un
|
||||||
|
* tirage vide si la borne n'a pas de \c currentPower ou si l'attribution n'est pas sûre.
|
||||||
|
*
|
||||||
|
* \par Deux cas
|
||||||
|
* - Borne MESURÉE par phase (\c meteredPhases() non vide) : ses phases mesurées, si elles
|
||||||
|
* sont aussi nombreuses que les phases visées. Sinon elle bascule, et rien n'est attribué.
|
||||||
|
* C'est la règle du lot e2, inchangée.
|
||||||
|
* - Borne NON MESURÉE par phase (aucun état \c currentPhaseX ni \c currentPowerPhaseX : le
|
||||||
|
* V2C Trydan publie \c currentL1..3) : les phases que le moteur lui suppose déjà
|
||||||
|
* (\c phasesForCount() — la phase A en monophasé, comme \c chargerPhaseKey() pour L4 et
|
||||||
|
* \c prepareInformation() pour l'amont), à condition que la borne annonce autant de phases
|
||||||
|
* que le cycle en vise (\c EvCharger::phaseCount()), et que ce soit une ou trois. Une
|
||||||
|
* triphasée qui charge une voiture monophasée, ou une borne en cours de bascule, n'est donc
|
||||||
|
* pas attribuée.
|
||||||
|
* \note Une borne qui PUBLIE des courants par phase, tous nuls alors qu'elle tire, n'est pas
|
||||||
|
* « non mesurée » : sa mesure se contredit, et rien n'est attribué.
|
||||||
|
* \note L'hypothèse de la phase A est celle de tout le moteur. Une monophasée câblée sur une
|
||||||
|
* autre phase la met en défaut, ici comme dans L4 : c'est le plugin qui peut la lever, en
|
||||||
|
* publiant ses courants par phase.
|
||||||
|
*/
|
||||||
|
TirageBorne evAttributableDraw(EvCharger *ev, uint phaseCount) const;
|
||||||
|
|
||||||
|
//! Lot délestage V2C — le plafond d'une borne qui lui rend ce qu'elle tire (règle du lot e2).
|
||||||
|
struct PlafondRendu {
|
||||||
|
double watts = -1; //!< Plafond en W, multiple de 230 W par phase ; −1 sans limite.
|
||||||
|
bool depasse = false; //!< Une phase de la borne, borne comprise, est au-dessus du seuil.
|
||||||
|
bool attribuable = false;//!< Le tirage de la borne a été rendu (\c evAttributableDraw()).
|
||||||
|
};
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Lot délestage V2C — limite − (tirage de la phase − tirage attribué à la borne) − 1 A,
|
||||||
|
* à l'ampère inférieur, × phases. Le calcul d'\c evOffPeakAllowanceW(), mis en commun avec
|
||||||
|
* \c evPhaseAllowanceW() ; son contrat est écrit sur la première.
|
||||||
|
*/
|
||||||
|
PlafondRendu evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
|
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
|
||||||
*
|
*
|
||||||
@ -1176,8 +1264,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 +1294,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 +1403,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 +1428,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 +1622,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;
|
||||||
|
|||||||
@ -555,7 +555,9 @@ QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadActio
|
|||||||
a.counts = niveau.counts;
|
a.counts = niveau.counts;
|
||||||
a.levels = {niveau};
|
a.levels = {niveau};
|
||||||
|
|
||||||
a.clamped = !applique || differe(demande, appliquees.value(a.loadId), adapter);
|
// Déjà écrêtée par la garde de soutirage de l'arbitre, avant le dispatch : la demande
|
||||||
|
// reçue ici est la demande bornée, l'écrêtage reste un écrêtage.
|
||||||
|
a.clamped = demande.clamped || !applique || differe(demande, appliquees.value(a.loadId), adapter);
|
||||||
if (a.clamped)
|
if (a.clamped)
|
||||||
ecretages.append({a.loadId, decrire(demande, adapter),
|
ecretages.append({a.loadId, decrire(demande, adapter),
|
||||||
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});
|
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});
|
||||||
|
|||||||
@ -613,6 +613,27 @@ 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;
|
||||||
|
// Lot délestage V2C (question 5) — HORS HEURES CREUSES AUSSI, la somme éco + confort d'une
|
||||||
|
// BORNE tient dans son plafond de cycle. En « Éco avec minimum », le niveau éco (le
|
||||||
|
// plancher) s'ajoutait au confort, borné seul par le plafond : sous une limite de 25 A, une
|
||||||
|
// maison à 10 A sur A, le plafond de 12,5 A plus 6 A d'éco faisaient 16 A, la phase à 26 A,
|
||||||
|
// et L4 coupait — puis la borne repartait au cycle suivant. Le confort est donc servi dans
|
||||||
|
// ce que l'éco laisse du plafond, et le plancher porte sur la somme : la part confort n'a
|
||||||
|
// que ce qui manque à l'éco pour l'atteindre. Seule la BORNE de D9 est reprise ici ; le
|
||||||
|
// partage de l'achat (l'achat cède ce que le surplus finance) reste aux heures creuses.
|
||||||
|
else if (lc.adapter == QStringLiteral("evcharger") && niveauxEco.contains(lc.id)
|
||||||
|
&& niveauxEco.value(lc.id).targetW > 0 && lc.declared.maxPowerW > 0) {
|
||||||
|
const double ecoW = niveauxEco.value(lc.id).targetW;
|
||||||
|
lcConfort.declared.maxPowerW = qMax(0.0, lc.declared.maxPowerW - ecoW);
|
||||||
|
lcConfort.declared.minPowerW = qMax(0.0, lc.declared.minPowerW - ecoW);
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
|||||||
@ -126,7 +126,16 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
// La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne
|
// La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne
|
||||||
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
|
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
|
||||||
if (c == QLatin1String(DecisionCode::OverloadThrottled)) {
|
if (c == QLatin1String(DecisionCode::OverloadThrottled)) {
|
||||||
const QString pourquoi = p.contains(QStringLiteral("phase"))
|
// Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite :
|
||||||
|
// le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu.
|
||||||
|
const QString ou = p.contains(QStringLiteral("phase"))
|
||||||
|
? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString())
|
||||||
|
: QString();
|
||||||
|
const QString pourquoi = p.contains(QStringLiteral("thresholdA"))
|
||||||
|
? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)")
|
||||||
|
.arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble()))
|
||||||
|
.arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString())
|
||||||
|
: p.contains(QStringLiteral("phase"))
|
||||||
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
|
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
|
||||||
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
|
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
|
||||||
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));
|
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));
|
||||||
|
|||||||
@ -183,7 +183,9 @@ constexpr const char *DrawCap = "DRAW_CAP";
|
|||||||
* \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue),
|
* \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue),
|
||||||
* \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque
|
* \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque
|
||||||
* \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur
|
* \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur
|
||||||
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable).
|
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable), \c thresholdA (le seuil
|
||||||
|
* où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent**
|
||||||
|
* quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui).
|
||||||
*
|
*
|
||||||
* \par Ce qu'il publie, et ce qu'il ne décide pas
|
* \par Ce qu'il publie, et ce qu'il ne décide pas
|
||||||
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi
|
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi
|
||||||
|
|||||||
@ -85,3 +85,24 @@ QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW
|
|||||||
params.insert(QStringLiteral("minOnBypassed"), true);
|
params.insert(QStringLiteral("minOnBypassed"), true);
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QList<double> tiragesCoherentsAvecLeTotal(const QList<double> &parPhaseW, double totalW)
|
||||||
|
{
|
||||||
|
QList<double> tirages = parPhaseW;
|
||||||
|
if (tirages.isEmpty())
|
||||||
|
return tirages;
|
||||||
|
double somme = 0;
|
||||||
|
double importe = 0;
|
||||||
|
for (double w : tirages) {
|
||||||
|
somme += w;
|
||||||
|
importe += qMax(0.0, w);
|
||||||
|
}
|
||||||
|
const double manque = totalW - somme;
|
||||||
|
// Sous le demi-watt, c'est l'arrondi d'une somme de flottants, pas une lecture en retard : la
|
||||||
|
// corriger changerait des décisions au douzième chiffre sans rien protéger.
|
||||||
|
if (manque <= 0.5)
|
||||||
|
return tirages; // les phases disent au moins ce que dit le total : rien à corriger
|
||||||
|
for (double &w : tirages)
|
||||||
|
w += importe > 0 ? manque * qMax(0.0, w) / importe : manque / tirages.size();
|
||||||
|
return tirages;
|
||||||
|
}
|
||||||
|
|||||||
@ -111,6 +111,30 @@ DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
|
|||||||
//! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
|
//! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
|
||||||
QString drawCapSourceCode(DrawCapSource s);
|
QString drawCapSourceCode(DrawCapSource s);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Tirages par phase rendus COHÉRENTS avec le total du compteur — jamais abaissés.
|
||||||
|
*
|
||||||
|
* \param parPhaseW Tirage lu sur chaque phase, en watts (import > 0, export < 0).
|
||||||
|
* \param totalW Puissance totale du même compteur, en watts (import > 0).
|
||||||
|
* \return Les mêmes tirages si leur somme atteint le total, à un demi-watt près. Sinon, le
|
||||||
|
* MANQUE (total − somme) est ajouté aux phases qui importent, au prorata de ce qu'elles lisent
|
||||||
|
* — ou à parts égales si aucune n'importe. Aucune valeur n'est jamais diminuée. Liste vide :
|
||||||
|
* rendue telle quelle.
|
||||||
|
*
|
||||||
|
* \par Pourquoi
|
||||||
|
* Les phases d'un compteur triphasé somment à son total : la phase la plus chargée porte AU MOINS
|
||||||
|
* le tiers de ce que la maison importe. Une somme des phases sous le total ne peut donc venir que
|
||||||
|
* de lectures en retard, et c'est le total qu'il faut croire pour la sécurité. Relevé le
|
||||||
|
* 2026-10-27 sur une installation d'essai : 22,3 kW importés (32,3 A par phase) pendant que les
|
||||||
|
* phases publiaient 29,1 A, puis 30,4 A — la protection a attendu 1 min 45 qu'elles franchissent
|
||||||
|
* la limite. Le sens de l'erreur commande la règle : relever une phase ne peut que faire agir la
|
||||||
|
* protection plus tôt ou plus fort ; c'est le seul sens admissible dans une couche de sécurité.
|
||||||
|
*
|
||||||
|
* \note Le prorata est une HYPOTHÈSE de répartition ; la seule garantie est sur le maximum, qui ne
|
||||||
|
* tombe jamais sous le tiers du total importé.
|
||||||
|
*/
|
||||||
|
QList<double> tiragesCoherentsAvecLeTotal(const QList<double> &parPhaseW, double totalW);
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit.
|
* \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -81,13 +81,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
|
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
|
||||||
|
|
||||||
params.clear(); returns.clear();
|
params.clear(); returns.clear();
|
||||||
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything.";
|
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. "
|
||||||
returns.insert("phasePowerLimit", enumValueName(Uint));
|
"[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): "
|
||||||
|
"the engine allocates under the effective limit and its overload protection acts above it.";
|
||||||
|
returns = phasePowerLimitSchema();
|
||||||
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
|
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
|
||||||
|
|
||||||
params.clear(); returns.clear();
|
params.clear(); returns.clear();
|
||||||
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled.";
|
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled. "
|
||||||
|
"[ETM] Optional phaseSafetyMarginPercent, 0 to 25: absent, the margin is kept; out of range, "
|
||||||
|
"EnergyErrorInvalidParameter and nothing changes.";
|
||||||
params.insert("phasePowerLimit", enumValueName(Uint));
|
params.insert("phasePowerLimit", enumValueName(Uint));
|
||||||
|
params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint));
|
||||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||||
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
|
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
|
||||||
|
|
||||||
@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
params.clear();
|
params.clear();
|
||||||
description = "Emitted whenever the phase power limit configuration changes.";
|
description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
|
||||||
params.insert("phasePowerLimit", enumValueName(Uint));
|
params = phasePowerLimitSchema();
|
||||||
registerNotification("PhasePowerLimitChanged", description, params);
|
registerNotification("PhasePowerLimitChanged", description, params);
|
||||||
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
|
// [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
|
||||||
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
|
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
|
||||||
|
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
||||||
|
});
|
||||||
|
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
|
||||||
|
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
||||||
});
|
});
|
||||||
|
|
||||||
params.clear();
|
params.clear();
|
||||||
@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
|
|||||||
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
Q_UNUSED(params)
|
Q_UNUSED(params)
|
||||||
QVariantMap returns;
|
return createReply(phasePowerLimitPayload());
|
||||||
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit());
|
}
|
||||||
return createReply(returns);
|
|
||||||
|
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitSchema()
|
||||||
|
{
|
||||||
|
// [ETM] UNE déclaration pour GetPhasePowerLimit et PhasePowerLimitChanged (règle 11).
|
||||||
|
QVariantMap schema;
|
||||||
|
schema.insert("phasePowerLimit", enumValueName(Uint));
|
||||||
|
schema.insert("phaseSafetyMarginPercent", enumValueName(Uint));
|
||||||
|
schema.insert("effectivePhaseLimit", enumValueName(Double));
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitPayload() const
|
||||||
|
{
|
||||||
|
return {{"phasePowerLimit", m_smartChargingManager->phasePowerLimit()},
|
||||||
|
{"phaseSafetyMarginPercent", m_smartChargingManager->phaseSafetyMarginPercent()},
|
||||||
|
{"effectivePhaseLimit", m_smartChargingManager->effectivePhaseLimit()}};
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
|
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
|
||||||
|
// [ETM] La marge est jugée AVANT toute écriture : refusée, rien ne change — pas même la limite.
|
||||||
|
if (params.contains("phaseSafetyMarginPercent")
|
||||||
|
&& params.value("phaseSafetyMarginPercent").toUInt() > SmartChargingManager::PhaseSafetyMarginMaxPercent) {
|
||||||
|
QVariantMap returns;
|
||||||
|
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter));
|
||||||
|
return createReply(returns);
|
||||||
|
}
|
||||||
|
if (params.contains("phaseSafetyMarginPercent"))
|
||||||
|
m_smartChargingManager->setPhaseSafetyMarginPercent(params.value("phaseSafetyMarginPercent").toUInt());
|
||||||
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
|
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
|
||||||
QVariantMap returns;
|
QVariantMap returns;
|
||||||
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
|
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
|
||||||
|
|||||||
@ -239,6 +239,14 @@ private:
|
|||||||
* celui qu'on ne teste pas tous les jours.
|
* celui qu'on ne teste pas tous les jours.
|
||||||
*/
|
*/
|
||||||
static QVariantMap loadTelemetrySchema();
|
static QVariantMap loadTelemetrySchema();
|
||||||
|
/*!
|
||||||
|
* \brief [ETM] Charge utile commune de \c GetPhasePowerLimit et \c PhasePowerLimitChanged :
|
||||||
|
* la limite, la marge de sécurité et le seuil effectif qui en résulte.
|
||||||
|
* \return Le schéma (\c phasePowerLimitSchema) ou les valeurs courantes (\c phasePowerLimitPayload).
|
||||||
|
*/
|
||||||
|
static QVariantMap phasePowerLimitSchema();
|
||||||
|
//! \brief [ETM] Valeurs courantes, dans la forme de \c phasePowerLimitSchema(). \return La charge utile.
|
||||||
|
QVariantMap phasePowerLimitPayload() const;
|
||||||
|
|
||||||
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
|
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
|
||||||
//! et \c OptimizerStatusChanged. \return Le schéma des champs.
|
//! et \c OptimizerStatusChanged. \return Le schéma des champs.
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||||
|
|
||||||
#include "smartchargingmanager.h"
|
#include "smartchargingmanager.h"
|
||||||
|
#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal()
|
||||||
#include "evcharger.h"
|
#include "evcharger.h"
|
||||||
#include "rootmeter.h"
|
#include "rootmeter.h"
|
||||||
#include "energysettings.h"
|
#include "energysettings.h"
|
||||||
@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa
|
|||||||
{
|
{
|
||||||
EnergySettings settings;
|
EnergySettings settings;
|
||||||
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
|
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
|
||||||
|
// [ETM] Marge de sécurité (2026-10-27) : absente du réglage, elle vaut 10 % ; hors bornes (un
|
||||||
|
// fichier édité à la main), elle reprend 10 % plutôt qu'une valeur qu'on refuserait par RPC.
|
||||||
|
m_phaseSafetyMarginPercent = settings.value("phaseSafetyMarginPercent", 10).toUInt();
|
||||||
|
if (m_phaseSafetyMarginPercent > PhaseSafetyMarginMaxPercent)
|
||||||
|
m_phaseSafetyMarginPercent = 10;
|
||||||
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
|
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
|
||||||
// 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique.
|
// 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique.
|
||||||
//
|
//
|
||||||
@ -186,6 +192,39 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SmartChargingManager::setPhaseSafetyMarginPercent(uint percent)
|
||||||
|
{
|
||||||
|
// [ETM] Hors bornes : refus, et l'état ne bouge pas.
|
||||||
|
if (percent > PhaseSafetyMarginMaxPercent)
|
||||||
|
return false;
|
||||||
|
if (m_phaseSafetyMarginPercent != percent) {
|
||||||
|
m_phaseSafetyMarginPercent = percent;
|
||||||
|
emit phaseSafetyMarginPercentChanged(m_phaseSafetyMarginPercent);
|
||||||
|
|
||||||
|
EnergySettings settings;
|
||||||
|
settings.setValue("phaseSafetyMarginPercent", m_phaseSafetyMarginPercent);
|
||||||
|
|
||||||
|
#ifndef ENERGY_SIMULATION
|
||||||
|
// Même raison que setPhasePowerLimit() : le seuil change, le cycle le prend tout de suite.
|
||||||
|
update(QDateTime::currentDateTime());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<QString, double> SmartChargingManager::phaseConsumptionW() const
|
||||||
|
{
|
||||||
|
if (!m_rootMeter)
|
||||||
|
return {};
|
||||||
|
// [ETM] Les phases relevées au niveau du total si elles sont en retard sur lui — voir
|
||||||
|
// tiragesCoherentsAvecLeTotal() : la phase la plus chargée porte au moins le tiers du total.
|
||||||
|
const QList<double> w = tiragesCoherentsAvecLeTotal({m_rootMeter->currentPowerPhaseA(),
|
||||||
|
m_rootMeter->currentPowerPhaseB(),
|
||||||
|
m_rootMeter->currentPowerPhaseC()},
|
||||||
|
m_rootMeter->currentPower());
|
||||||
|
return {{"A", w.at(0)}, {"B", w.at(1)}, {"C", w.at(2)}};
|
||||||
|
}
|
||||||
|
|
||||||
double SmartChargingManager::acquisitionTolerance() const
|
double SmartChargingManager::acquisitionTolerance() const
|
||||||
{
|
{
|
||||||
return m_acquisitionTolerance;
|
return m_acquisitionTolerance;
|
||||||
@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
// [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
|
||||||
{"B", m_rootMeter->currentPowerPhaseB()},
|
// marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
|
||||||
{"C", m_rootMeter->currentPowerPhaseC()}};
|
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
||||||
|
|
||||||
bool limitExceeded = false;
|
bool limitExceeded = false;
|
||||||
|
|
||||||
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
||||||
|
|
||||||
// Note: for now we assume the phases are always connected this way:
|
// Note: for now we assume the phases are always connected this way:
|
||||||
// EV phase A -> house phase A
|
// EV phase A -> house phase A
|
||||||
@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
|||||||
|
|
||||||
foreach (const QString &phase, currentPhaseConsumption.keys()) {
|
foreach (const QString &phase, currentPhaseConsumption.keys()) {
|
||||||
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
|
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
|
||||||
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)";
|
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << effectivePhaseLimit() << "A)";
|
||||||
limitExceeded = true;
|
limitExceeded = true;
|
||||||
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
||||||
requiredThrottlePower[phase] = overshot;
|
requiredThrottlePower[phase] = overshot;
|
||||||
@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
|||||||
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
||||||
|
|
||||||
|
|
||||||
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
// [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
|
||||||
{"B", m_rootMeter->currentPowerPhaseB()},
|
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
||||||
{"C", m_rootMeter->currentPowerPhaseC()}};
|
|
||||||
|
|
||||||
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
||||||
|
|
||||||
QHash<QString, double> availablePhasePower = {{"A", 0},
|
QHash<QString, double> availablePhasePower = {{"A", 0},
|
||||||
{"B", 0},
|
{"B", 0},
|
||||||
@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
|||||||
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
||||||
{
|
{
|
||||||
// We don't assume we have the entire phase power for charging...
|
// We don't assume we have the entire phase power for charging...
|
||||||
double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230;
|
double housePhaseLimitPower = effectivePhaseLimit() * 230; // [ETM] marge de sécurité
|
||||||
|
|
||||||
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
||||||
|
|
||||||
@ -637,6 +675,12 @@ void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
|||||||
evChargers.append(evCharger);
|
evChargers.append(evCharger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [ETM] Les bornes préparées CE cycle — exactement celles que l'étape 2 va traiter. La table
|
||||||
|
// m_processInfos ne le dit pas : elle n'est jamais vidée, et operator[] y crée une entrée par
|
||||||
|
// défaut (1 phase, pas de bascule) dès qu'on la lit ainsi, dans verifyOverloadProtection() par
|
||||||
|
// exemple. Lue par internalHasProcessInfo(), voir sa documentation.
|
||||||
|
m_preparedChargers = QSet<EvCharger *>(evChargers.constBegin(), evChargers.constEnd());
|
||||||
|
|
||||||
// 2. Prepare all information avaialble for each charger
|
// 2. Prepare all information avaialble for each charger
|
||||||
foreach (EvCharger *evCharger, evChargers) {
|
foreach (EvCharger *evCharger, evChargers) {
|
||||||
|
|
||||||
@ -1314,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime)
|
|||||||
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
||||||
|
|
||||||
// Adjust down to not overload any phases.
|
// Adjust down to not overload any phases.
|
||||||
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit);
|
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
||||||
|
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit)
|
||||||
|
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
|
||||||
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
|
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
|
||||||
|
|
||||||
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
|
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
|
||||||
@ -1396,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime)
|
|||||||
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
|
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
|
||||||
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
|
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
|
||||||
|
|
||||||
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit);
|
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
||||||
|
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit)
|
||||||
|
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
|
||||||
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
|
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
|
||||||
|
|
||||||
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection
|
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
#include "energymanagerconfiguration.h"
|
#include "energymanagerconfiguration.h"
|
||||||
#include "types/charginginfo.h"
|
#include "types/charginginfo.h"
|
||||||
@ -63,6 +64,30 @@ public:
|
|||||||
uint phasePowerLimit() const;
|
uint phasePowerLimit() const;
|
||||||
void setPhasePowerLimit(uint phasePowerLimit);
|
void setPhasePowerLimit(uint phasePowerLimit);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief [ETM] Marge de sécurité sous la limite de phase, en pourcents (décision du 2026-10-27).
|
||||||
|
* \return 10 par défaut ; persistée avec la limite (clé \c phaseSafetyMarginPercent).
|
||||||
|
* \note Le moteur vise \c effectivePhaseLimit(), pas \c phasePowerLimit() : pour le plafond
|
||||||
|
* qu'il déclare et alloue, et pour le seuil où la protection de surcharge agit.
|
||||||
|
*/
|
||||||
|
uint phaseSafetyMarginPercent() const { return m_phaseSafetyMarginPercent; }
|
||||||
|
/*!
|
||||||
|
* \brief [ETM] Règle la marge de sécurité.
|
||||||
|
* \param percent Entre 0 et \c PhaseSafetyMarginMaxPercent inclus.
|
||||||
|
* \return Faux, sans rien changer, hors de ces bornes.
|
||||||
|
* \note 0 est admis : c'est le comportement d'avant la marge, et la bonne valeur pour une
|
||||||
|
* installation dont la limite saisie est déjà une limite réduite.
|
||||||
|
*/
|
||||||
|
bool setPhaseSafetyMarginPercent(uint percent);
|
||||||
|
//! [ETM] Borne haute de la marge : au-delà, la maison perd plus d'un quart de son branchement.
|
||||||
|
static constexpr uint PhaseSafetyMarginMaxPercent = 25;
|
||||||
|
/*!
|
||||||
|
* \brief [ETM] Le seuil effectif par phase, en ampères : limite × (1 − marge).
|
||||||
|
* \return 0 quand la limite n'est pas réglée (protection désactivée, contrat amont).
|
||||||
|
*/
|
||||||
|
double effectivePhaseLimit() const
|
||||||
|
{ return m_phasePowerConsumptionLimit * (100.0 - m_phaseSafetyMarginPercent) / 100.0; }
|
||||||
|
|
||||||
double acquisitionTolerance() const;
|
double acquisitionTolerance() const;
|
||||||
void setAcquisitionTolerance(double acquisitionTolerance);
|
void setAcquisitionTolerance(double acquisitionTolerance);
|
||||||
|
|
||||||
@ -99,6 +124,7 @@ public:
|
|||||||
|
|
||||||
signals:
|
signals:
|
||||||
void phasePowerLimitChanged(int phasePowerLimit);
|
void phasePowerLimitChanged(int phasePowerLimit);
|
||||||
|
void phaseSafetyMarginPercentChanged(uint percent); // [ETM]
|
||||||
void acquisitionToleranceChanged(double acquisitionTolerance);
|
void acquisitionToleranceChanged(double acquisitionTolerance);
|
||||||
void batteryLevelConsiderationChanged(double batteryLevelConsideration);
|
void batteryLevelConsiderationChanged(double batteryLevelConsideration);
|
||||||
void lockOnUnplugChanged(bool lockOnUnplug);
|
void lockOnUnplugChanged(bool lockOnUnplug);
|
||||||
@ -113,7 +139,9 @@ signals:
|
|||||||
|
|
||||||
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
|
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
|
||||||
// All changes below are visibility-only (private → protected / virtual added).
|
// All changes below are visibility-only (private → protected / virtual added).
|
||||||
// Zero logic change. Revert by deleting this block and restoring private slots.
|
// Zero logic change — EXCEPT items 9 to 11 of the inventory (lot délestage triphasé,
|
||||||
|
// 2026-10-27), which change the overload protection on purpose and say so.
|
||||||
|
// Revert by deleting this block and restoring private slots.
|
||||||
//
|
//
|
||||||
// INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et
|
// INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et
|
||||||
// échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la
|
// échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la
|
||||||
@ -139,6 +167,19 @@ signals:
|
|||||||
// protection de surcharge. Documenté ci-dessous.
|
// protection de surcharge. Documenté ci-dessous.
|
||||||
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
|
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
|
||||||
// ci-dessous. Pas un réglage : jamais persistée.
|
// ci-dessous. Pas un réglage : jamais persistée.
|
||||||
|
// 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() :
|
||||||
|
// les bornes préparées au dernier cycle. Seul
|
||||||
|
// lecteur : internalHasProcessInfo().
|
||||||
|
// 10. phaseSafetyMarginPercent(), setPhaseSafetyMarginPercent(), effectivePhaseLimit(),
|
||||||
|
// PhaseSafetyMarginMaxPercent, phaseSafetyMarginPercentChanged()
|
||||||
|
// — la marge de sécurité (décision du 2026-10-27),
|
||||||
|
// documentée en tête de la classe. Elle CHANGE la
|
||||||
|
// logique amont, en connaissance de cause : le seuil
|
||||||
|
// de verifyOverloadProtection() et de sa reprise, la
|
||||||
|
// limite de prepareInformation() et les deux
|
||||||
|
// allowances du proxy (marques [ETM] dans le .cpp).
|
||||||
|
// 11. phaseConsumptionW() — lecture des phases cohérente avec le total, seule
|
||||||
|
// lecture de la protection et de sa reprise.
|
||||||
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
|
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
|
||||||
// — crochets ETM, virtuels, documentés ci-dessous.
|
// — crochets ETM, virtuels, documentés ci-dessous.
|
||||||
// Ce sont EUX qui garantissent « une charge, un
|
// Ce sont EUX qui garantissent « une charge, un
|
||||||
@ -311,8 +352,27 @@ protected:
|
|||||||
* \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de
|
* \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de
|
||||||
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
|
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
|
||||||
* budget du cycle est divisé par trois.
|
* budget du cycle est divisé par trois.
|
||||||
|
* \note **Lu sur l'ensemble des bornes préparées ce cycle, pas sur la table.** La table
|
||||||
|
* \c m_processInfos n'est jamais vidée, et l'amont la lit aussi par \c operator[], qui y
|
||||||
|
* insère une entrée par défaut : \c verifyOverloadProtection() le fait pour toute borne qu'il
|
||||||
|
* rabaisse. Tester la table rendait donc VRAI pour une borne sans voiture après sa première
|
||||||
|
* surcharge, et la borne triphasée devenait monophasée dans les comptes (relevé du
|
||||||
|
* 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée
|
||||||
|
* laissait son entrée de la veille (\c testAnUnassignedCarTakesItsPhasesAway).
|
||||||
*/
|
*/
|
||||||
bool internalHasProcessInfo(EvCharger *evCharger) const { return m_processInfos.contains(evCharger); } // [ETM] new
|
/*!
|
||||||
|
* \brief [ETM] Tirage de chaque phase au compteur principal, en watts, cohérent avec le total.
|
||||||
|
* \return Clés \c "A", \c "B", \c "C" : \c currentPowerPhaseX relevés par
|
||||||
|
* \c tiragesCoherentsAvecLeTotal() si leur somme est sous \c currentPower — jamais abaissés.
|
||||||
|
* Vide sans compteur principal.
|
||||||
|
* \note Seule lecture des phases par la protection de surcharge et sa reprise, et par la
|
||||||
|
* publication de ses gestes (\c EnergyArbitrator::noterGesteBorne()) : le dépassement publié
|
||||||
|
* est celui qui a décidé.
|
||||||
|
*/
|
||||||
|
QHash<QString, double> phaseConsumptionW() const;
|
||||||
|
|
||||||
|
bool internalHasProcessInfo(EvCharger *evCharger) const // [ETM] new
|
||||||
|
{ return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); }
|
||||||
// [ETM] END
|
// [ETM] END
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
@ -344,6 +404,7 @@ private:
|
|||||||
|
|
||||||
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
|
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
|
||||||
QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
|
QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
|
||||||
|
QSet<EvCharger *> m_preparedChargers; // [ETM] préparées au dernier prepareInformation()
|
||||||
QHash<EvCharger *, ChargingActions> m_chargingActions;
|
QHash<EvCharger *, ChargingActions> m_chargingActions;
|
||||||
|
|
||||||
QDateTime m_lastSpotMarketPlanning;
|
QDateTime m_lastSpotMarketPlanning;
|
||||||
@ -352,6 +413,7 @@ private:
|
|||||||
QHash<EvCharger *, bool> m_overloadProtectionActive;
|
QHash<EvCharger *, bool> m_overloadProtectionActive;
|
||||||
|
|
||||||
uint m_phasePowerConsumptionLimit = 25;
|
uint m_phasePowerConsumptionLimit = 25;
|
||||||
|
uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27
|
||||||
|
|
||||||
double m_acquisitionTolerance = 0.5;
|
double m_acquisitionTolerance = 0.5;
|
||||||
double m_batteryLevelConsideration = 0.9;
|
double m_batteryLevelConsideration = 0.9;
|
||||||
|
|||||||
@ -332,7 +332,8 @@ void TestCharging::addChargingInfo()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
uint phasePowerLimit = 25000;
|
uint phasePowerLimit = 25000;
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
verifyEnergyError(response);
|
verifyEnergyError(response);
|
||||||
|
|
||||||
removeDevices();
|
removeDevices();
|
||||||
@ -691,7 +692,8 @@ void TestCharging::testEcoMode()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||||
|
|
||||||
// Add mock meter
|
// Add mock meter
|
||||||
@ -1069,7 +1071,8 @@ void TestCharging::testEcoModePhaseSwitching()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||||
|
|
||||||
// Add mock meter
|
// Add mock meter
|
||||||
@ -1381,7 +1384,8 @@ void TestCharging::testBatteryLevelConsideration()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||||
|
|
||||||
// Add mock meter
|
// Add mock meter
|
||||||
@ -1602,7 +1606,8 @@ void TestCharging::batteryLevelConsiderationStopCharging()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||||
|
|
||||||
// Add mock meter
|
// Add mock meter
|
||||||
@ -1840,7 +1845,8 @@ void TestCharging::testOverloadProtectionManualMode()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||||
|
|
||||||
// Add mock meter
|
// Add mock meter
|
||||||
@ -2011,7 +2017,8 @@ void TestCharging::testOverloadProtectionEcoMode()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||||
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||||
|
|
||||||
// Add mock meter
|
// Add mock meter
|
||||||
|
|||||||
@ -451,6 +451,23 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin
|
|||||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QUuid EnergyTestBase::addUnmeteredCharger(const QString &phases, double maxChargingCurrentUpperLimit, quint16 port)
|
||||||
|
{
|
||||||
|
const QVariantList thingParams = {
|
||||||
|
QVariantMap{{"paramTypeId", "{4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5}"}, {"value", port}},
|
||||||
|
QVariantMap{{"paramTypeId", "{ec3e73a0-cca9-415a-bc64-9e841d666853}"}, {"value", phases}},
|
||||||
|
QVariantMap{{"paramTypeId", "{803ba501-ae0f-4d58-8097-6ebb0928b3d8}"}, {"value", maxChargingCurrentUpperLimit}},
|
||||||
|
};
|
||||||
|
QVariantMap params;
|
||||||
|
params.insert("thingClassId", mockChargerUnmeteredThingClassId.toString());
|
||||||
|
params.insert("name", "Unmetered charger");
|
||||||
|
params.insert("thingParams", thingParams);
|
||||||
|
|
||||||
|
QVariant response = injectAndWait("Integrations.AddThing", params);
|
||||||
|
verifyThingError(response);
|
||||||
|
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||||
|
}
|
||||||
|
|
||||||
QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port)
|
QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port)
|
||||||
{
|
{
|
||||||
QVariantList thingParams;
|
QVariantList thingParams;
|
||||||
|
|||||||
@ -51,6 +51,9 @@ static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5
|
|||||||
static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7");
|
static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7");
|
||||||
static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c");
|
static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c");
|
||||||
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
|
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
|
||||||
|
//! \brief Borne qui mesure sa PUISSANCE mais pas ses phases au sens du moteur — `currentL1..3`
|
||||||
|
//! comme le plugin V2C Trydan, jamais `currentPhaseX`. `EvCharger::meteredPhases()` y est vide.
|
||||||
|
static QUuid mockChargerUnmeteredThingClassId = QUuid("e297f8c4-934f-4af3-a655-29b5b799289b");
|
||||||
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
|
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
|
||||||
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
|
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
|
||||||
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
|
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
|
||||||
@ -110,6 +113,9 @@ public:
|
|||||||
QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657);
|
QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657);
|
||||||
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
|
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
|
||||||
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
|
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
|
||||||
|
//! \brief Ajoute une borne sans courants par phase lisibles par le moteur (façon V2C Trydan).
|
||||||
|
//! \param phases Câblage, `A`, `B`, `C` ou `ABC` — fixe, sans bascule.
|
||||||
|
QUuid addUnmeteredCharger(const QString &phases = "A", double maxChargingCurrentUpperLimit = 16, quint16 port = 26677);
|
||||||
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
|
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
|
||||||
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
|
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
|
|||||||
horsService.observerMinute(huitJours.first());
|
horsService.observerMinute(huitJours.first());
|
||||||
QVERIFY(!horsService.profil().calcule);
|
QVERIFY(!horsService.profil().calcule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestLoadModel::testPhaseDrawsAreNeverBelowTheTotal()
|
||||||
|
{
|
||||||
|
// Le cas relevé le 2026-10-27 : 29,1 A lus sur chaque phase pour 22 277 W au total. Le manque
|
||||||
|
// est réparti au prorata : trois phases égales, trois tiers du total.
|
||||||
|
QList<double> w = tiragesCoherentsAvecLeTotal({6700, 6700, 6700}, 22277);
|
||||||
|
for (double x : w)
|
||||||
|
QVERIFY(qAbs(x - 22277.0 / 3) < 0.01);
|
||||||
|
|
||||||
|
// Cohérentes, ou AU-DESSUS du total (lecture plus fraîche que le total) : rien ne bouge.
|
||||||
|
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 6000), (QList<double>{1000, 2000, 3000}));
|
||||||
|
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 5000), (QList<double>{1000, 2000, 3000}));
|
||||||
|
|
||||||
|
// Une phase qui EXPORTE n'est jamais relevée vers l'import, et ne se voit jamais abaissée :
|
||||||
|
// le manque va aux phases qui importent, au prorata de ce qu'elles lisent.
|
||||||
|
w = tiragesCoherentsAvecLeTotal({-1000, 1000, 3000}, 4000);
|
||||||
|
QCOMPARE(w.at(0), -1000.0);
|
||||||
|
QCOMPARE(w.at(1), 1250.0);
|
||||||
|
QCOMPARE(w.at(2), 3750.0);
|
||||||
|
|
||||||
|
// Aucune phase n'importe (compteur sans courants par phase : tout à 0) : le total se répartit à
|
||||||
|
// parts égales — la phase la plus chargée en porte au moins le tiers, c'est la seule garantie.
|
||||||
|
w = tiragesCoherentsAvecLeTotal({0, 0, 0}, 9000);
|
||||||
|
QCOMPARE(w, (QList<double>{3000, 3000, 3000}));
|
||||||
|
|
||||||
|
// En export, rien à relever.
|
||||||
|
QCOMPARE(tiragesCoherentsAvecLeTotal({-500, -500, -500}, -1500), (QList<double>{-500, -500, -500}));
|
||||||
|
QVERIFY(tiragesCoherentsAvecLeTotal({}, 1000).isEmpty());
|
||||||
|
}
|
||||||
|
|||||||
@ -108,6 +108,8 @@ private slots:
|
|||||||
void testStoredInstantsSurviveATimeZoneChange();
|
void testStoredInstantsSurviveATimeZoneChange();
|
||||||
void testReportingDayBoundaryIsPublishedNotDeducible();
|
void testReportingDayBoundaryIsPublishedNotDeducible();
|
||||||
void testDrawMarginComparesHeadroomsNotCaps();
|
void testDrawMarginComparesHeadroomsNotCaps();
|
||||||
|
//! Lot délestage triphasé — des phases en retard sur le total sont RELEVÉES, jamais abaissées.
|
||||||
|
void testPhaseDrawsAreNeverBelowTheTotal();
|
||||||
|
|
||||||
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
|
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
|
||||||
void testTheContentIsTheRevisionIdentity();
|
void testTheContentIsTheRevisionIdentity();
|
||||||
|
|||||||
@ -223,6 +223,7 @@ private slots:
|
|||||||
void testAProtocol10OptimizerIsAccepted();
|
void testAProtocol10OptimizerIsAccepted();
|
||||||
void testTracesReplayWithZeroDivergence();
|
void testTracesReplayWithZeroDivergence();
|
||||||
void testSafetyWinsOverTheOptimizer();
|
void testSafetyWinsOverTheOptimizer();
|
||||||
|
void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum();
|
||||||
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
||||||
void cleanupTestCase();
|
void cleanupTestCase();
|
||||||
|
|
||||||
@ -936,6 +937,116 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
|
|||||||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestRefOptimizer::testTheDrawCapHoldsAgainstAnOptimizerAtMaximum()
|
||||||
|
{
|
||||||
|
// DÉFAUTS 4 et 5 — relevés le 2026-10-27 sur une installation d'essai, sous un optimiseur qui
|
||||||
|
// demande le MAXIMUM à tout. La protection déleste ; au cycle suivant, le compteur est revenu
|
||||||
|
// juste sous le seuil, le délestage se relâche, l'optimiseur redemande tout, et la surcharge
|
||||||
|
// reprend une minute. Aucune mémoire entre la protection et le plan suivant : la marge que le
|
||||||
|
// compteur laisse n'était opposée à AUCUNE consigne d'optimiseur — seulement à la cascade du
|
||||||
|
// rule-based. Ici la borne (triphasée, sans voiture) et la charge variable 2 jouent le rôle de
|
||||||
|
// la borne et du chauffe-eau de l'essai.
|
||||||
|
monter(Banc::Restreint);
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||||
|
const QUuid borneId = addCharger("ABC", 16, 26818);
|
||||||
|
Thing *borne = tm->findConfiguredThing(borneId);
|
||||||
|
QVERIFY(borne);
|
||||||
|
for (const char *e : {"connected", "pluggedIn"})
|
||||||
|
borne->setStateValue(e, true);
|
||||||
|
borne->setStateValue("power", false);
|
||||||
|
borne->setStateValue("maxChargingCurrent", 6);
|
||||||
|
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo",
|
||||||
|
QVariantMap{{"chargingInfo", QVariantMap{{"evChargerId", borneId},
|
||||||
|
{"chargingMode", "ChargingModeEco"}}}})
|
||||||
|
.toMap().value("params").toMap().value("energyError").toString(),
|
||||||
|
QString("EnergyErrorNoError"));
|
||||||
|
m_arb->setPhasePowerLimit(25);
|
||||||
|
RefOptimizer ref;
|
||||||
|
brancher(ref, Transport::Unix, {"--mode", "over-limit"});
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Un pas : le compteur est POSÉ (total et phases), la réponse de l'optimiseur attendue, puis
|
||||||
|
// les charges variables consomment leur consigne. La borne, elle, mesure ce qu'elle tient.
|
||||||
|
QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||||
|
const auto pas = [&](double totalW, double phaseA, int dtS = 60) {
|
||||||
|
t = t.addSecs(dtS);
|
||||||
|
m_compteur->setStateValue("currentPower", totalW);
|
||||||
|
for (const char *ph : {"A", "B", "C"}) {
|
||||||
|
m_compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), phaseA);
|
||||||
|
m_compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), phaseA * 230);
|
||||||
|
}
|
||||||
|
const int avant = m_jugees;
|
||||||
|
m_arb->recordMeterUpdate(t);
|
||||||
|
m_arb->simulationCallUpdate(t);
|
||||||
|
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur jamais jugée");
|
||||||
|
settle(30);
|
||||||
|
for (const QString &v : {m_var1, m_var2}) {
|
||||||
|
Thing *th = tm->findConfiguredThing(ThingId(v));
|
||||||
|
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const auto var2W = [&]() {
|
||||||
|
return tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble();
|
||||||
|
};
|
||||||
|
const auto borneA = [&]() {
|
||||||
|
return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0;
|
||||||
|
};
|
||||||
|
const auto decision = [&](const QString &id) {
|
||||||
|
for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList())
|
||||||
|
if (v.toMap().value("loadId").toString() == id)
|
||||||
|
return v.toMap().value("decision").toMap();
|
||||||
|
return QVariantMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Export : l'optimiseur demande 50 kW à tout, les adaptateurs écrêtent au matériel.
|
||||||
|
for (int k = 0; k < 3; ++k)
|
||||||
|
pas(-20000, -20);
|
||||||
|
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||||
|
QCOMPARE(borneA(), 16);
|
||||||
|
QCOMPARE(var2W(), 3000.0);
|
||||||
|
|
||||||
|
// 2. LA CHARGE EN WATTS (le chauffe-eau de l'essai). 40 A par phase : la protection déleste
|
||||||
|
// la cascade en ordre inverse du rang, la PAC puis la charge 2.
|
||||||
|
pas(40 * 230 * 3, 40);
|
||||||
|
const double var2Apres = var2W();
|
||||||
|
QVERIFY2(var2Apres < 3000, qUtf8Printable(QString::number(var2Apres)));
|
||||||
|
|
||||||
|
// 3. Le compteur revient JUSTE sous le seuil : 22 A par phase (seuil 22,5 A, 25 A − 10 %).
|
||||||
|
// La marge ne laisse que 0,5 A par phase : ce qui a été retiré ne peut pas revenir.
|
||||||
|
pas(22 * 230 * 3, 22);
|
||||||
|
QVERIFY2(var2W() <= var2Apres + 0.5 * 230 + 1,
|
||||||
|
qUtf8Printable(QStringLiteral("la charge 2 est remontée de %1 à %2 W").arg(var2Apres).arg(var2W())));
|
||||||
|
// DÉFAUT 5 — la consigne écrêtée par la sécurité porte le motif de la sécurité, pas celui que
|
||||||
|
// l'optimiseur avait donné à sa demande.
|
||||||
|
QCOMPARE(decision(m_var2).value("code").toString(), QString(DecisionCode::DrawCap));
|
||||||
|
|
||||||
|
// 4. La place revient : tout ce que l'optimiseur demande tient sous le seuil, il est servi.
|
||||||
|
// Six minutes : le verrou de réenclenchement de la borne, coupée en 2, est expiré.
|
||||||
|
pas(-20000, -20, 360);
|
||||||
|
pas(-20000, -20, 360);
|
||||||
|
QCOMPARE(borneA(), 16);
|
||||||
|
QCOMPARE(var2W(), 3000.0);
|
||||||
|
|
||||||
|
// 5. LA BORNE. 26 A par phase : la protection la rabaisse sans la couper. (Le harnais rappelle
|
||||||
|
// la protection après le cycle sur le MÊME compteur : au-delà, deux gestes la coupent.)
|
||||||
|
pas(26 * 230 * 3, 26);
|
||||||
|
const int borneApres = borneA();
|
||||||
|
QVERIFY2(borneApres > 0 && borneApres < 16, qUtf8Printable(QString::number(borneApres)));
|
||||||
|
|
||||||
|
// 6. Juste sous le seuil : la borne ne remonte pas au-delà de ce que la marge laisse.
|
||||||
|
pas(22 * 230 * 3, 22);
|
||||||
|
QVERIFY2(borneA() <= borneApres,
|
||||||
|
qUtf8Printable(QStringLiteral("la borne est remontée de %1 à %2 A").arg(borneApres).arg(borneA())));
|
||||||
|
|
||||||
|
// 7. La place revient : la borne retrouve ses 16 A.
|
||||||
|
pas(-20000, -20);
|
||||||
|
pas(-20000, -20);
|
||||||
|
QCOMPARE(borneA(), 16);
|
||||||
|
}
|
||||||
|
|
||||||
void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
|
void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
|
||||||
{
|
{
|
||||||
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token
|
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
#include "simulationbase.h"
|
#include "simulationbase.h"
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
class EnergyArbitrator;
|
class EnergyArbitrator;
|
||||||
@ -73,13 +74,53 @@ private:
|
|||||||
};
|
};
|
||||||
//! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à
|
//! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à
|
||||||
//! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon.
|
//! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon.
|
||||||
enum class BorneHc { Monophasee, Triphasee, Bascule };
|
//! Lot délestage V2C : les mêmes, monophasée sur A et triphasée, SANS courants par phase
|
||||||
|
//! lisibles par le moteur (`currentL1..3`, façon V2C Trydan).
|
||||||
|
enum class BorneHc { Monophasee, Triphasee, Bascule, MonophaseeNonMesuree, TriphaseeNonMesuree };
|
||||||
//! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
|
//! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
|
||||||
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
|
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
|
||||||
BorneHc borne = BorneHc::Monophasee);
|
BorneHc borne = BorneHc::Monophasee);
|
||||||
//! 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);
|
||||||
|
|
||||||
|
// ── Lot délestage triphasé — une borne SANS voiture assignée, limite de phase 30 A ────────
|
||||||
|
struct BancTriphase {
|
||||||
|
EnergyArbitrator *arb = nullptr;
|
||||||
|
Thing *compteur = nullptr;
|
||||||
|
Thing *borne = nullptr;
|
||||||
|
QString borneId;
|
||||||
|
};
|
||||||
|
//! Serveur neuf, compteur, borne 16 A « Éco » branchée et sans voiture (le cas de l'invité,
|
||||||
|
//! LM-1208-b). \p phases : câblage du mock ; \p bascule : la borne porte `desiredPhaseCount`
|
||||||
|
//! (mise en triphasé si elle est câblée sur trois).
|
||||||
|
BancTriphase preparerBancTriphase(const QString &nom, const QString &phases, bool bascule);
|
||||||
|
|
||||||
|
// ── 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 +281,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();
|
||||||
@ -300,6 +353,20 @@ private slots:
|
|||||||
void testOffPeakFullPowerAtDawnStaysInTheCeiling();
|
void testOffPeakFullPowerAtDawnStaysInTheCeiling();
|
||||||
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
|
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
|
||||||
void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases();
|
void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases();
|
||||||
|
//! Lot délestage V2C — borne SANS courants par phase lisibles (façon V2C Trydan), limite 25 A,
|
||||||
|
//! marge 10 % : la consigne se pose au plafond qui tient compte de la borne, sans alterner
|
||||||
|
//! (monophasée sur A, triphasée ; maison seule, puis lave-vaisselle sur A).
|
||||||
|
void testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit();
|
||||||
|
//! Lot délestage V2C — quand l'attribution n'est pas sûre, rien n'est rendu : triphasée avec
|
||||||
|
//! une voiture monophasée (phases visées ≠ phases annoncées), borne qui publie des courants
|
||||||
|
//! par phase nuls alors qu'elle tire.
|
||||||
|
void testUnmeteredChargerGivesNothingBackWhenUnsure();
|
||||||
|
//! Lot délestage V2C (question 5) — hors heures creuses, sous le rule-based, une borne limitée
|
||||||
|
//! par sa phase se pose sous la limite sans alterner, maison légère ou chargée.
|
||||||
|
void testWaterfallChargerSettlesUnderThePhaseLimit();
|
||||||
|
//! Lot délestage V2C (question 5) — EN DÉPASSEMENT, la règle de jour ne rend rien : phases du
|
||||||
|
//! compteur aveugles à la borne, la stratégie la tient à son plancher.
|
||||||
|
void testWaterfallChargerGetsNothingBackInOverload();
|
||||||
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||||
void testEveryGetterReturnsWhatItDeclares();
|
void testEveryGetterReturnsWhatItDeclares();
|
||||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
||||||
@ -346,6 +413,23 @@ private slots:
|
|||||||
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
|
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
|
||||||
void testSheddingLogKeepsTheLastEpisodes();
|
void testSheddingLogKeepsTheLastEpisodes();
|
||||||
|
|
||||||
|
// ── Lot délestage triphasé (2026-10-27) ─────────────────────────────────
|
||||||
|
//! Après une surcharge, la borne garde ses phases dans les comptes : allocation, commande et
|
||||||
|
//! matériel disent le même nombre de phases — triphasée sans bascule, à bascule, monophasée.
|
||||||
|
void testOverloadLeavesTheChargerItsPhases_data();
|
||||||
|
void testOverloadLeavesTheChargerItsPhases();
|
||||||
|
//! Une voiture monophasée désassignée ne laisse pas « 1 phase » à une borne triphasée.
|
||||||
|
void testAnUnassignedCarTakesItsPhasesAway();
|
||||||
|
//! Une borne qui tire sur trois phases ce que le moteur croit commandé sur une : divergente.
|
||||||
|
void testCommandDivergenceSeesAPhaseCountError();
|
||||||
|
//! Des courants par phase en retard sur le total ne retardent pas la protection.
|
||||||
|
void testOverloadReactsToTheTotalWhenPhasesLag();
|
||||||
|
//! Délesté sous son minOn, le chauffe-eau franchit le verrou ET ses relais s'ouvrent.
|
||||||
|
void testSheddingOpensTheRelaysUnderMinOn();
|
||||||
|
//! La marge de sécurité : 10 % par défaut, réglable et bornée, persistée, et c'est à 90 %
|
||||||
|
//! de la limite que la protection agit.
|
||||||
|
void testPhaseSafetyMargin();
|
||||||
|
|
||||||
void printStates(Thing *thing);
|
void printStates(Thing *thing);
|
||||||
void updateChargerMeter(Thing *thing);
|
void updateChargerMeter(Thing *thing);
|
||||||
|
|
||||||
|
|||||||
@ -187,6 +187,43 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
} else if (thing->thingClassId() == chargerUnmeteredThingClassId) {
|
||||||
|
// Lot délestage V2C — une borne qui mesure sa puissance mais PAS ses phases au sens du
|
||||||
|
// moteur : `currentL1..3` (noms du plugin V2C Trydan), jamais `currentPhaseX`. Son nombre
|
||||||
|
// de phases est celui de son câblage, fixe : aucune bascule.
|
||||||
|
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||||
|
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||||
|
quint16 port = thing->paramValue(paramType.id()).toUInt();
|
||||||
|
if (!controller->listen(QHostAddress::Any, port)) {
|
||||||
|
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
|
||||||
|
delete controller;
|
||||||
|
info->finish(Thing::ThingErrorThingInUse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
|
||||||
|
if (query.hasQueryItem("connected"))
|
||||||
|
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
|
||||||
|
|
||||||
|
if (query.hasQueryItem("power"))
|
||||||
|
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
|
||||||
|
|
||||||
|
if (query.hasQueryItem("pluggedIn"))
|
||||||
|
thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool());
|
||||||
|
|
||||||
|
if (query.hasQueryItem("maxChargingCurrent"))
|
||||||
|
thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt());
|
||||||
|
|
||||||
|
updateUnmeteredChargerMeter(thing);
|
||||||
|
});
|
||||||
|
|
||||||
|
m_controllers.insert(thing, controller);
|
||||||
|
info->finish(Thing::ThingErrorNoError);
|
||||||
|
|
||||||
|
thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt());
|
||||||
|
updateUnmeteredChargerMeter(thing);
|
||||||
|
return;
|
||||||
|
|
||||||
} else if (thing->thingClassId() == simpleChargerThingClassId) {
|
} else if (thing->thingClassId() == simpleChargerThingClassId) {
|
||||||
EnergyMockController *controller = new EnergyMockController(thing, this);
|
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||||
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||||
@ -699,6 +736,15 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (thing->thingClassId() == chargerUnmeteredThingClassId) {
|
||||||
|
if (actionType.name() == "maxChargingCurrent")
|
||||||
|
thing->setStateValue("maxChargingCurrent",
|
||||||
|
action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt());
|
||||||
|
if (actionType.name() == "power")
|
||||||
|
thing->setStateValue("power", action.paramValue(actionType.paramTypes().findByName("power").id()).toBool());
|
||||||
|
updateUnmeteredChargerMeter(thing);
|
||||||
|
}
|
||||||
|
|
||||||
if (thing->thingClassId() == simpleChargerThingClassId) {
|
if (thing->thingClassId() == simpleChargerThingClassId) {
|
||||||
if (actionType.name() == "maxChargingCurrent") {
|
if (actionType.name() == "maxChargingCurrent") {
|
||||||
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
|
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
|
||||||
@ -751,6 +797,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
|||||||
info->finish(Thing::ThingErrorNoError);
|
info->finish(Thing::ThingErrorNoError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void IntegrationPluginEnergyMocks::updateUnmeteredChargerMeter(Thing *thing)
|
||||||
|
{
|
||||||
|
// Le courant commandé, tiré sur les phases câblées — comme updateChargerMeter(), mais publié
|
||||||
|
// sous `currentL1..3`, que le moteur ne lit pas : EvCharger::meteredPhases() reste vide.
|
||||||
|
const Electricity::Phases cablees = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
|
||||||
|
const uint phaseCount = Electricity::getPhaseCount(cablees);
|
||||||
|
thing->setStateValue("phaseCount", phaseCount);
|
||||||
|
|
||||||
|
const bool tire = thing->stateValue("power").toBool() && thing->stateValue("connected").toBool()
|
||||||
|
&& thing->stateValue("pluggedIn").toBool();
|
||||||
|
const double courantA = tire ? thing->stateValue("maxChargingCurrent").toDouble() : 0.0;
|
||||||
|
thing->setStateValue("currentL1", cablees.testFlag(Electricity::PhaseA) ? courantA : 0.0);
|
||||||
|
thing->setStateValue("currentL2", cablees.testFlag(Electricity::PhaseB) ? courantA : 0.0);
|
||||||
|
thing->setStateValue("currentL3", cablees.testFlag(Electricity::PhaseC) ? courantA : 0.0);
|
||||||
|
thing->setStateValue("currentPower", courantA * 230 * phaseCount);
|
||||||
|
thing->setStateValue("charging", tire);
|
||||||
|
}
|
||||||
|
|
||||||
void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing)
|
void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing)
|
||||||
{
|
{
|
||||||
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
|
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
|
||||||
|
|||||||
@ -78,6 +78,7 @@ private:
|
|||||||
|
|
||||||
// Update the meter data of the charger based on the current max charging current, connected, power and plugged in state
|
// Update the meter data of the charger based on the current max charging current, connected, power and plugged in state
|
||||||
void updateChargerMeter(Thing *thing);
|
void updateChargerMeter(Thing *thing);
|
||||||
|
void updateUnmeteredChargerMeter(Thing *thing);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -717,6 +717,146 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "chargerUnmetered",
|
||||||
|
"displayName": "Mocked charger without per-phase metering",
|
||||||
|
"id": "e297f8c4-934f-4af3-a655-29b5b799289b",
|
||||||
|
"createMethods": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"interfaces": [
|
||||||
|
"evcharger",
|
||||||
|
"smartmeterconsumer",
|
||||||
|
"connectable"
|
||||||
|
],
|
||||||
|
"paramTypes": [
|
||||||
|
{
|
||||||
|
"id": "4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5",
|
||||||
|
"name": "port",
|
||||||
|
"displayName": "Port",
|
||||||
|
"type": "uint",
|
||||||
|
"defaultValue": 6658
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ec3e73a0-cca9-415a-bc64-9e841d666853",
|
||||||
|
"name": "phases",
|
||||||
|
"displayName": "Phases connected",
|
||||||
|
"type": "QString",
|
||||||
|
"allowedValues": [
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"C",
|
||||||
|
"ABC"
|
||||||
|
],
|
||||||
|
"defaultValue": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "803ba501-ae0f-4d58-8097-6ebb0928b3d8",
|
||||||
|
"name": "maxChargingCurrentUpperLimit",
|
||||||
|
"displayName": "Maximum charging current",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Ampere",
|
||||||
|
"defaultValue": 32
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateTypes": [
|
||||||
|
{
|
||||||
|
"id": "b35e0da3-7fc1-4c42-b6b4-2c43f9cb937a",
|
||||||
|
"name": "connected",
|
||||||
|
"displayName": "Connected",
|
||||||
|
"type": "bool",
|
||||||
|
"defaultValue": false,
|
||||||
|
"cached": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6ce62b4f-39e1-4693-b204-e399ea4e6637",
|
||||||
|
"name": "power",
|
||||||
|
"displayName": "Charging enabled",
|
||||||
|
"displayNameAction": "Enable/disable charging",
|
||||||
|
"type": "bool",
|
||||||
|
"defaultValue": true,
|
||||||
|
"writable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1b1c75b1-362e-4341-960d-4024b2f28341",
|
||||||
|
"name": "maxChargingCurrent",
|
||||||
|
"displayName": "Maximum charging current",
|
||||||
|
"displayNameAction": "Set maximum charging current",
|
||||||
|
"type": "double",
|
||||||
|
"defaultValue": 6,
|
||||||
|
"minValue": 6,
|
||||||
|
"maxValue": 32,
|
||||||
|
"unit": "Ampere",
|
||||||
|
"writable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4406b3d3-0102-4932-94ee-e678cc850106",
|
||||||
|
"name": "totalEnergyConsumed",
|
||||||
|
"displayName": "Total consumed energy",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "KiloWattHour",
|
||||||
|
"defaultValue": 0,
|
||||||
|
"cached": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "06b4ceea-5893-4776-b854-4e6c67469b47",
|
||||||
|
"name": "pluggedIn",
|
||||||
|
"displayName": "Car is plugged in",
|
||||||
|
"type": "bool",
|
||||||
|
"defaultValue": false,
|
||||||
|
"cached": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cca231ab-9fb5-4565-90b4-56ac33ccbcd4",
|
||||||
|
"name": "charging",
|
||||||
|
"displayName": "Charging",
|
||||||
|
"type": "bool",
|
||||||
|
"defaultValue": false,
|
||||||
|
"cached": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "79601cfe-433b-4fcb-b61f-5b6a0b8b0292",
|
||||||
|
"name": "phaseCount",
|
||||||
|
"displayName": "Active phases",
|
||||||
|
"type": "uint",
|
||||||
|
"minValue": 1,
|
||||||
|
"maxValue": 3,
|
||||||
|
"defaultValue": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "923e31e9-04be-43fc-a79d-10098f7aadc1",
|
||||||
|
"name": "currentPower",
|
||||||
|
"displayName": "Current power consumption",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Watt",
|
||||||
|
"defaultValue": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edfb0a5d-6897-4b27-9ae4-2f636ab700a0",
|
||||||
|
"name": "currentL1",
|
||||||
|
"displayName": "Current L1",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Ampere",
|
||||||
|
"defaultValue": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8bf7ff7b-78a3-4917-a4bd-1e9b7d95ca53",
|
||||||
|
"name": "currentL2",
|
||||||
|
"displayName": "Current L2",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Ampere",
|
||||||
|
"defaultValue": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "20292cd6-dd59-4608-9d38-18096f484cbf",
|
||||||
|
"name": "currentL3",
|
||||||
|
"displayName": "Current L3",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Ampere",
|
||||||
|
"defaultValue": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "simpleCharger",
|
"name": "simpleCharger",
|
||||||
"displayName": "Mocked charger simple",
|
"displayName": "Mocked charger simple",
|
||||||
|
|||||||
@ -65,6 +65,8 @@ TABLE_MODES = {
|
|||||||
'old-data': ('—', 'valide'),
|
'old-data': ('—', 'valide'),
|
||||||
'echo-value': ('—', 'valide'),
|
'echo-value': ('—', 'valide'),
|
||||||
'major-first': ('—', 'valide'),
|
'major-first': ('—', 'valide'),
|
||||||
|
# Une consigne hors des bornes est un plan VALIDE : c'est au moteur de l'écrêter, pas au contrat.
|
||||||
|
'over-limit': ('—', 'valide'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
|
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
|
||||||
|
|||||||
@ -83,7 +83,8 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg
|
|||||||
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data`
|
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data`
|
||||||
(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) ·
|
(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) ·
|
||||||
`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure
|
`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure
|
||||||
jugée avant le token).
|
jugée avant le token) · `over-limit` (toute consigne en watts portée à 50 kW, au-delà des bornes
|
||||||
|
de la charge et du branchement : le moteur doit l'écrêter).
|
||||||
|
|
||||||
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
|
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
|
||||||
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité
|
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité
|
||||||
|
|||||||
@ -117,7 +117,7 @@ PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `trun
|
|||||||
|
|
||||||
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
|
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
|
||||||
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type',
|
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type',
|
||||||
'old-data', 'echo-value', 'major-first']
|
'old-data', 'echo-value', 'major-first', 'over-limit']
|
||||||
|
|
||||||
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
|
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
|
||||||
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
|
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
|
||||||
@ -389,6 +389,13 @@ def plan(ctx, mode, horizon_s, token):
|
|||||||
if a['loadId'] in disponibles:
|
if a['loadId'] in disponibles:
|
||||||
del actions[i]
|
del actions[i]
|
||||||
break
|
break
|
||||||
|
elif mode == 'over-limit':
|
||||||
|
# Un optimiseur qui demande le MAXIMUM à tout ce qui se règle en watts, au-delà des bornes
|
||||||
|
# de la charge et du branchement. Le moteur doit l'écrêter (invariant 2), jamais le poser.
|
||||||
|
for a in actions:
|
||||||
|
if a.get('kind') == 'setpoint':
|
||||||
|
a['powerW'] = 50000.0
|
||||||
|
a['reason'] = 'mode over-limit (test) : 50 kW demandés'
|
||||||
elif mode == 'echo-token' and token:
|
elif mode == 'echo-token' and token:
|
||||||
for a in actions:
|
for a in actions:
|
||||||
a['reason'] = f'{a["reason"]} — écho {token}'
|
a['reason'] = f'{a["reason"]} — écho {token}'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user