test(mock): la tension par phase, et LM-1210-a enfin exercée

Le mock posait currentPhaseX = currentPowerPhaseX / 230 : les deux familles
d'états étaient cohérentes par construction, donc ni triphasée déséquilibrée
ni tension ≠ 230 V n'étaient fabricables. Les paramètres sont optionnels —
sans eux le comportement d'origine tient, et aucun scénario existant ne bouge.

LM-1210-a est enfin exercée : le choix de la pire phase faute de masque était
écrit, raisonné, et jamais vérifié. Phase B saturée à 30 A sous un plafond de
25 pendant que A et C exportent, et la maison exporte GLOBALEMENT — la marge
retenue est celle de B, négative, et non celle du total qui rassurerait.

Et l'ouverture a corrigé une ERREUR DE RAISONNEMENT, pas seulement comblé un
trou. Mon premier test attendait qu'une phase en export donne une marge
supérieure au plafond : c'est ce que le design affirmait. Il a échoué —
calculateAllowanceAmpere() part du plafond et ne fait que des qMin, donc la
marge y est écrêtée. Physiquement juste, mais la phrase était fausse. La
conclusion tenait, la raison non, et rien ne pouvait le révéler tant que le
déséquilibre était infabricable. Corrigée dans la spec et dans le code.

C'est la deuxième fois qu'ajouter de la diversité au mock trouve un défaut
plutôt que de seulement débloquer un test — après dryRelay, qui avait révélé
le déréférencement du contrôleur.

Et une note à côté de la garde du compteur : l'app dépend de `connected` pour
REFUSER D'AFFICHER, jamais pour COMPTER, et elle a raison. Si connected ment,
l'écran dit « hors ligne » à tort et rien n'est mal compté. Une comptabilité
n'a pas droit à cette tolérance — même donnée non fiable, deux usages, deux
verdicts. S'abstenir sur un doute est sûr, accumuler sur un doute ne l'est
pas. Quelqu'un lirait l'asymétrie comme une incohérence sans cette ligne.

Suite : simulation 64/64, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-03 07:50:56 +02:00
parent 9c5e3b146c
commit f3cddedee0
9 changed files with 185 additions and 12 deletions

View File

@ -1,3 +1,33 @@
powersync-energy-plugin-nymea (1.15.2+etm52) trixie; urgency=medium
* MOCK — tension et courant PAR PHASE réglables indépendamment des puissances. Le mock posait
`currentPhaseX = currentPowerPhaseX / 230` : les deux familles d'états étaient cohérentes PAR
CONSTRUCTION, si bien qu'aucun scénario ne pouvait produire une triphasée déséquilibrée ni
une tension ≠ 230 V. Paramètres OPTIONNELS : sans eux, le comportement d'origine est conservé
et aucun scénario existant ne bouge.
* LM-1210-a ENFIN EXERCÉE — le choix de la pire phase faute de masque était écrit, raisonné, et
jamais vérifié. Phase B saturée à 30 A sous un plafond de 25 pendant que A et C exportent, et
la maison exporte GLOBALEMENT : la marge retenue est celle de B, négative, et non celle du
total qui rassurerait à tort. Ce manque était nommé DEUX FOIS par deux chemins indépendants —
l'inventaire du mock et le balayage des contrôles — avant d'être comblé.
* ET L'OUVERTURE A CORRIGÉ UNE ERREUR DE RAISONNEMENT, pas seulement comblé un trou. Le premier
test attendait qu'une phase en export donne une marge SUPÉRIEURE au plafond — c'est ce que le
design affirmait. Il a échoué : `calculateAllowanceAmpere()` part du plafond et ne fait que
des `qMin`, donc la marge y est ÉCRÊTÉE. Physiquement juste, mais la phrase était fausse : la
conclusion tenait, la raison non. Corrigée dans la spec et dans le code.
* C'est la DEUXIÈME fois qu'ajouter de la diversité au mock trouve un défaut plutôt que de
seulement débloquer un test — après `dryRelay`, qui avait révélé le déréférencement du
contrôleur. La diversité du mock n'est pas un confort de test.
* Note écrite à côté de la garde du compteur : l'app dépend de `connected` pour REFUSER
D'AFFICHER, jamais pour COMPTER — et elle a raison. Si `connected` ment, l'écran dit « hors
ligne » à tort, rien n'est mal compté. Une comptabilité n'a pas droit à cette tolérance :
même donnée non fiable, deux usages, deux verdicts. S'abstenir sur un doute est sûr,
accumuler sur un doute ne l'est pas.
* Suite : simulation 64/64, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 03 Sep 2026 17:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm51) trixie; urgency=medium
* URGENT — CONTRADICTION SUPPRIMÉE entre `progressMeasurable` et `regime`. Le premier dit ce

View File

@ -49,17 +49,30 @@ mélangeait.
> était invisible tant qu'aucune classe ne la contredisait. **Le mock avait sa propre prémisse
> fausse**, protégée par son homogénéité.
**2. Un compteur par phase incohérent.** `currentPhaseX` et `currentPowerPhaseX` sont posés
ensemble par le mock, dans un rapport fixe de 230. Aucun scénario ne peut produire une tension
réelle ≠ 230 V, ni un compteur qui ne publierait qu'une des deux familles.
**2. ~~Un compteur par phase incohérent.~~ — OUVERT le 2026-09-03.** `voltagePhaseX` et
`currentPhaseX` sont désormais réglables **indépendamment** des puissances, par des paramètres
OPTIONNELS — sans eux, le comportement dérivé d'origine est conservé et aucun scénario existant
ne bouge.
> **Et l'ouverture a corrigé une erreur de RAISONNEMENT, pas seulement comblé un trou.** Le
> premier test de `LM-1210-a` attendait qu'une phase en export donne une marge *supérieure* au
> plafond — c'est ce que le design affirmait. Il a échoué : `calculateAllowanceAmpere()` part du
> plafond et ne fait que des `qMin`, donc la marge y est **écrêtée**. Physiquement juste — l'export
> ne permet pas de tirer plus que le disjoncteur — mais la phrase du design était fausse. **La
> conclusion tenait, la raison non**, et rien ne pouvait le révéler tant que le déséquilibre était
> infabricable.
**3. `temperature`.** Le code teste `hasState("temperature")` et **aucune classe du mock ne le
porte** : la branche « sonde présente » n'a jamais été exécutée. Elle est écrite, elle n'est pas
testée.
**4. Une borne triphasée à phases déséquilibrées.** Les états existent, mais aucun helper de test
ne les pose indépendamment — d'où le pessimisme de `LM-1210-a` qui n'a jamais été exercé sur un
vrai déséquilibre.
**4. ~~Une triphasée déséquilibrée.~~ — OUVERT le 2026-09-03.**
`testWorstPhaseBindsWhenPhasesAreUnbalanced` exerce enfin `LM-1210-a` : phase B saturée à 30 A
sous un plafond de 25 pendant que A et C exportent, et la maison **exporte globalement**. La
marge retenue est celle de B — négative — et non celle du total, qui rassurerait à tort.
Ce manque était nommé **deux fois par deux chemins indépendants** — cet inventaire et le balayage
des contrôles — avant d'être comblé. C'est ce qui lui a donné sa priorité.
## La règle qui en sort

View File

@ -986,7 +986,8 @@ DrawMargin EnergyArbitrator::houseDrawMargin() const
// monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur la pire.
// Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au
// disjoncteur. Le pessimisme ne mord d'ailleurs que là où il doit : une phase qui exporte
// a une marge PLUS GRANDE que le plafond, et ne borne personne.
// est AU MAXIMUM de la marge — celle-ci étant écrêtée au plafond, l'export ne permettant pas
// de tirer plus que le disjoncteur — et ne borne donc jamais.
return resolveDrawMargin(plafonds,
phaseDrawW(Electricity::PhaseNone, phasePowerLimit()),
meter->currentPower());

View File

