Compare commits

..

16 Commits

Author SHA1 Message Date
Patrick Schurig
88ca21cfea debian : entrée de changelog 1.15.2+etm77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-27 17:17:43 +01:00
Patrick Schurig
dff1e6f9ff fusion de fix/delestage-triphase (délestage triphasé, marge de sécurité, borne V2C)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-27 17:17:43 +01:00
Patrick Schurig
bb21e199ae decisions : référence régénérée (lot délestage V2C)
Quatre fonctions nouvelles (81 lignes). Ailleurs, 515 lignes changent, toutes
dans run, scénario 1 phase 32 A : de 10:11 à 11:30 UTC la borne, bornée par
la limite de phase, tient 20-22 A au lieu d'alterner 19/20 A (50 cycles) ;
la voiture garde ensuite un point d'avance (457 lignes de batteryLevel), et
l'échéance du soir démarre 3 min plus tard et atteint 100 % à 21:55 au lieu
de 21:59. Aucune autre fonction ne change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:36:11 +01:00
Patrick Schurig
afdfaae4aa test : la règle de jour ne rend rien en dépassement (garde éprouvée)
testWaterfallChargerGetsNothingBackInOverload : phases du compteur
aveugles à la borne, 24 A sur A pour un seuil de 22,5 A. La stratégie tient
la borne à son plancher (1 380 W) ; sans la garde de dépassement
d'evPhaseAllowanceW(), elle lui rendait 2 530 W pendant le dépassement.
Aucun test existant ne rougissait sans cette garde.

Au passage, deux formulations retirées des commentaires (garde publique).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:17:02 +01:00
Patrick Schurig
1e4ec45e12 moteur : la somme éco + confort d'une borne tient dans son plafond, hors heures creuses aussi
En « Éco avec minimum », le niveau éco (le plancher, acheté) s'ajoutait au
confort, que seul le plafond de cycle bornait : sous 25 A et 10 % de
marge, une maison à 10 A sur A, 12,5 A de plafond plus 6 A d'éco faisaient
16 A, la phase à 26 A, et L4 coupait la borne — qui repartait au cycle
suivant.

Le confort d'une borne est servi dans ce que l'éco laisse du plafond, et
le plancher porte sur la somme. Seule la borne de D9 est reprise ; le
partage de l'achat (l'achat cède ce que le surplus finance) reste réservé
aux heures creuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:56:55 +01:00
Patrick Schurig
70cdaed481 moteur : le tirage d'une borne non mesurée par phase est rendu à ses phases (V2C)
Une borne qui ne publie pas ses courants par phase (V2C Trydan :
currentL1..3) avait meteredPhases() vide, et le plafond d'heures creuses
ne lui rendait rien de ce qu'elle tire : il la lisait comme une charge de
la maison sur sa phase, et la consigne alternait chaque nuit (16, 6, 13,
6 A sous 25 A et 10 % de marge ; 6 A tenus avec 8 A de plus sur A).

evAttributableDraw() : le currentPower de la borne est attribué aux phases
que le moteur lui suppose déjà (phasesForCount(), la phase A en
monophasé, comme chargerPhaseKey() pour L4), un tiers par phase en
triphasé. Seulement si c'est sûr : aucun état par phase publié, autant de
phases annoncées que visées (pas de triphasée avec voiture monophasée, pas
de bascule), une ou trois phases. Sinon rien, comme avant.

Question 5 du lot délestage triphasé : hors heures creuses,
evPhaseAllowanceW() rend aussi ce tirage, moins 1 A, hors dépassement
(16, 9, 16, 9 A → 16 A tenus). En dépassement, la place libre reste la
règle. Le calcul est mis en commun avec evOffPeakAllowanceW()
(evCeilingGivingBackDraw()).

