Compare commits

..

25 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
Patrick Schurig
0a88a0f97f commentaires : la marge de progression renvoie au document de conception
La justification de ProgressionMinW cite la négociation la plus lente
par son document (DESIGN_COMMANDE_PERDUE.md §9.1) plutôt que comme un
relevé. Dans les tests, « banc » désignait le scénario : mot remplacé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:58:17 +01:00
Patrick Schurig
f9f2e1e5c5 decisions : référence régénérée (verdict de l'écart commande/mesure)
Contre la référence d'etm75, 29 fonctions changent, toutes par le
verdict de l'écart commande/mesure ou ce qui en découle :
- 21 par `command.divergent` seul (marches suivies et premières
  commandes qui ne divergent plus ; mesures remises à la main par le
  harnais qui divergent) ;
- `run` : `expectedW` de la borne commandée par le proxy (mémoire de
  commande tous émetteurs) et 46 cycles de gel sous arrêt verrouillé ;
- 7 où la garde de refus mord enfin : datée à l'horloge murale, elle
  était inerte dans toute la suite de simulation.
Plus les 6 fonctions de test nouvelles. Détail dans le rapport du lot.

run-decisions.sh check : IDENTIQUE (24 415 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:52:02 +01:00
Patrick Schurig
c9e4a251ca test : le rejeu de borne part d'une date fixe
rejouerBorne() partait de l'horloge murale, comme le test de
reproduction devait le faire tant que l'écart était daté à l'horloge
murale. Le moteur n'en dépend plus, et l'enregistreur `decisions` date
les cycles : la référence n'était pas reproductible à la milliseconde
près. Mêmes chiffres de rejeu avec une date fixe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:40:11 +01:00
Patrick Schurig
e856c48084 test : un vrai refus reste vu, sur la borne et sur les autres charges
testARealRefusalIsStillSeen — borne figée sous une consigne à la hausse :
refus au cycle qui suit la commande, comme avant le correctif ; montée
qui progresse puis cale : latence tant qu'elle se rapproche, refus au
cycle où elle cale ; descente suivie de 500 W seulement : refus au cycle
suivant, la progression n'excuse jamais une descente.

testWattLoadsFollowedOrRefusedUnderAMeter — relais et consigne continue
sous compteur dédié : marche suivie dans les deux sens sans refus ; refus
réel vu au deuxième cycle (la latence par défaut de 60 s n'est pas
dépassée par un cycle de 60 s) ; sans compteur, rien ne change.

testSgReadyDivergenceIsPublishedOnly — sous compteur dédié, une PAC qui
monte en minutes n'est pas divergente, une PAC qui ne démarre pas l'est ;
son recrédit (puissance déclarée) n'en dépend pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:05 +01:00
Patrick Schurig
c4e517a8d9 borne : retenir ce que la borne tient, et geler l'arrêt verrouillé (P2)
La borne retenait la consigne VOULUE par son adaptateur, et seulement
quand le waterfall la commandait. Deux faux refus en sortaient :
- une coupure refusée par le verrou de marche/arrêt (300 s) laissait la
  borne à 6 A pendant qu'on attendait 0 W ;
- une borne rabaissée par la protection L4, ou commandée par le proxy
  (échéance, spot), restait jugée contre la dernière consigne du
  waterfall.

La mémoire de commande est posée par EnergyArbitrator::
executeChargingAction(), point unique de tous les émetteurs, avec ce qui
a PASSÉ les verrous et l'instant de la commande.

Pendant que l'arrêt est verrouillé, la borne déclare lockMinPowerW =
courant minimal × 230 × phases, comme un relais sous minOn : le
waterfall gèle ces watts (LOCK_MIN_ON) au lieu de les prêter.

Le verrou borne la SOMME éco + confort : la part confort n'est relevée
que de ce que la passe éco ne couvre pas déjà. Sans cela, une borne
servie à 6 A en éco sous arrêt verrouillé recevait 12 A (trouvé par la
preuve decisions, aucun test ne le voyait).

Tests : testLockedStopFreezesTheMinimumAndIsNoRefusal,
testALockedStopDoesNotAddToTheEcoFloor, et une garde L4 dans
testOverloadProtectionReasonOnTheCharger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:31:00 +01:00
Patrick Schurig
0dc2a1aeba moteur : l'écart commande/mesure est jugé au début du cycle (P1)
L'écart était jugé APRÈS le dispatch, entre la consigne qui venait de
partir et la mesure de la précédente, puis consommé au cycle suivant
avant d'être rejugé. Toute marche de consigne suivie par la borne se
lisait donc comme un refus vieux d'un cycle (60 s, au-delà des 30 s /
7 s de la borne) : recrédit suspendu, coupure, et une boucle de trois
cycles sous un surplus constant (~1,5 kW exportés en moyenne).

jugerCommande() rend désormais UN jugement par cycle, au début du cycle,
lu par le recrédit et par la télémétrie :
- la commande EN VIGUEUR contre la mesure fraîche ; s'ils concordent
  (max(200 W, 15 %)), l'épisode se referme ;
- l'ouverture est datée à l'instant de la commande, en temps de cycle,
  et non plus à l'horloge murale de la dernière publication ;
- le sens est celui que la commande demande à la charge, et non plus
  deviné au niveau de la mesure ;
- en montée seulement, une mesure rapprochée de la commande de plus de
  200 W depuis le cycle précédent est une latence. La descente n'a
  jamais cette excuse.

command.divergent publie ce verdict ; expectedW et observedW restent
les nombres de la publication.

Le test de reproduction inverse ses assertions « DÉFAUT » : sur trois
rampes (instantanée, 30 s / 7 s, 120 s), 13 A tenus dès le premier
cycle, aucune suspension, export de 10 W dès que la borne a rejoint sa
consigne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:30:45 +01:00
Patrick Schurig
41ce86a634 test : une marche de consigne suivie fait suspendre le recrédit (reproduction)
Borne monophasée 6-16 A, 3 000 W de surplus constant. L'écart commande/mesure
est jugé après le dispatch, entre la consigne qui vient de partir et la mesure
de la précédente, puis consommé au cycle suivant par qualifierRefus() avant
d'être rejugé. Une marche suivie se lit donc comme un refus vieux d'un cycle,
au-delà des 30 s / 7 s de la borne : recrédit retenu, coupure (bloquée par le
verrou de 300 s, 6 A tirés pour 0 attendu), puis 7 A, puis 13 A, et ainsi de
suite. La borne tire 1 495 W en moyenne sur 3 000.

Trois rampes : consigne atteinte au pas suivant, 30 s / 7 s, 120 s. Les deux
premières donnent la même trace, cycle pour cycle.

Le test FIGE le défaut. Le correctif devra inverser les deux assertions
marquées « DÉFAUT ». Aucune ligne du moteur n'est modifiée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:46:18 +01:00
Patrick Schurig
9640ac73f2 debian : entrée de changelog 1.15.2+etm75
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 11:47:45 +01:00
Patrick Schurig
e449019390 Merge feature/hc-batterie dans release/etm75
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 11:47:34 +01:00
31 changed files with 2949 additions and 204 deletions

View File

@ -1,3 +1,58 @@
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
attend les heures creuses (EV_WAITING_OFF_PEAK) et y charge à la pleine
puissance permise (EV_OFF_PEAK_FULL_POWER), sous la limite de phase
mesurée moins 1 A ; la somme éco + confort tient dans le plafond de la
borne. Sans tarif à heures creuses, aucune décision ne change.
* Batterie : la décharge est bloquée pendant que la voiture charge en heures
creuses (limite de décharge SunSpec, rafraîchie au tiers du minuteur,
levée inverse) ; une consigne refusée est réessayée en s'espaçant, la
levée jamais. Bloc storage dans la télémétrie.
* Délestage visible : la protection de surcharge publie ce qu'elle fait à la
borne (OVERLOAD_THROTTLED : abaissée ou coupée, phase, limite,
dépassement) ; journal court des 20 derniers épisodes de délestage, toutes
charges, par NymeaEnergy.GetSheddingLog (lecture sans droit, en mémoire) ;
télémétrie shedding (revision, open), notifiée à l'ouverture et à la
fermeture. La protection décide exactement comme avant.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 25 Oct 2026 13:00:00 +0100
powersync-energy-plugin-nymea (1.15.2+etm74) trixie; urgency=medium powersync-energy-plugin-nymea (1.15.2+etm74) trixie; urgency=medium
* Protocole optimiseur 1.3 : le moteur annonce 1.3. Forme normée de * Protocole optimiseur 1.3 : le moteur annonce 1.3. Forme normée de

View File

@ -25,12 +25,18 @@ rien sur les cas qu'il ne sait pas produire.
| `charger` | ✅ | ✅ | ❌ | | `charger` | ✅ | ✅ | ❌ |
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* | | `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
| `simpleCharger` | ❌ | ❌ | ❌ | | `simpleCharger` | ❌ | ❌ | ❌ |
| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ |
| `powerSwitch` | ✅ | ❌ | — | | `powerSwitch` | ✅ | ❌ | — |
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
| `energyStorage` | ✅ | ❌ | — | | `energyStorage` | ✅ | ❌ | — |
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — | | `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
| `car`, `notification` | ❌ | ❌ | — | | `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` **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` / 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. `unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.

View File

@ -51,8 +51,8 @@ LoadTelemetry EvAdapter::telemetry() const
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
{ {
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` ne sert qu'à // Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` sert à savoir
// savoir si l'instant tombe en heures creuses (lot e2). // si l'instant tombe en heures creuses (lot e2), et si l'arrêt est verrouillé (P2, plus bas).
LoadContext ctx; LoadContext ctx;
ctx.id = m_charger->thing()->id().toString(); ctx.id = m_charger->thing()->id().toString();
ctx.adapter = QStringLiteral("evcharger"); ctx.adapter = QStringLiteral("evcharger");
@ -176,9 +176,20 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy(); ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy();
ctx.telemetry.sessionWh = m_charger->sessionEnergyWh(); ctx.telemetry.sessionWh = m_charger->sessionEnergyWh();
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond // Pas de plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable. // contexte).
ctx.telemetry.lockMaxPowerW = -1; ctx.telemetry.lockMaxPowerW = -1;
// P2 — l'ARRÊT VERROUILLÉ engage le courant minimal, comme un relais sous minOn. Une borne en
// marche dont le verrou de marche/arrêt (300 s par défaut) n'est pas expiré ne s'arrêtera pas,
// quoi qu'on lui commande : elle tirera son minimum. Le déclarer fait GELER ces watts par le
// waterfall ; ne pas le déclarer les laissait prêter aux suivantes, et la coupure que le plan
// croyait faire se lisait ensuite comme un refus de la borne — c'est le moteur qui la tenait.
if (m_charger->chargingEnabled() && m_charger->chargingEnabledLocked(now)) {
const uint phases = m_commandeValide && m_commandePhases > 0 ? m_commandePhases
: m_charger->phaseCount();
ctx.telemetry.lockMinPowerW = m_charger->maxChargingCurrentMinValue() * 230.0
* qMax(1u, phases);
}
return ctx; return ctx;
} }
@ -287,18 +298,10 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable) m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now; m_lastActionAt = now;
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de // §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Elle n'est plus posée
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de // ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est // proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce // elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
// qu'on a commandé et ce que la borne fait n'est pas CALCULABLE.
//
// On retient la commande ÉCRÊTÉE, pas la demande : c'est elle qui est partie au matériel,
// et c'est donc elle que la borne devrait refléter. Comparer la demande ferait voir un
// écart là où l'adaptateur a simplement fait son travail.
m_commandeA = enclencher ? clampedA : 0;
m_commandePhases = phases;
m_commandeValide = true;
LoadAction applied = action; LoadAction applied = action;
applied.currentA = clampedA; applied.currentA = clampedA;

