fix(3g-1): l'écrêtage par la limite de phase avait été perdu
Troisième défaut latent de 3g-1, et le seul des trois qui touche la sécurité.
Corrigé à part du lot 3g-2, pour pouvoir être relu ou rétroporté seul.
L'écrêtage d'une consigne EV par la limite de phase vivait dans
planSurplusCharging() : `newValue = qMin(newRawValue, allowanceOnRootMeter)`.
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
la décision du waterfall est censée l'être, et alors plus rien ne borne
l'allocation avant qu'elle ne parte au matériel. Le seul filet restant serait
verifyOverloadProtection(), APRÈS coup.
Le plafond redevient donc une borne DÉCLARÉE de l'adaptateur
(LoadContext.declared.maxPowerW), ce qui le place là où la règle 4 d'AGENTS le
veut : le waterfall ne peut pas allouer au-delà, au lieu d'être rattrapé.
Deux points de sécurité tenus explicitement :
- Le plafond ne descend 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, qui tourne en temps réel sur powerBalanceChanged et
garde le dernier mot après le dispatch. Écrêter jusqu'à 0 ici ferait
décider la coupure par la stratégie.
- L'allowance est la place LIBRE, sans rendre à la borne ce qu'elle tire
déjà. Le retour 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.
Nouveau motif PHASE_LIMIT {limitW, requiredW} : quand la place libre tombe
sous ce que la borne exige, c'est la limite de phase qui interdit, pas le
budget. Dire « budget sous le plancher » enverrait attendre le soleil là où
il faut regarder l'abonnement et les autres consommateurs — deux causes, deux
gestes (règle 7-b).
Suite simulation `run` 16/16, testOverloadProtectionEcoMode vert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
This commit is contained in:
parent
4f987e5b1d
commit
e5e20db9fd
@ -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) |
|
||||
|
||||
@ -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<int>(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();
|
||||
|
||||
@ -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<EnergyArbitrator *>(this)->phasesForCount(phaseCount), limite);
|
||||
|
||||
return qMax(0.0, allowanceA) * 230.0 * phaseCount;
|
||||
}
|
||||
|
||||
double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const
|
||||
{
|
||||
if (!ev)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
/*!
|
||||
|
||||
@ -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)));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user