run : le scénario 1 phase 32 A alternait 19/20 A sous la limite de phase
et tient 20-21 A ; ses points de charge passent de 80/85/87 à 81/86/88 %
(88 est la valeur de l'amont), 100 % à l'échéance inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:56:55 +01:00
Patrick Schurig
3084b885d8 test : reproductions du lot délestage V2C (rouges sur b054780)
Mock : classe chargerUnmetered, une borne qui mesure sa puissance mais
publie ses courants sous currentL1..3 (comme le plugin V2C Trydan), jamais
sous currentPhaseX. EvCharger::meteredPhases() y est vide ; aucune borne du
mock ne produisait ce cas.

- testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit : heures creuses,
  limite 25 A, marge 10 %, borne non mesurée (mono sur A, tri) ; rouge :
  16, 6, 13, 6, 13… A, et 6 A tenus avec le lave-vaisselle.
- testUnmeteredChargerGivesNothingBackWhenUnsure : triphasée avec voiture
  monophasée, courants par phase publiés et nuls ; vert (témoin).
- testWaterfallChargerSettlesUnderThePhaseLimit (question 5) : hors heures
  creuses ; rouge : 16, 9, 16, 9… A (maison légère), 16 A puis coupée par
  L4 à 26 A pour 25 (maison chargée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:16:36 +01:00
Patrick Schurig
b0547806d8 decisions : référence régénérée (lot délestage triphasé)
Six fonctions nouvelles. Hors elles, 94 lignes changent dans 8 fonctions, toutes expliquées :
- seuil à 22,5 A pour 25 A (marge de 10 %) : draw.authorisedW −575 W, dépassements et marges
  DRAW_CAP au seuil (testSheddingLogKeepsTheLastEpisodes, testSheddingLogCoversEveryMechanism,
  testL4ShedsAPacAndSaysItForcedTheLock, testWorstPhaseBindsWhenPhasesAreUnbalanced) ;
- entrées de test déplacées pour la marge, et thresholdA publié
  (testOverloadProtectionReasonOnTheCharger, testOverloadProtectionOnAManualChargerIsLogged) ;
- testBlockedDischargeShowsAtTheMeterAndL4Decides : le plafond d'heures creuses descend avec le
  seuil, la borne passe de 16 à 6 A ;
- testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter (marge nulle) : phases aveugles à la borne,
  relevées au niveau du total ; consignes inchangées.
run et toutes les autres fonctions : identiques.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:44:41 +01:00
Patrick Schurig
8eb2f7b00b moteur : la cohérence avec le total ignore un manque sous le demi-watt
Un manque de 10⁻¹² W, arrondi de la somme des phases, changeait des décisions au douzième
chiffre dans le scénario run de la preuve decisions, sans rien protéger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:18:46 +01:00
Patrick Schurig
3dfb74dcb3 test : la marge de sécurité dans les tests existants
- Tests de l'algorithme amont (charging, simulation::run) et famille « heures creuses à pleine
  puissance » : marge nulle, posée explicitement au point d'appel. Ils figent une arithmétique
  contre la limite ; la marge déplace le seuil sans toucher à ce calcul.
- testWorstPhaseBindsWhenPhasesAreUnbalanced : marge et plafond au seuil (22,5 A pour 25 A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:12:15 +01:00
Patrick Schurig
5ab5213aad moteur : la marge de soutirage borne aussi les consignes d'un optimiseur (défauts 4 et 5)
La marge que le compteur laisse sous le seuil ne bornait que la cascade du rule-based. Sous un
optimiseur qui demande le maximum, ce que la protection venait de retirer revenait au cycle
suivant, dès que le compteur repassait sous la limite : 32,2 A pendant une minute sur
l'installation d'essai, après un délestage du chauffe-eau.

bornerOptimiseurAuSoutirage(), avant le dispatch : une consigne d'optimiseur ne fait monter une
charge que de la marge libre (pire phase, consommée dans l'ordre du créneau). Elle ne réduit
rien — c'est la protection qui réduit. L'engagé d'une borne est sa dernière commande tenue, si
bien que le dispatch ne défait plus, dans le même cycle, ce que la protection vient de faire.
Une consigne écrêtée publie DRAW_CAP et clamped, plus le motif de l'optimiseur (défaut 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:48:30 +01:00
Patrick Schurig
440a45f526 moteur : marge de sécurité de 10 % et phases cohérentes avec le total (défaut 2)
Décision du 2026-10-27 : le moteur vise 90 % de la limite de phase — 27 A pour 30 A — pour le
plafond qu'il déclare et alloue, et pour le seuil où la protection agit. Réglable de 0 à 25 %,
persistée avec la limite (phaseSafetyMarginPercent), publiée par GetPhasePowerLimit et
PhasePowerLimitChanged (même charge utile : limite, marge, effectivePhaseLimit), écrite par le
paramètre optionnel de SetPhasePowerLimit. Le seuil atteint DrawCap, la protection amont et sa
reprise, la limite de prepareInformation(), les deux allowances du proxy, et le phaseLimitA
envoyé à l'optimiseur. OVERLOAD_THROTTLED porte thresholdA quand la marge n'est pas nulle.

Défaut 2 : sur l'installation d'essai, les courants par phase publiés étaient en retard sur le
total (29,1 A lus pour 32,3 A importés par phase) et la protection a attendu 1 min 45. Les phases
somment au total : la plus chargée en porte au moins le tiers. tiragesCoherentsAvecLeTotal()
relève les phases quand leur somme est sous le total, jamais ne les abaisse ; c'est la lecture de
la protection, de sa reprise, du journal de ses gestes et de la cascade.

Tests existants mis à jour : les dépassements se mesurent au seuil (22,5 A pour 25 A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:43:20 +01:00
Patrick Schurig
cf0e65abc7 moteur : une borne non préparée ce cycle n'a pas de ChargingProcessInfo (défaut 1)
internalHasProcessInfo() testait la table m_processInfos, qui n'est jamais vidée et que
verifyOverloadProtection() remplit par operator[] (entrée par défaut : 1 phase, pas de bascule).
Après la première surcharge, une borne triphasée sans voiture assignée devenait monophasée pour
evTargetPhases() : plafond 16 × 230 × 1, conversion W → A sur une phase, 3 680 W alloués et
attendus pour 11 040 W tirés. Même effet, sans surcharge, avec l'entrée d'une voiture
monophasée désassignée.

Le manager retient désormais les bornes qu'il prépare à chaque cycle (m_preparedChargers), et
l'accesseur dit ce que sa documentation promettait : préparée CE cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:32:57 +01:00
Patrick Schurig
f20b08a3a2 test : reproductions du lot délestage triphasé (rouges sur 1.15.2+etm76)
- testOverloadLeavesTheChargerItsPhases (3 cas) et testAnUnassignedCarTakesItsPhasesAway :
  une borne triphasée sans voiture, ou dont la voiture monophasée est désassignée, comptée sur
  une phase — 3 680 W alloués et attendus pour 16 A × 3.
- testOverloadReactsToTheTotalWhenPhasesLag : des courants par phase en retard sur le total
  retardent la protection.
- testPhaseSafetyMargin : la marge de 10 % (décision du 2026-10-27).
- testTheDrawCapHoldsAgainstAnOptimizerAtMaximum (refoptimizer) : sous un optimiseur qui
  demande le maximum, ce que la protection a retiré revient au cycle suivant.
- Témoins verts sur etm76 : testSheddingOpensTheRelaysUnderMinOn (le délestage franchit minOn
  et ouvre les relais) et testCommandDivergenceSeesAPhaseCountError (×3 vu en régime établi).
- Optimiseur de référence : mode over-limit (toute consigne en watts à 50 kW).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:28:42 +01:00
Patrick Schurig
2d06de0bc1 debian : entrée de changelog 1.15.2+etm76
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 20:07:21 +01:00
Patrick Schurig
c75cc32470 fusion : fix/recredit-oscillation (écart commande/mesure jugé au début du cycle, borne qui retient ce qu'elle tient)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 20:07:08 +01:00
29 changed files with 1882 additions and 113 deletions

View File

@ -1,3 +1,38 @@
powersync-energy-plugin-nymea (1.15.2+etm77) trixie; urgency=medium
* Délestage : marge de sécurité réglable sur la limite de phase (10 % par
défaut, 0 à 25 %, phaseSafetyMarginPercent) ; le moteur vise la limite
effective (effectivePhaseLimit) pour le plafond, l'allocation, la
protection et la limite transmise à l'optimiseur.
* Une borne triphasée qui ne peut pas basculer n'est plus comptée en
monophasé après un délestage (allocation et consigne justes).
* Les courants par phase sont rendus cohérents avec le total du compteur
(relevés, jamais abaissés) : la protection ne réagit plus en retard.
* Sous un optimiseur, une hausse de consigne est bornée par ce que le
soutirage permet ; une consigne écrêtée publie DRAW_CAP.
* Une borne qui ne publie pas ses courants par phase (V2C Trydan) rend son
tirage mesuré à la phase que le moteur lui suppose : la consigne ne
bascule plus sous la limite de phase, en heures creuses comme de jour.
* Hors heures creuses aussi, la somme éco + confort d'une borne tient dans
son plafond de cycle.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 27 Oct 2026 17:17:43 +0100
powersync-energy-plugin-nymea (1.15.2+etm76) trixie; urgency=medium
* Borne au surplus : la consigne n'oscille plus sous un surplus constant.
L'écart commande/mesure est jugé au début du cycle, sur la commande en
vigueur et la mesure fraîche, daté à l'instant de la commande ; une montée
qui progresse est une latence, une descente jamais. Une marche suivie ne
suspend plus le recrédit ; un vrai refus reste vu au cycle suivant.
* La borne retient ce qu'elle tient, quel que soit l'émetteur (répartition,
échéance, protection L4) ; un arrêt refusé par le verrou de marche/arrêt
gèle son courant minimal (LOCK_MIN_ON) au lieu de le prêter. Le verrou
borne la somme éco + confort.
* command.divergent publie ce verdict (même cycle que RECREDIT_SUSPENDED).
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 25 Oct 2026 20:07:00 +0100
powersync-energy-plugin-nymea (1.15.2+etm75) trixie; urgency=medium
* Heures creuses : zones au prix le plus bas du tarif. En mode éco, la borne

View File

@ -25,12 +25,18 @@ rien sur les cas qu'il ne sait pas produire.
| `charger` | ✅ | ✅ | ❌ |
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
| `simpleCharger` | ❌ | ❌ | ❌ |
| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ |
| `powerSwitch` | ✅ | ❌ | — |
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
| `energyStorage` | ✅ | ❌ | — |
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
| `car`, `notification` | ❌ | ❌ | — |
`chargerUnmetered` mesure sa puissance mais publie ses courants sous `currentL1..3`, comme le
plugin V2C Trydan, et jamais sous `currentPhaseX` : `EvCharger::meteredPhases()` y est vide. Les
autres bornes du mock publiaient toutes leurs phases, si bien qu'une borne **non mesurée par
phase** — le cas d'un V2C Trydan — était introuvable par la suite.
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` /
`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.

View File

@ -17,6 +17,7 @@
#include "scheduler/optimizerstatus.h"
#include "scheduler/publishedplan.h"
#include "scheduler/optimizertrace.h"
#include "scheduler/optimizerplan.h"
#include "types/decisionreason.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
@ -532,6 +533,12 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
m_waterfallEvIds = plan.waterfallEvIds;
// Lot délestage triphasé — la marge de soutirage s'oppose aussi aux consignes d'un OPTIMISEUR,
// avant qu'elles partent : ce que la protection a retiré ne revient pas tant que le compteur
// ne lui en laisse pas la place. Le rule-based, lui, est déjà borné dans sa cascade.
if (plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur))
bornerOptimiseurAuSoutirage(slot, ctx);
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
@ -979,6 +986,82 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
"ce qui pouvait l'être. La cause est hors du waterfall.";
}
void EnergyArbitrator::bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx)
{
const DrawMargin marge = ctx.drawMargin;
if (!marge.borne)
return; // aucun plafond en vigueur : rien à opposer
QHash<QString, const LoadContext *> charges;
for (const LoadContext &lc : ctx.loads)
charges.insert(lc.id, &lc);
double libreParPhaseW = qMax(0.0, marge.watts);
for (LoadAction &a : slot.actions) {
if (!OptimizerPlan::estActionOptimiseur(a) || m_shedActions.contains(a.loadId))
continue;
const LoadContext *lc = charges.value(a.loadId, nullptr);
if (!lc || !lc->telemetry.available)
continue;
const bool borne = lc->adapter == QLatin1String("evcharger");
const int phases = qMax(1, lc->declared.phases);
// Ce que la charge TIENT déjà, et qu'on ne lui retire pas ici.
double engageW = qMax(0.0, lc->telemetry.currentPowerW);
if (borne)
if (const std::optional<double> commandee = puissanceCommandee(a.loadId))
engageW = qMax(0.0, *commandee);
// Ce que l'action poserait, écrêtée au matériel comme l'adaptateur le fera. Pour une borne,
// le matériel est son courant maximal sur ses phases : `declared.maxPowerW` est l'enveloppe
// de la cascade, que l'EvAdapter ne réapplique pas.
const double materielW = borne ? lc->declared.maxA * 230.0 * phases : lc->declared.maxPowerW;
double demandeW = 0;
if (a.kind == LoadAction::Setpoint)
demandeW = materielW > 0 ? qMin(a.powerW, materielW) : a.powerW;
else if (a.kind == LoadAction::State)
demandeW = lc->declared.estimatedPowerW.value(a.state, 0.0);
else
continue;
const double plafondW = engageW + libreParPhaseW * phases;
if (demandeW <= plafondW + 0.5) {
libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, demandeW - engageW) / phases);
continue;
}
double accordeW = plafondW;
if (a.kind == LoadAction::Setpoint) {
if (borne) // l'adaptateur arrondit au plus proche : on lui donne un ampère entier
accordeW = std::floor(plafondW / (230.0 * phases) + 1e-6) * 230.0 * phases;
a.powerW = accordeW;
a.estimatedPowerW = accordeW;
} else {
// L'état le plus haut qui tient sous le plafond. Les états 1 et 2 valent 0 W : c'est 2
// qui est retenu, jamais 1 (ECS-413).
int etat = 2;
for (int e = 3; e <= 4; ++e)
if (lc->declared.estimatedPowerW.value(e, 0.0) <= plafondW + 0.5)
etat = e;
a.state = etat;
accordeW = lc->declared.estimatedPowerW.value(etat, 0.0);
}
libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, accordeW - engageW) / phases);
// DÉFAUT 5 — le motif est celui du mécanisme qui a produit l'issue, pas celui que
// l'optimiseur avait donné à sa demande. Le niveau unique `optimizer` reste : c'est
// l'optimiseur qui a décidé, la sécurité qui a borné.
a.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, false, 0, false) };
a.clamped = true;
if (!a.levels.isEmpty())
a.levels.first().reason = a.reason;
qCInfo(dcNymeaEnergy()).noquote()
<< "[Arbitre] Garde de soutirage" << a.loadId << "— l'optimiseur demandait"
<< qRound(demandeW) << "W, la charge tient" << qRound(engageW) << "W, accordé"
<< qRound(accordeW) << "W |" << renderFr(a.reason, lc->label);
}
}
void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
{
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) {
@ -1068,6 +1151,11 @@ DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const
{QStringLiteral("cut"), t.cut},
{QStringLiteral("overshootW"), qRound(t.overshootW)},
{QStringLiteral("limitA"), phasePowerLimit()}};
// Lot délestage triphasé — le SEUIL où la protection agit, sous la limite de la marge de
// sécurité. Absent quand la marge est nulle : seuil et limite se confondent alors, et la
// charge utile reste celle d'avant ce lot.
if (phaseSafetyMarginPercent() > 0)
params.insert(QStringLiteral("thresholdA"), qRound(effectivePhaseLimit() * 10) / 10.0);
// Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée,
// aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP.
if (!t.phase.isEmpty())
@ -1174,11 +1262,11 @@ bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::Cha
// jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon.
QString phase;
double depassementW = 0;
if (RootMeter *compteur = internalRootMeter()) {
const double limiteW = 230.0 * phasePowerLimit();
const QHash<QString, double> parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()},
{QStringLiteral("B"), compteur->currentPowerPhaseB()},
{QStringLiteral("C"), compteur->currentPowerPhaseC()}};
if (internalRootMeter()) {
// Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total,
// limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé.
const double limiteW = 230.0 * effectivePhaseLimit();
const QHash<QString, double> parPhase = phaseConsumptionW();
if (evCharger->phaseCount() == 1) {
phase = chargerPhaseFor(evCharger);
depassementW = qMax(0.0, parPhase.value(phase) - limiteW);
@ -1378,7 +1466,10 @@ QList<DrawCap> EnergyArbitrator::drawCaps() const
if (limiteA > 0) {
DrawCap disjoncteur;
disjoncteur.source = DrawCapSource::Breaker;
disjoncteur.perPhaseW = limiteA * 230.0;
// Lot délestage triphasé (décision du 2026-10-27) — le plafond est le SEUIL, la limite
// moins la marge de sécurité : 27 A pour 30 A à 10 %. La cascade alloue sous lui, et la
// protection agit au-dessus (SmartChargingManager::verifyOverloadProtection()).
disjoncteur.perPhaseW = effectivePhaseLimit() * 230.0;
plafonds.append(disjoncteur);
}
@ -1401,17 +1492,22 @@ QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi
const QList<Electricity::Phase> toutes =
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
for (Electricity::Phase p : toutes) {
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
// Tirage de chaque phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
QList<double> lus;
for (Electricity::Phase p : toutes)
lus.append((limiteA - meter->calculateAllowanceAmpere(p, limiteA)) * 230.0);
// Lot délestage triphasé — les TROIS phases d'abord, rendues cohérentes avec le total, et le
// masque ensuite : des phases en retard sur le total ont retardé la protection de 1 min 45
// (relevé du 2026-10-27). Voir tiragesCoherentsAvecLeTotal(), qui ne fait que relever.
lus = tiragesCoherentsAvecLeTotal(lus, meter->currentPower());
for (int i = 0; i < toutes.size(); ++i) {
if (masque != Electricity::PhaseNone && !masque.testFlag(toutes.at(i)))
continue;
// Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
const double margeA = meter->calculateAllowanceAmpere(p, limiteA);
tirages.append((limiteA - margeA) * 230.0);
tirages.append(lus.at(i));
}
return tirages;
}
@ -1485,7 +1581,20 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
// l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre
// n'est jamais négatif ; ce qu'il faut retirer à la maison, si.
return qMax(0.0, marge.watts) * phaseCount;
const double libreW = qMax(0.0, marge.watts) * phaseCount;
// Lot délestage V2C (question 5 du lot délestage triphasé) — HORS DÉPASSEMENT, la place libre
// seule fait alterner une borne qui tient une bonne part de sa phase : pleine, la place tombe ;
// au plancher, elle remonte (16, 9, 16, 9 A sous 25 A et 10 % de marge, maison à 4 A). Le
// plafond qui lui rend son tirage, moins 1 A, se pose en un cycle — c'est la règle des heures
// creuses (lot e2, D8). EN DÉPASSEMENT, la place libre reste la règle, et la raison de sécurité
// ci-dessus avec elle : on ne rend rien à une borne sur une phase au-dessus du seuil.
// Le MAXIMUM des deux garde la règle d'avant quand le tirage n'est pas attribuable (le plafond
// rendu vaut alors la place libre moins 1 A) ou quand la borne tire moins d'un ampère.
const PlafondRendu rendu = evCeilingGivingBackDraw(ev, phaseCount);
if (!rendu.attribuable || rendu.depasse || rendu.watts < 0)
return libreW;
return qMax(libreW, rendu.watts);
}
namespace {
@ -1497,38 +1606,90 @@ constexpr double MargeHeuresCreusesA = 1.0;
double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const
{
return evCeilingGivingBackDraw(ev, phaseCount).watts;
}
EnergyArbitrator::TirageBorne EnergyArbitrator::evAttributableDraw(EvCharger *ev, uint phaseCount) const
{
TirageBorne tirage;
if (!ev || phaseCount == 0)
return tirage;
// Ce que la borne tire, MESURÉ. Pas la consigne : une voiture qui prend 8 A d'une borne
// commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 ouvrirait 8 A qu'elle peut
// prendre d'un coup. Une borne sans mesure ne rend rien.
if (ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull())
return tirage;
const Electricity::Phases mesurees = ev->meteredPhases();
if (mesurees != Electricity::PhaseNone) {
// Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas —
// et la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule
// (phases mesurées ≠ phases visées), on ne rend donc rien.
if (Electricity::getPhaseCount(mesurees) != phaseCount)
return tirage;
tirage.phases = mesurees;
} else {
// Lot délestage V2C — une borne qui ne publie PAS ses courants par phase (le V2C Trydan
// publie `currentL1..3`, que l'interface ne connaît pas). meteredPhases() est vide, et
// jusqu'ici rien n'était rendu : le plafond lisait la borne comme une charge de la maison
// sur sa phase, et la consigne alternait chaque nuit (16, 6, 13, 6 A sous 25 A).
//
// Ses phases sont celles que le moteur lui suppose DÉJÀ — phasesForCount(), la phase A en
// monophasé : c'est le masque de ce plafond, celui de L4 (chargerPhaseKey()) et celui de
// l'amont (prepareInformation()). Attribuer son tirage ailleurs ferait deux hypothèses.
//
// Seulement quand c'est sûr :
// - une borne qui publie des états par phase, tous nuls alors qu'elle tire, a une mesure
// qui se contredit — elle n'est pas « non mesurée » ;
// - elle doit annoncer autant de phases que le cycle en vise. Une triphasée qui charge
// une voiture monophasée (le cycle vise 1, elle annonce 3), ou une borne en cours de
// bascule, tire sur des phases que rien ne dit ;
// - une ou trois phases : la monophasée sur sa phase, la triphasée un tiers par phase.
// En biphasé, le masque A+B est une convention de l'amont, pas une observation : rien
// n'est rendu (décision du 2026-10-27 : mono et tri seulement).
for (const char *etat : {"currentPhaseA", "currentPhaseB", "currentPhaseC",
"currentPowerPhaseA", "currentPowerPhaseB", "currentPowerPhaseC"}) {
if (ev->thing()->hasState(etat))
return tirage;
}
if ((phaseCount != 1 && phaseCount != 3) || ev->phaseCount() != phaseCount)
return tirage;
tirage.phases = const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
}
tirage.watts = qMax(0.0, ev->currentPower());
return tirage;
}
EnergyArbitrator::PlafondRendu EnergyArbitrator::evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const
{
PlafondRendu plafond;
RootMeter *meter = internalRootMeter();
if (!ev || phaseCount == 0 || !meter)
return -1;
return plafond;
const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty())
return -1;
return plafond;
// Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui
// prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16
// ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien.
const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull();
const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone;
// Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et
// la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases
// mesurées ≠ phases visées), on ne rend donc rien.
const bool attribuable = tirees != Electricity::PhaseNone
&& Electricity::getPhaseCount(tirees) == phaseCount;
const Electricity::Phases masque = attribuable
? tirees : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0;
const TirageBorne tirage = evAttributableDraw(ev, phaseCount);
plafond.attribuable = tirage.phases != Electricity::PhaseNone;
const Electricity::Phases masque = plafond.attribuable
? tirage.phases : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
const double tireW = plafond.attribuable ? tirage.watts : 0.0;
// Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui
// annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la
// limite. Au mieux, une phase vaut sa limite.
QList<double> sansLaBorne = phaseDrawW(masque, phasePowerLimit());
const QList<double> avecLaBorne = phaseDrawW(masque, phasePowerLimit());
plafond.depasse = resolveDrawMargin(plafonds, avecLaBorne, meter->currentPower()).watts < 0;
QList<double> sansLaBorne = avecLaBorne;
for (double &w : sansLaBorne)
w = qMax(0.0, w - tireW / phaseCount);
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
qMax(0.0, meter->currentPower() - tireW));
if (!marge.borne)
return -1;
return plafond;
// Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche,
// et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié.
@ -1538,7 +1699,8 @@ double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) con
// vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à
// sortir du dépassement ou jusqu'au plancher, où L4 décide.
const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA);
return qMax(0.0, parPhaseA) * 230.0 * phaseCount;
plafond.watts = qMax(0.0, parPhaseA) * 230.0 * phaseCount;
return plafond;
}
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
@ -3647,8 +3809,11 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex
// producteur ? » de l'apprentissage (observerPourApprentissage()).
if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty())
fil.pvCurrentW = qAbs(m_em->currentPowerProduction());
// Lot délestage triphasé — l'optimiseur reçoit le SEUIL que le moteur fera respecter (limite
// moins la marge de sécurité), pas la limite : un plan bâti sur la limite serait écrêté à
// chaque cycle par la garde de soutirage.
if (phasePowerLimit() > 0)
fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())};
fil.phaseLimitA = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()};
if (ctx.battery.present)
fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0);
if (m_loadConfigStore)

