moteur : marge de sécurité de 10 % et phases cohérentes avec le total (défaut 2)

Décision du 2026-10-27 : le moteur vise 90 % de la limite de phase — 27 A pour 30 A — pour le
plafond qu'il déclare et alloue, et pour le seuil où la protection agit. Réglable de 0 à 25 %,
persistée avec la limite (phaseSafetyMarginPercent), publiée par GetPhasePowerLimit et
PhasePowerLimitChanged (même charge utile : limite, marge, effectivePhaseLimit), écrite par le
paramètre optionnel de SetPhasePowerLimit. Le seuil atteint DrawCap, la protection amont et sa
reprise, la limite de prepareInformation(), les deux allowances du proxy, et le phaseLimitA
envoyé à l'optimiseur. OVERLOAD_THROTTLED porte thresholdA quand la marge n'est pas nulle.

Défaut 2 : sur l'installation d'essai, les courants par phase publiés étaient en retard sur le
total (29,1 A lus pour 32,3 A importés par phase) et la protection a attendu 1 min 45. Les phases
somment au total : la plus chargée en porte au moins le tiers. tiragesCoherentsAvecLeTotal()
relève les phases quand leur somme est sous le total, jamais ne les abaisse ; c'est la lecture de
la protection, de sa reprise, du journal de ses gestes et de la cascade.

Tests existants mis à jour : les dépassements se mesurent au seuil (22,5 A pour 25 A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-27 12:43:20 +01:00
parent cf0e65abc7
commit 440a45f526
12 changed files with 307 additions and 61 deletions

View File

@ -1068,6 +1068,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())
@ -1174,11 +1179,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);
@ -1378,7 +1383,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);
} }
@ -1401,17 +1409,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;
} }
@ -3647,8 +3660,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)

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 // 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"));

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 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

View File

@ -85,3 +85,22 @@ 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;
if (manque <= 0)
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,29 @@ 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. 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.
* *

View File

@ -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 &params) JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap &params)
{ {
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 &params) JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap &params)
{ {
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));

View File

@ -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.

View File