@ -131,6 +131,17 @@ struct LoadContextTelemetry {
* ses booléens, mais a laissé \c sessionEnergy à sa dernière valeur. La politique dépend du
* plugin, ce qui la rend inutilisable comme garde.
*
* \par L'asymétrie n'est PAS une incohérence, et elle mérite d'être dite
* L'app **dépend** de `connected` dans son écran de borne, et elle a raison : elle s'en sert
* pour **refuser d'afficher**, jamais pour **compter**. Si `connected` ment, l'écran dit
* « hors ligne » à tort — rien n'est mal compté, aucune obligation ne se déclare tenue.
*
* **Une comptabilité n'a pas droit à cette tolérance.** Même donnée non fiable, deux usages,
* deux verdicts : s'abstenir sur un doute est sûr, accumuler sur un doute ne l'est pas. C'est
* pourquoi la garde ci-dessus ne s'appuie pas sur un état de plugin — non parce que l'app
* aurait tort de le faire, mais parce que le coût d'une erreur n'est pas le même des deux
* côtés.
*
* \note Ce que ce champ évite, chiffré : base reprise à 0 pendant l'absence, retour du
* compteur avec ses 10 kWh réels, et \c deliveredWh saute à 10 000 en un cycle —
* l'obligation se déclare **tenue sans que rien n'ait été livré**. Et `R6` ne s'y oppose

View File

@ -875,8 +875,10 @@ charge est câblée. Une monophasée de phase inconnue peut tomber sur n'importe
la pire. Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au
disjoncteur.
Le pessimisme est **borné en pratique** : une phase qui exporte a une marge *plus grande* que le
plafond et ne borne personne. Il ne mord que sur une triphasée réellement déséquilibrée — et il
Le pessimisme est **borné en pratique** : une phase qui exporte est **au maximum de la marge** —
laquelle est écrêtée au plafond, l'export ne permettant pas de tirer plus que le disjoncteur — et
ne borne donc jamais. *(Formulation corrigée le 2026-09-03 : le texte disait « une marge plus
grande que le plafond », ce qui est faux. La conclusion tenait, la raison non.)* Il ne mord que sur une triphasée réellement déséquilibrée — et il
coûte alors du **rendement, jamais de la sécurité**. Lever la limite demande un masque de phases
déclaré, donc config, interface et migration : hors de ce lot.

View File

@ -140,6 +140,15 @@ QNetworkReply *EnergyTestBase::setMeterStates(const QVariantMap &phasesPower, bo
query.addQueryItem("currentPowerPhaseA", QString::number(phasesPower.value("A").toInt()));
query.addQueryItem("currentPowerPhaseB", QString::number(phasesPower.value("B").toInt()));
query.addQueryItem("currentPowerPhaseC", QString::number(phasesPower.value("C").toInt()));
// Tension et courant par phase, OPTIONNELS : passés dans `phasesPower` sous les clés
// "uA"/"uB"/"uC" et "iA"/"iB"/"iC". Absents, le mock dérive comme avant — aucun scénario
// existant ne bouge. Présents, ils permettent enfin une triphasée DÉSÉQUILIBRÉE et une
// tension ≠ 230 V, que le banc ne savait pas produire.
for (const auto &p : {std::make_pair("uA", "voltagePhaseA"), std::make_pair("uB", "voltagePhaseB"),
std::make_pair("uC", "voltagePhaseC"), std::make_pair("iA", "currentPhaseA"),
std::make_pair("iB", "currentPhaseB"), std::make_pair("iC", "currentPhaseC")})
if (phasesPower.contains(p.first))
query.addQueryItem(p.second, QString::number(phasesPower.value(p.first).toDouble()));
requestUrl.setQuery(query);
QNetworkRequest request(requestUrl);

View File

@ -7634,6 +7634,86 @@ void Simulation::testLifetimeCounterDropNeverRebasesTheProgress()
#endif
}
/*!
* \brief [\rule{LM-1210-a}] Sur une triphasée DÉSÉQUILIBRÉE, c'est la PIRE phase qui borne.
*
* \par Un choix écrit, raisonné, et jamais exercé — jusqu'ici
* `LoadDeclared::phases` est un NOMBRE, pas un masque : le moteur ignore sur quelle phase une
* charge est câblée. Il retient donc la pire, seul choix qui ne devine pas — deviner dans une
* couche de sécurité se paie au disjoncteur.
*
* Ce test n'existait pas parce que **le mock ne savait produire qu'une triphasée équilibrée** :
* il posait `currentPhaseX = currentPowerPhaseX / 230`, cohérent par construction. Le manque
* était nommé deux fois — par l'inventaire du mock et par le balayage des contrôles — avant
* d'être comblé.
*
* \par Ce qu'il épingle
* La maison EXPORTE globalement, et une seule phase sature. La marge doit être celle de cette
* phase-là, négative, et non celle du total qui paraîtrait confortable.
*/
void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit",
QVariantMap({{"phasePowerLimit", 25}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
// DÉSÉQUILIBRE : la phase B sature à 30 A sous un plafond de 25, pendant que A et C
// EXPORTENT. Impossible à écrire avant : le mock dérivait les courants des puissances.
meter->setStateValue("currentPhaseA", -10); // export
meter->setStateValue("currentPhaseB", 30); // SATURÉE
meter->setStateValue("currentPhaseC", -10); // export
meter->setStateValue("currentPower", -2300); // la maison exporte GLOBALEMENT
const DrawMargin marge = arb->houseDrawMargin();
QVERIFY2(marge.borne, "un plafond est réglé : la marge doit exister");
// 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 25 A − 30 A = −5 A, soit
// −1150 W. Le total, lui, afficherait un confort trompeur : la maison exporte.
QCOMPARE(qRound(marge.watts), -1150);
QCOMPARE(marge.phase, 1); // phase B (0 = A, 1 = B, 2 = C)
// 2. ET LA MARGE EST NÉGATIVE alors que le compteur global est en EXPORT. C'est tout l'objet
// du choix : sans la lecture par phase, l'arbitre croirait disposer de 2 300 W à dépenser
// pendant qu'une phase est déjà au-delà de son disjoncteur.
QVERIFY2(meter->stateValue("currentPower").toDouble() < 0,
"la maison exporte : c'est le cas qui rend le pessimisme non trivial");
QVERIFY2(marge.watts < 0,
"la pire phase borne, même quand le total rassure");
// 3. Contre-jour : rééquilibrée, la même maison retrouve une marge confortable. Sans ce
// second point, le test ne distinguerait pas « la pire phase l'emporte » de « la marge
// est toujours négative ».
meter->setStateValue("currentPhaseB", -10);
const DrawMargin equilibree = arb->houseDrawMargin();
// La marge est ÉCRÊTÉE AU PLAFOND, et non augmentée par l'export : `calculateAllowanceAmpere`
// part de `phasePowerLimit` et ne fait que des `qMin`. Physiquement juste — l'export ne
// permet pas de tirer plus que le disjoncteur — mais ce n'est PAS ce que le design disait
// (« une phase qui exporte a une marge plus grande que le plafond »), et la phrase a été
// corrigée. La conclusion, elle, tient : une phase qui exporte est au maximum de la marge,
// donc elle ne borne jamais.
QCOMPARE(qRound(equilibree.watts), qRound(25.0 * 230.0));
QVERIFY2(equilibree.watts > 0, "rééquilibrée, la marge redevient positive");
#endif
}
void Simulation::testCountsSumToTarget()
{
#ifndef ETM_ARBITRATOR

View File

@ -201,6 +201,7 @@ private slots:
void testProgressMeasurabilityIsDeclaredAtConfigTime();
void testHealthyButUnmeasuredDiffersFromFaulty();
void testLifetimeCounterDropNeverRebasesTheProgress();
void testWorstPhaseBindsWhenPhasesAreUnbalanced();
void testRecreditIsALoanAgainstObedience();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();

View File

@ -233,9 +233,35 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
int currentPowerPhaseB = query.queryItemValue("currentPowerPhaseB").toInt();
int currentPowerPhaseC = query.queryItemValue("currentPowerPhaseC").toInt();
thing->setStateValue("currentPhaseA", currentPowerPhaseA / 230.0);
thing->setStateValue("currentPhaseB", currentPowerPhaseB / 230.0);
thing->setStateValue("currentPhaseC", currentPowerPhaseC / 230.0);
// TENSION ET COURANT PAR PHASE, réglables INDÉPENDAMMENT des puissances.
//
// Sans cela, le mock posait `currentPhaseX = currentPowerPhaseX / 230` : les deux
// familles d'états étaient COHÉRENTES PAR CONSTRUCTION, et aucun scénario ne
// pouvait produire une tension réelle ≠ 230 V, un déséquilibre entre phases, ni
// un compteur qui ne publierait qu'une des deux familles. C'est ce qui a rendu le
// défaut d'asymétrie de `calculateAllowanceAmpere()` invisible à la suite
// pendant des mois (docs/INVENTAIRE_MOCK.md, manque n°2).
//
// Les paramètres sont OPTIONNELS : sans eux, le comportement dérivé d'origine est
// conservé, et aucun scénario existant ne bouge.
auto tension = [&](const char *nom) {
return query.hasQueryItem(nom) ? query.queryItemValue(nom).toDouble() : 230.0;
};
const double uA = tension("voltagePhaseA");
const double uB = tension("voltagePhaseB");
const double uC = tension("voltagePhaseC");
thing->setStateValue("voltagePhaseA", uA);
thing->setStateValue("voltagePhaseB", uB);
thing->setStateValue("voltagePhaseC", uC);
// Le COURANT peut être imposé directement — un compteur réel le mesure, il ne le
// dérive pas de la puissance.
thing->setStateValue("currentPhaseA", query.hasQueryItem("currentPhaseA")
? query.queryItemValue("currentPhaseA").toDouble() : currentPowerPhaseA / uA);
thing->setStateValue("currentPhaseB", query.hasQueryItem("currentPhaseB")
? query.queryItemValue("currentPhaseB").toDouble() : currentPowerPhaseB / uB);
thing->setStateValue("currentPhaseC", query.hasQueryItem("currentPhaseC")
? query.queryItemValue("currentPhaseC").toDouble() : currentPowerPhaseC / uC);
thing->setStateValue("currentPowerPhaseA", currentPowerPhaseA);
thing->setStateValue("currentPowerPhaseB", currentPowerPhaseB);