View File

@ -803,6 +803,39 @@ public:
*/
void shedWaterfallForOverload(const QDateTime &now);
/*!
* \brief Garde de soutirage : une consigne d'OPTIMISEUR ne fait monter une charge que de la
* marge que le compteur laisse sous le seuil (lot délestage triphasé, défauts 4 et 5).
*
* \param slot Créneau du cycle, modifié en place AVANT le dispatch. Seules les actions
* d'optimiseur (niveau unique \c optimizer) sont lues ; celles que L4 revendique ce cycle
* (\c m_shedActions) sont laissées au délestage.
* \param ctx Contexte du cycle : \c drawMargin (seuil, phase la plus chargée, signée) et
* la télémétrie des charges.
*
* \par Pourquoi
* La marge de soutirage bornait la cascade du rule-based, et RIEN d'autre : un optimiseur qui
* demande le maximum voyait ses consignes écrêtées au matériel seulement. Relevé le 2026-10-27 :
* la protection déleste, le compteur repasse juste sous la limite, le délestage se relâche,
* l'optimiseur redemande 3 kW, et la surcharge reprend une minute. La règle de sécurité était
* celle du disjoncteur ; elle devient celle du moteur, quel que soit le décideur.
*
* \par Contrat
* - Seule une HAUSSE est bornée, jamais une baisse : réduire est le geste de la protection, et
* la garde ne réduit rien sous ce que la charge tient déjà. Sous un compteur en dépassement
* (marge négative), la charge garde ce qu'elle tient.
* - Plafond = engagé + marge libre × phases de la charge. L'engagé d'une borne est sa dernière
* commande TENUE (la protection l'a peut-être rabaissée dans ce même cycle) ; sinon sa mesure.
* Celui des autres charges est leur télémétrie.
* - La marge est la pire phase, et chaque hausse servie la consomme pour les suivantes, dans
* l'ordre du créneau.
* - Une consigne écrêtée porte \c DRAW_CAP (le mécanisme qui a produit l'issue, règle 7-b) et
* \c clamped ; une borne est écrêtée à l'ampère entier inférieur, une PAC à l'état le plus haut
* qui tient (jamais 1).
* - Sans plafond en vigueur (limite non réglée), rien n'est borné.
*/
void bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx);
//! \brief Replace dans \c slot les décisions des charges délestées par L4.
//! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE :
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
@ -884,12 +917,17 @@ public:
* 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 la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà.
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
* \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la
* borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la
* marge est négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
* consommation lui rendrait exactement la puissance qui cause le dépassement
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
* fausse : le code ne l'a jamais fait.
* (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser).
* \note HORS DÉPASSEMENT (lot délestage V2C, question 5), le plafond vaut au moins celui qui
* rend à la borne son tirage attribuable, moins 1 A (\c evCeilingGivingBackDraw()). Sans
* lui, une borne qui tient une bonne part de sa phase alternait : pleine, la place libre
* tombait ; au plancher, elle remontait (16, 9, 16, 9 A sous 25 A et 10 % de marge). Le
* maximum des deux garde la règle d'avant quand rien n'est attribuable, ou quand la borne
* tire moins d'un ampère.
* \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.
*/
@ -912,9 +950,11 @@ public:
*
* \par Contrat
* - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et
* seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans
* mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même.
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est
* sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou,
* pour une borne qui ne publie pas ses phases, celles que le moteur lui suppose (lot
* délestage V2C). Sinon — borne sans mesure, à l'arrêt, ou qui bascule — rien n'est rendu,
* et la marge s'applique quand même.
* - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire
* sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur
* voit ne se fabrique donc pas de place au-delà de la limite.
@ -927,7 +967,8 @@ public:
* mange jamais la marge.
*
* \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là,
* \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas.
* \c evPhaseAllowanceW() reste la règle ; depuis le lot délestage V2C, elle rend elle aussi
* le tirage hors dépassement.
*/
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
@ -1010,6 +1051,53 @@ protected:
const QDateTime &currentDateTime) override;
private:
//! Lot délestage V2C — ce qu'une borne tire, et sur quelles phases. \c phases vaut
//! \c PhaseNone quand l'attribution n'est pas sûre : rien n'est alors rendu.
struct TirageBorne {
Electricity::Phases phases = Electricity::PhaseNone;
double watts = 0;
};
/*!
* \brief Lot délestage V2C — le tirage MESURÉ d'une borne, attribué à ses phases quand c'est sûr.
*
* \param ev Borne. \param phaseCount Nombre de phases VISÉ par le cycle.
* \return Les phases et la puissance (\c currentPower de la borne, jamais sa consigne) ; un
* tirage vide si la borne n'a pas de \c currentPower ou si l'attribution n'est pas sûre.
*
* \par Deux cas
* - Borne MESURÉE par phase (\c meteredPhases() non vide) : ses phases mesurées, si elles
* sont aussi nombreuses que les phases visées. Sinon elle bascule, et rien n'est attribué.
* C'est la règle du lot e2, inchangée.
* - Borne NON MESURÉE par phase (aucun état \c currentPhaseX ni \c currentPowerPhaseX : le
* V2C Trydan publie \c currentL1..3) : les phases que le moteur lui suppose déjà
* (\c phasesForCount() — la phase A en monophasé, comme \c chargerPhaseKey() pour L4 et
* \c prepareInformation() pour l'amont), à condition que la borne annonce autant de phases
* que le cycle en vise (\c EvCharger::phaseCount()), et que ce soit une ou trois. Une
* triphasée qui charge une voiture monophasée, ou une borne en cours de bascule, n'est donc
* pas attribuée.
* \note Une borne qui PUBLIE des courants par phase, tous nuls alors qu'elle tire, n'est pas
* « non mesurée » : sa mesure se contredit, et rien n'est attribué.
* \note L'hypothèse de la phase A est celle de tout le moteur. Une monophasée câblée sur une
* autre phase la met en défaut, ici comme dans L4 : c'est le plugin qui peut la lever, en
* publiant ses courants par phase.
*/
TirageBorne evAttributableDraw(EvCharger *ev, uint phaseCount) const;
//! Lot délestage V2C — le plafond d'une borne qui lui rend ce qu'elle tire (règle du lot e2).
struct PlafondRendu {
double watts = -1; //!< Plafond en W, multiple de 230 W par phase ; −1 sans limite.
bool depasse = false; //!< Une phase de la borne, borne comprise, est au-dessus du seuil.
bool attribuable = false;//!< Le tirage de la borne a été rendu (\c evAttributableDraw()).
};
/*!
* \brief Lot délestage V2C — limite − (tirage de la phase − tirage attribué à la borne) − 1 A,
* à l'ampère inférieur, × phases. Le calcul d'\c evOffPeakAllowanceW(), mis en commun avec
* \c evPhaseAllowanceW() ; son contrat est écrit sur la première.
*/
PlafondRendu evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const;
/*!
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
*

View File

@ -555,7 +555,9 @@ QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadActio
a.counts = niveau.counts;
a.levels = {niveau};
a.clamped = !applique || differe(demande, appliquees.value(a.loadId), adapter);
// Déjà écrêtée par la garde de soutirage de l'arbitre, avant le dispatch : la demande
// reçue ici est la demande bornée, l'écrêtage reste un écrêtage.
a.clamped = demande.clamped || !applique || differe(demande, appliquees.value(a.loadId), adapter);
if (a.clamped)
ecretages.append({a.loadId, decrire(demande, adapter),
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});

View File

@ -613,6 +613,20 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
LoadContext lcConfort = lc;
if (plafondPartage.contains(lc.id))
lcConfort.declared.minPowerW = 0;
// Lot délestage V2C (question 5) — HORS HEURES CREUSES AUSSI, la somme éco + confort d'une
// BORNE tient dans son plafond de cycle. En « Éco avec minimum », le niveau éco (le
// plancher) s'ajoutait au confort, borné seul par le plafond : sous une limite de 25 A, une
// maison à 10 A sur A, le plafond de 12,5 A plus 6 A d'éco faisaient 16 A, la phase à 26 A,
// et L4 coupait — puis la borne repartait au cycle suivant. Le confort est donc servi dans
// ce que l'éco laisse du plafond, et le plancher porte sur la somme : la part confort n'a
// que ce qui manque à l'éco pour l'atteindre. Seule la BORNE de D9 est reprise ici ; le
// partage de l'achat (l'achat cède ce que le surplus finance) reste aux heures creuses.
else if (lc.adapter == QStringLiteral("evcharger") && niveauxEco.contains(lc.id)
&& niveauxEco.value(lc.id).targetW > 0 && lc.declared.maxPowerW > 0) {
const double ecoW = niveauxEco.value(lc.id).targetW;
lcConfort.declared.maxPowerW = qMax(0.0, lc.declared.maxPowerW - ecoW);
lcConfort.declared.minPowerW = qMax(0.0, lc.declared.minPowerW - ecoW);
}
// Même règle pour la puissance ENGAGÉE par un verrou : elle borne la SOMME éco + confort,
// qui est la consigne. Relevée en entier sur la part confort, elle s'ajoutait à un achat
// éco qui la couvrait déjà : une borne tenue à 6 A par son verrou d'arrêt, servie à 6 A

View File

@ -126,7 +126,16 @@ QString renderFr(const DecisionReason &reason, const QString &label)
// La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
if (c == QLatin1String(DecisionCode::OverloadThrottled)) {
const QString pourquoi = p.contains(QStringLiteral("phase"))
// Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite :
// le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu.
const QString ou = p.contains(QStringLiteral("phase"))
? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString())
: QString();
const QString pourquoi = p.contains(QStringLiteral("thresholdA"))
? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)")
.arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble()))
.arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString())
: p.contains(QStringLiteral("phase"))
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));

View File

@ -183,7 +183,9 @@ constexpr const char *DrawCap = "DRAW_CAP";
* \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue),
* \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque
* \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable).
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable), \c thresholdA (le seuil
* où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent**
* quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui).
*
* \par Ce qu'il publie, et ce qu'il ne décide pas
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi

View File

@ -85,3 +85,24 @@ QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW
params.insert(QStringLiteral("minOnBypassed"), true);
return params;
}
QList<double> tiragesCoherentsAvecLeTotal(const QList<double> &parPhaseW, double totalW)
{
QList<double> tirages = parPhaseW;
if (tirages.isEmpty())
return tirages;
double somme = 0;
double importe = 0;
for (double w : tirages) {
somme += w;
importe += qMax(0.0, w);
}
const double manque = totalW - somme;
// Sous le demi-watt, c'est l'arrondi d'une somme de flottants, pas une lecture en retard : la
// corriger changerait des décisions au douzième chiffre sans rien protéger.
if (manque <= 0.5)
return tirages; // les phases disent au moins ce que dit le total : rien à corriger
for (double &w : tirages)
w += importe > 0 ? manque * qMax(0.0, w) / importe : manque / tirages.size();
return tirages;
}

View File

@ -111,6 +111,30 @@ DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
//! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
QString drawCapSourceCode(DrawCapSource s);
/*!
* \brief Tirages par phase rendus COHÉRENTS avec le total du compteur — jamais abaissés.
*
* \param parPhaseW Tirage lu sur chaque phase, en watts (import > 0, export < 0).
* \param totalW Puissance totale du même compteur, en watts (import > 0).
* \return Les mêmes tirages si leur somme atteint le total, à un demi-watt près. Sinon, le
* MANQUE (total − somme) est ajouté aux phases qui importent, au prorata de ce qu'elles lisent
* — ou à parts égales si aucune n'importe. Aucune valeur n'est jamais diminuée. Liste vide :
* rendue telle quelle.
*
* \par Pourquoi
* Les phases d'un compteur triphasé somment à son total : la phase la plus chargée porte AU MOINS
* le tiers de ce que la maison importe. Une somme des phases sous le total ne peut donc venir que
* de lectures en retard, et c'est le total qu'il faut croire pour la sécurité. Relevé le
* 2026-10-27 sur une installation d'essai : 22,3 kW importés (32,3 A par phase) pendant que les
* phases publiaient 29,1 A, puis 30,4 A — la protection a attendu 1 min 45 qu'elles franchissent
* la limite. Le sens de l'erreur commande la règle : relever une phase ne peut que faire agir la
* protection plus tôt ou plus fort ; c'est le seul sens admissible dans une couche de sécurité.
*
* \note Le prorata est une HYPOTHÈSE de répartition ; la seule garantie est sur le maximum, qui ne
* tombe jamais sous le tiers du total importé.
*/
QList<double> tiragesCoherentsAvecLeTotal(const QList<double> &parPhaseW, double totalW);
/*!
* \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit.
*

View File

@ -81,13 +81,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
params.clear(); returns.clear();
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything.";
returns.insert("phasePowerLimit", enumValueName(Uint));
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. "
"[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): "
"the engine allocates under the effective limit and its overload protection acts above it.";
returns = phasePowerLimitSchema();
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
params.clear(); returns.clear();
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled.";
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled. "
"[ETM] Optional phaseSafetyMarginPercent, 0 to 25: absent, the margin is kept; out of range, "
"EnergyErrorInvalidParameter and nothing changes.";
params.insert("phasePowerLimit", enumValueName(Uint));
params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// Notifications
params.clear();
description = "Emitted whenever the phase power limit configuration changes.";
params.insert("phasePowerLimit", enumValueName(Uint));
description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
params = phasePowerLimitSchema();
registerNotification("PhasePowerLimitChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
// [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
emit PhasePowerLimitChanged(phasePowerLimitPayload());
});
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
emit PhasePowerLimitChanged(phasePowerLimitPayload());
});
params.clear();
@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit());
return createReply(returns);
return createReply(phasePowerLimitPayload());
}
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitSchema()
{
// [ETM] UNE déclaration pour GetPhasePowerLimit et PhasePowerLimitChanged (règle 11).
QVariantMap schema;
schema.insert("phasePowerLimit", enumValueName(Uint));
schema.insert("phaseSafetyMarginPercent", enumValueName(Uint));
schema.insert("effectivePhaseLimit", enumValueName(Double));
return schema;
}
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitPayload() const
{
return {{"phasePowerLimit", m_smartChargingManager->phasePowerLimit()},
{"phaseSafetyMarginPercent", m_smartChargingManager->phaseSafetyMarginPercent()},
{"effectivePhaseLimit", m_smartChargingManager->effectivePhaseLimit()}};
}
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap &params)
{
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
// [ETM] La marge est jugée AVANT toute écriture : refusée, rien ne change — pas même la limite.
if (params.contains("phaseSafetyMarginPercent")
&& params.value("phaseSafetyMarginPercent").toUInt() > SmartChargingManager::PhaseSafetyMarginMaxPercent) {
QVariantMap returns;
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter));
return createReply(returns);
}
if (params.contains("phaseSafetyMarginPercent"))
m_smartChargingManager->setPhaseSafetyMarginPercent(params.value("phaseSafetyMarginPercent").toUInt());
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
QVariantMap returns;
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));

View File

@ -239,6 +239,14 @@ private:
* celui qu'on ne teste pas tous les jours.
*/
static QVariantMap loadTelemetrySchema();
/*!
* \brief [ETM] Charge utile commune de \c GetPhasePowerLimit et \c PhasePowerLimitChanged :
* la limite, la marge de sécurité et le seuil effectif qui en résulte.
* \return Le schéma (\c phasePowerLimitSchema) ou les valeurs courantes (\c phasePowerLimitPayload).
*/
static QVariantMap phasePowerLimitSchema();
//! \brief [ETM] Valeurs courantes, dans la forme de \c phasePowerLimitSchema(). \return La charge utile.
QVariantMap phasePowerLimitPayload() const;
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
//! et \c OptimizerStatusChanged. \return Le schéma des champs.

View File

@ -23,6 +23,7 @@
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "smartchargingmanager.h"
#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal()
#include "evcharger.h"
#include "rootmeter.h"
#include "energysettings.h"
@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa
{
EnergySettings settings;
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
// [ETM] Marge de sécurité (2026-10-27) : absente du réglage, elle vaut 10 % ; hors bornes (un
// fichier édité à la main), elle reprend 10 % plutôt qu'une valeur qu'on refuserait par RPC.
m_phaseSafetyMarginPercent = settings.value("phaseSafetyMarginPercent", 10).toUInt();
if (m_phaseSafetyMarginPercent > PhaseSafetyMarginMaxPercent)
m_phaseSafetyMarginPercent = 10;
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
// 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique.
//
@ -186,6 +192,39 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit)
}
}
bool SmartChargingManager::setPhaseSafetyMarginPercent(uint percent)
{
// [ETM] Hors bornes : refus, et l'état ne bouge pas.
if (percent > PhaseSafetyMarginMaxPercent)
return false;
if (m_phaseSafetyMarginPercent != percent) {
m_phaseSafetyMarginPercent = percent;
emit phaseSafetyMarginPercentChanged(m_phaseSafetyMarginPercent);
EnergySettings settings;
settings.setValue("phaseSafetyMarginPercent", m_phaseSafetyMarginPercent);
#ifndef ENERGY_SIMULATION
// Même raison que setPhasePowerLimit() : le seuil change, le cycle le prend tout de suite.
update(QDateTime::currentDateTime());
#endif
}
return true;
}
QHash<QString, double> SmartChargingManager::phaseConsumptionW() const
{
if (!m_rootMeter)
return {};
// [ETM] Les phases relevées au niveau du total si elles sont en retard sur lui — voir
// tiragesCoherentsAvecLeTotal() : la phase la plus chargée porte au moins le tiers du total.
const QList<double> w = tiragesCoherentsAvecLeTotal({m_rootMeter->currentPowerPhaseA(),
m_rootMeter->currentPowerPhaseB(),
m_rootMeter->currentPowerPhaseC()},
m_rootMeter->currentPower());
return {{"A", w.at(0)}, {"B", w.at(1)}, {"C", w.at(2)}};
}
double SmartChargingManager::acquisitionTolerance() const
{
return m_acquisitionTolerance;
@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDate
return;
}
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
{"B", m_rootMeter->currentPowerPhaseB()},
{"C", m_rootMeter->currentPowerPhaseC()}};
// [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
// marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
bool limitExceeded = false;
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
// Note: for now we assume the phases are always connected this way:
// EV phase A -> house phase A
@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDate
foreach (const QString &phase, currentPhaseConsumption.keys()) {
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)";
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << effectivePhaseLimit() << "A)";
limitExceeded = true;
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
requiredThrottlePower[phase] = overshot;
@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
{"B", m_rootMeter->currentPowerPhaseB()},
{"C", m_rootMeter->currentPowerPhaseC()}};
// [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
QHash<QString, double> availablePhasePower = {{"A", 0},
{"B", 0},
@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
{
// We don't assume we have the entire phase power for charging...
double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230;
double housePhaseLimitPower = effectivePhaseLimit() * 230; // [ETM] marge de sécurité
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
@ -637,6 +675,12 @@ void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
evChargers.append(evCharger);
}
// [ETM] Les bornes préparées CE cycle — exactement celles que l'étape 2 va traiter. La table
// m_processInfos ne le dit pas : elle n'est jamais vidée, et operator[] y crée une entrée par
// défaut (1 phase, pas de bascule) dès qu'on la lit ainsi, dans verifyOverloadProtection() par
// exemple. Lue par internalHasProcessInfo(), voir sa documentation.
m_preparedChargers = QSet<EvCharger *>(evChargers.constBegin(), evChargers.constEnd());
// 2. Prepare all information avaialble for each charger
foreach (EvCharger *evCharger, evChargers) {
@ -1314,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
// Adjust down to not overload any phases.
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit);
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit)
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
@ -1396,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit);
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit)
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection

View File

@ -27,6 +27,7 @@
#include <QObject>
#include <QTimer>
#include <QSet>
#include "energymanagerconfiguration.h"
#include "types/charginginfo.h"
@ -63,6 +64,30 @@ public:
uint phasePowerLimit() const;
void setPhasePowerLimit(uint phasePowerLimit);
/*!
* \brief [ETM] Marge de sécurité sous la limite de phase, en pourcents (décision du 2026-10-27).
* \return 10 par défaut ; persistée avec la limite (clé \c phaseSafetyMarginPercent).
* \note Le moteur vise \c effectivePhaseLimit(), pas \c phasePowerLimit() : pour le plafond
* qu'il déclare et alloue, et pour le seuil où la protection de surcharge agit.
*/
uint phaseSafetyMarginPercent() const { return m_phaseSafetyMarginPercent; }
/*!
* \brief [ETM] Règle la marge de sécurité.
* \param percent Entre 0 et \c PhaseSafetyMarginMaxPercent inclus.
* \return Faux, sans rien changer, hors de ces bornes.
* \note 0 est admis : c'est le comportement d'avant la marge, et la bonne valeur pour une
* installation dont la limite saisie est déjà une limite réduite.
*/
bool setPhaseSafetyMarginPercent(uint percent);
//! [ETM] Borne haute de la marge : au-delà, la maison perd plus d'un quart de son branchement.
static constexpr uint PhaseSafetyMarginMaxPercent = 25;
/*!
* \brief [ETM] Le seuil effectif par phase, en ampères : limite × (1 − marge).
* \return 0 quand la limite n'est pas réglée (protection désactivée, contrat amont).
*/
double effectivePhaseLimit() const
{ return m_phasePowerConsumptionLimit * (100.0 - m_phaseSafetyMarginPercent) / 100.0; }
double acquisitionTolerance() const;
void setAcquisitionTolerance(double acquisitionTolerance);
@ -99,6 +124,7 @@ public:
signals:
void phasePowerLimitChanged(int phasePowerLimit);
void phaseSafetyMarginPercentChanged(uint percent); // [ETM]
void acquisitionToleranceChanged(double acquisitionTolerance);
void batteryLevelConsiderationChanged(double batteryLevelConsideration);
void lockOnUnplugChanged(bool lockOnUnplug);
@ -113,7 +139,9 @@ signals:
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
// All changes below are visibility-only (private → protected / virtual added).
// Zero logic change. Revert by deleting this block and restoring private slots.
// Zero logic change — EXCEPT items 9 to 11 of the inventory (lot délestage triphasé,
// 2026-10-27), which change the overload protection on purpose and say so.
// Revert by deleting this block and restoring private slots.
//
// INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et
// échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la
@ -139,6 +167,19 @@ signals:
// protection de surcharge. Documenté ci-dessous.
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
// ci-dessous. Pas un réglage : jamais persistée.
// 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() :
// les bornes préparées au dernier cycle. Seul
// lecteur : internalHasProcessInfo().
// 10. phaseSafetyMarginPercent(), setPhaseSafetyMarginPercent(), effectivePhaseLimit(),
// PhaseSafetyMarginMaxPercent, phaseSafetyMarginPercentChanged()
// — la marge de sécurité (décision du 2026-10-27),
// documentée en tête de la classe. Elle CHANGE la
// logique amont, en connaissance de cause : le seuil
// de verifyOverloadProtection() et de sa reprise, la
// limite de prepareInformation() et les deux
// allowances du proxy (marques [ETM] dans le .cpp).
// 11. phaseConsumptionW() — lecture des phases cohérente avec le total, seule
// lecture de la protection et de sa reprise.
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
// — crochets ETM, virtuels, documentés ci-dessous.
// Ce sont EUX qui garantissent « une charge, un
@ -311,8 +352,27 @@ protected:
* \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
* budget du cycle est divisé par trois.
* \note **Lu sur l'ensemble des bornes préparées ce cycle, pas sur la table.** La table
* \c m_processInfos n'est jamais vidée, et l'amont la lit aussi par \c operator[], qui y
* insère une entrée par défaut : \c verifyOverloadProtection() le fait pour toute borne qu'il
* rabaisse. Tester la table rendait donc VRAI pour une borne sans voiture après sa première
* surcharge, et la borne triphasée devenait monophasée dans les comptes (relevé du
* 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée
* laissait son entrée de la veille (\c testAnUnassignedCarTakesItsPhasesAway).
*/
bool internalHasProcessInfo(EvCharger *evCharger) const { return m_processInfos.contains(evCharger); } // [ETM] new
/*!
* \brief [ETM] Tirage de chaque phase au compteur principal, en watts, cohérent avec le total.
* \return Clés \c "A", \c "B", \c "C" : \c currentPowerPhaseX relevés par
* \c tiragesCoherentsAvecLeTotal() si leur somme est sous \c currentPower — jamais abaissés.
* Vide sans compteur principal.
* \note Seule lecture des phases par la protection de surcharge et sa reprise, et par la
* publication de ses gestes (\c EnergyArbitrator::noterGesteBorne()) : le dépassement publié
* est celui qui a décidé.
*/
QHash<QString, double> phaseConsumptionW() const;
bool internalHasProcessInfo(EvCharger *evCharger) const // [ETM] new
{ return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); }
// [ETM] END
private slots:
@ -344,6 +404,7 @@ private:
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
QSet<EvCharger *> m_preparedChargers; // [ETM] préparées au dernier prepareInformation()
QHash<EvCharger *, ChargingActions> m_chargingActions;
QDateTime m_lastSpotMarketPlanning;
@ -352,6 +413,7 @@ private:
QHash<EvCharger *, bool> m_overloadProtectionActive;
uint m_phasePowerConsumptionLimit = 25;
uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27
double m_acquisitionTolerance = 0.5;
double m_batteryLevelConsideration = 0.9;

View File

@ -332,7 +332,8 @@ void TestCharging::addChargingInfo()
// Set phase power limit
uint phasePowerLimit = 25000;
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
verifyEnergyError(response);
removeDevices();
@ -691,7 +692,8 @@ void TestCharging::testEcoMode()
// Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter
@ -1069,7 +1071,8 @@ void TestCharging::testEcoModePhaseSwitching()
// Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter
@ -1381,7 +1384,8 @@ void TestCharging::testBatteryLevelConsideration()
// Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter
@ -1602,7 +1606,8 @@ void TestCharging::batteryLevelConsiderationStopCharging()
// Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter
@ -1840,7 +1845,8 @@ void TestCharging::testOverloadProtectionManualMode()
// Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter
@ -2011,7 +2017,8 @@ void TestCharging::testOverloadProtectionEcoMode()
// Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter

View File

@ -451,6 +451,23 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addUnmeteredCharger(const QString &phases, double maxChargingCurrentUpperLimit, quint16 port)
{
const QVariantList thingParams = {
QVariantMap{{"paramTypeId", "{4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5}"}, {"value", port}},
QVariantMap{{"paramTypeId", "{ec3e73a0-cca9-415a-bc64-9e841d666853}"}, {"value", phases}},
QVariantMap{{"paramTypeId", "{803ba501-ae0f-4d58-8097-6ebb0928b3d8}"}, {"value", maxChargingCurrentUpperLimit}},
};
QVariantMap params;
params.insert("thingClassId", mockChargerUnmeteredThingClassId.toString());
params.insert("name", "Unmetered charger");
params.insert("thingParams", thingParams);
QVariant response = injectAndWait("Integrations.AddThing", params);
verifyThingError(response);
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port)
{
QVariantList thingParams;

View File

@ -51,6 +51,9 @@ static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5
static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7");
static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c");
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
//! \brief Borne qui mesure sa PUISSANCE mais pas ses phases au sens du moteur — `currentL1..3`
//! comme le plugin V2C Trydan, jamais `currentPhaseX`. `EvCharger::meteredPhases()` y est vide.
static QUuid mockChargerUnmeteredThingClassId = QUuid("e297f8c4-934f-4af3-a655-29b5b799289b");
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
@ -110,6 +113,9 @@ public:
QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657);
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
//! \brief Ajoute une borne sans courants par phase lisibles par le moteur (façon V2C Trydan).
//! \param phases Câblage, `A`, `B`, `C` ou `ABC` — fixe, sans bascule.
QUuid addUnmeteredCharger(const QString &phases = "A", double maxChargingCurrentUpperLimit = 16, quint16 port = 26677);
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);

View File

