fix(motifs): BELOW_MIN_POWER là où le mécanisme connaît la cause précise

Audit demandé par Patrick : un motif qui décrit la cause générique là où le mécanisme
connaît la cause précise. Deux branches en portaient le défaut, et une troisième le
porte ailleurs.

Branche à paliers : un budget de 300 W devant un premier palier de 500 W publiait
SURPLUS_INSUFFICIENT. Ce n'est pas un manque de surplus, c'est une granularité.

Branche sg-ready : le code publié était SG_NORMAL — correct sur le fond, donc l'app n'a
jamais été trompée. C'est le RENDU FRANÇAIS qui disait « Surplus insuffisant (933 W) »
devant un état 3 à 1 500 W, et c'est l'installateur qui lit journalctl pendant une mise
en service qui l'était. Le paramètre state nomme désormais l'état hors d'atteinte :
dire « sous le plancher » sans dire lequel remplacerait une phrase vague par une autre.

La distinction conservée est celle que Patrick a posée : budget ≤ 0 laisse
SURPLUS_INSUFFICIENT / SG_NORMAL — il n'y a rien. Budget positif sous le premier palier
donne BELOW_MIN_POWER — il y en a, mais pas au bon format.

Troisième cas, non corrigé ici : EV_IDLE dit « pas de surplus » là où la borne a un
plancher de 6 A. Son site est le proxy amont, que 3g remplace ; l'y corriger
maintenant reviendrait à écrire deux fois la même règle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-26 11:02:50 +02:00
parent c94e78a194
commit f134048cfb
5 changed files with 94 additions and 14 deletions

View File

@ -352,6 +352,12 @@ un ensemble **discontinu**. C'est ce que fait le matériel réel — une borne n
règle, une charge de rang 1 au plancher élevé retiendrait en otage un budget qu'elle ne peut
pas utiliser, et tout le waterfall en aval resterait à sec pendant qu'elle-même ne démarre pas.
> **« Il n'y a pas assez » contre « il y en a, mais pas au bon format ».** C'est toute la
> distinction que le motif `BELOW_MIN_POWER` porte, et elle décide du geste :
> `SURPLUS_INSUFFICIENT` et `SG_NORMAL` disent qu'il n'y a **rien** (budget ≤ 0) — on attend le
> soleil. `BELOW_MIN_POWER` dit qu'il y a du budget mais qu'**aucun palier ne sait le prendre**
> — on relit une fiche technique.
> **Le cas qui rend la règle nécessaire** : 2 kW de surplus, deux bornes à 1,38 kW de plancher
> (6 A monophasé). Réparties également, 1 kW chacune, **aucune ne démarre et les 2 kW partent au
> réseau**. En cascade, la première prend 1,38 kW et charge ; la seconde reçoit le reliquat,
@ -513,11 +519,11 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
| `LOCK_MIN_OFF_CAPPED` | `appliedW`, `budgetW`, `requestedW` | Le verrou `minOff` plafonne sans interdire |
| `SURPLUS_SETPOINT` | `budgetW`, `setpointW`, `stepped` | Consigne servie par le surplus ; `stepped` = arrondie sur un palier déclaré |
| `SURPLUS_INSUFFICIENT` | `budgetW` | Le budget ne payait aucun palier — **aucun verrou n'a mordu** |
| `BELOW_MIN_POWER` | `budgetW`, `minPowerW` | Budget **sous le plancher** de cette charge : 0 W, et le budget passe à la suivante |
| `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 |
| `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) |
| `SG_NORMAL` | `budgetW` | PAC en état 2 (normal, mains off) |
| `SG_NORMAL` | `budgetW` | PAC en état 2 : **aucun surplus à allouer** (budget ≤ 0). Un budget positif mais sous l'état 3 donne `BELOW_MIN_POWER`, pas celui-ci |
| `DEGRADED_L2` | — | Consigne de repli du watchdog L2 (compteur muet) |
| `SAFE_STATE_RELAY` | — | État sûr avant retrait : tous relais ouverts (ECS-413) |
| `SAFE_STATE_SETPOINT` | — | État sûr avant retrait : consigne 0 W (ECS-413) |

View File

