fusion de fix/delestage-triphase (délestage triphasé, marge de sécurité, borne V2C)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
Patrick Schurig 2026-10-27 17:17:43 +01:00
commit dff1e6f9ff
28 changed files with 1847 additions and 113 deletions

View File

@ -25,12 +25,18 @@ rien sur les cas qu'il ne sait pas produire.
| `charger` | ✅ | ✅ | ❌ |
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
| `simpleCharger` | ❌ | ❌ | ❌ |
| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ |
| `powerSwitch` | ✅ | ❌ | — |
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
| `energyStorage` | ✅ | ❌ | — |
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
| `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`
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.

View File

@ -17,6 +17,7 @@
#include "scheduler/optimizerstatus.h"
#include "scheduler/publishedplan.h"
#include "scheduler/optimizertrace.h"
#include "scheduler/optimizerplan.h"
#include "types/decisionreason.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
@ -532,6 +533,12 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
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())
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
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.";
}
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
{
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("overshootW"), qRound(t.overshootW)},
{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,
// aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP.
if (!t.phase.isEmpty())
@ -1174,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.
QString phase;
double depassementW = 0;
if (RootMeter *compteur = internalRootMeter()) {
const double limiteW = 230.0 * phasePowerLimit();
const QHash<QString, double> parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()},
{QStringLiteral("B"), compteur->currentPowerPhaseB()},
{QStringLiteral("C"), compteur->currentPowerPhaseC()}};
if (internalRootMeter()) {
// Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total,
// limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé.
const double limiteW = 230.0 * effectivePhaseLimit();
const QHash<QString, double> parPhase = phaseConsumptionW();
if (evCharger->phaseCount() == 1) {
phase = chargerPhaseFor(evCharger);
depassementW = qMax(0.0, parPhase.value(phase) - limiteW);
@ -1378,7 +1466,10 @@ QList<DrawCap> EnergyArbitrator::drawCaps() const
if (limiteA > 0) {
DrawCap disjoncteur;
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);
}
@ -1401,17 +1492,22 @@ QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi
const QList<Electricity::Phase> toutes =
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
for (Electricity::Phase p : toutes) {
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
// Tirage de chaque 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.
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;
// 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);
tirages.append(lus.at(i));
}
return tirages;
}
@ -1485,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
// 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.
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 {
@ -1497,38 +1606,90 @@ constexpr double MargeHeuresCreusesA = 1.0;
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();
if (!ev || phaseCount == 0 || !meter)
return -1;
return plafond;
const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty())
return -1;
return plafond;
// Ce que la borne tire, MESURÉ, et sur quelles phases. 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.
const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull();
const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : 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.
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;
const TirageBorne tirage = evAttributableDraw(ev, phaseCount);
plafond.attribuable = tirage.phases != Electricity::PhaseNone;
const Electricity::Phases masque = plafond.attribuable
? tirage.phases : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
const double tireW = plafond.attribuable ? tirage.watts : 0.0;
// 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
// 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)
w = qMax(0.0, w - tireW / phaseCount);
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
qMax(0.0, meter->currentPower() - tireW));
if (!marge.borne)
return -1;
return plafond;
// 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é.
@ -1538,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'à
// sortir du dépassement ou jusqu'au plancher, où L4 décide.
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
@ -3647,8 +3809,11 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex
// producteur ? » de l'apprentissage (observerPourApprentissage()).
if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty())
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)
fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())};
fil.phaseLimitA = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()};
if (ctx.battery.present)
fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0);
if (m_loadConfigStore)

View File

@ -803,6 +803,39 @@ public:
*/
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.
//! \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.
@ -884,12 +917,17 @@ public:
* waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
* 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à.
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
* \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la
* borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la
* 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
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
* fausse : le code ne l'a jamais fait.
* (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser).
* \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
* instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
*/
@ -912,9 +950,11 @@ public:
*
* \par Contrat
* - 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
* seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans
* mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même.
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est
* sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou,
* 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
* 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.
@ -927,7 +967,8 @@ public:
* mange jamais la marge.
*
* \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;
@ -1010,6 +1051,53 @@ protected:
const QDateTime &currentDateTime) override;
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.
*

View File

@ -555,7 +555,9 @@ QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadActio
a.counts = niveau.counts;
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)
ecretages.append({a.loadId, decrire(demande, adapter),
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});

View File

@ -613,6 +613,20 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
LoadContext lcConfort = lc;
if (plafondPartage.contains(lc.id))
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

View File

@ -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
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
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)")
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));

View File

@ -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 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
* 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
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi

View File

@ -85,3 +85,24 @@ QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW
params.insert(QStringLiteral("minOnBypassed"), true);
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;
}

View File

@ -111,6 +111,30 @@ DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
//! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
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.
*

View File

@ -81,13 +81,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
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.";
returns.insert("phasePowerLimit", enumValueName(Uint));
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. "
"[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);
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("o:phaseSafetyMarginPercent", enumValueName(Uint));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// Notifications
params.clear();
description = "Emitted whenever the phase power limit configuration changes.";
params.insert("phasePowerLimit", enumValueName(Uint));
description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
params = phasePowerLimitSchema();
registerNotification("PhasePowerLimitChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
// [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
emit PhasePowerLimitChanged(phasePowerLimitPayload());
});
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
emit PhasePowerLimitChanged(phasePowerLimitPayload());
});
params.clear();
@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit());
return createReply(returns);
return createReply(phasePowerLimitPayload());
}
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 &params)
{
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);
QVariantMap returns;
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));

View File

@ -239,6 +239,14 @@ private:
* celui qu'on ne teste pas tous les jours.
*/
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
//! et \c OptimizerStatusChanged. \return Le schéma des champs.

