fix(3g-1): sous la réserve de stockage, pas de recrédit

LM-1203-b dit « le budget est ANNULÉ, pas réduit », et le motif publié dit
« aucune charge n'est servie ». Le code disait autre chose : la réserve ne
mordait que sur les AUGMENTATIONS.

Le recrédit anti-clignotement (correction B) rend à chaque charge sa
consommation de début de cycle avant d'arrondir. Il le faisait aussi sous la
réserve — donc toute charge déjà en marche gardait exactement sa puissance,
indéfiniment, pendant que la télémétrie annonçait qu'aucune n'était servie.

Le recrédit suppose que le budget est une MESURE dont la consommation de la
charge a déjà été retranchée. Sous la réserve, le budget n'est pas une mesure
mais une DÉCISION de retenir : recréditer dedans rend exactement ce qu'on
venait de confisquer. Le recrédit est donc supprimé dans ce seul cas, pour les
charges en watts comme pour les états SG-Ready — sans quoi une PAC en état 3
ou 4 y resterait elle aussi indéfiniment.

Conséquence sur le motif : ce que la réserve retient à une charge a DEUX
termes, et il faut les deux. Le surplus annulé, le même pour toutes, et sa
propre consommation, que le recrédit lui aurait rendue. Sans le second, une
charge en marche coupée par la réserve sur un surplus nul publiait « surplus
insuffisant » — vrai sur le surplus, faux sur la cause : sans la réserve, elle
aurait continué de tourner.

Le défaut ne s'est vu qu'en exécutant réellement les décisions du waterfall :
batteryLevelConsiderationStopCharging échouait, la borne restant allumée là
où l'amont la coupe.

Suite charging vert (dont les deux tests de réserve), simulation `run` 16/16,
testEcsSurplusPV / testSgReadySurplus / testLoadTelemetryRpc verts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
This commit is contained in:
Patrick Schurig 2026-08-27 10:35:31 +02:00
parent 38a831407a
commit e1fbb224fa
2 changed files with 37 additions and 5 deletions

View File

@ -117,6 +117,18 @@ et **aucune n'est neutre** :
> changelog comme un changement de comportement assumé. Une batterie sous son seuil de réserve
> ne devrait pas plus alimenter un chauffe-eau qu'une voiture.
**DÉCISION : montée dans le budget**, appliquée en 3g-1.
**Complément 3g-2, trouvé en exécutant vraiment les décisions du waterfall.** La règle ne
mordait que sur les AUGMENTATIONS : le recrédit anti-clignotement rendait à chaque charge sa
propre consommation, y compris sous la réserve, si bien qu'une charge déjà en marche le restait.
Le motif publié disait pourtant « aucune charge n'est servie ». **Sous la réserve, le recrédit
est désormais supprimé** — le budget est une décision de retenir, pas une mesure, et recréditer
dedans rend exactement ce qu'on venait de confisquer (LM-1203-b : « annulé, pas réduit »).
Le motif `BATTERY_RESERVE` chiffre en conséquence les deux termes retenus : le surplus annulé
**et** la consommation propre de la charge. Éprouvé par `batteryLevelConsiderationStopCharging`,
qui échouait avant cette correction.
### 3.3 Le nombre de phases se décide en ampères, le waterfall parle en watts
`getBestPhaseCount()` choisit 1 ou 3 phases **à partir du courant brut**, puis divise. Le

View File

@ -236,8 +236,17 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
// PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback).
const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW;
budget.recreditedW += lc.telemetry.currentPowerW;
// 3g-2 — SAUF sous la réserve de stockage, et c'est la seule exception. Le recrédit
// suppose que le budget est une MESURE dont la conso de la charge a déjà été retranchée ;
// sous la réserve, le budget n'est pas une mesure mais une DÉCISION de retenir
// (LM-1203-b : « le budget est annulé, pas réduit »). Recréditer dans un budget décidé nul
// rend exactement ce qu'on venait de confisquer : toute charge déjà en marche le restait,
// la réserve ne mordait que sur les augmentations, et le motif publié — « aucune charge
// n'est servie » — était faux. Attrapé par batteryLevelConsiderationStopCharging au moment
// où les bornes ont commencé à exécuter réellement les décisions du waterfall.
const double recreditW = m_reserveActive ? 0.0 : lc.telemetry.currentPowerW;
const double budgetW = remainingSurplusW + recreditW;
budget.recreditedW += recreditW;
const QList<int> &levels = lc.declared.powerLevels;
const bool dynamic = levels.isEmpty();
@ -384,14 +393,20 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
la.reason = { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)},
{"stepped", !dynamic}} };
else if (m_reserveActive && m_reserveWithheldW > 0)
else if (m_reserveActive && (m_reserveWithheldW + lc.telemetry.currentPowerW) > 0)
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
// chercher une panne inexistante.
//
// 3g-2 — ce que la réserve retient à CETTE charge a deux termes, et il faut les deux :
// le surplus annulé (le même pour toutes), ET sa propre consommation, que le recrédit
// lui aurait rendue. Sans le second, une charge en marche coupée par la réserve sur un
// surplus nul publiait « surplus insuffisant » — vrai sur le surplus, faux sur la
// cause : sans la réserve, elle aurait continué de tourner.
la.reason = { DecisionCode::BatteryReserve,
{{"socPercent", qRound(m_reserveSocPct)},
{"reservePercent", qRound(m_reserveSeuilPct)},
{"withheldW", qRound(m_reserveWithheldW)}} };
{"withheldW", qRound(m_reserveWithheldW + lc.telemetry.currentPowerW)}} };
else if (sousPlancher)
// §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.
@ -416,7 +431,12 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
// Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2)
// revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?"
const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0);
// 3g-2 — même exception que pour les charges en watts : sous la réserve, pas de recrédit.
// Une PAC en état 3 ou 4 y resterait sinon indéfiniment, la réserve n'agissant que sur les
// montées d'état. Voir la note détaillée dans buildSetpointAction().
const double allocatedNowW = m_reserveActive
? 0.0
: lc.declared.estimatedPowerW.value(currentState, 0.0);
const double budgetW = remainingSurplusW + allocatedNowW;
budget.recreditedW += allocatedNowW;