diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 2b96929..aa804d2 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1068,6 +1068,11 @@ DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const {QStringLiteral("cut"), t.cut}, {QStringLiteral("overshootW"), qRound(t.overshootW)}, {QStringLiteral("limitA"), phasePowerLimit()}}; + // Lot délestage triphasé — le SEUIL où la protection agit, sous la limite de la marge de + // sécurité. Absent quand la marge est nulle : seuil et limite se confondent alors, et la + // charge utile reste celle d'avant ce lot. + if (phaseSafetyMarginPercent() > 0) + params.insert(QStringLiteral("thresholdA"), qRound(effectivePhaseLimit() * 10) / 10.0); // Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée, // aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP. if (!t.phase.isEmpty()) @@ -1174,11 +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. QString phase; double depassementW = 0; - if (RootMeter *compteur = internalRootMeter()) { - const double limiteW = 230.0 * phasePowerLimit(); - const QHash parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()}, - {QStringLiteral("B"), compteur->currentPowerPhaseB()}, - {QStringLiteral("C"), compteur->currentPowerPhaseC()}}; + if (internalRootMeter()) { + // Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total, + // limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé. + const double limiteW = 230.0 * effectivePhaseLimit(); + const QHash parPhase = phaseConsumptionW(); if (evCharger->phaseCount() == 1) { phase = chargerPhaseFor(evCharger); depassementW = qMax(0.0, parPhase.value(phase) - limiteW); @@ -1378,7 +1383,10 @@ QList EnergyArbitrator::drawCaps() const if (limiteA > 0) { DrawCap disjoncteur; disjoncteur.source = DrawCapSource::Breaker; - disjoncteur.perPhaseW = limiteA * 230.0; + // Lot délestage triphasé (décision du 2026-10-27) — le plafond est le SEUIL, la limite + // moins la marge de sécurité : 27 A pour 30 A à 10 %. La cascade alloue sous lui, et la + // protection agit au-dessus (SmartChargingManager::verifyOverloadProtection()). + disjoncteur.perPhaseW = effectivePhaseLimit() * 230.0; plafonds.append(disjoncteur); } @@ -1401,17 +1409,22 @@ QList EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi const QList toutes = { Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC }; - for (Electricity::Phase p : toutes) { - if (masque != Electricity::PhaseNone && !masque.testFlag(p)) + // Tirage de chaque phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt + // qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui + // diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en + // ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend + // l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures. + QList lus; + for (Electricity::Phase p : toutes) + lus.append((limiteA - meter->calculateAllowanceAmpere(p, limiteA)) * 230.0); + // Lot délestage triphasé — les TROIS phases d'abord, rendues cohérentes avec le total, et le + // masque ensuite : des phases en retard sur le total ont retardé la protection de 1 min 45 + // (relevé du 2026-10-27). Voir tiragesCoherentsAvecLeTotal(), qui ne fait que relever. + lus = tiragesCoherentsAvecLeTotal(lus, meter->currentPower()); + for (int i = 0; i < toutes.size(); ++i) { + if (masque != Electricity::PhaseNone && !masque.testFlag(toutes.at(i))) continue; - - // Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt - // qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui - // diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en - // ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend - // l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures. - const double margeA = meter->calculateAllowanceAmpere(p, limiteA); - tirages.append((limiteA - margeA) * 230.0); + tirages.append(lus.at(i)); } return tirages; } @@ -3647,8 +3660,11 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex // producteur ? » de l'apprentissage (observerPourApprentissage()). if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty()) fil.pvCurrentW = qAbs(m_em->currentPowerProduction()); + // Lot délestage triphasé — l'optimiseur reçoit le SEUIL que le moteur fera respecter (limite + // moins la marge de sécurité), pas la limite : un plan bâti sur la limite serait écrêté à + // chaque cycle par la garde de soutirage. if (phasePowerLimit() > 0) - fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())}; + fil.phaseLimitA = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()}; if (ctx.battery.present) fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0); if (m_loadConfigStore) diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 7bbd312..4c1faf0 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -126,7 +126,16 @@ QString renderFr(const DecisionReason &reason, const QString &label) // La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne // reçoit, puis pourquoi : le branchement, la phase, sa limite. if (c == QLatin1String(DecisionCode::OverloadThrottled)) { - const QString pourquoi = p.contains(QStringLiteral("phase")) + // Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite : + // le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu. + const QString ou = p.contains(QStringLiteral("phase")) + ? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString()) + : QString(); + const QString pourquoi = p.contains(QStringLiteral("thresholdA")) + ? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)") + .arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble())) + .arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString()) + : p.contains(QStringLiteral("phase")) ? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)") .arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA")) : QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA")); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index edcfb76..85aea17 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -183,7 +183,9 @@ constexpr const char *DrawCap = "DRAW_CAP"; * \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue), * \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque * \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur - * la phase la plus chargée, sans qu'une phase soit à elle seule coupable). + * la phase la plus chargée, sans qu'une phase soit à elle seule coupable), \c thresholdA (le seuil + * où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent** + * quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui). * * \par Ce qu'il publie, et ce qu'il ne décide pas * \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi diff --git a/energyplugin/etm/types/drawcap.cpp b/energyplugin/etm/types/drawcap.cpp index 17db774..54abcf4 100644 --- a/energyplugin/etm/types/drawcap.cpp +++ b/energyplugin/etm/types/drawcap.cpp @@ -85,3 +85,22 @@ QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW params.insert(QStringLiteral("minOnBypassed"), true); return params; } + +QList tiragesCoherentsAvecLeTotal(const QList &parPhaseW, double totalW) +{ + QList 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; +} diff --git a/energyplugin/etm/types/drawcap.h b/energyplugin/etm/types/drawcap.h index a9232af..b53ad32 100644 --- a/energyplugin/etm/types/drawcap.h +++ b/energyplugin/etm/types/drawcap.h @@ -111,6 +111,29 @@ DrawMargin resolveDrawMargin(const QList &caps, //! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed". QString drawCapSourceCode(DrawCapSource s); +/*! + * \brief Tirages par phase rendus COHÉRENTS avec le total du compteur — jamais abaissés. + * + * \param parPhaseW Tirage lu sur chaque phase, en watts (import > 0, export < 0). + * \param totalW Puissance totale du même compteur, en watts (import > 0). + * \return Les mêmes tirages si leur somme atteint le total. 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 tiragesCoherentsAvecLeTotal(const QList &parPhaseW, double totalW); + /*! * \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit. * diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index e32adb8..7c74725 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -81,13 +81,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // Table figée par TestCharging::testNymeaEnergyPermissionScopes. params.clear(); returns.clear(); - description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything."; - returns.insert("phasePowerLimit", enumValueName(Uint)); + description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. " + "[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): " + "the engine allocates under the effective limit and its overload protection acts above it."; + returns = phasePowerLimitSchema(); registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone); params.clear(); returns.clear(); - description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled."; + description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled. " + "[ETM] Optional phaseSafetyMarginPercent, 0 to 25: absent, the margin is kept; out of range, " + "EnergyErrorInvalidParameter and nothing changes."; params.insert("phasePowerLimit", enumValueName(Uint)); + params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint)); returns.insert("energyError", enumRef()); registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin); @@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // Notifications params.clear(); - description = "Emitted whenever the phase power limit configuration changes."; - params.insert("phasePowerLimit", enumValueName(Uint)); + description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin."; + params = phasePowerLimitSchema(); registerNotification("PhasePowerLimitChanged", description, params); - connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){ - emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}}); + // [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs. + connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){ + emit PhasePowerLimitChanged(phasePowerLimitPayload()); + }); + connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){ + emit PhasePowerLimitChanged(phasePowerLimitPayload()); }); params.clear(); @@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString & JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms) { Q_UNUSED(params) - QVariantMap returns; - returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit()); - return createReply(returns); + return createReply(phasePowerLimitPayload()); +} + +QVariantMap NymeaEnergyJsonHandler::phasePowerLimitSchema() +{ + // [ETM] UNE déclaration pour GetPhasePowerLimit et PhasePowerLimitChanged (règle 11). + QVariantMap schema; + schema.insert("phasePowerLimit", enumValueName(Uint)); + schema.insert("phaseSafetyMarginPercent", enumValueName(Uint)); + schema.insert("effectivePhaseLimit", enumValueName(Double)); + return schema; +} + +QVariantMap NymeaEnergyJsonHandler::phasePowerLimitPayload() const +{ + return {{"phasePowerLimit", m_smartChargingManager->phasePowerLimit()}, + {"phaseSafetyMarginPercent", m_smartChargingManager->phaseSafetyMarginPercent()}, + {"effectivePhaseLimit", m_smartChargingManager->effectivePhaseLimit()}}; } JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms) { int phasePowerLimit = params.value("phasePowerLimit").toUInt(); + // [ETM] La marge est jugée AVANT toute écriture : refusée, rien ne change — pas même la limite. + if (params.contains("phaseSafetyMarginPercent") + && params.value("phaseSafetyMarginPercent").toUInt() > SmartChargingManager::PhaseSafetyMarginMaxPercent) { + QVariantMap returns; + returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)); + return createReply(returns); + } + if (params.contains("phaseSafetyMarginPercent")) + m_smartChargingManager->setPhaseSafetyMarginPercent(params.value("phaseSafetyMarginPercent").toUInt()); m_smartChargingManager->setPhasePowerLimit(phasePowerLimit); QVariantMap returns; returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError)); diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index f5b8748..208c50f 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -239,6 +239,14 @@ private: * celui qu'on ne teste pas tous les jours. */ static QVariantMap loadTelemetrySchema(); + /*! + * \brief [ETM] Charge utile commune de \c GetPhasePowerLimit et \c PhasePowerLimitChanged : + * la limite, la marge de sécurité et le seuil effectif qui en résulte. + * \return Le schéma (\c phasePowerLimitSchema) ou les valeurs courantes (\c phasePowerLimitPayload). + */ + static QVariantMap phasePowerLimitSchema(); + //! \brief [ETM] Valeurs courantes, dans la forme de \c phasePowerLimitSchema(). \return La charge utile. + QVariantMap phasePowerLimitPayload() const; //! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus //! et \c OptimizerStatusChanged. \return Le schéma des champs. diff --git a/energyplugin/smartchargingmanager.cpp b/energyplugin/smartchargingmanager.cpp index 0730326..443967e 100644 --- a/energyplugin/smartchargingmanager.cpp +++ b/energyplugin/smartchargingmanager.cpp @@ -23,6 +23,7 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "smartchargingmanager.h" +#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal() #include "evcharger.h" #include "rootmeter.h" #include "energysettings.h" @@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa { EnergySettings settings; m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt(); + // [ETM] Marge de sécurité (2026-10-27) : absente du réglage, elle vaut 10 % ; hors bornes (un + // fichier édité à la main), elle reprend 10 % plutôt qu'une valeur qu'on refuserait par RPC. + m_phaseSafetyMarginPercent = settings.value("phaseSafetyMarginPercent", 10).toUInt(); + if (m_phaseSafetyMarginPercent > PhaseSafetyMarginMaxPercent) + m_phaseSafetyMarginPercent = 10; m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble(); // 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique. // @@ -186,6 +192,39 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit) } } +bool SmartChargingManager::setPhaseSafetyMarginPercent(uint percent) +{ + // [ETM] Hors bornes : refus, et l'état ne bouge pas. + if (percent > PhaseSafetyMarginMaxPercent) + return false; + if (m_phaseSafetyMarginPercent != percent) { + m_phaseSafetyMarginPercent = percent; + emit phaseSafetyMarginPercentChanged(m_phaseSafetyMarginPercent); + + EnergySettings settings; + settings.setValue("phaseSafetyMarginPercent", m_phaseSafetyMarginPercent); + +#ifndef ENERGY_SIMULATION + // Même raison que setPhasePowerLimit() : le seuil change, le cycle le prend tout de suite. + update(QDateTime::currentDateTime()); +#endif + } + return true; +} + +QHash 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 w = tiragesCoherentsAvecLeTotal({m_rootMeter->currentPowerPhaseA(), + m_rootMeter->currentPowerPhaseB(), + m_rootMeter->currentPowerPhaseC()}, + m_rootMeter->currentPower()); + return {{"A", w.at(0)}, {"B", w.at(1)}, {"C", w.at(2)}}; +} + double SmartChargingManager::acquisitionTolerance() const { return m_acquisitionTolerance; @@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate return; } - QHash currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, - {"B", m_rootMeter->currentPowerPhaseB()}, - {"C", m_rootMeter->currentPowerPhaseC()}}; + // [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la + // marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27. + QHash currentPhaseConsumption = phaseConsumptionW(); bool limitExceeded = false; - double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; + double consumptionLimitInWatt = 230 * effectivePhaseLimit(); // Note: for now we assume the phases are always connected this way: // EV phase A -> house phase A @@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate foreach (const QString &phase, currentPhaseConsumption.keys()) { if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) { - qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)"; + qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << effectivePhaseLimit() << "A)"; limitExceeded = true; double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt; requiredThrottlePower[phase] = overshot; @@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur << manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]"; - QHash currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, - {"B", m_rootMeter->currentPowerPhaseB()}, - {"C", m_rootMeter->currentPowerPhaseC()}}; + // [ETM] Mêmes lectures et même seuil que verifyOverloadProtection(). + QHash currentPhaseConsumption = phaseConsumptionW(); - double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; + double consumptionLimitInWatt = 230 * effectivePhaseLimit(); QHash availablePhasePower = {{"A", 0}, {"B", 0}, @@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime) { // 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 ---------------"; @@ -1320,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime) bool chargingEnabled = newRawValue >= minimumRequiredAmps; // Adjust down to not overload any phases. - double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit); + // [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite. + double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit) + - (m_phasePowerConsumptionLimit - effectivePhaseLimit()); double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere); uint newValueInt = newValue >= 0 ? qRound(newValue) : 0; @@ -1402,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime) double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount; qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase"; - double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit); + // [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite. + double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit) + - (m_phasePowerConsumptionLimit - effectivePhaseLimit()); qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]"; // Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 56805e8..3b81f60 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -64,6 +64,30 @@ public: uint phasePowerLimit() const; void setPhasePowerLimit(uint phasePowerLimit); + /*! + * \brief [ETM] Marge de sécurité sous la limite de phase, en pourcents (décision du 2026-10-27). + * \return 10 par défaut ; persistée avec la limite (clé \c phaseSafetyMarginPercent). + * \note Le moteur vise \c effectivePhaseLimit(), pas \c phasePowerLimit() : pour le plafond + * qu'il déclare et alloue, et pour le seuil où la protection de surcharge agit. + */ + uint phaseSafetyMarginPercent() const { return m_phaseSafetyMarginPercent; } + /*! + * \brief [ETM] Règle la marge de sécurité. + * \param percent Entre 0 et \c PhaseSafetyMarginMaxPercent inclus. + * \return Faux, sans rien changer, hors de ces bornes. + * \note 0 est admis : c'est le comportement d'avant la marge, et la bonne valeur pour une + * installation dont la limite saisie est déjà une limite réduite. + */ + bool setPhaseSafetyMarginPercent(uint percent); + //! [ETM] Borne haute de la marge : au-delà, la maison perd plus d'un quart de son branchement. + static constexpr uint PhaseSafetyMarginMaxPercent = 25; + /*! + * \brief [ETM] Le seuil effectif par phase, en ampères : limite × (1 − marge). + * \return 0 quand la limite n'est pas réglée (protection désactivée, contrat amont). + */ + double effectivePhaseLimit() const + { return m_phasePowerConsumptionLimit * (100.0 - m_phaseSafetyMarginPercent) / 100.0; } + double acquisitionTolerance() const; void setAcquisitionTolerance(double acquisitionTolerance); @@ -100,6 +124,7 @@ public: signals: void phasePowerLimitChanged(int phasePowerLimit); + void phaseSafetyMarginPercentChanged(uint percent); // [ETM] void acquisitionToleranceChanged(double acquisitionTolerance); void batteryLevelConsiderationChanged(double batteryLevelConsideration); void lockOnUnplugChanged(bool lockOnUnplug); @@ -114,7 +139,9 @@ signals: // [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/). // All changes below are visibility-only (private → protected / virtual added). -// Zero logic change. Revert by deleting this block and restoring private slots. +// Zero logic change — EXCEPT items 9 to 11 of the inventory (lot délestage triphasé, +// 2026-10-27), which change the overload protection on purpose and say so. +// Revert by deleting this block and restoring private slots. // // INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et // échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la @@ -143,6 +170,16 @@ signals: // 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() : // les bornes préparées au dernier cycle. Seul // lecteur : internalHasProcessInfo(). +// 10. phaseSafetyMarginPercent(), setPhaseSafetyMarginPercent(), effectivePhaseLimit(), +// PhaseSafetyMarginMaxPercent, phaseSafetyMarginPercentChanged() +// — la marge de sécurité (décision du 2026-10-27), +// documentée en tête de la classe. Elle CHANGE la +// logique amont, en connaissance de cause : le seuil +// de verifyOverloadProtection() et de sa reprise, la +// limite de prepareInformation() et les deux +// allowances du proxy (marques [ETM] dans le .cpp). +// 11. phaseConsumptionW() — lecture des phases cohérente avec le total, seule +// lecture de la protection et de sa reprise. // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() // — crochets ETM, virtuels, documentés ci-dessous. // Ce sont EUX qui garantissent « une charge, un @@ -323,6 +360,17 @@ protected: * 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée * 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 phaseConsumptionW() const; + bool internalHasProcessInfo(EvCharger *evCharger) const // [ETM] new { return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); } // [ETM] END @@ -365,6 +413,7 @@ private: QHash m_overloadProtectionActive; uint m_phasePowerConsumptionLimit = 25; + uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27 double m_acquisitionTolerance = 0.5; double m_batteryLevelConsideration = 0.9; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index dc21b93..d90aad1 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables() horsService.observerMinute(huitJours.first()); QVERIFY(!horsService.profil().calcule); } + +void TestLoadModel::testPhaseDrawsAreNeverBelowTheTotal() +{ + // Le cas relevé le 2026-10-27 : 29,1 A lus sur chaque phase pour 22 277 W au total. Le manque + // est réparti au prorata : trois phases égales, trois tiers du total. + QList 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{1000, 2000, 3000})); + QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 5000), (QList{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{3000, 3000, 3000})); + + // En export, rien à relever. + QCOMPARE(tiragesCoherentsAvecLeTotal({-500, -500, -500}, -1500), (QList{-500, -500, -500})); + QVERIFY(tiragesCoherentsAvecLeTotal({}, 1000).isEmpty()); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 51d14bb..c95e3d4 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -108,6 +108,8 @@ private slots: void testStoredInstantsSurviveATimeZoneChange(); void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); + //! Lot délestage triphasé — des phases en retard sur le total sont RELEVÉES, jamais abaissées. + void testPhaseDrawsAreNeverBelowTheTotal(); //! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide. void testTheContentIsTheRevisionIdentity(); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 6907d2f..7d236cf 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6896,8 +6896,10 @@ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock() QCOMPARE(p.value("shed").toBool(), true); QCOMPARE(p.value("source").toString(), QString("connection")); QCOMPARE(p.value("phase").toString(), QString("A")); - QCOMPARE(p.value("overshootW").toInt(), 1150); - QCOMPARE(p.value("marginW").toInt(), -1150); + // (30 − 22,5) × 230 : le dépassement se mesure au SEUIL, limite de 25 A moins la marge de + // 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é. 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"); QVERIFY(journal().isEmpty()); - // 1. RABAISSÉE — un four s'allume : 12 A de maison, 28 A sur la phase A pour 25. La - // protection rabaisse la borne du dépassement arrondi à l'ampère supérieur. + // 1. RABAISSÉE — un four s'allume : 9 A de maison, 25 A sur la phase A pour un seuil de + // 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); - cycle(12); + cycle(9); const int tenusA = consigneA(s.borne); QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); @@ -12724,15 +12728,16 @@ void Simulation::testOverloadProtectionReasonOnTheCharger() QCOMPARE(p.value("cut").toBool(), false); QCOMPARE(p.value("phase").toString(), QStringLiteral("A")); 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 // dit ce qu'elle laisse à la borne. QVERIFY(!entree().contains("levels")); QVERIFY(!decision().contains("level")); QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230); QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"), - QStringLiteral("Borne abaissée à %1 A : le branchement atteignait sa limite (phase A, " - "25 A), dépassement de 690 W").arg(tenusA)); + QStringLiteral("Borne abaissée à %1 A : le branchement atteignait son seuil de protection " + "(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. QVERIFY(!notifs.isEmpty()); QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap(); @@ -12764,8 +12769,9 @@ void Simulation::testOverloadProtectionReasonOnTheCharger() QCOMPARE(p.value("fromA").toInt(), 16); QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0); const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"); - QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait sa limite " - "(phase A, 25 A)")), qUtf8Printable(coupee)); + QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait son seuil de " + "protection (phase A, 22,5 A sous une limite de 25 A)")), + qUtf8Printable(coupee)); evts = journal(); QCOMPARE(evts.size(), 1); e = evts.first().toMap(); @@ -12868,10 +12874,11 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged() QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[] QVERIFY(!snap.contains("shedding")); - // 1. 28 A sur la phase A : la protection rabaisse la borne, deux fois dans l'appel (dans le - // cycle, puis sur powerBalanceChanged — le compteur n'a pas encore bougé). + // 1. 25 A sur la phase A, seuil à 22,5 A (marge de sécurité de 10 %) : la protection rabaisse + // 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); - cycle(28); + cycle(25); const int tenusA = consigneA(s.borne); QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); 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("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); 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")); // 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); 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). QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged); const QDateTime t2 = t0.addSecs(3610); @@ -13025,7 +13033,7 @@ void Simulation::testSheddingLogCoversEveryMechanism() for (const QVariantMap &ev : {ecs, pac}) { QCOMPARE(ev.value("source").toString(), QStringLiteral("connection")); 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)); QVERIFY(!ev.contains("endedAt")); } @@ -13561,13 +13569,19 @@ void Simulation::testPhaseSafetyMargin() cycle(-25); QCOMPARE(consigneA(b.borne), 16); cycle(12); // 12 + 16 = 28 A - QVERIFY2(consigneA(b.borne) < 16, qUtf8Printable(QString::number(consigneA(b.borne)))); - // Le geste est jugé sur le SEUIL : 28 − 27 = 1 A de trop, la borne perd 1 A, pas 0. - QCOMPARE(consigneA(b.borne), 15); + // Le geste de la protection, lu au journal : la cascade, elle, coupe aussi la borne faute de + // surplus, et la consigne finale ne dirait pas qui a agi. À 30 A, il n'y aurait aucun geste. + 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. + enableNotifications({"NymeaEnergy"}); QSignalSpy notif(m_mockTcpServer, &MockTcpServer::outgoingData); QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 15}}), QString("EnergyErrorNoError")); + if (notif.isEmpty()) + notif.wait(); g = lire(); QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 15u); QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 25.5);