View File

@ -46,9 +46,9 @@ public:
/*! /*!
* \brief Construit l'entrée loads[] §5 du SurplusContext. * \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors * \param now Temps de cycle (\c ctx.timestamp). Il sert à savoir s'il tombe en heures
* waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses * creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
* (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent. * et si l'ARRÊT est verrouillé (voir la note P2).
* \return LoadContext incluant declared, limits, needs et télémétrie EV. * \return LoadContext incluant declared, limits, needs et télémétrie EV.
* \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum * \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle * d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
@ -57,6 +57,9 @@ public:
* \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par * \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par
* \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la * \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la
* borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée. * borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée.
* \note **P2** — borne en marche dont le verrou de marche/arrêt n'a pas expiré :
* \c telemetry.lockMinPowerW = courant minimal × 230 × phases. Elle ne s'arrêtera pas ce
* cycle, et le waterfall gèle ce qu'elle tirera, comme pour un relais sous \c minOn.
*/ */
LoadContext toLoadContext(const QDateTime &now) const override; LoadContext toLoadContext(const QDateTime &now) const override;
@ -139,8 +142,9 @@ public:
EvCharger *evCharger() const { return m_charger; } EvCharger *evCharger() const { return m_charger; }
/*! /*!
* \brief Dernière commande envoyée au matériel par cet adaptateur. * \brief Dernière commande tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée. * PASSÉ ses verrous (\c noterCommandeTenue()).
* \param[out] ampere Courant tenu (A), 0 si la charge a été coupée.
* \param[out] phases Nombre de phases commandé. * \param[out] phases Nombre de phases commandé.
* \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et * \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et
* c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas. * c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas.
@ -148,9 +152,30 @@ public:
bool derniereCommande(uint &ampere, uint &phases) const bool derniereCommande(uint &ampere, uint &phases) const
{ ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; } { ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; }
//! \brief Instant de la dernière commande, à l'horloge de l'émetteur (temps de cycle pour le
//! waterfall). \return Invalide tant qu'aucune commande n'est partie.
QDateTime derniereCommandeAt() const { return m_commandeAt; }
/*!
* \brief Note la commande que la borne TIENT, quel qu'en soit l'émetteur (P2).
* \param active Marche après les verrous. \param courantA Courant après les verrous (A).
* \param phases Phases commandées. \param at Instant de la commande.
* \note Appelée par \c EnergyArbitrator::executeChargingAction(), le point unique par où
* passent le waterfall, le proxy et la protection L4 : posée par un seul d'entre eux, la
* mémoire se périmait dès qu'un autre écrivait.
*/
void noterCommandeTenue(bool active, uint courantA, uint phases, const QDateTime &at)
{
m_commandeA = active ? courantA : 0;
m_commandePhases = phases;
m_commandeValide = true;
m_commandeAt = at;
}
private: private:
/*! /*!
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée. * \brief Dernière commande RÉELLEMENT tenue par le matériel — écrêtée et passée par les
* verrous de la borne, pas demandée.
* *
* \par Pourquoi ce souvenir n'existait pas * \par Pourquoi ce souvenir n'existait pas
* Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou * Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou
@ -161,6 +186,9 @@ private:
* *
* \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel. * \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel.
* Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail. * Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail.
* \note **P2** — et ce qui a PASSÉ les verrous de marche/arrêt et de courant : un arrêt
* refusé par le verrou de 300 s laisse la borne en marche à son courant minimal, et c'est
* ce courant qui est retenu, pas 0.
*/ */
uint m_commandeA = 0; uint m_commandeA = 0;
uint m_commandePhases = 0; uint m_commandePhases = 0;
@ -168,6 +196,7 @@ private:
//! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle //! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle
//! consomme, ce qui est le cas NORMAL du mode manuel. //! consomme, ce qui est le cas NORMAL du mode manuel.
bool m_commandeValide = false; bool m_commandeValide = false;
QDateTime m_commandeAt; //!< Instant de la dernière commande.
EvCharger *m_charger; EvCharger *m_charger;
EnergyArbitrator *m_parent; EnergyArbitrator *m_parent;