@ -1,3 +1,32 @@
powersync-energy-plugin-nymea (1.15.2+etm19) trixie; urgency=medium
* §12 suite — BELOW_MIN_POWER étendu aux deux branches qui connaissaient la cause précise
et publiaient la générique. La distinction est « il n'y a pas assez » contre « il y en a,
mais pas au bon format », et elle décide du geste : attendre le soleil, ou relire une
fiche technique.
* Branche à paliers (relay-router, etmvariableload fixed) : un budget de 300 W devant un
premier palier de 500 W n'est pas un manque de surplus, c'est une granularité. Elle
publiait SURPLUS_INSUFFICIENT ; elle publie désormais BELOW_MIN_POWER avec le palier
hors d'atteinte.
* Branche sg-ready : elle publiait SG_NORMAL — correct sur le fond, mais son rendu français
disait « Surplus insuffisant (933 W) » devant un état 3 à 1 500 W. Le code publié n'a
jamais trompé l'app ; c'est l'installateur qui lit journalctl pendant une mise en service
qui l'était. Un budget positif sous l'état 3 donne maintenant BELOW_MIN_POWER, avec le
paramètre state qui NOMME l'état hors d'atteinte : dire « sous le plancher » sans dire
lequel remplacerait une phrase vague par une autre.
* SG_NORMAL ne sort donc plus que pour un budget nul ou négatif, et sa phrase devient
« Aucun surplus à allouer » — vraie dans ce seul cas, là où « surplus insuffisant »
couvrait aussi, et à tort, un budget de 933 W.
* SURPLUS_INSUFFICIENT est conservé tel quel pour budget ≤ 0 : il reste juste quand il n'y a
réellement rien.
* ⚠️ CHANGEMENT DE CONTRAT — BELOW_MIN_POWER est un code nouveau, et son paramètre state est
optionnel. Un client sans clé de rendu affichera son repli.
* Le cas EV porte le même défaut (EV_IDLE dit « pas de surplus » là où la borne a un plancher
de 6 A), mais son site est le proxy amont, que la transplantation 3g remplace. Corrigé là
plutôt qu'ici, sans quoi on écrirait deux fois la même règle.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 14:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm18) trixie; urgency=medium
* Lot E §12 — le plancher de modulation. Le domaine d'une consigne cesse d'être

View File

@ -202,7 +202,8 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
const bool dynamic = levels.isEmpty();
double setpointW;
bool sousPlancher = false;
bool sousPlancher = false; //!< Budget > 0 mais sous le premier palier atteignable.
double plancherW = 0; //!< Ce palier, précisément — le motif doit le NOMMER.
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;
@ -218,6 +219,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
// servir la première à fond en fait charger une.
if (lc.declared.minPowerW > 0 && setpointW > 0 && setpointW < lc.declared.minPowerW) {
setpointW = 0;
plancherW = lc.declared.minPowerW;
sousPlancher = true;
}
} else {
@ -229,6 +231,19 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
else break;
}
setpointW = chosen;
// §12 — même distinction qu'en dynamic, et le plancher y est DÉRIVÉ : levels[0] vaut
// toujours 0, donc le premier palier atteignable est levels[1].
//
// Ce qui se joue ici est la différence entre « il n'y a pas assez » et « il y en a,
// mais pas au bon format ». Un budget de 300 W devant un premier palier de 500 W
// n'est pas un manque de surplus : c'est une granularité. Les deux phrases envoient
// l'installateur vers des gestes opposés — attendre le soleil, ou relire son câblage.
const int premier = levels.size() > 1 ? levels.at(1) : 0;
if (premier > 0 && budgetW > 0 && chosen == 0) {
plancherW = premier;
sousPlancher = true;
}
}
// ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ.
@ -312,8 +327,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
// §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la
// seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique.
la.reason = { DecisionCode::BelowMinPower,
{{"budgetW", qRound(budgetW)},
{"minPowerW", qRound(lc.declared.minPowerW)}} };
{{"budgetW", qRound(budgetW)}, {"minPowerW", qRound(plancherW)}} };
else
la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} };
@ -379,7 +393,17 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
else if (bestState == 3)
la.reason = { DecisionCode::SgRecommended,
{{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} };
else if (budgetW > 0 && p3 > 0)
// §12 — il y a du budget, mais aucun état ne sait le prendre : une PAC SG-Ready n'a
// pas d'« état 3 à moitié ». Le motif NOMME l'état hors d'atteinte et sa puissance
// estimée — dire « minPowerW » sans dire lequel remplacerait une phrase vague par
// une autre.
la.reason = { DecisionCode::BelowMinPower,
{{"budgetW", qRound(budgetW)}, {"minPowerW", qRound(p3)},
{"state", 3}} };
else
// Budget nul ou négatif : il n'y a réellement RIEN à allouer. « Surplus insuffisant »
// est alors la vérité, et c'est le seul cas où elle l'est.
la.reason = { DecisionCode::SgNormal, {{"budgetW", qRound(budgetW)}} };
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).