@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
horsService.observerMinute(huitJours.first());
QVERIFY(!horsService.profil().calcule);
}
void TestLoadModel::testPhaseDrawsAreNeverBelowTheTotal()
{
// Le cas relevé le 2026-10-27 : 29,1 A lus sur chaque phase pour 22 277 W au total. Le manque
// est réparti au prorata : trois phases égales, trois tiers du total.
QList<double> w = tiragesCoherentsAvecLeTotal({6700, 6700, 6700}, 22277);
for (double x : w)
QVERIFY(qAbs(x - 22277.0 / 3) < 0.01);
// Cohérentes, ou AU-DESSUS du total (lecture plus fraîche que le total) : rien ne bouge.
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 6000), (QList<double>{1000, 2000, 3000}));
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 5000), (QList<double>{1000, 2000, 3000}));
// Une phase qui EXPORTE n'est jamais relevée vers l'import, et ne se voit jamais abaissée :
// le manque va aux phases qui importent, au prorata de ce qu'elles lisent.
w = tiragesCoherentsAvecLeTotal({-1000, 1000, 3000}, 4000);
QCOMPARE(w.at(0), -1000.0);
QCOMPARE(w.at(1), 1250.0);
QCOMPARE(w.at(2), 3750.0);
// Aucune phase n'importe (compteur sans courants par phase : tout à 0) : le total se répartit à
// parts égales — la phase la plus chargée en porte au moins le tiers, c'est la seule garantie.
w = tiragesCoherentsAvecLeTotal({0, 0, 0}, 9000);
QCOMPARE(w, (QList<double>{3000, 3000, 3000}));
// En export, rien à relever.
QCOMPARE(tiragesCoherentsAvecLeTotal({-500, -500, -500}, -1500), (QList<double>{-500, -500, -500}));
QVERIFY(tiragesCoherentsAvecLeTotal({}, 1000).isEmpty());
}

View File

@ -108,6 +108,8 @@ private slots:
void testStoredInstantsSurviveATimeZoneChange();
void testReportingDayBoundaryIsPublishedNotDeducible();
void testDrawMarginComparesHeadroomsNotCaps();
//! Lot délestage triphasé — des phases en retard sur le total sont RELEVÉES, jamais abaissées.
void testPhaseDrawsAreNeverBelowTheTotal();
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
void testTheContentIsTheRevisionIdentity();

View File

@ -223,6 +223,7 @@ private slots:
void testAProtocol10OptimizerIsAccepted();
void testTracesReplayWithZeroDivergence();
void testSafetyWinsOverTheOptimizer();
void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum();
void testAnEchoedTokenLeaksNowhereEndToEnd();
void cleanupTestCase();
@ -936,6 +937,116 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
}
void TestRefOptimizer::testTheDrawCapHoldsAgainstAnOptimizerAtMaximum()
{
// DÉFAUTS 4 et 5 — relevés le 2026-10-27 sur une installation d'essai, sous un optimiseur qui
// demande le MAXIMUM à tout. La protection déleste ; au cycle suivant, le compteur est revenu
// juste sous le seuil, le délestage se relâche, l'optimiseur redemande tout, et la surcharge
// reprend une minute. Aucune mémoire entre la protection et le plan suivant : la marge que le
// compteur laisse n'était opposée à AUCUNE consigne d'optimiseur — seulement à la cascade du
// rule-based. Ici la borne (triphasée, sans voiture) et la charge variable 2 jouent le rôle de
// la borne et du chauffe-eau de l'essai.
monter(Banc::Restreint);
if (QTest::currentTestFailed())
return;
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid borneId = addCharger("ABC", 16, 26818);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
for (const char *e : {"connected", "pluggedIn"})
borne->setStateValue(e, true);
borne->setStateValue("power", false);
borne->setStateValue("maxChargingCurrent", 6);
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo",
QVariantMap{{"chargingInfo", QVariantMap{{"evChargerId", borneId},
{"chargingMode", "ChargingModeEco"}}}})
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
m_arb->setPhasePowerLimit(25);
RefOptimizer ref;
brancher(ref, Transport::Unix, {"--mode", "over-limit"});
if (QTest::currentTestFailed())
return;
// Un pas : le compteur est POSÉ (total et phases), la réponse de l'optimiseur attendue, puis
// les charges variables consomment leur consigne. La borne, elle, mesure ce qu'elle tient.
QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
const auto pas = [&](double totalW, double phaseA, int dtS = 60) {
t = t.addSecs(dtS);
m_compteur->setStateValue("currentPower", totalW);
for (const char *ph : {"A", "B", "C"}) {
m_compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), phaseA);
m_compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), phaseA * 230);
}
const int avant = m_jugees;
m_arb->recordMeterUpdate(t);
m_arb->simulationCallUpdate(t);
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur jamais jugée");
settle(30);
for (const QString &v : {m_var1, m_var2}) {
Thing *th = tm->findConfiguredThing(ThingId(v));
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
}
};
const auto var2W = [&]() {
return tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble();
};
const auto borneA = [&]() {
return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0;
};
const auto decision = [&](const QString &id) {
for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList())
if (v.toMap().value("loadId").toString() == id)
return v.toMap().value("decision").toMap();
return QVariantMap();
};
// 1. Export : l'optimiseur demande 50 kW à tout, les adaptateurs écrêtent au matériel.
for (int k = 0; k < 3; ++k)
pas(-20000, -20);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(borneA(), 16);
QCOMPARE(var2W(), 3000.0);
// 2. LA CHARGE EN WATTS (le chauffe-eau de l'essai). 40 A par phase : la protection déleste
// la cascade en ordre inverse du rang, la PAC puis la charge 2.
pas(40 * 230 * 3, 40);
const double var2Apres = var2W();
QVERIFY2(var2Apres < 3000, qUtf8Printable(QString::number(var2Apres)));
// 3. Le compteur revient JUSTE sous le seuil : 22 A par phase (seuil 22,5 A, 25 A − 10 %).
// La marge ne laisse que 0,5 A par phase : ce qui a été retiré ne peut pas revenir.
pas(22 * 230 * 3, 22);
QVERIFY2(var2W() <= var2Apres + 0.5 * 230 + 1,
qUtf8Printable(QStringLiteral("la charge 2 est remontée de %1 à %2 W").arg(var2Apres).arg(var2W())));
// DÉFAUT 5 — la consigne écrêtée par la sécurité porte le motif de la sécurité, pas celui que
// l'optimiseur avait donné à sa demande.
QCOMPARE(decision(m_var2).value("code").toString(), QString(DecisionCode::DrawCap));
// 4. La place revient : tout ce que l'optimiseur demande tient sous le seuil, il est servi.
// Six minutes : le verrou de réenclenchement de la borne, coupée en 2, est expiré.
pas(-20000, -20, 360);
pas(-20000, -20, 360);
QCOMPARE(borneA(), 16);
QCOMPARE(var2W(), 3000.0);
// 5. LA BORNE. 26 A par phase : la protection la rabaisse sans la couper. (Le harnais rappelle
// la protection après le cycle sur le MÊME compteur : au-delà, deux gestes la coupent.)
pas(26 * 230 * 3, 26);
const int borneApres = borneA();
QVERIFY2(borneApres > 0 && borneApres < 16, qUtf8Printable(QString::number(borneApres)));
// 6. Juste sous le seuil : la borne ne remonte pas au-delà de ce que la marge laisse.
pas(22 * 230 * 3, 22);
QVERIFY2(borneA() <= borneApres,
qUtf8Printable(QStringLiteral("la borne est remontée de %1 à %2 A").arg(borneApres).arg(borneA())));
// 7. La place revient : la borne retrouve ses 16 A.
pas(-20000, -20);
pas(-20000, -20);
QCOMPARE(borneA(), 16);
}
void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
{
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token

File diff suppressed because it is too large Load Diff

View File

@ -74,13 +74,27 @@ private:
};
//! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à
//! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon.
enum class BorneHc { Monophasee, Triphasee, Bascule };
//! Lot délestage V2C : les mêmes, monophasée sur A et triphasée, SANS courants par phase
//! lisibles par le moteur (`currentL1..3`, façon V2C Trydan).
enum class BorneHc { Monophasee, Triphasee, Bascule, MonophaseeNonMesuree, TriphaseeNonMesuree };
//! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
BorneHc borne = BorneHc::Monophasee);
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
// ── Lot délestage triphasé — une borne SANS voiture assignée, limite de phase 30 A ────────
struct BancTriphase {
EnergyArbitrator *arb = nullptr;
Thing *compteur = nullptr;
Thing *borne = nullptr;
QString borneId;
};
//! Serveur neuf, compteur, borne 16 A « Éco » branchée et sans voiture (le cas de l'invité,
//! LM-1208-b). \p phases : câblage du mock ; \p bascule : la borne porte `desiredPhaseCount`
//! (mise en triphasé si elle est câblée sur trois).
BancTriphase preparerBancTriphase(const QString &nom, const QString &phases, bool bascule);
// ── Recrédit — une borne mesurée par un compteur dédié, cycle par cycle ──────────────────
//! Ce que la télémétrie dit de la borne à un cycle, et ce qu'elle a reçu.
struct CycleBorne {
@ -339,6 +353,20 @@ private slots:
void testOffPeakFullPowerAtDawnStaysInTheCeiling();
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases();
//! Lot délestage V2C — borne SANS courants par phase lisibles (façon V2C Trydan), limite 25 A,
//! marge 10 % : la consigne se pose au plafond qui tient compte de la borne, sans alterner
//! (monophasée sur A, triphasée ; maison seule, puis lave-vaisselle sur A).
void testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit();
//! Lot délestage V2C — quand l'attribution n'est pas sûre, rien n'est rendu : triphasée avec
//! une voiture monophasée (phases visées ≠ phases annoncées), borne qui publie des courants
//! par phase nuls alors qu'elle tire.
void testUnmeteredChargerGivesNothingBackWhenUnsure();
//! Lot délestage V2C (question 5) — hors heures creuses, sous le rule-based, une borne limitée
//! par sa phase se pose sous la limite sans alterner, maison légère ou chargée.
void testWaterfallChargerSettlesUnderThePhaseLimit();
//! Lot délestage V2C (question 5) — EN DÉPASSEMENT, la règle de jour ne rend rien : phases du
//! compteur aveugles à la borne, la stratégie la tient à son plancher.
void testWaterfallChargerGetsNothingBackInOverload();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
@ -385,6 +413,23 @@ private slots:
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
void testSheddingLogKeepsTheLastEpisodes();
// ── Lot délestage triphasé (2026-10-27) ─────────────────────────────────
//! Après une surcharge, la borne garde ses phases dans les comptes : allocation, commande et
//! matériel disent le même nombre de phases — triphasée sans bascule, à bascule, monophasée.
void testOverloadLeavesTheChargerItsPhases_data();
void testOverloadLeavesTheChargerItsPhases();
//! Une voiture monophasée désassignée ne laisse pas « 1 phase » à une borne triphasée.
void testAnUnassignedCarTakesItsPhasesAway();
//! Une borne qui tire sur trois phases ce que le moteur croit commandé sur une : divergente.
void testCommandDivergenceSeesAPhaseCountError();
//! Des courants par phase en retard sur le total ne retardent pas la protection.
void testOverloadReactsToTheTotalWhenPhasesLag();
//! Délesté sous son minOn, le chauffe-eau franchit le verrou ET ses relais s'ouvrent.
void testSheddingOpensTheRelaysUnderMinOn();
//! La marge de sécurité : 10 % par défaut, réglable et bornée, persistée, et c'est à 90 %
//! de la limite que la protection agit.
void testPhaseSafetyMargin();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);