View File

@ -17,6 +17,7 @@
#include "scheduler/optimizerstatus.h" #include "scheduler/optimizerstatus.h"
#include "scheduler/publishedplan.h" #include "scheduler/publishedplan.h"
#include "scheduler/optimizertrace.h" #include "scheduler/optimizertrace.h"
#include "scheduler/optimizerplan.h"
#include "types/decisionreason.h" #include "types/decisionreason.h"
#include "types/surpluscontext.h" #include "types/surpluscontext.h"
#include "types/plan.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. // adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
m_waterfallEvIds = plan.waterfallEvIds; 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()) // 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 applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market 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."; "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 void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
{ {
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) { 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("cut"), t.cut},
{QStringLiteral("overshootW"), qRound(t.overshootW)}, {QStringLiteral("overshootW"), qRound(t.overshootW)},
{QStringLiteral("limitA"), phasePowerLimit()}}; {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, // 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. // aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP.
if (!t.phase.isEmpty()) if (!t.phase.isEmpty())
@ -1093,6 +1181,17 @@ void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const Chargin
// touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4). // touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4).
SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime); SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime);
// P2 — la borne retient ce qu'elle TIENT, quel que soit l'émetteur : c'est contre cette
// commande-là que sa mesure est jugée au cycle suivant. Le waterfall, le proxy et la
// protection L4 passent tous ici ; une coupure refusée par le verrou de 300 s y reste en
// marche à son courant, et une borne rabaissée par L4 y est à son nouveau courant.
if (EvAdapter *ev = m_adapters.value(evCharger->id().toString(), nullptr))
ev->noterCommandeTenue(activationPasse ? chargingAction.chargingEnabled() : avantActive,
courantPasse ? demandeA : avantA,
chargingAction.desiredPhaseCount() > 0
? chargingAction.desiredPhaseCount() : evCharger->phaseCount(),
currentDateTime);
if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA, if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA,
activationPasse ? chargingAction.chargingEnabled() : avantActive, activationPasse ? chargingAction.chargingEnabled() : avantActive,
courantPasse ? demandeA : avantA, currentDateTime)) courantPasse ? demandeA : avantA, currentDateTime))
@ -1163,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. // jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon.
QString phase; QString phase;
double depassementW = 0; double depassementW = 0;
if (RootMeter *compteur = internalRootMeter()) { if (internalRootMeter()) {
const double limiteW = 230.0 * phasePowerLimit(); // Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total,
const QHash<QString, double> parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()}, // limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé.
{QStringLiteral("B"), compteur->currentPowerPhaseB()}, const double limiteW = 230.0 * effectivePhaseLimit();
{QStringLiteral("C"), compteur->currentPowerPhaseC()}}; const QHash<QString, double> parPhase = phaseConsumptionW();
if (evCharger->phaseCount() == 1) { if (evCharger->phaseCount() == 1) {
phase = chargerPhaseFor(evCharger); phase = chargerPhaseFor(evCharger);
depassementW = qMax(0.0, parPhase.value(phase) - limiteW); depassementW = qMax(0.0, parPhase.value(phase) - limiteW);
@ -1367,7 +1466,10 @@ QList<DrawCap> EnergyArbitrator::drawCaps() const
if (limiteA > 0) { if (limiteA > 0) {
DrawCap disjoncteur; DrawCap disjoncteur;
disjoncteur.source = DrawCapSource::Breaker; 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); plafonds.append(disjoncteur);
} }
@ -1390,17 +1492,22 @@ QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi
const QList<Electricity::Phase> toutes = const QList<Electricity::Phase> toutes =
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC }; { Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
for (Electricity::Phase p : toutes) { // Tirage de chaque phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
if (masque != Electricity::PhaseNone && !masque.testFlag(p)) // 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; continue;
tirages.append(lus.at(i));
// 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);
} }
return tirages; return tirages;
} }
@ -1474,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 // 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 // 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. // 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 { namespace {
@ -1486,38 +1606,90 @@ constexpr double MargeHeuresCreusesA = 1.0;
double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const 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(); RootMeter *meter = internalRootMeter();
if (!ev || phaseCount == 0 || !meter) if (!ev || phaseCount == 0 || !meter)
return -1; return plafond;
const QList<DrawCap> plafonds = drawCaps(); const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty()) if (plafonds.isEmpty())
return -1; return plafond;
// Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui const TirageBorne tirage = evAttributableDraw(ev, phaseCount);
// prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 plafond.attribuable = tirage.phases != Electricity::PhaseNone;
// ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien. const Electricity::Phases masque = plafond.attribuable
const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull(); ? tirage.phases : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone; const double tireW = plafond.attribuable ? tirage.watts : 0.0;
// 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;
// Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui // 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 // 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. // 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) for (double &w : sansLaBorne)
w = qMax(0.0, w - tireW / phaseCount); w = qMax(0.0, w - tireW / phaseCount);
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne, const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
qMax(0.0, meter->currentPower() - tireW)); qMax(0.0, meter->currentPower() - tireW));
if (!marge.borne) if (!marge.borne)
return -1; return plafond;
// Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche, // 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é. // et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié.
@ -1527,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'à // 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. // sortir du dépassement ou jusqu'au plancher, où L4 décide.
const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA); 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 bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
@ -1797,26 +1970,19 @@ void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const
void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const
{ {
// Le jugement est fait ICI, au début du cycle, et nulle part ailleurs : la commande en
// vigueur contre la mesure fraîche. Il était fait après le dispatch, contre la consigne qui
// venait de partir et la mesure de la précédente — toute marche suivie se lisait comme un
// refus vieux d'un cycle (60 s, au-delà des 30 s / 7 s d'une borne), et le recrédit d'une
// borne qui obéit était suspendu à chaque marche. La consigne oscillait sous un surplus
// constant.
const std::optional<JugementCommande> j = jugerCommande(lc.id, now);
if (!j)
return; // rien ne mesure, ou rien n'a été commandé : rien à qualifier
const QDateTime depuis = m_divergenceDepuis.value(lc.id); const QDateTime depuis = m_divergenceDepuis.value(lc.id);
if (!depuis.isValid()) if (depuis.isValid())
return; // aucun écart en cours : rien à qualifier lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
lc.telemetry.commandRefused = j->refus;
const int ecoule = static_cast<int>(depuis.secsTo(now));
lc.telemetry.refusedSinceS = qMax(0, ecoule);
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr);
if (!a)
a = m_adapters.value(lc.id, nullptr);
if (!a)
return;
// Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux —
// commander la baisse sans être suivi — est justement celui qui se constate le plus vite.
const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0;
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
} }
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
@ -2384,7 +2550,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
QVariantMap mech; QVariantMap mech;
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger")); mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
orphan.insert(QStringLiteral("mechanism"), mech); orphan.insert(QStringLiteral("mechanism"), mech);
attachMeasurements(orphan, action.loadId); attachMeasurements(orphan, action.loadId, now);
loads.append(orphan); loads.append(orphan);
continue; continue;
} }
@ -2507,7 +2673,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
mech.insert(QStringLiteral("kind"), v.mechanismKind); mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech); entry.insert(QStringLiteral("mechanism"), mech);
} }
attachMeasurements(entry, action.loadId); attachMeasurements(entry, action.loadId, now);
loads.append(entry); loads.append(entry);
} }
@ -2533,14 +2699,119 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
namespace { namespace {
//! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de //! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de
//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent. //! mesure et arrondi à l'ampère s'y logent. Valeur de conception, à ajuster sur relevé.
//! Valeur de conception, à ajuster sur relevé.
constexpr double DivergenceAbsolueW = 200.0; constexpr double DivergenceAbsolueW = 200.0;
constexpr double DivergenceRelative = 0.15; constexpr double DivergenceRelative = 0.15;
//! Rapprochement minimal, d'un cycle au suivant, pour qu'une mesure soit « en progression ».
//! La MÊME grandeur que la tolérance absolue, et c'est voulu : un mouvement que le moteur ne
//! distingue pas d'une concordance ne se distingue pas non plus du bruit. Une charge figée ne le
//! franchit jamais ; la négociation la plus lente de DESIGN_COMMANDE_PERDUE.md §9.1 (479 s pour
//! ~3 kW, soit ~375 W par cycle de 60 s) le franchit. Et l'excuse est BORNÉE par construction : chaque cycle excusé
//! rapproche la mesure d'au moins 200 W, donc un écart de E watts ne s'excuse jamais plus de
//! E / 200 cycles.
constexpr double ProgressionMinW = DivergenceAbsolueW;
double toleranceConcordance(double commandeW)
{
return qMax(DivergenceAbsolueW, DivergenceRelative * commandeW);
}
}
std::optional<EnergyArbitrator::JugementCommande>
EnergyArbitrator::jugerCommande(const QString &loadId, const QDateTime &now) const
{
// Un seul jugement par cycle : le recrédit (début du cycle) et la télémétrie (fin) lisent le
// MÊME. Deux comparaisons, à deux instants, diraient deux choses de la même charge.
const auto cache = m_jugementCommande.constFind(loadId);
if (cache != m_jugementCommande.constEnd() && cache->at == now)
return *cache;
// Le jugement du cycle PRÉCÉDENT — la référence de la progression.
const JugementCommande precedent = cache != m_jugementCommande.constEnd()
? *cache : JugementCommande();
ILoadAdapter *a = m_loadAdapters.value(loadId, nullptr);
if (!a)
a = m_adapters.value(loadId, nullptr);
const MesureCharge mc = mesurerCharge(loadId);
// Une charge jamais commandée n'est pas une charge commandée à zéro ; une charge que rien ne
// mesure n'est pas une charge qui concorde. Dans les deux cas : rien à juger, et l'épisode
// éventuel est oublié sans conclusion.
const std::optional<double> commandeeW = a ? puissanceCommandee(loadId) : std::nullopt;
if (!commandeeW || mc.source == QLatin1String("none")) {
m_jugementCommande.remove(loadId);
m_commandeEnVigueur.remove(loadId);
m_divergenceDepuis.remove(loadId);
return std::nullopt;
}
const double commandeW = *commandeeW;
// --- L'INSTANT DE LA COMMANDE, en temps de cycle ---------------------------------------
// Elle a changé depuis le dernier jugement : elle est partie au dernier dispatch, s'il a eu
// lieu depuis. Sinon (repli L2, protection L4 hors cycle) l'instant est inconnu, et on prend
// le plus récent possible — une latence accordée à tort retarde un verdict d'un cycle, un
// refus prononcé à tort suspend le recrédit d'une charge saine.
// Une borne connaît l'instant de sa commande, quel qu'en soit l'émetteur (L4 écrit hors cycle).
QPair<double, QDateTime> &enVigueur = m_commandeEnVigueur[loadId];
if (!enVigueur.second.isValid()
|| qAbs(commandeW - enVigueur.first) > toleranceConcordance(enVigueur.first)) {
const EvAdapter *ev = m_adapters.value(loadId, nullptr);
const QDateTime instantBorne = ev ? ev->derniereCommandeAt() : QDateTime();
// Le dispatch d'un cycle porte le MÊME temps que le jugement fait au début de ce cycle :
// il le suit, d'où le « au moins aussi récent ».
const bool parDispatch = m_actionsAppliqueesAt.isValid() && m_actionsAppliqueesAt <= now
&& (!precedent.at.isValid() || m_actionsAppliqueesAt >= precedent.at);
if (instantBorne.isValid() && instantBorne <= now)
enVigueur = { commandeW, instantBorne };
else
enVigueur = { commandeW, parDispatch ? m_actionsAppliqueesAt : now };
}
JugementCommande j;
j.at = now;
j.commandeW = commandeW;
j.mesureW = mc.powerW;
j.source = mc.source;
j.commandeDepuis = enVigueur.second;
const double ecartW = qAbs(commandeW - mc.powerW);
if (ecartW <= toleranceConcordance(commandeW)) {
// CONCORDANCE : l'épisode se referme, quelle que soit sa durée.
if (m_divergenceDepuis.contains(loadId))
qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId
<< "après" << m_divergenceDepuis.value(loadId).secsTo(now) << "s"
<< "— attendu" << commandeW << "W, mesuré" << mc.powerW << "W";
m_divergenceDepuis.remove(loadId);
m_jugementCommande.insert(loadId, j);
return j;
}
// JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié : sans ces lignes, un épisode
// qui s'ouvre et se referme entre deux lectures ne laisse aucune trace.
if (!m_divergenceDepuis.contains(loadId)) {
m_divergenceDepuis.insert(loadId, j.commandeDepuis); // daté à l'instant de la COMMANDE
qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId
<< "— attendu" << commandeW << "W, mesuré" << mc.powerW << "W,"
<< "écart" << (mc.powerW - commandeW) << "W";
}
// Le SENS est celui que la commande demande à la charge : monter si elle est au-dessus de la
// mesure, descendre sinon. Il était deviné au NIVEAU de la mesure (sous ou au-dessus de la
// moitié du plafond), si bien qu'une montée vers 13 A sur 16 était jugée en descente.
j.montee = commandeW > mc.powerW;
// PROGRESSION — en montée SEULEMENT, et sur le même instrument : deux sources ne se
// comparent pas. La descente n'a jamais cette excuse : commander la baisse sans être suivi est
// le refus qui coûte de l'argent, et il reste jugé sur la latence courte, au même cycle.
j.enProgression = j.montee && precedent.at.isValid() && precedent.source == mc.source
&& qAbs(commandeW - precedent.mesureW) - ecartW > ProgressionMinW;
const qint64 ecouleS = j.commandeDepuis.secsTo(now);
j.refus = ecouleS > a->commandSettleS(j.montee) && !j.enProgression;
m_jugementCommande.insert(loadId, j);
return j;
} }
void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId, void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId,
const QString &source, double mesureW) const const QString &source, double mesureW,
const QDateTime &now) const
{ {
// RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`, // RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`,
// qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout. // qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout.
@ -2553,58 +2824,34 @@ void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString
const std::optional<double> commandeeW = puissanceCommandee(loadId); const std::optional<double> commandeeW = puissanceCommandee(loadId);
if (!commandeeW) if (!commandeeW)
return; return;
const double attenduW = *commandeeW;
const double ecart = qAbs(attenduW - mesureW); // Le VERDICT est celui du cycle — le jugement que le recrédit a lu, fait au début du cycle
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW); // sur la commande en vigueur et la mesure fraîche. Il n'est PAS refait ici sur la consigne
// qui vient de partir : c'était le défaut, une marche suivie se lisait comme un refus. Sans
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un // commande en vigueur au début du cycle (première commande), le jugement est fait ici, sur
// déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence // celle qui vient de partir : récente, donc jamais divergente.
// entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait const std::optional<JugementCommande> j = jugerCommande(loadId, now);
// indéfiniment parce que plus personne ne commandait. const bool divergent = j && j->refus;
// JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié.
//
// `divergent` et `since` ne vivaient que dans la charge utile de télémétrie et dans ce
// QHash en mémoire — donc nulle part. Rien ne comptait les épisodes : un redémarrage de
// nymead effaçait l'état, et le hash est PURGÉ dès que l'écart cesse, si bien qu'une
// divergence qui s'ouvre et se referme entre deux lectures ne laissait aucune trace. Même
// un observateur externe échantillonnant en continu aurait raté les épisodes courts, qui
// sont probablement les plus nombreux.
//
// Sans ces deux lignes, « attendre que les traces parlent » attend indéfiniment : le
// silence du journal était garanti par construction, pas par l'absence du phénomène.
if (!divergent) {
if (m_divergenceDepuis.contains(loadId)) {
const qint64 dureeS = m_divergenceDepuis.value(loadId).secsTo(QDateTime::currentDateTime());
qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId
<< "après" << dureeS << "s"
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W";
}
m_divergenceDepuis.remove(loadId);
} else if (!m_divergenceDepuis.contains(loadId)) {
const QDateTime debut = m_lastTelemetryPublish.isValid()
? m_lastTelemetryPublish : QDateTime::currentDateTime();
m_divergenceDepuis.insert(loadId, debut.toUTC()); // ancré à la capture
qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W,"
<< "écart" << (mesureW - attenduW) << "W";
}
QVariantMap commande; QVariantMap commande;
commande.insert(QStringLiteral("expectedW"), attenduW); commande.insert(QStringLiteral("expectedW"), *commandeeW);
commande.insert(QStringLiteral("observedW"), mesureW); commande.insert(QStringLiteral("observedW"), mesureW);
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une // Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La // des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la // et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
// même chose du même cycle. // la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
commande.insert(QStringLiteral("divergent"), divergent); commande.insert(QStringLiteral("divergent"), divergent);
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'une
// latence. C'est pourtant TOUTE la différence entre un fonctionnement normal et le cas
// relevé le 2026-08-31, où l'écart durait indéfiniment parce que plus personne ne commandait.
if (divergent) if (divergent)
commande.insert(QStringLiteral("since"), commande.insert(QStringLiteral("since"),
m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate)); m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate));
entry.insert(QStringLiteral("command"), commande); entry.insert(QStringLiteral("command"), commande);
} }
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId,
const QDateTime &now) const
{ {
if (!m_tm) if (!m_tm)
return; return;
@ -2630,7 +2877,7 @@ void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loa
mesure.insert(QStringLiteral("powerW"), powerW); mesure.insert(QStringLiteral("powerW"), powerW);
entry.insert(QStringLiteral("measurement"), mesure); entry.insert(QStringLiteral("measurement"), mesure);
attachCommandDivergence(entry, loadId, source, powerW); attachCommandDivergence(entry, loadId, source, powerW, now);
// Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa // Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne // raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
@ -2740,7 +2987,7 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
} }
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce // Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien. // que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
attachMeasurements(entry, id); attachMeasurements(entry, id, now);
loads.append(entry); loads.append(entry);
}; };
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare. // Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
@ -3562,8 +3809,11 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex
// producteur ? » de l'apprentissage (observerPourApprentissage()). // producteur ? » de l'apprentissage (observerPourApprentissage()).
if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty()) if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty())
fil.pvCurrentW = qAbs(m_em->currentPowerProduction()); 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) if (phasePowerLimit() > 0)
fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())}; fil.phaseLimitA = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()};
if (ctx.battery.present) if (ctx.battery.present)
fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0); fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0);
if (m_loadConfigStore) if (m_loadConfigStore)

