diff --git a/docs/DESIGN_COMMANDE_PERDUE.md b/docs/DESIGN_COMMANDE_PERDUE.md index 3914218..e4c2fcd 100644 --- a/docs/DESIGN_COMMANDE_PERDUE.md +++ b/docs/DESIGN_COMMANDE_PERDUE.md @@ -70,7 +70,44 @@ au niveau de la charge, publié **seulement quand la comparaison est possible** contre `none`, il n'y a rien à comparer et l'objet est **absent** — pas `divergent: false`, qui affirmerait la concordance qu'on ne peut pas constater. Même règle que partout ailleurs ici. -**4.3 — Et la COMPTABILITÉ, qui est le vrai coût.** +**4.3 — La COMPTABILITÉ : le diagnostic était à corriger, et le vrai défaut est plus grave.** + +> **MESURÉ le 2026-09-01, avant d'implanter.** Le constat disait « un budget de surplus qui +> ignore ces watts ». **Ce n'est pas ce qui se passe.** Quand une charge consomme et **obéit**, +> la comptabilité est juste : sa conso est recréditée puis réallouée à elle-même, et les +> suivantes ne reçoivent que le surplus réel (mesuré : export 500 W, A à 4 400 W obéissante → B +> reçoit 500 W). **Corriger cela aurait été corriger un non-défaut.** + +**Le vrai défaut : le recrédit est un PRÊT consenti contre l'obéissance.** Son hypothèse est +écrite dans le code — *« le budget est une mesure dont la conso de la charge a déjà été +retranchée »* — c'est-à-dire **« je peux rendre ces watts parce que je peux les reprendre »**. +Rien ne vérifie que l'emprunteur rembourse. + +Mesuré, le cas où la promesse est engagée : export 500 W, A consomme 4 400 W, plancher de A à +5 000 W. A est commandée à **0**, et ses 4 400 W sont **redistribués à B**. + +| | si A obéit | si A n'obéit pas | +|---|---|---| +| A | tombe à 0 | reste à 4 400 W | +| B | 4 000 W réels | 4 000 W **fictifs** | +| maison | 4 000 W pour 4 900 de production | **8 400 W pour 4 900** → 3 500 W importés | + +**Et l'identité de réconciliation TIENT dans les deux cas** — `surplus + recrédit − alloué == +restant`. C'est ce qui distingue un prêt d'une erreur : la comptabilité est juste, c'est +l'**hypothèse sur laquelle elle repose** qui n'est pas vérifiée. Aucun contrôle de cohérence ne +pouvait donc l'attraper. + +**Ce qui change maintenant : l'obéissance est CONSTATABLE.** `command.divergent` (étape 2) dit +si la charge a suivi au cycle précédent. Le recrédit peut donc être **conditionné** — c'est la +proposition, et elle demande une décision parce qu'elle a son propre risque : l'anti-clignotement +existe pour une raison, et le suspendre trop vite ferait osciller une charge saine sur un écart +transitoire. `since` est précisément ce qui permet de distinguer les deux. + +**Ce que le §4.3 d'origine proposait — retrancher la conso subie du budget — est SANS OBJET** : +elle en est déjà retranchée, par le compteur. La note est conservée ici pour que personne ne la +réimplante en la croyant oubliée. + +**4.3-bis — l'ancienne formulation, conservée pour mémoire.** `draw.committedW` à 0 pendant que 4,4 kW sont tirés n'est pas un défaut d'affichage : le budget de surplus est calculé sans ces watts, donc **le moteur alloue de l'énergie qui n'existe pas**. Une charge que l'arbitre ne commande plus mais qui consomme est de la **consommation fixe**, au diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index e19d514..dac935e 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7180,6 +7180,87 @@ void Simulation::testCommandDivergenceIsPublishedNotDiagnosed() #endif } +/*! + * \brief [Commande perdue, étape 3] Le recrédit est un PRÊT consenti contre l'obéissance. + * + * \par Ce que la mesure a corrigé dans le diagnostic + * Le constat de l'app disait « un budget de surplus qui ignore ces watts ». Vérification faite, + * **ce n'est pas ce qui se passe** : quand une charge consomme et obéit, la comptabilité est + * juste — sa conso est recréditée puis réallouée à elle-même, et les suivantes ne reçoivent que + * le surplus réel. Corriger cela aurait été corriger un non-défaut. + * + * \par Le vrai défaut, et il est plus grave + * Le recrédit anti-clignotement repose sur une hypothèse écrite dans le code : *« le budget est + * une mesure dont la conso de la charge a déjà été retranchée »* — autrement dit **« je peux + * rendre ces watts parce que je peux les reprendre »**. C'est un **prêt consenti contre + * l'obéissance**, et rien ne vérifie que l'emprunteur rembourse. + * + * Ce test épingle le cas où la promesse est engagée : A est commandée à **0** et ses 4 400 W + * sont redistribués. Si A obéit, tout est juste. **Si A n'obéit pas** — commande perdue, mode + * manuel, véhicule qui limite — la maison tire 8 400 W pour 4 900 W de production, soit + * **3 500 W importés que personne n'a décidés**. + * + * \warning **Ce test épingle le comportement ACTUEL, qui est correct SOUS HYPOTHÈSE.** Il + * devient faux le jour où le recrédit sera conditionné à l'obéissance constatée + * (`command.divergent`) — et ce jour-là, c'est l'allocation de B qu'il faudra revoir, pas le + * test. La distinction est écrite ici pour qu'on ne le « corrige » pas par réflexe. + */ +void Simulation::testRecreditIsALoanAgainstObedience() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + RuleBasedScheduler sched(arb); + + SurplusContext ctx; + ctx.timestamp = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0)); + ctx.meter.exportW = 500; // 500 W exportés SEULEMENT + + LoadContext a; + a.id = "A-coupee"; a.label = "A"; a.adapter = "etmvariableload"; a.priority = 1; + a.declared.maxPowerW = 6000; + a.declared.minPowerW = 5000; // plancher AU-DESSUS du budget : A est coupée + a.telemetry.currentPowerW = 4400; // …mais elle consomme 4 400 W à cet instant + ctx.loads.append(a); + + LoadContext b; + b.id = "B-suivante"; b.label = "B"; b.adapter = "etmvariableload"; b.priority = 2; + b.declared.maxPowerW = 4000; + ctx.loads.append(b); + + const Plan plan = sched.getPlan(ctx); + QHash parId; + for (const LoadAction &act : plan.timeSlots.first().actions) + parId.insert(act.loadId, act); + + // 1. A est coupée — son plancher n'est pas payable, et le budget PASSE à la suivante. + QCOMPARE(qRound(parId.value("A-coupee").estimatedPowerW), 0); + QCOMPARE(parId.value("A-coupee").reason.code, QString(DecisionCode::BelowMinPower)); + + // 2. B reçoit 4 000 W — c'est-à-dire la conso de A, PRÊTÉE d'avance sur sa coupure. + QCOMPARE(qRound(parId.value("B-suivante").estimatedPowerW), 4000); + + // 3. LE PRÊT EST VISIBLE DANS LA COMPTABILITÉ, et c'est ce qui le rend vérifiable : 4 000 W + // alloués pour 500 W de surplus réel. L'écart est exactement le recrédit. + QCOMPARE(qRound(plan.budget.surplusW), 500); + QCOMPARE(qRound(plan.budget.recreditedW), 4400); + QCOMPARE(qRound(plan.budget.allocatedW), 4000); + QVERIFY2(plan.budget.allocatedW > plan.budget.surplusW, + "le recrédit prête d'avance ce que la coupure de A doit libérer"); + + // 4. L'identité de réconciliation TIENT malgré tout — surplus + recrédit − alloué == restant. + // C'est ce qui distingue un prêt d'une erreur : la comptabilité est juste, c'est + // l'hypothèse qu'elle repose dessus qui n'est pas vérifiée. + QCOMPARE(qRound(plan.budget.surplusW + plan.budget.recreditedW - plan.budget.allocatedW), + qRound(plan.budget.remainingW)); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 5c44e5c..bb217d2 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -198,6 +198,7 @@ private slots: void testEcoPassBuysAndSaysTheTrajectory(); void testEcoFloorMissedSaysWhatWasLostAndWhy(); void testCommandDivergenceIsPublishedNotDiagnosed(); + void testRecreditIsALoanAgainstObedience(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock();