diff --git a/INTERFACE.md b/INTERFACE.md index b071e0e..7f53fdc 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -577,6 +577,7 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa | `SURPLUS_INSUFFICIENT` | `budgetW` | Le budget ne payait aucun palier — **aucun verrou n'a mordu** | | `BATTERY_RESERVE` | `socPercent`, `reservePercent`, `withheldW` | Le surplus **existe** mais la réserve de stockage l'interdit : **aucune** charge n'est servie | | `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `o:state` | Budget **sous le premier palier atteignable** : 0 W, et le budget passe à la suivante. `state` nommé pour les mécanismes à états | +| `PHASE_LIMIT` *(3g-2)* | `limitW`, `requiredW` | C'est la **limite de phase** qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil | | `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) | | `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) | | `SG_RECOMMENDED` | `budgetW`, `estimatedW` | PAC en état 3 (recommandation) | diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 0febc8a..6469ad4 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -74,9 +74,29 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const m_targetPhases = 1; ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases); - ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; ctx.declared.phases = static_cast(m_targetPhases); + // --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE ------------------- + // + // L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne + // traverse plus. Sans lui, le waterfall allouerait une puissance que le branchement ne + // supporte pas, et la seule chose qui rattraperait serait verifyOverloadProtection() + // APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme + // n'importe quelle borne d'adaptateur (règle 4). + ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; + const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); + if (plafondPhaseW >= 0) { + // …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot + // pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition + // des rôles qu'elle porte : la limite de phase RABAISSE la borne à son minimum, elle + // ne la coupe pas. Couper est le geste de la couche L4 — verifyOverloadProtection(), + // qui tourne en temps réel sur powerBalanceChanged et garde le dernier mot après ce + // dispatch. Écrêter jusqu'à 0 ici ferait décider la coupure par la stratégie, à + // contre-emploi du modèle de sécurité (AGENTS §MODÈLE DE SÉCURITÉ). + ctx.declared.maxPowerW = qMin(ctx.declared.maxPowerW, + qMax(plafondPhaseW, ctx.declared.minPowerW)); + } + ctx.telemetry.currentPowerW = m_charger->currentPower(); ctx.telemetry.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 8ca2b0e..066bb49 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -435,6 +435,37 @@ double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const return minA * acquisitionTolerance() * 230.0 * phaseCount; } +double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const +{ + if (!ev || phaseCount == 0) + return -1; + + // 0 = non réglé : la protection de surcharge est désactivée (contrat amont de + // SetPhasePowerLimit). Rien à écrêter, et surtout rien à annoncer — un plafond publié + // ici serait une limite inventée. + const uint limite = phasePowerLimit(); + if (limite == 0) + return -1; + + RootMeter *meter = internalRootMeter(); + if (!meter) + return -1; + + // Place LIBRE par phase, telle que la calcule le proxy — même fonction, même masque de + // phases, et surtout SANS rendre à la borne ce qu'elle tire déjà. + // + // Ce dernier point est délibéré et il est de sécurité. Rendre sa propre consommation à une + // borne serait plus juste en régime établi, mais ferait disparaître la réponse à la + // surcharge : sur une phase en dépassement, la place libre est NÉGATIVE, et c'est ce signe + // qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle + // consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement. + // (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.) + const double allowanceA = meter->calculateAllowanceAmpere( + const_cast(this)->phasesForCount(phaseCount), limite); + + return qMax(0.0, allowanceA) * 230.0 * phaseCount; +} + double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const { if (!ev) diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index f24e004..5932364 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -272,6 +272,29 @@ public: */ double evFloorW(EvCharger *ev, uint phaseCount) const; + /*! + * \brief [3g-2] Puissance maximale (W) que la protection de surcharge laisse à cette borne. + * + * \param ev Borne. \param phaseCount Nombre de phases VISÉ. + * \return Plafond en watts, ou **−1 si aucune limite** — \c phasePowerLimit à 0 signifie + * « non réglé » (contrat amont), et la protection est alors désactivée. + * + * \par Pourquoi cette méthode existe + * L'écrêtage par la limite de phase vivait dans \c planSurplusCharging() + * (\c calculateAllowanceAmpere, `qMin(newRawValue, allowance)`). En transplantant la + * décision de surplus dans le waterfall, 3g-1 a laissé cet écrêtage derrière lui — sans + * conséquence tant que rien n'était exécuté, mais 3g-2 exécute. Sans ce plafond, le + * waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule + * chose qui rattraperait serait \c verifyOverloadProtection() **après coup**. + * + * \note Le plafond est un **plafond de charge**, pas une marge : l'allowance du compteur + * est la place LIBRE, à laquelle on rend ce que la borne tire déjà — sans quoi une borne + * en charge se verrait interdire ce qu'elle consomme déjà. + * \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet + * instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée. + */ + double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const; + signals: /*! * \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 948ee66..5f64388 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -248,6 +248,26 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, if (dynamic) { // Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré. const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW; + // 3g-2 — le PLAFOND est passé SOUS le plancher. Pour une charge de configuration + // c'est impossible (isValid() le refuse) ; pour une borne, le plafond du cycle est + // écrêté par la protection de surcharge, et il peut donc descendre sous ce que la + // borne exige. Aucune allocation ne la démarrera, et le dire « budget sous le + // plancher » enverrait chercher du soleil là où il faut regarder l'abonnement + // (règle 7-b). Le budget n'est pas consommé : il passe entier à la suivante. + if (lc.declared.minPowerW > 0 && maxW < lc.declared.minPowerW) { + LoadAction la; + la.loadId = lc.id; + la.kind = LoadAction::Setpoint; + la.funding = LoadAction::Surplus; + la.chargingEnabled = false; + la.powerW = 0; + la.estimatedPowerW = 0; + la.reason = { DecisionCode::PhaseLimit, + {{"limitW", qRound(maxW)}, {"requiredW", qRound(lc.declared.minPowerW)}} }; + remainingSurplusW = budgetW; // rien d'engagé : le recrédit repart intact + return la; + } + setpointW = qBound(0.0, budgetW, maxW); // §12 / LM-1203 — le domaine n'est pas [0, max] mais {0} ∪ [min, max]. Sous le diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index a8f2a47..6bea4d4 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -89,6 +89,13 @@ QString renderFr(const DecisionReason &reason, const QString &label) .arg(w("budgetW")).arg(label).arg(w("minPowerW")); } + // 3g-2 — la limite de phase, pas le budget. Deux gestes opposés : revoir l'abonnement ou + // délester la maison, contre attendre le soleil. + if (c == QLatin1String(DecisionCode::PhaseLimit)) + return QStringLiteral("Limite de phase : %1 W disponibles pour %2, sous les %3 W qu'elle " + "exige — aucun démarrage possible ce cycle") + .arg(w("limitW")).arg(label).arg(w("requiredW")); + // --- SG-Ready (PAC) ----------------------------------------------------- if (c == QLatin1String(DecisionCode::LockMinStateHold)) return QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)") diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index cd8eeae..964fba3 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -105,6 +105,19 @@ constexpr const char *BatteryReserve = "BATTERY_RESERVE"; * phrase vague par une autre. */ constexpr const char *BelowMinPower = "BELOW_MIN_POWER"; +/*! + * \brief params: \c limitW, \c requiredW — la LIMITE DE PHASE interdit, pas le budget. + * + * 3g-2. La protection de surcharge (L4, \c phasePowerLimit) borne ce qu'une borne peut tirer + * indépendamment de tout surplus. Quand cette borne tombe sous le plancher de la borne, aucune + * allocation ne peut la démarrer — et le dire « budget sous le plancher » enverrait chercher du + * soleil là où il faut regarder l'abonnement et les autres consommateurs de la maison + * (règle 7-b : le motif nomme le mécanisme qui a produit l'issue). + * + * \note Émis uniquement quand \c phasePowerLimit est réglé : à 0 la protection est désactivée + * (contrat amont) et aucun écrêtage n'a lieu, donc rien à annoncer. + */ +constexpr const char *PhaseLimit = "PHASE_LIMIT"; // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 849b9f3..f146312 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -124,6 +124,8 @@ signals: // ici, la doc appartient à l'amont. // 4. internalEvChargers(), internalChargingActions(), internalRootMeter(), // internalProcessInfo() — accesseurs ETM neufs, documentés ci-dessous. +// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont +// privées, documentés ci-dessous. protected slots: virtual void update(const QDateTime ¤tDateTime); // [ETM] virtual added void prepareInformation(const QDateTime ¤tDateTime); // [ETM] private → protected @@ -146,6 +148,15 @@ protected: uint bestPhaseCountFor(EvCharger *evCharger, double surplusAmpere) { return getBestPhaseCount(evCharger, surplusAmpere); } + /*! + * \brief [ETM 3g-2] Masque de phases correspondant à un nombre de phases. + * \note `private → protected`, même raison que \c bestPhaseCountFor : l'arbitre en a + * besoin pour interroger \c RootMeter::calculateAllowanceAmpere() avec le MÊME masque + * que le proxy. Redériver le masque dans l'arbitre en ferait deux versions. + */ + Electricity::Phases phasesForCount(uint phaseCount) + { return getAscendingPhasesForCount(phaseCount); } + // [ETM] Read-only state accessors — inline, no copies, no logic. /*! diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 1eccd17..70eb434 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -4052,6 +4052,13 @@ void Simulation::testDecisionReasonRendering() QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, " "le budget passe à la charge suivante") }, + // 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste + // attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement. + { { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} }, + QStringLiteral("Borne du garage"), + QStringLiteral("Limite de phase : 1200 W disponibles pour Borne du garage, sous les " + "4140 W qu'elle exige — aucun démarrage possible ce cycle") }, + // --- Sécurité et cycle de vie --- { { DecisionCode::DegradedL2 }, QString(), QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") }, @@ -4084,7 +4091,9 @@ void Simulation::testDecisionReasonRendering() DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, // 3g-1 : ajoutés au catalogue sans être ajoutés ICI — la clôture avait dérivé, donc // la garantie « tout code a une ligne de rendu éprouvée » ne tenait plus. - DecisionCode::BatteryReserve, DecisionCode::BelowMinPower }; + DecisionCode::BatteryReserve, DecisionCode::BelowMinPower, + // 3g-2 + DecisionCode::PhaseLimit }; for (const QString &code : catalogue) QVERIFY2(codesCouverts.contains(code), qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code)));