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:
parent
cf0e65abc7
commit
440a45f526
@ -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)
|
||||||
|
|||||||
@ -126,7 +126,16 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
// La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne
|
// La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne
|
||||||
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
|
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
|
||||||
if (c == QLatin1String(DecisionCode::OverloadThrottled)) {
|
if (c == QLatin1String(DecisionCode::OverloadThrottled)) {
|
||||||
const QString pourquoi = p.contains(QStringLiteral("phase"))
|
// Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite :
|
||||||
|
// le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu.
|
||||||
|
const QString ou = p.contains(QStringLiteral("phase"))
|
||||||
|
? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString())
|
||||||
|
: QString();
|
||||||
|
const QString pourquoi = p.contains(QStringLiteral("thresholdA"))
|
||||||
|
? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)")
|
||||||
|
.arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble()))
|
||||||
|
.arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString())
|
||||||
|
: p.contains(QStringLiteral("phase"))
|
||||||
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
|
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
|
||||||
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
|
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
|
||||||
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));
|
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));
|
||||||
|
|||||||
@ -183,7 +183,9 @@ constexpr const char *DrawCap = "DRAW_CAP";
|
|||||||
* \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue),
|
* \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue),
|
||||||
* \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque
|
* \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque
|
||||||
* \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur
|
* \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur
|
||||||
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable).
|
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable), \c thresholdA (le seuil
|
||||||
|
* où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent**
|
||||||
|
* quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui).
|
||||||
*
|
*
|
||||||
* \par Ce qu'il publie, et ce qu'il ne décide pas
|
* \par Ce qu'il publie, et ce qu'il ne décide pas
|
||||||
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi
|
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi
|
||||||
|
|||||||
@ -85,3 +85,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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -81,13 +81,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
|
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
|
||||||
|
|
||||||
params.clear(); returns.clear();
|
params.clear(); returns.clear();
|
||||||
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything.";
|
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. "
|
||||||
returns.insert("phasePowerLimit", enumValueName(Uint));
|
"[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): "
|
||||||
|
"the engine allocates under the effective limit and its overload protection acts above it.";
|
||||||
|
returns = phasePowerLimitSchema();
|
||||||
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
|
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
|
||||||
|
|
||||||
params.clear(); returns.clear();
|
params.clear(); returns.clear();
|
||||||
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled.";
|
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled. "
|
||||||
|
"[ETM] Optional phaseSafetyMarginPercent, 0 to 25: absent, the margin is kept; out of range, "
|
||||||
|
"EnergyErrorInvalidParameter and nothing changes.";
|
||||||
params.insert("phasePowerLimit", enumValueName(Uint));
|
params.insert("phasePowerLimit", enumValueName(Uint));
|
||||||
|
params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint));
|
||||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||||
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
|
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
|
||||||
|
|
||||||
@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
params.clear();
|
params.clear();
|
||||||
description = "Emitted whenever the phase power limit configuration changes.";
|
description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
|
||||||
params.insert("phasePowerLimit", enumValueName(Uint));
|
params = phasePowerLimitSchema();
|
||||||
registerNotification("PhasePowerLimitChanged", description, params);
|
registerNotification("PhasePowerLimitChanged", description, params);
|
||||||
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
|
// [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
|
||||||
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
|
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
|
||||||
|
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
||||||
|
});
|
||||||
|
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
|
||||||
|
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
||||||
});
|
});
|
||||||
|
|
||||||
params.clear();
|
params.clear();
|
||||||
@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
|
|||||||
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
Q_UNUSED(params)
|
Q_UNUSED(params)
|
||||||
QVariantMap returns;
|
return createReply(phasePowerLimitPayload());
|
||||||
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit());
|
}
|
||||||
return createReply(returns);
|
|
||||||
|
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitSchema()
|
||||||
|
{
|
||||||
|
// [ETM] UNE déclaration pour GetPhasePowerLimit et PhasePowerLimitChanged (règle 11).
|
||||||
|
QVariantMap schema;
|
||||||
|
schema.insert("phasePowerLimit", enumValueName(Uint));
|
||||||
|
schema.insert("phaseSafetyMarginPercent", enumValueName(Uint));
|
||||||
|
schema.insert("effectivePhaseLimit", enumValueName(Double));
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitPayload() const
|
||||||
|
{
|
||||||
|
return {{"phasePowerLimit", m_smartChargingManager->phasePowerLimit()},
|
||||||
|
{"phaseSafetyMarginPercent", m_smartChargingManager->phaseSafetyMarginPercent()},
|
||||||
|
{"effectivePhaseLimit", m_smartChargingManager->effectivePhaseLimit()}};
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
|
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
|
||||||
|
// [ETM] La marge est jugée AVANT toute écriture : refusée, rien ne change — pas même la limite.
|
||||||
|
if (params.contains("phaseSafetyMarginPercent")
|
||||||
|
&& params.value("phaseSafetyMarginPercent").toUInt() > SmartChargingManager::PhaseSafetyMarginMaxPercent) {
|
||||||
|
QVariantMap returns;
|
||||||
|
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter));
|
||||||
|
return createReply(returns);
|
||||||
|
}
|
||||||
|
if (params.contains("phaseSafetyMarginPercent"))
|
||||||
|
m_smartChargingManager->setPhaseSafetyMarginPercent(params.value("phaseSafetyMarginPercent").toUInt());
|
||||||
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
|
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
|
||||||
QVariantMap returns;
|
QVariantMap returns;
|
||||||
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
|
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
|
||||||
|
|||||||
@ -239,6 +239,14 @@ private:
|
|||||||
* celui qu'on ne teste pas tous les jours.
|
* celui qu'on ne teste pas tous les jours.
|
||||||
*/
|
*/
|
||||||
static QVariantMap loadTelemetrySchema();
|
static QVariantMap loadTelemetrySchema();
|
||||||
|
/*!
|
||||||
|
* \brief [ETM] Charge utile commune de \c GetPhasePowerLimit et \c PhasePowerLimitChanged :
|
||||||
|
* la limite, la marge de sécurité et le seuil effectif qui en résulte.
|
||||||
|
* \return Le schéma (\c phasePowerLimitSchema) ou les valeurs courantes (\c phasePowerLimitPayload).
|
||||||
|
*/
|
||||||
|
static QVariantMap phasePowerLimitSchema();
|
||||||
|
//! \brief [ETM] Valeurs courantes, dans la forme de \c phasePowerLimitSchema(). \return La charge utile.
|
||||||
|
QVariantMap phasePowerLimitPayload() const;
|
||||||
|
|
||||||
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
|
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
|
||||||
//! et \c OptimizerStatusChanged. \return Le schéma des champs.
|
//! et \c OptimizerStatusChanged. \return Le schéma des champs.
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||||
|
|
||||||
#include "smartchargingmanager.h"
|
#include "smartchargingmanager.h"
|
||||||
|
#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal()
|
||||||
#include "evcharger.h"
|
#include "evcharger.h"
|
||||||
#include "rootmeter.h"
|
#include "rootmeter.h"
|
||||||
#include "energysettings.h"
|
#include "energysettings.h"
|
||||||
@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa
|
|||||||
{
|
{
|
||||||
EnergySettings settings;
|
EnergySettings settings;
|
||||||
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
|
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
|
||||||
|
// [ETM] Marge de sécurité (2026-10-27) : absente du réglage, elle vaut 10 % ; hors bornes (un
|
||||||
|
// fichier édité à la main), elle reprend 10 % plutôt qu'une valeur qu'on refuserait par RPC.
|
||||||
|
m_phaseSafetyMarginPercent = settings.value("phaseSafetyMarginPercent", 10).toUInt();
|
||||||
|
if (m_phaseSafetyMarginPercent > PhaseSafetyMarginMaxPercent)
|
||||||
|
m_phaseSafetyMarginPercent = 10;
|
||||||
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
|
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
|
||||||
// 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique.
|
// 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique.
|
||||||
//
|
//
|
||||||
@ -186,6 +192,39 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SmartChargingManager::setPhaseSafetyMarginPercent(uint percent)
|
||||||
|
{
|
||||||
|
// [ETM] Hors bornes : refus, et l'état ne bouge pas.
|
||||||
|
if (percent > PhaseSafetyMarginMaxPercent)
|
||||||
|
return false;
|
||||||
|
if (m_phaseSafetyMarginPercent != percent) {
|
||||||
|
m_phaseSafetyMarginPercent = percent;
|
||||||
|
emit phaseSafetyMarginPercentChanged(m_phaseSafetyMarginPercent);
|
||||||
|
|
||||||
|
EnergySettings settings;
|
||||||
|
settings.setValue("phaseSafetyMarginPercent", m_phaseSafetyMarginPercent);
|
||||||
|
|
||||||
|
#ifndef ENERGY_SIMULATION
|
||||||
|
// Même raison que setPhasePowerLimit() : le seuil change, le cycle le prend tout de suite.
|
||||||
|
update(QDateTime::currentDateTime());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<QString, double> SmartChargingManager::phaseConsumptionW() const
|
||||||
|
{
|
||||||
|
if (!m_rootMeter)
|
||||||
|
return {};
|
||||||
|
// [ETM] Les phases relevées au niveau du total si elles sont en retard sur lui — voir
|
||||||
|
// tiragesCoherentsAvecLeTotal() : la phase la plus chargée porte au moins le tiers du total.
|
||||||
|
const QList<double> w = tiragesCoherentsAvecLeTotal({m_rootMeter->currentPowerPhaseA(),
|
||||||
|
m_rootMeter->currentPowerPhaseB(),
|
||||||
|
m_rootMeter->currentPowerPhaseC()},
|
||||||
|
m_rootMeter->currentPower());
|
||||||
|
return {{"A", w.at(0)}, {"B", w.at(1)}, {"C", w.at(2)}};
|
||||||
|
}
|
||||||
|
|
||||||
double SmartChargingManager::acquisitionTolerance() const
|
double SmartChargingManager::acquisitionTolerance() const
|
||||||
{
|
{
|
||||||
return m_acquisitionTolerance;
|
return m_acquisitionTolerance;
|
||||||
@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
// [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
|
||||||
{"B", m_rootMeter->currentPowerPhaseB()},
|
// marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
|
||||||
{"C", m_rootMeter->currentPowerPhaseC()}};
|
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
||||||
|
|
||||||
bool limitExceeded = false;
|
bool limitExceeded = false;
|
||||||
|
|
||||||
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
||||||
|
|
||||||
// Note: for now we assume the phases are always connected this way:
|
// Note: for now we assume the phases are always connected this way:
|
||||||
// EV phase A -> house phase A
|
// EV phase A -> house phase A
|
||||||
@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
|||||||
|
|
||||||
foreach (const QString &phase, currentPhaseConsumption.keys()) {
|
foreach (const QString &phase, currentPhaseConsumption.keys()) {
|
||||||
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
|
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
|
||||||
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)";
|
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << effectivePhaseLimit() << "A)";
|
||||||
limitExceeded = true;
|
limitExceeded = true;
|
||||||
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
||||||
requiredThrottlePower[phase] = overshot;
|
requiredThrottlePower[phase] = overshot;
|
||||||
@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
|||||||
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
||||||
|
|
||||||
|
|
||||||
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
// [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
|
||||||
{"B", m_rootMeter->currentPowerPhaseB()},
|
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
||||||
{"C", m_rootMeter->currentPowerPhaseC()}};
|
|
||||||
|
|
||||||
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
||||||
|
|
||||||
QHash<QString, double> availablePhasePower = {{"A", 0},
|
QHash<QString, double> availablePhasePower = {{"A", 0},
|
||||||
{"B", 0},
|
{"B", 0},
|
||||||
@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
|||||||
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
||||||
{
|
{
|
||||||
// We don't assume we have the entire phase power for charging...
|
// We don't assume we have the entire phase power for charging...
|
||||||
double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230;
|
double housePhaseLimitPower = effectivePhaseLimit() * 230; // [ETM] marge de sécurité
|
||||||
|
|
||||||
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
||||||
|
|
||||||
@ -1320,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime)
|
|||||||
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
||||||
|
|
||||||
// Adjust down to not overload any phases.
|
// Adjust down to not overload any phases.
|
||||||
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit);
|
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
||||||
|
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit)
|
||||||
|
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
|
||||||
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
|
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
|
||||||
|
|
||||||
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
|
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
|
||||||
@ -1402,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime)
|
|||||||
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
|
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
|
||||||
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
|
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
|
||||||
|
|
||||||
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit);
|
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
||||||
|
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit)
|
||||||
|
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
|
||||||
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
|
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
|
||||||
|
|
||||||
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection
|
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
|
|||||||
horsService.observerMinute(huitJours.first());
|
horsService.observerMinute(huitJours.first());
|
||||||
QVERIFY(!horsService.profil().calcule);
|
QVERIFY(!horsService.profil().calcule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestLoadModel::testPhaseDrawsAreNeverBelowTheTotal()
|
||||||
|
{
|
||||||
|
// Le cas relevé le 2026-10-27 : 29,1 A lus sur chaque phase pour 22 277 W au total. Le manque
|
||||||
|
// est réparti au prorata : trois phases égales, trois tiers du total.
|
||||||
|
QList<double> w = tiragesCoherentsAvecLeTotal({6700, 6700, 6700}, 22277);
|
||||||
|
for (double x : w)
|
||||||
|
QVERIFY(qAbs(x - 22277.0 / 3) < 0.01);
|
||||||
|
|
||||||
|
// Cohérentes, ou AU-DESSUS du total (lecture plus fraîche que le total) : rien ne bouge.
|
||||||
|
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 6000), (QList<double>{1000, 2000, 3000}));
|
||||||
|
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 5000), (QList<double>{1000, 2000, 3000}));
|
||||||
|
|
||||||
|
// Une phase qui EXPORTE n'est jamais relevée vers l'import, et ne se voit jamais abaissée :
|
||||||
|
// le manque va aux phases qui importent, au prorata de ce qu'elles lisent.
|
||||||
|
w = tiragesCoherentsAvecLeTotal({-1000, 1000, 3000}, 4000);
|
||||||
|
QCOMPARE(w.at(0), -1000.0);
|
||||||
|
QCOMPARE(w.at(1), 1250.0);
|
||||||
|
QCOMPARE(w.at(2), 3750.0);
|
||||||
|
|
||||||
|
// Aucune phase n'importe (compteur sans courants par phase : tout à 0) : le total se répartit à
|
||||||
|
// parts égales — la phase la plus chargée en porte au moins le tiers, c'est la seule garantie.
|
||||||
|
w = tiragesCoherentsAvecLeTotal({0, 0, 0}, 9000);
|
||||||
|
QCOMPARE(w, (QList<double>{3000, 3000, 3000}));
|
||||||
|
|
||||||
|
// En export, rien à relever.
|
||||||
|
QCOMPARE(tiragesCoherentsAvecLeTotal({-500, -500, -500}, -1500), (QList<double>{-500, -500, -500}));
|
||||||
|
QVERIFY(tiragesCoherentsAvecLeTotal({}, 1000).isEmpty());
|
||||||
|
}
|
||||||
|
|||||||
@ -108,6 +108,8 @@ private slots:
|
|||||||
void testStoredInstantsSurviveATimeZoneChange();
|
void testStoredInstantsSurviveATimeZoneChange();
|
||||||
void testReportingDayBoundaryIsPublishedNotDeducible();
|
void testReportingDayBoundaryIsPublishedNotDeducible();
|
||||||
void testDrawMarginComparesHeadroomsNotCaps();
|
void testDrawMarginComparesHeadroomsNotCaps();
|
||||||
|
//! Lot délestage triphasé — des phases en retard sur le total sont RELEVÉES, jamais abaissées.
|
||||||
|
void testPhaseDrawsAreNeverBelowTheTotal();
|
||||||
|
|
||||||
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
|
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
|
||||||
void testTheContentIsTheRevisionIdentity();
|
void testTheContentIsTheRevisionIdentity();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user