View File

@ -23,6 +23,7 @@
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "smartchargingmanager.h"
#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal()
#include "evcharger.h"
#include "rootmeter.h"
#include "energysettings.h"
@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa
{
EnergySettings settings;
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();
// 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
{
return m_acquisitionTolerance;
@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDate
return;
}
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
{"B", m_rootMeter->currentPowerPhaseB()},
{"C", m_rootMeter->currentPowerPhaseC()}};
// [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
// marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
bool limitExceeded = false;
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
// Note: for now we assume the phases are always connected this way:
// EV phase A -> house phase A
@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDate
foreach (const QString &phase, currentPhaseConsumption.keys()) {
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;
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
requiredThrottlePower[phase] = overshot;
@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
{"B", m_rootMeter->currentPowerPhaseB()},
{"C", m_rootMeter->currentPowerPhaseC()}};
// [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
QHash<QString, double> availablePhasePower = {{"A", 0},
{"B", 0},
@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
{
// 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 ---------------";
@ -637,6 +675,12 @@ void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
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
foreach (EvCharger *evCharger, evChargers) {
@ -1314,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
// 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);
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
@ -1396,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
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";
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]";
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection

View File

@ -27,6 +27,7 @@
#include <QObject>
#include <QTimer>
#include <QSet>
#include "energymanagerconfiguration.h"
#include "types/charginginfo.h"
@ -63,6 +64,30 @@ public:
uint phasePowerLimit() const;
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;
void setAcquisitionTolerance(double acquisitionTolerance);
@ -99,6 +124,7 @@ public:
signals:
void phasePowerLimitChanged(int phasePowerLimit);
void phaseSafetyMarginPercentChanged(uint percent); // [ETM]
void acquisitionToleranceChanged(double acquisitionTolerance);
void batteryLevelConsiderationChanged(double batteryLevelConsideration);
void lockOnUnplugChanged(bool lockOnUnplug);
@ -113,7 +139,9 @@ signals:
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
// 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
// é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.
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documenté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()
// — crochets ETM, virtuels, documentés ci-dessous.
// 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
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
* 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
private slots:
@ -344,6 +404,7 @@ private:
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
QSet<EvCharger *> m_preparedChargers; // [ETM] préparées au dernier prepareInformation()
QHash<EvCharger *, ChargingActions> m_chargingActions;
QDateTime m_lastSpotMarketPlanning;
@ -352,6 +413,7 @@ private:
QHash<EvCharger *, bool> m_overloadProtectionActive;
uint m_phasePowerConsumptionLimit = 25;
uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27
double m_acquisitionTolerance = 0.5;
double m_batteryLevelConsideration = 0.9;

View File

@ -332,7 +332,8 @@ void TestCharging::addChargingInfo()
// Set phase power limit
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);
removeDevices();
@ -691,7 +692,8 @@ void TestCharging::testEcoMode()
// Set phase power limit
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");
// Add mock meter
@ -1069,7 +1071,8 @@ void TestCharging::testEcoModePhaseSwitching()
// Set phase power limit
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");
// Add mock meter
@ -1381,7 +1384,8 @@ void TestCharging::testBatteryLevelConsideration()
// Set phase power limit
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");
// Add mock meter
@ -1602,7 +1606,8 @@ void TestCharging::batteryLevelConsiderationStopCharging()
// Set phase power limit
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");
// Add mock meter
@ -1840,7 +1845,8 @@ void TestCharging::testOverloadProtectionManualMode()
// Set phase power limit
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");
// Add mock meter
@ -2011,7 +2017,8 @@ void TestCharging::testOverloadProtectionEcoMode()
// Set phase power limit
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");
// Add mock meter

View File

@ -451,6 +451,23 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin
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)
{
QVariantList thingParams;

View File

@ -51,6 +51,9 @@ static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5
static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7");
static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c");
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 mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
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 addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
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 addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);

View File

@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
horsService.observerMinute(huitJours.first());
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());
}

View File

@ -108,6 +108,8 @@ private slots:
void testStoredInstantsSurviveATimeZoneChange();
void testReportingDayBoundaryIsPublishedNotDeducible();
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.
void testTheContentIsTheRevisionIdentity();

View File

@ -223,6 +223,7 @@ private slots:
void testAProtocol10OptimizerIsAccepted();
void testTracesReplayWithZeroDivergence();
void testSafetyWinsOverTheOptimizer();
void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum();
void testAnEchoedTokenLeaksNowhereEndToEnd();
void cleanupTestCase();
@ -936,6 +937,116 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
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()
{
// 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

View File

@ -74,13 +74,27 @@ private:
};
//! 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.
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.
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
BorneHc borne = BorneHc::Monophasee);
//! 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);
// ── 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 {
@ -339,6 +353,20 @@ private slots:
void testOffPeakFullPowerAtDawnStaysInTheCeiling();
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
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.
void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
@ -385,6 +413,23 @@ private slots:
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
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 updateChargerMeter(Thing *thing);

View File

@ -187,6 +187,43 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
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) {
EnergyMockController *controller = new EnergyMockController(thing, this);
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 (actionType.name() == "maxChargingCurrent") {
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
@ -751,6 +797,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
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)
{
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());

View File

@ -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
void updateChargerMeter(Thing *thing);
void updateUnmeteredChargerMeter(Thing *thing);
};

View File

@ -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",
"displayName": "Mocked charger simple",

View File

@ -65,6 +65,8 @@ TABLE_MODES = {
'old-data': ('—', 'valide'),
'echo-value': ('—', '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

View File

@ -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`
(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
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
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité

View File

@ -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',
'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
# 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:
del actions[i]
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:
for a in actions:
a['reason'] = f'{a["reason"]} — écho {token}'