View File

@ -803,6 +803,39 @@ public:
*/ */
void shedWaterfallForOverload(const QDateTime &now); 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. //! \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 : //! \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. //! 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 * waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
* chose qui rattraperait serait \c verifyOverloadProtection() **après coup**. * 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à. * \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est * borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre * 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 * consommation lui rendrait exactement la puissance qui cause le dépassement
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était * (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser).
* fausse : le code ne l'a jamais fait. * \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 * \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. * instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
*/ */
@ -912,9 +950,11 @@ public:
* *
* \par Contrat * \par Contrat
* - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais * - 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 * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est
* seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans * sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou,
* mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même. * 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 * - 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 * 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. * voit ne se fabrique donc pas de place au-delà de la limite.
@ -927,7 +967,8 @@ public:
* mange jamais la marge. * mange jamais la marge.
* *
* \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là, * \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; double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
@ -1010,6 +1051,53 @@ protected:
const QDateTime &currentDateTime) override; const QDateTime &currentDateTime) override;
private: 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. * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
* *
@ -1176,8 +1264,9 @@ private:
* *
* \param entry Entrée de charge en cours de construction, modifiée en place. * \param entry Entrée de charge en cours de construction, modifiée en place.
* \param loadId Identifiant de la charge, clé dans le magasin de configuration. * \param loadId Identifiant de la charge, clé dans le magasin de configuration.
* \param now Temps de cycle — celui du jugement commande/mesure publié.
*/ */
void attachMeasurements(QVariantMap &entry, const QString &loadId) const; void attachMeasurements(QVariantMap &entry, const QString &loadId, const QDateTime &now) const;
//! \brief La source de mesure d'une charge et sa valeur — la règle UNIQUE de \c measurement. //! \brief La source de mesure d'une charge et sa valeur — la règle UNIQUE de \c measurement.
struct MesureCharge { struct MesureCharge {
@ -1205,9 +1294,49 @@ private:
* \c refusedSinceS. \param now Temps de cycle. * \c refusedSinceS. \param now Temps de cycle.
* \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop * \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop
* court pour une borne, trop long pour un relais. * court pour une borne, trop long pour un relais.
* \note Le jugement est celui de \c jugerCommande(), fait AVANT le plan : la commande en
* vigueur contre la mesure fraîche.
*/ */
void qualifierRefus(LoadContext &lc, const QDateTime &now) const; void qualifierRefus(LoadContext &lc, const QDateTime &now) const;
//! \brief Le jugement commande/mesure d'une charge, pour UN cycle (\c jugerCommande()).
struct JugementCommande {
QDateTime at; //!< Temps du cycle qui l'a produit.
double commandeW = 0; //!< La commande EN VIGUEUR pendant la mesure.
double mesureW = 0; //!< La mesure fraîche.
QString source; //!< Source de la mesure (\c meter | \c device).
QDateTime commandeDepuis; //!< Instant de la commande, en temps de cycle.
bool montee = false;//!< La commande demande à la charge de MONTER.
bool enProgression = false; //!< Rapprochée de la commande depuis le cycle précédent.
bool refus = false;//!< Écart au-delà de la latence, sans progression.
};
/*!
* \brief Juge l'écart entre la commande EN VIGUEUR et la mesure FRAÎCHE d'une charge.
* \param loadId Charge. \param now Temps de cycle.
* \return Le jugement ; \c std::nullopt si rien ne mesure, si rien n'a été commandé, ou si
* la charge n'a pas d'adaptateur.
*
* \par Pourquoi au DÉBUT du cycle
* Jugé après le dispatch, l'écart comparait la consigne qui venait de partir à la mesure de
* la précédente : toute marche suivie se lisait comme un refus vieux d'un cycle, et le
* recrédit d'une borne qui obéit était suspendu à chaque marche. Au début du cycle, la
* commande jugée est celle que la charge avait reçue quand la mesure a été prise.
*
* \par Latence, progression, refus
* - écart ≤ \c max(200 W, 15 %) : concordance, l'épisode se referme ;
* - sinon, un écart RÉCENT est une latence : temps depuis la commande ≤
* \c ILoadAdapter::commandSettleS ;
* - en MONTÉE seulement, une mesure qui s'est rapprochée de la commande de plus de 200 W
* depuis le cycle précédent est une latence (voiture qui négocie, PAC qui démarre). La
* DESCENTE n'a jamais cette excuse : c'est le refus qui coûte de l'argent ;
* - au-delà : refus.
*
* Un seul jugement par cycle, mis en cache : le recrédit et la télémétrie lisent le même.
*/
std::optional<JugementCommande> jugerCommande(const QString &loadId,
const QDateTime &now) const;
/*! /*!
* \brief Alimente l'avancement d'une charge NON-VE depuis son compteur désigné. * \brief Alimente l'avancement d'une charge NON-VE depuis son compteur désigné.
* \param[in,out] lc Charge du cycle. * \param[in,out] lc Charge du cycle.
@ -1274,6 +1403,15 @@ private:
* *
* \param[in,out] entry Entrée de télémétrie de la charge. * \param[in,out] entry Entrée de télémétrie de la charge.
* \param loadId Charge. \param source Source de mesure déjà résolue. \param mesureW Sa valeur. * \param loadId Charge. \param source Source de mesure déjà résolue. \param mesureW Sa valeur.
* \param now Temps de cycle.
*
* \par Le verdict est celui du cycle, pas une seconde comparaison
* \c expectedW (la commande qui vient de partir) et \c observedW (la mesure publiée) sont les
* deux nombres de la publication ; \c divergent est le verdict de \c jugerCommande(), fait au
* début du cycle sur la commande en vigueur — celui-là même qui suspend le recrédit. Un écart
* RÉCENT, ou en progression vers une commande à la hausse, est une latence : \c divergent
* reste faux. Sans commande en vigueur au début du cycle (première commande), on juge celle
* qui vient de partir : récente, donc jamais divergente.
* *
* \par L'écart ne vaut pas la même chose selon la source, dans les DEUX sens * \par L'écart ne vaut pas la même chose selon la source, dans les DEUX sens
* | | concordance | divergence | * | | concordance | divergence |
@ -1290,7 +1428,8 @@ private:
* publie, il ne se diagnostique pas — la cause n'est pas dans les watts. * publie, il ne se diagnostique pas — la cause n'est pas dans les watts.
*/ */
void attachCommandDivergence(QVariantMap &entry, const QString &loadId, void attachCommandDivergence(QVariantMap &entry, const QString &loadId,
const QString &source, double mesureW) const; const QString &source, double mesureW,
const QDateTime &now) const;
/*! /*!
* \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée. * \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée.
@ -1483,7 +1622,15 @@ private:
//! \brief Mode mémorisé rendu, publié avec l'événement de fin. //! \brief Mode mémorisé rendu, publié avec l'événement de fin.
QHash<QString, int> m_derogationModeRendu; QHash<QString, int> m_derogationModeRendu;
//! loadId → ouverture de l'épisode de divergence, datée à l'instant de la COMMANDE (temps de
//! cycle). Vidé dès que la mesure rejoint la commande.
mutable QHash<QString, QDateTime> m_divergenceDepuis; mutable QHash<QString, QDateTime> m_divergenceDepuis;
//! loadId → la commande en vigueur et l'instant où elle a pris effet (temps de cycle). Une
//! variation sous la tolérance de concordance n'est pas une nouvelle commande.
mutable QHash<QString, QPair<double, QDateTime>> m_commandeEnVigueur;
//! loadId → dernier jugement (\c jugerCommande()). Celui du cycle courant est relu par la
//! télémétrie ; celui du cycle précédent sert de référence à la PROGRESSION.
mutable QHash<QString, JugementCommande> m_jugementCommande;
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE : //! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE :
//! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()). //! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()).
mutable QHash<QString, double> m_ecoAchatWh; mutable QHash<QString, double> m_ecoAchatWh;

View File

@ -555,7 +555,9 @@ QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadActio
a.counts = niveau.counts; a.counts = niveau.counts;
a.levels = {niveau}; 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) if (a.clamped)
ecretages.append({a.loadId, decrire(demande, adapter), ecretages.append({a.loadId, decrire(demande, adapter),
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")}); applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});

View File

@ -613,6 +613,27 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
LoadContext lcConfort = lc; LoadContext lcConfort = lc;
if (plafondPartage.contains(lc.id)) if (plafondPartage.contains(lc.id))
lcConfort.declared.minPowerW = 0; 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
// en éco, recevait 12 A.
if (niveauxEco.contains(lc.id))
lcConfort.telemetry.lockMinPowerW =
qMax(0.0, lc.telemetry.lockMinPowerW - niveauxEco.value(lc.id).targetW);
if (lc.adapter == QStringLiteral("sg-ready")) if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else else

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 // 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. // reçoit, puis pourquoi : le branchement, la phase, sa limite.
if (c == QLatin1String(DecisionCode::OverloadThrottled)) { 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)") ? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA")) .arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").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 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 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 * \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 * \par Ce qu'il publie, et ce qu'il ne décide pas
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi * \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); params.insert(QStringLiteral("minOnBypassed"), true);
return params; 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". //! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
QString drawCapSourceCode(DrawCapSource s); 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. * \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. // Table figée par TestCharging::testNymeaEnergyPermissionScopes.
params.clear(); returns.clear(); 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."; 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)); "[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); registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
params.clear(); returns.clear(); 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("phasePowerLimit", enumValueName(Uint));
params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>()); returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin); registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// Notifications // Notifications
params.clear(); params.clear();
description = "Emitted whenever the phase power limit configuration changes."; description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
params.insert("phasePowerLimit", enumValueName(Uint)); params = phasePowerLimitSchema();
registerNotification("PhasePowerLimitChanged", description, params); registerNotification("PhasePowerLimitChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){ // [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}}); connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
emit PhasePowerLimitChanged(phasePowerLimitPayload());
});
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
emit PhasePowerLimitChanged(phasePowerLimitPayload());
}); });
params.clear(); params.clear();
@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap &params) JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap &params)
{ {
Q_UNUSED(params) Q_UNUSED(params)
QVariantMap returns; return createReply(phasePowerLimitPayload());
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit()); }
return createReply(returns);
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) JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap &params)
{ {
int phasePowerLimit = params.value("phasePowerLimit").toUInt(); 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); m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
QVariantMap returns; QVariantMap returns;
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError)); returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));

View File

@ -239,6 +239,14 @@ private:
* celui qu'on ne teste pas tous les jours. * celui qu'on ne teste pas tous les jours.
*/ */
static QVariantMap loadTelemetrySchema(); 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 //! \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. //! et \c OptimizerStatusChanged. \return Le schéma des champs.

View File

@ -23,6 +23,7 @@
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "smartchargingmanager.h" #include "smartchargingmanager.h"
#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal()
#include "evcharger.h" #include "evcharger.h"
#include "rootmeter.h" #include "rootmeter.h"
#include "energysettings.h" #include "energysettings.h"
@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa
{ {
EnergySettings settings; EnergySettings settings;
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt(); 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(); 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. // 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 double SmartChargingManager::acquisitionTolerance() const
{ {
return m_acquisitionTolerance; return m_acquisitionTolerance;
@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDate
return; return;
} }
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, // [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
{"B", m_rootMeter->currentPowerPhaseB()}, // marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
{"C", m_rootMeter->currentPowerPhaseC()}}; QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
bool limitExceeded = false; bool limitExceeded = false;
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; double consumptionLimitInWatt = 230 * effectivePhaseLimit();
// Note: for now we assume the phases are always connected this way: // Note: for now we assume the phases are always connected this way:
// EV phase A -> house phase A // EV phase A -> house phase A
@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDate
foreach (const QString &phase, currentPhaseConsumption.keys()) { foreach (const QString &phase, currentPhaseConsumption.keys()) {
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) { 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; limitExceeded = true;
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt; double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
requiredThrottlePower[phase] = overshot; requiredThrottlePower[phase] = overshot;
@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]"; << manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, // [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
{"B", m_rootMeter->currentPowerPhaseB()}, QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
{"C", m_rootMeter->currentPowerPhaseC()}};
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; double consumptionLimitInWatt = 230 * effectivePhaseLimit();
QHash<QString, double> availablePhasePower = {{"A", 0}, QHash<QString, double> availablePhasePower = {{"A", 0},
{"B", 0}, {"B", 0},
@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime) void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
{ {
// We don't assume we have the entire phase power for charging... // 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 ---------------"; qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
@ -637,6 +675,12 @@ void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
evChargers.append(evCharger); 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 // 2. Prepare all information avaialble for each charger
foreach (EvCharger *evCharger, evChargers) { foreach (EvCharger *evCharger, evChargers) {
@ -1314,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
bool chargingEnabled = newRawValue >= minimumRequiredAmps; bool chargingEnabled = newRawValue >= minimumRequiredAmps;
// Adjust down to not overload any phases. // 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); double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0; uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
@ -1396,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount; 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"; 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]"; 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 // 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 <QObject>
#include <QTimer> #include <QTimer>
#include <QSet>
#include "energymanagerconfiguration.h" #include "energymanagerconfiguration.h"
#include "types/charginginfo.h" #include "types/charginginfo.h"
@ -63,6 +64,30 @@ public:
uint phasePowerLimit() const; uint phasePowerLimit() const;
void setPhasePowerLimit(uint phasePowerLimit); 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; double acquisitionTolerance() const;
void setAcquisitionTolerance(double acquisitionTolerance); void setAcquisitionTolerance(double acquisitionTolerance);
@ -99,6 +124,7 @@ public:
signals: signals:
void phasePowerLimitChanged(int phasePowerLimit); void phasePowerLimitChanged(int phasePowerLimit);
void phaseSafetyMarginPercentChanged(uint percent); // [ETM]
void acquisitionToleranceChanged(double acquisitionTolerance); void acquisitionToleranceChanged(double acquisitionTolerance);
void batteryLevelConsiderationChanged(double batteryLevelConsideration); void batteryLevelConsiderationChanged(double batteryLevelConsideration);
void lockOnUnplugChanged(bool lockOnUnplug); void lockOnUnplugChanged(bool lockOnUnplug);
@ -113,7 +139,9 @@ signals:
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/). // [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
// All changes below are visibility-only (private → protected / virtual added). // 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 // 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 // é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. // protection de surcharge. Documenté ci-dessous.
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée // 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
// ci-dessous. Pas un réglage : jamais persisté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() // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
// — crochets ETM, virtuels, documentés ci-dessous. // — crochets ETM, virtuels, documentés ci-dessous.
// Ce sont EUX qui garantissent « une charge, un // 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 * \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 * valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
* budget du cycle est divisé par trois. * 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 // [ETM] END
private slots: private slots:
@ -344,6 +404,7 @@ private:
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules; QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
QHash<EvCharger *, ChargingProcessInfo> m_processInfos; QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
QSet<EvCharger *> m_preparedChargers; // [ETM] préparées au dernier prepareInformation()
QHash<EvCharger *, ChargingActions> m_chargingActions; QHash<EvCharger *, ChargingActions> m_chargingActions;
QDateTime m_lastSpotMarketPlanning; QDateTime m_lastSpotMarketPlanning;
@ -352,6 +413,7 @@ private:
QHash<EvCharger *, bool> m_overloadProtectionActive; QHash<EvCharger *, bool> m_overloadProtectionActive;
uint m_phasePowerConsumptionLimit = 25; uint m_phasePowerConsumptionLimit = 25;
uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27
double m_acquisitionTolerance = 0.5; double m_acquisitionTolerance = 0.5;
double m_batteryLevelConsideration = 0.9; double m_batteryLevelConsideration = 0.9;

View File

@ -332,7 +332,8 @@ void TestCharging::addChargingInfo()
// Set phase power limit // Set phase power limit
uint phasePowerLimit = 25000; 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); verifyEnergyError(response);
removeDevices(); removeDevices();
@ -691,7 +692,8 @@ void TestCharging::testEcoMode()
// Set phase power limit // Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); 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"); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter // Add mock meter
@ -1069,7 +1071,8 @@ void TestCharging::testEcoModePhaseSwitching()
// Set phase power limit // Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); 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"); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter // Add mock meter
@ -1381,7 +1384,8 @@ void TestCharging::testBatteryLevelConsideration()
// Set phase power limit // Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); 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"); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter // Add mock meter
@ -1602,7 +1606,8 @@ void TestCharging::batteryLevelConsiderationStopCharging()
// Set phase power limit // Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); 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"); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter // Add mock meter
@ -1840,7 +1845,8 @@ void TestCharging::testOverloadProtectionManualMode()
// Set phase power limit // Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); 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"); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter // Add mock meter
@ -2011,7 +2017,8 @@ void TestCharging::testOverloadProtectionEcoMode()
// Set phase power limit // Set phase power limit
params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); 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"); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
// Add mock meter // Add mock meter

View File

@ -451,6 +451,23 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin
return response.toMap().value("params").toMap().value("thingId").toUuid(); 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) QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port)
{ {
QVariantList thingParams; 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 mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7");
static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c"); static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c");
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3"); 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 mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f"); static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747"); 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 addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657);
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658); QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659); 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 addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661); QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);