@ -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 &currentDate
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 &currentDate
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 &currentDateTime) void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
{ {
// 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 ---------------";
@ -1320,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
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;
@ -1402,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
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

View File

@ -64,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);
@ -100,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);
@ -114,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
@ -143,6 +170,16 @@ signals:
// 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() : // 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() :
// les bornes préparées au dernier cycle. Seul // les bornes préparées au dernier cycle. Seul
// lecteur : internalHasProcessInfo(). // 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
@ -323,6 +360,17 @@ protected:
* 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée * 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée
* laissait son entrée de la veille (\c testAnUnassignedCarTakesItsPhasesAway). * laissait son entrée de la veille (\c testAnUnassignedCarTakesItsPhasesAway).
*/ */
/*!
* \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 bool internalHasProcessInfo(EvCharger *evCharger) const // [ETM] new
{ return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); } { return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); }
// [ETM] END // [ETM] END
@ -365,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;

View File

@ -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());
}

View File

@ -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();

View File

@ -6896,8 +6896,10 @@ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock()
QCOMPARE(p.value("shed").toBool(), true); QCOMPARE(p.value("shed").toBool(), true);
QCOMPARE(p.value("source").toString(), QString("connection")); QCOMPARE(p.value("source").toString(), QString("connection"));
QCOMPARE(p.value("phase").toString(), QString("A")); QCOMPARE(p.value("phase").toString(), QString("A"));
QCOMPARE(p.value("overshootW").toInt(), 1150); // (30 − 22,5) × 230 : le dépassement se mesure au SEUIL, limite de 25 A moins la marge de
QCOMPARE(p.value("marginW").toInt(), -1150); // sécurité de 10 % (lot délestage triphasé) — 1 150 W au-dessus de la limite avant lui.
QCOMPARE(p.value("overshootW").toInt(), 1725);
QCOMPARE(p.value("marginW").toInt(), -1725);
// Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté. // Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté.
QCOMPARE(p.value("shedW").toInt(), 3000); QCOMPARE(p.value("shedW").toInt(), 3000);
@ -12711,10 +12713,12 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
QVERIFY2(!snap.contains("shedding"), "aucun délestage : le bloc doit être absent, pas à zéro"); QVERIFY2(!snap.contains("shedding"), "aucun délestage : le bloc doit être absent, pas à zéro");
QVERIFY(journal().isEmpty()); QVERIFY(journal().isEmpty());
// 1. RABAISSÉE — un four s'allume : 12 A de maison, 28 A sur la phase A pour 25. La // 1. RABAISSÉE — un four s'allume : 9 A de maison, 25 A sur la phase A pour un seuil de
// protection rabaisse la borne du dépassement arrondi à l'ampère supérieur. // 22,5 A (25 A moins la marge de sécurité de 10 %). La protection rabaisse la borne du
// dépassement arrondi à l'ampère supérieur. (12 A de maison avant la marge : 5,5 A de trop
// au seuil, deux gestes sur un compteur figé, et la borne passait sous son plancher.)
QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged);
cycle(12); cycle(9);
const int tenusA = consigneA(s.borne); const int tenusA = consigneA(s.borne);
QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA)));
QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled));
@ -12724,15 +12728,16 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
QCOMPARE(p.value("cut").toBool(), false); QCOMPARE(p.value("cut").toBool(), false);
QCOMPARE(p.value("phase").toString(), QStringLiteral("A")); QCOMPARE(p.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(p.value("limitA").toInt(), 25); QCOMPARE(p.value("limitA").toInt(), 25);
QCOMPARE(p.value("overshootW").toInt(), 690); // (28 − 25) × 230 QCOMPARE(p.value("thresholdA").toDouble(), 22.5); // le seuil, sous la marge de 10 %
QCOMPARE(p.value("overshootW").toInt(), 575); // (25 − 22,5) × 230
// Préséance L4 : l'action entière est celle de la protection — sans niveaux, et l'allocation // Préséance L4 : l'action entière est celle de la protection — sans niveaux, et l'allocation
// dit ce qu'elle laisse à la borne. // dit ce qu'elle laisse à la borne.
QVERIFY(!entree().contains("levels")); QVERIFY(!entree().contains("levels"));
QVERIFY(!decision().contains("level")); QVERIFY(!decision().contains("level"));
QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230); QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230);
QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"), QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"),
QStringLiteral("Borne abaissée à %1 A : le branchement atteignait sa limite (phase A, " QStringLiteral("Borne abaissée à %1 A : le branchement atteignait son seuil de protection "
"25 A), dépassement de 690 W").arg(tenusA)); "(phase A, 22,5 A sous une limite de 25 A), dépassement de 575 W").arg(tenusA));
// La notification est partie, et elle porte le résumé du journal. // La notification est partie, et elle porte le résumé du journal.
QVERIFY(!notifs.isEmpty()); QVERIFY(!notifs.isEmpty());
QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap(); QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap();
@ -12764,8 +12769,9 @@ void Simulation::testOverloadProtectionReasonOnTheCharger()
QCOMPARE(p.value("fromA").toInt(), 16); QCOMPARE(p.value("fromA").toInt(), 16);
QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0); QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0);
const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"); const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne");
QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait sa limite " QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait son seuil de "
"(phase A, 25 A)")), qUtf8Printable(coupee)); "protection (phase A, 22,5 A sous une limite de 25 A)")),
qUtf8Printable(coupee));
evts = journal(); evts = journal();
QCOMPARE(evts.size(), 1); QCOMPARE(evts.size(), 1);
e = evts.first().toMap(); e = evts.first().toMap();
@ -12868,10 +12874,11 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged()
QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[] QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[]
QVERIFY(!snap.contains("shedding")); QVERIFY(!snap.contains("shedding"));
// 1. 28 A sur la phase A : la protection rabaisse la borne, deux fois dans l'appel (dans le // 1. 25 A sur la phase A, seuil à 22,5 A (marge de sécurité de 10 %) : la protection rabaisse
// cycle, puis sur powerBalanceChanged — le compteur n'a pas encore bougé). // la borne, deux fois dans l'appel (dans le cycle, puis sur powerBalanceChanged — le
// compteur n'a pas encore bougé). À 28 A, 5,5 A de trop deux fois la passaient sous 6 A.
QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged);
cycle(28); cycle(25);
const int tenusA = consigneA(s.borne); const int tenusA = consigneA(s.borne);
QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA)));
QVERIFY(!telemetrieParId(snap).contains(borne)); // toujours hors de loads[] QVERIFY(!telemetrieParId(snap).contains(borne)); // toujours hors de loads[]
@ -12886,7 +12893,7 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged()
QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16); QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16);
QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA);
QCOMPARE(e.value("phase").toString(), QStringLiteral("A")); QCOMPARE(e.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(e.value("overshootW").toInt(), 690); QCOMPARE(e.value("overshootW").toInt(), 575); // (25 − 22,5) × 230
QVERIFY(!e.contains("endedAt")); QVERIFY(!e.contains("endedAt"));
// 2. La maison redescend : verifyOverloadProtectionRecovery() rend les 16 A manuels. La tenue // 2. La maison redescend : verifyOverloadProtectionRecovery() rend les 16 A manuels. La tenue
@ -12992,7 +12999,8 @@ void Simulation::testSheddingLogCoversEveryMechanism()
QCOMPARE(qRound(tele.value("ecs-delestee").value("mechanism").toMap().value("stageW").toDouble()), 2000); QCOMPARE(qRound(tele.value("ecs-delestee").value("mechanism").toMap().value("stageW").toDouble()), 2000);
QVERIFY(!snap.contains("shedding")); QVERIFY(!snap.contains("shedding"));
// DÉPASSEMENT de 2 300 W sur la phase A (35 A pour 25) : L4 déleste en ordre inverse du rang, // DÉPASSEMENT de 2 875 W sur la phase A (35 A pour un seuil de 22,5 A — 25 A moins la marge
// de sécurité de 10 %) : L4 déleste en ordre inverse du rang,
// le chauffe-eau (rang 3, 2 000 W) puis la PAC (rang 2, 3 000 W). // le chauffe-eau (rang 3, 2 000 W) puis la PAC (rang 2, 3 000 W).
QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged); QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged);
const QDateTime t2 = t0.addSecs(3610); const QDateTime t2 = t0.addSecs(3610);
@ -13025,7 +13033,7 @@ void Simulation::testSheddingLogCoversEveryMechanism()
for (const QVariantMap &ev : {ecs, pac}) { for (const QVariantMap &ev : {ecs, pac}) {
QCOMPARE(ev.value("source").toString(), QStringLiteral("connection")); QCOMPARE(ev.value("source").toString(), QStringLiteral("connection"));
QCOMPARE(ev.value("phase").toString(), QStringLiteral("A")); QCOMPARE(ev.value("phase").toString(), QStringLiteral("A"));
QCOMPARE(ev.value("overshootW").toInt(), 2300); QCOMPARE(ev.value("overshootW").toInt(), 2875); // (35 − 22,5) × 230 : au seuil, marge de 10 %
QCOMPARE(ev.value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate)); QCOMPARE(ev.value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate));
QVERIFY(!ev.contains("endedAt")); QVERIFY(!ev.contains("endedAt"));
} }
@ -13561,13 +13569,19 @@ void Simulation::testPhaseSafetyMargin()
cycle(-25); cycle(-25);
QCOMPARE(consigneA(b.borne), 16); QCOMPARE(consigneA(b.borne), 16);
cycle(12); // 12 + 16 = 28 A cycle(12); // 12 + 16 = 28 A
QVERIFY2(consigneA(b.borne) < 16, qUtf8Printable(QString::number(consigneA(b.borne)))); // Le geste de la protection, lu au journal : la cascade, elle, coupe aussi la borne faute de
// Le geste est jugé sur le SEUIL : 28 − 27 = 1 A de trop, la borne perd 1 A, pas 0. // surplus, et la consigne finale ne dirait pas qui a agi. À 30 A, il n'y aurait aucun geste.
QCOMPARE(consigneA(b.borne), 15); const QVariantList evts = evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog"));
QCOMPARE(evts.size(), 1);
QCOMPARE(evts.first().toMap().value("loadId").toString(), b.borneId);
QCOMPARE(evts.first().toMap().value("overshootW").toInt(), 230); // (28 − 27) × 230
// 3. Écriture : la marge seule ne bouge pas la limite ; notifiée, persistée. // 3. Écriture : la marge seule ne bouge pas la limite ; notifiée, persistée.
enableNotifications({"NymeaEnergy"});
QSignalSpy notif(m_mockTcpServer, &MockTcpServer::outgoingData); QSignalSpy notif(m_mockTcpServer, &MockTcpServer::outgoingData);
QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 15}}), QString("EnergyErrorNoError")); QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 15}}), QString("EnergyErrorNoError"));
if (notif.isEmpty())
notif.wait();
g = lire(); g = lire();
QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 15u); QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 15u);
QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 25.5); QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 25.5);