View File

@ -66,10 +66,18 @@ QString renderFr(const DecisionReason &reason, const QString &label)
.arg(w("budgetW")).arg(label);
// §12 — la phrase nomme le PLANCHER, pas le manque de surplus : le budget peut être
// confortable et rester sous le minimum que ce matériel accepte.
if (c == QLatin1String(DecisionCode::BelowMinPower))
return QStringLiteral("Budget %1 W sous le plancher de %2 (%3 W) — 0 W, "
if (c == QLatin1String(DecisionCode::BelowMinPower)) {
// Quand le mécanisme a des ÉTATS, dire lequel était hors d'atteinte : « sous le
// plancher » sans nommer le palier remplacerait une phrase vague par une autre.
if (p.contains(QStringLiteral("state")))
return QStringLiteral("Budget %1 W sous l'état %2 de %3 (%4 W estimés) — état 2, "
"le budget passe à la charge suivante")
.arg(w("budgetW")).arg(p.value(QStringLiteral("state")).toInt())
.arg(label).arg(w("minPowerW"));
return QStringLiteral("Budget %1 W sous le premier palier de %2 (%3 W) — 0 W, "
"le budget passe à la charge suivante")
.arg(w("budgetW")).arg(label).arg(w("minPowerW"));
}
// --- SG-Ready (PAC) -----------------------------------------------------
if (c == QLatin1String(DecisionCode::LockMinStateHold))
@ -82,7 +90,11 @@ QString renderFr(const DecisionReason &reason, const QString &label)
return QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)")
.arg(w("budgetW")).arg(label).arg(w("estimatedW"));
if (c == QLatin1String(DecisionCode::SgNormal))
return QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
// §12 — depuis que BELOW_MIN_POWER porte le cas « budget présent mais sous l'état 3 »,
// celui-ci ne sort plus QUE pour un budget nul ou négatif. La phrase peut donc dire
// « aucun surplus » sans mentir, là où « surplus insuffisant » couvrait aussi, et à
// tort, un budget de 933 W devant un état à 1 500 W.
return QStringLiteral("Aucun surplus à allouer (%1 W) — %2 en normal (état 2, non pilotée)")
.arg(w("budgetW")).arg(label);
// --- Sécurité et cycle de vie ------------------------------------------

View File

@ -62,13 +62,22 @@ constexpr const char *SurplusSetpoint = "SURPLUS_SETPOINT";
//! params: \c budgetW — le budget ne payait aucun palier ; aucun verrou n'a mordu.
constexpr const char *SurplusInsufficient = "SURPLUS_INSUFFICIENT";
/*!
* \brief params: \c budgetW, \c minPowerW — le budget est sous le PLANCHER de la charge.
* \brief params: \c budgetW, \c minPowerW, \c o:state — le budget est sous le PLANCHER.
*
* §12 / LM-1203. Distinct de \c SURPLUS_INSUFFICIENT, qui dit « le budget ne payait aucun
* palier ». Ici le budget est peut-être confortable dans l'absolu — il est simplement sous le
* minimum que CE matériel accepte, et la charge a donc reçu 0 pendant que le budget partait à
* la suivante. Les confondre enverrait diagnostiquer un manque de soleil là où il faut lire
* une fiche technique (ECS-309 : le motif nomme le mécanisme qui a produit l'issue).
* §12 / LM-1203. La distinction qu'il porte est **« il n'y a pas assez » contre « il y en a,
* mais pas au bon format »** :
*
* | situation | motif |
* |---|---|
* | budget ≤ 0 | \c SURPLUS_INSUFFICIENT / \c SG_NORMAL — il n'y a rien |
* | 0 < budget < premier palier atteignable | **\c BELOW_MIN_POWER** — il y a, mais pas au bon format |
*
* Les confondre envoie diagnostiquer un manque de soleil là où il faut lire une fiche
* technique (ECS-309 : le motif nomme le mécanisme qui a produit l'issue).
*
* \note \c state n'est présent que pour les mécanismes à ÉTATS (sg-ready), où il nomme
* l'état hors d'atteinte. Dire « sous le plancher » sans dire lequel remplacerait une
* phrase vague par une autre.
*/
constexpr const char *BelowMinPower = "BELOW_MIN_POWER";