View File

@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
horsService.observerMinute(huitJours.first()); horsService.observerMinute(huitJours.first());
QVERIFY(!horsService.profil().calcule); 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 testStoredInstantsSurviveATimeZoneChange();
void testReportingDayBoundaryIsPublishedNotDeducible(); void testReportingDayBoundaryIsPublishedNotDeducible();
void testDrawMarginComparesHeadroomsNotCaps(); 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. //! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
void testTheContentIsTheRevisionIdentity(); void testTheContentIsTheRevisionIdentity();

View File

@ -223,6 +223,7 @@ private slots:
void testAProtocol10OptimizerIsAccepted(); void testAProtocol10OptimizerIsAccepted();
void testTracesReplayWithZeroDivergence(); void testTracesReplayWithZeroDivergence();
void testSafetyWinsOverTheOptimizer(); void testSafetyWinsOverTheOptimizer();
void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum();
void testAnEchoedTokenLeaksNowhereEndToEnd(); void testAnEchoedTokenLeaksNowhereEndToEnd();
void cleanupTestCase(); void cleanupTestCase();
@ -936,6 +937,116 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
QCOMPARE(triplet(), QString("attendu|vivant|optimizer")); 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() void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
{ {
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token // 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

@ -27,6 +27,7 @@
#include "simulationbase.h" #include "simulationbase.h"
#include <functional>
#include <memory> #include <memory>
class EnergyArbitrator; class EnergyArbitrator;
@ -73,13 +74,53 @@ private:
}; };
//! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à //! 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. //! 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. //! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif, ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
BorneHc borne = BorneHc::Monophasee); BorneHc borne = BorneHc::Monophasee);
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée. //! 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); 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 {
int mesureW = 0; //!< ce que la borne tire au début du cycle
QString motif;
int refusDepuisS = -1;
int retenuW = 0;
int surplusW = 0;
int recrediteW = 0;
int consigneA = 0; //!< ce que la borne a reçu ; 0 = coupée
int consigneW = 0;
int attenduW = 0;
bool divergent = false;
};
//! Tirage de la borne au cycle \p k, depuis la commande reçue au cycle précédent et le
//! tirage précédent (W). C'est le modèle de la borne : rampe, refus, figeage.
using TirageBorne = std::function<double(int k, double commandeW, double tireW)>;
/*!
* \brief Rejoue \p cycles cycles de 60 s d'une borne monophasée 6-16 A, maison à 300 W.
* \param nom Suffixe du fichier de configuration. \param tirage Modèle de la borne.
* \param productionW Production au cycle k (W). \param cycles Nombre de cycles.
* \return La trace, cycle par cycle ; vide si le scénario n'a pas pu être posé.
*/
QList<CycleBorne> rejouerBorne(const QString &nom, const TirageBorne &tirage,
const std::function<double(int)> &productionW, int cycles);
private slots: private slots:
void run_data(); void run_data();
void run(); void run();
@ -240,6 +281,18 @@ private slots:
void testPhaseVoltageDoesNotLeakBetweenPhases(); void testPhaseVoltageDoesNotLeakBetweenPhases();
void testMeterEnergyActuallyAccumulates(); void testMeterEnergyActuallyAccumulates();
void testRecreditIsALoanAgainstObedience(); void testRecreditIsALoanAgainstObedience();
//! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient.
void testRecreditSuspendedAfterAnObeyedStep();
//! Un vrai refus reste vu au cycle qui suit la commande ; la descente n'a pas d'excuse.
void testARealRefusalIsStillSeen();
//! Relais et consignes sous compteur dédié : marche suivie, refus réel, sans compteur.
void testWattLoadsFollowedOrRefusedUnderAMeter();
//! SG-Ready sous compteur dédié : la divergence se publie, le recrédit n'en dépend pas.
void testSgReadyDivergenceIsPublishedOnly();
//! P2 — un arrêt verrouillé engage le courant minimal : gelé au budget, aucun faux refus.
void testLockedStopFreezesTheMinimumAndIsNoRefusal();
//! P2 — le verrou d'arrêt borne la somme éco + confort, il ne s'y ajoute pas.
void testALockedStopDoesNotAddToTheEcoFloor();
void testARefusedStateIsDistinguishableFromAnApplied(); void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock(); void testL4ShedsAPacAndSaysItForcedTheLock();
void testDerogationTakesTheHeadButNotTheSafety(); void testDerogationTakesTheHeadButNotTheSafety();
@ -300,6 +353,20 @@ private slots:
void testOffPeakFullPowerAtDawnStaysInTheCeiling(); void testOffPeakFullPowerAtDawnStaysInTheCeiling();
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne. //! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases(); 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. //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares(); void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
@ -346,6 +413,23 @@ private slots:
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés. //! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
void testSheddingLogKeepsTheLastEpisodes(); 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 printStates(Thing *thing);
void updateChargerMeter(Thing *thing); void updateChargerMeter(Thing *thing);

View File

@ -187,6 +187,43 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
return; 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) { } else if (thing->thingClassId() == simpleChargerThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this); EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port"); 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 (thing->thingClassId() == simpleChargerThingClassId) {
if (actionType.name() == "maxChargingCurrent") { if (actionType.name() == "maxChargingCurrent") {
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt(); uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
@ -751,6 +797,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
info->finish(Thing::ThingErrorNoError); 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) void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing)
{ {
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString()); 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 // 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 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", "name": "simpleCharger",
"displayName": "Mocked charger simple", "displayName": "Mocked charger simple",

View File

@ -65,6 +65,8 @@ TABLE_MODES = {
'old-data': ('—', 'valide'), 'old-data': ('—', 'valide'),
'echo-value': ('—', 'valide'), 'echo-value': ('—', 'valide'),
'major-first': ('—', '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 # 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` 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]}`) · (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 `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 `--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é (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', MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type', '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 # 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 # 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: if a['loadId'] in disponibles:
del actions[i] del actions[i]
break 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: elif mode == 'echo-token' and token:
for a in actions: for a in actions:
a['reason'] = f'{a["reason"]} — écho {token}' a['reason'] = f'{a["reason"]} — écho {token}'