View File

@ -187,6 +187,43 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
return;
} else if (thing->thingClassId() == chargerUnmeteredThingClassId) {
// Lot délestage V2C — une borne qui mesure sa puissance mais PAS ses phases au sens du
// moteur : `currentL1..3` (noms du plugin V2C Trydan), jamais `currentPhaseX`. Son nombre
// de phases est celui de son câblage, fixe : aucune bascule.
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("connected"))
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("pluggedIn"))
thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool());
if (query.hasQueryItem("maxChargingCurrent"))
thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt());
updateUnmeteredChargerMeter(thing);
});
m_controllers.insert(thing, controller);
info->finish(Thing::ThingErrorNoError);
thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt());
updateUnmeteredChargerMeter(thing);
return;
} else if (thing->thingClassId() == simpleChargerThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
@ -699,6 +736,15 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
}
if (thing->thingClassId() == chargerUnmeteredThingClassId) {
if (actionType.name() == "maxChargingCurrent")
thing->setStateValue("maxChargingCurrent",
action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt());
if (actionType.name() == "power")
thing->setStateValue("power", action.paramValue(actionType.paramTypes().findByName("power").id()).toBool());
updateUnmeteredChargerMeter(thing);
}
if (thing->thingClassId() == simpleChargerThingClassId) {
if (actionType.name() == "maxChargingCurrent") {
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
@ -751,6 +797,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
info->finish(Thing::ThingErrorNoError);
}
void IntegrationPluginEnergyMocks::updateUnmeteredChargerMeter(Thing *thing)
{
// Le courant commandé, tiré sur les phases câblées — comme updateChargerMeter(), mais publié
// sous `currentL1..3`, que le moteur ne lit pas : EvCharger::meteredPhases() reste vide.
const Electricity::Phases cablees = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
const uint phaseCount = Electricity::getPhaseCount(cablees);
thing->setStateValue("phaseCount", phaseCount);
const bool tire = thing->stateValue("power").toBool() && thing->stateValue("connected").toBool()
&& thing->stateValue("pluggedIn").toBool();
const double courantA = tire ? thing->stateValue("maxChargingCurrent").toDouble() : 0.0;
thing->setStateValue("currentL1", cablees.testFlag(Electricity::PhaseA) ? courantA : 0.0);
thing->setStateValue("currentL2", cablees.testFlag(Electricity::PhaseB) ? courantA : 0.0);
thing->setStateValue("currentL3", cablees.testFlag(Electricity::PhaseC) ? courantA : 0.0);
thing->setStateValue("currentPower", courantA * 230 * phaseCount);
thing->setStateValue("charging", tire);
}
void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing)
{
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());

View File

@ -78,6 +78,7 @@ private:
// Update the meter data of the charger based on the current max charging current, connected, power and plugged in state
void updateChargerMeter(Thing *thing);
void updateUnmeteredChargerMeter(Thing *thing);
};

View File

@ -717,6 +717,146 @@
}
]
},
{
"name": "chargerUnmetered",
"displayName": "Mocked charger without per-phase metering",
"id": "e297f8c4-934f-4af3-a655-29b5b799289b",
"createMethods": [
"user"
],
"interfaces": [
"evcharger",
"smartmeterconsumer",
"connectable"
],
"paramTypes": [
{
"id": "4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5",
"name": "port",
"displayName": "Port",
"type": "uint",
"defaultValue": 6658
},
{
"id": "ec3e73a0-cca9-415a-bc64-9e841d666853",
"name": "phases",
"displayName": "Phases connected",
"type": "QString",
"allowedValues": [
"A",
"B",
"C",
"ABC"
],
"defaultValue": "A"
},
{
"id": "803ba501-ae0f-4d58-8097-6ebb0928b3d8",
"name": "maxChargingCurrentUpperLimit",
"displayName": "Maximum charging current",
"type": "double",
"unit": "Ampere",
"defaultValue": 32
}
],
"stateTypes": [
{
"id": "b35e0da3-7fc1-4c42-b6b4-2c43f9cb937a",
"name": "connected",
"displayName": "Connected",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "6ce62b4f-39e1-4693-b204-e399ea4e6637",
"name": "power",
"displayName": "Charging enabled",
"displayNameAction": "Enable/disable charging",
"type": "bool",
"defaultValue": true,
"writable": true
},
{
"id": "1b1c75b1-362e-4341-960d-4024b2f28341",
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "double",
"defaultValue": 6,
"minValue": 6,
"maxValue": 32,
"unit": "Ampere",
"writable": true
},
{
"id": "4406b3d3-0102-4932-94ee-e678cc850106",
"name": "totalEnergyConsumed",
"displayName": "Total consumed energy",
"type": "double",
"unit": "KiloWattHour",
"defaultValue": 0,
"cached": true
},
{
"id": "06b4ceea-5893-4776-b854-4e6c67469b47",
"name": "pluggedIn",
"displayName": "Car is plugged in",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "cca231ab-9fb5-4565-90b4-56ac33ccbcd4",
"name": "charging",
"displayName": "Charging",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "79601cfe-433b-4fcb-b61f-5b6a0b8b0292",
"name": "phaseCount",
"displayName": "Active phases",
"type": "uint",
"minValue": 1,
"maxValue": 3,
"defaultValue": 1
},
{
"id": "923e31e9-04be-43fc-a79d-10098f7aadc1",
"name": "currentPower",
"displayName": "Current power consumption",
"type": "double",
"unit": "Watt",
"defaultValue": 0
},
{
"id": "edfb0a5d-6897-4b27-9ae4-2f636ab700a0",
"name": "currentL1",
"displayName": "Current L1",
"type": "double",
"unit": "Ampere",
"defaultValue": 0
},
{
"id": "8bf7ff7b-78a3-4917-a4bd-1e9b7d95ca53",
"name": "currentL2",
"displayName": "Current L2",
"type": "double",
"unit": "Ampere",
"defaultValue": 0
},
{
"id": "20292cd6-dd59-4608-9d38-18096f484cbf",
"name": "currentL3",
"displayName": "Current L3",
"type": "double",
"unit": "Ampere",
"defaultValue": 0
}
]
},
{
"name": "simpleCharger",
"displayName": "Mocked charger simple",

View File

@ -65,6 +65,8 @@ TABLE_MODES = {
'old-data': ('—', 'valide'),
'echo-value': ('—', 'valide'),
'major-first': ('—', 'valide'),
# Une consigne hors des bornes est un plan VALIDE : c'est au moteur de l'écrêter, pas au contrat.
'over-limit': ('—', 'valide'),
}
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans

View File

@ -83,7 +83,8 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data`
(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) ·
`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure
jugée avant le token).
jugée avant le token) · `over-limit` (toute consigne en watts portée à 50 kW, au-delà des bornes
de la charge et du branchement : le moteur doit l'écrêter).
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité

View File

@ -117,7 +117,7 @@ PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `trun
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type',
'old-data', 'echo-value', 'major-first']
'old-data', 'echo-value', 'major-first', 'over-limit']
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
@ -389,6 +389,13 @@ def plan(ctx, mode, horizon_s, token):
if a['loadId'] in disponibles:
del actions[i]
break
elif mode == 'over-limit':
# Un optimiseur qui demande le MAXIMUM à tout ce qui se règle en watts, au-delà des bornes
# de la charge et du branchement. Le moteur doit l'écrêter (invariant 2), jamais le poser.
for a in actions:
if a.get('kind') == 'setpoint':
a['powerW'] = 50000.0
a['reason'] = 'mode over-limit (test) : 50 kW demandés'
elif mode == 'echo-token' and token:
for a in actions:
a['reason'] = f'{a["reason"]} — écho {token}'