Compare commits
No commits in common. "main" and "feature/protocole-1.3" have entirely different histories.
main
...
feature/pr
@ -1,69 +1,3 @@
|
|||||||
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
|
|
||||||
|
|
||||||
* Protocole optimiseur 1.3 : le moteur annonce 1.3. Forme normée de
|
|
||||||
error.data d'un contexte GetPlan invalide (problems : chemin et code d'une
|
|
||||||
liste fermée, 50 au plus), token jugé avant la majeure au handshake ; le
|
|
||||||
moteur ne lit pas error.data, son pilotage ne change pas.
|
|
||||||
* Validateur du contrat, suite de conformité et optimiseur de référence
|
|
||||||
alignés sur 1.3 (forme exigée à partir de 1.3, avertie pour un 1.2).
|
|
||||||
|
|
||||||
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 23 Oct 2026 14:00:00 +0200
|
|
||||||
|
|
||||||
powersync-energy-plugin-nymea (1.15.2+etm73) trixie; urgency=medium
|
powersync-energy-plugin-nymea (1.15.2+etm73) trixie; urgency=medium
|
||||||
|
|
||||||
* SetLoadConfig : un refus publie son motif (LOAD_CONFIG_REFUSED, cause et
|
* SetLoadConfig : un refus publie son motif (LOAD_CONFIG_REFUSED, cause et
|
||||||
|
|||||||
@ -299,60 +299,3 @@ la descente, mais `applyAction()` renvoyait l'action **demandée**, donc `state
|
|||||||
vivra **hors du moteur**. Les confondre ferait hériter à ce lot d'une contrainte qui ne le
|
vivra **hors du moteur**. Les confondre ferait hériter à ce lot d'une contrainte qui ne le
|
||||||
concerne pas.
|
concerne pas.
|
||||||
- **Il ne déplace pas la frontière de sécurité.** L4 garde le dernier mot, L0 reste physique.
|
- **Il ne déplace pas la frontière de sécurité.** L4 garde le dernier mot, L0 reste physique.
|
||||||
|
|
||||||
## 8. Le délestage VISIBLE (lot du 2026-10-25)
|
|
||||||
|
|
||||||
Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une alerte
|
|
||||||
quelque part ? » Avant ce lot, non. Décision du 2026-10-22 : l'alerte passe par l'app, par les
|
|
||||||
événements JSON-RPC, sans aucun flux sortant (invariant 10). **Ce lot publie, il ne décide rien
|
|
||||||
de plus** : aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2, règle 4).
|
|
||||||
|
|
||||||
**8.1 — La borne : motif `OVERLOAD_THROTTLED`.** `verifyOverloadProtection()` rabaisse une borne
|
|
||||||
du dépassement arrondi à l'ampère supérieur, et la coupe sous son plancher. Ses consignes portent
|
|
||||||
`ChargingActionIssuerOverloadProtection`, mais rien n'en sortait. `executeChargingAction()` est
|
|
||||||
devenu virtuel (marque `[ETM]`, corps amont inchangé) : l'arbitre laisse partir la consigne,
|
|
||||||
puis la lit.
|
|
||||||
|
|
||||||
| Consigne | Effet |
|
|
||||||
|---|---|
|
|
||||||
| protection, baisse ou coupure | la tenue s'ouvre ou se prolonge ; la borne publie `OVERLOAD_THROTTLED` |
|
|
||||||
| protection, remise au minimum après coupure (`verifyOverloadProtectionRecovery`) | la tenue se prolonge |
|
|
||||||
| protection, retour au courant manuel (`verifyOverloadProtectionRecovery`) | la tenue est levée |
|
|
||||||
| tout autre émetteur (le cycle reprend la borne) | la tenue est levée |
|
|
||||||
| émetteur inconnu (consigne par défaut de la boucle amont, voir 8.5) | rien |
|
|
||||||
|
|
||||||
Paramètres : `fromA`, `toA` (0 si coupée), `cut`, `overshootW` (le plus fort dépassement de la
|
|
||||||
tenue), `limitA`, `phase` (absente pour une triphasée). La préséance est celle de L4 : l'entrée
|
|
||||||
perd ses niveaux, et `allocatedW` dit ce que la protection laisse à la borne. Hors d'un cycle
|
|
||||||
(`powerBalanceChanged`), l'instantané publié est complété et republié tel quel : son
|
|
||||||
`timestamp` reste celui du dernier cycle, et le geste est daté par le journal.
|
|
||||||
|
|
||||||
**8.2 — Le journal court.** En mémoire, 20 épisodes (`SheddingLog`), lu par
|
|
||||||
`NymeaEnergy.GetSheddingLog`, même droit que `GetLoadTelemetry`. **Perdu au redémarrage de
|
|
||||||
nymead : accepté.** Il est hors de la télémétrie parce qu'il la doublerait (20 épisodes à deux
|
|
||||||
charges utiles de mécanisme, à chaque battement). La télémétrie n'en porte que le résumé,
|
|
||||||
`shedding: {revision, open}`, **absent** tant que rien n'a été délesté depuis le démarrage.
|
|
||||||
|
|
||||||
- **Un épisode, pas un cycle.** Il s'ouvre au premier geste. Il se ferme à la fin du premier cycle
|
|
||||||
complet où la protection ne tient plus la charge, et la fin est datée du premier instant libre.
|
|
||||||
- **Toutes les charges de la cascade**, enregistrées dans `shedWaterfallForOverload()` sans liste
|
|
||||||
de types : « avant » et « après » sont la charge utile de `runtimeView()` (palier des relais,
|
|
||||||
consigne, état SG-Ready). Une charge de la cascade reste tenue tant que le plafond est épuisé au
|
|
||||||
compteur, ou que son motif est `DRAW_CAP`.
|
|
||||||
- « Après » garde l'état le plus bas de l'épisode, et `cut` dit si la charge a été coupée au moins
|
|
||||||
une fois. Une PAC rendue à l'état 2 n'est pas coupée.
|
|
||||||
|
|
||||||
**8.3 — La notification.** `shedding.revision` change à chaque ouverture et à chaque fermeture.
|
|
||||||
`telemetrySignificantlyDiffers()` le compare : `LoadTelemetryChanged` part, même quand rien
|
|
||||||
d'autre n'a bougé.
|
|
||||||
|
|
||||||
**8.4 — Limites connues.** Sous le mode dégradé L2, aucun cycle ne tourne : le journal ne se
|
|
||||||
ferme pas, il reprend au retour du compteur. Une charge que la cascade laisse éteinte parce que
|
|
||||||
la marge restante est sous son palier (`BELOW_MIN_POWER`) n'est plus tenue au sens du journal :
|
|
||||||
l'épisode se ferme quand le plafond n'est plus épuisé.
|
|
||||||
|
|
||||||
**8.5 — Défaut amont relevé, NON corrigé.** Dans la boucle de `verifyOverloadProtection()`, une
|
|
||||||
borne monophasée active dont la phase ne dépasse pas reçoit `ChargingAction()` par défaut :
|
|
||||||
non enclenchée, 0 A, émetteur inconnu. Elle est donc coupée (verrous permettant) quand une autre
|
|
||||||
phase dépasse. Ce lot ne le publie pas comme geste de protection et ne le corrige pas : c'est
|
|
||||||
une décision de sécurité, hors de son périmètre.
|
|
||||||
|
|||||||
@ -1,474 +0,0 @@
|
|||||||
# Heures creuses pour la borne, batterie protégée pendant sa charge — lot e1 (2026-10-24)
|
|
||||||
|
|
||||||
*Décisions de Patrick du 2026-10-24. Ce document décrit ce que le code fait, les preuves qui le
|
|
||||||
rejouent, et ce qu'il ne fait pas. Code : `etm/tariff/tariffconfig.*`,
|
|
||||||
`etm/scheduler/rulebasedscheduler.cpp`, `etm/adapters/storagedischargeguard.*`,
|
|
||||||
`etm/energyarbitrator.cpp` (`buildContext`, `update`, `applyDegradedMode`).*
|
|
||||||
|
|
||||||
## 1. Les heures creuses
|
|
||||||
|
|
||||||
**Règle** : les heures creuses sont **la ou les zones au prix le plus bas** d'un tarif à zones
|
|
||||||
horaires (`tou`) dont les prix **diffèrent** (`TariffConfig::zonesCreuses()`).
|
|
||||||
|
|
||||||
- « HC » n'a pas de code : le libellé est une donnée de l'installateur. Le prix se compare.
|
|
||||||
- Plusieurs zones au même prix minimal sont toutes creuses (une nuit et un début d'après-midi
|
|
||||||
saisis comme deux zones).
|
|
||||||
- **Aucune heure creuse** sans tarif, avec un tarif unique (`fixed`), ou avec des zones toutes au
|
|
||||||
même prix. Dans ces trois cas **rien de ce lot ne s'exécute** : le moteur est celui d'etm74
|
|
||||||
(preuve `decisions` IDENTIQUE, et `testWithoutOffPeakTheEngineIsEtm74`).
|
|
||||||
- Lecture murale, minute **absolue** par minute (`enHeuresCreuses()`, `prochaineBascule()`), dans
|
|
||||||
le fuseau de la box relu à chaque cycle. Les jours de 23 h et de 25 h sont exacts (nuit
|
|
||||||
22:00-06:00 de 7 h et de 9 h), épinglés sur des époques calculées hors de Qt.
|
|
||||||
- Le **prix** n'est toujours lu par aucune décision, et `SurplusContext::tariff` n'est toujours
|
|
||||||
pas rempli : l'optimiseur ne reçoit rien de neuf. Les heures creuses vivent dans
|
|
||||||
`SurplusContext::offPeak`, interne au moteur.
|
|
||||||
|
|
||||||
## 2. La borne : surplus le jour, complément en heures creuses
|
|
||||||
|
|
||||||
**Modes concernés** — `ChargingModeEcoWithMinCurrent`, `ChargingModeEcoMinWithTargetTime`,
|
|
||||||
`ChargingModeEcoWithTargetTime` : ceux dont le besoin réseau peut attendre. `EvAdapter` le
|
|
||||||
DÉCLARE (`LoadNeeds::offPeakShift`, `offPeakMinPowerW` = le plafond de cycle de la borne depuis
|
|
||||||
le lot e2, son plancher au lot e1 — §8, D7), le scheduler le DÉCIDE (règle 2). `ChargingModeEco` ne demande jamais le réseau, `ChargingModeNormal` est
|
|
||||||
piloté par l'utilisateur : tous deux sont inchangés.
|
|
||||||
|
|
||||||
| | hors heures creuses | en heures creuses |
|
|
||||||
|---|---|---|
|
|
||||||
| minimum réseau (`minPowerWAlways`) | **retiré** — surplus seulement | le **plafond** de la borne (lot e2, §8 D7) |
|
|
||||||
| démarrage sous tolérance (`EV_GRID_START`) | **refusé** (le minimum par une autre porte) | inchangé |
|
|
||||||
| motif d'une consigne nulle | **`EV_WAITING_OFF_PEAK`** `{at, startsAt, budgetW}` | motifs ordinaires |
|
|
||||||
| motif de la consigne servie | — | **`EV_OFF_PEAK_FULL_POWER`** (lot e2, §8 D7) |
|
|
||||||
| échéance (`EV_DEADLINE`, proxy) | inchangée | inchangée |
|
|
||||||
|
|
||||||
**Le changement de comportement** : une borne « Éco avec minimum » ne prend plus ses 6 A au
|
|
||||||
réseau le soir. Elle charge au surplus tant qu'il y en a, puis attend 22:00. Le motif le dit :
|
|
||||||
« Borne attend les heures creuses (22:00) — hors heures creuses, surplus seulement (… W) ».
|
|
||||||
`EV_WAITING_OFF_PEAK` remplace `SURPLUS_INSUFFICIENT`, `BELOW_MIN_POWER` et `BATTERY_RESERVE`,
|
|
||||||
parce que la cause de la consigne nulle n'est plus le manque de surplus. Le plafond de soutirage
|
|
||||||
(`DRAW_CAP`), le refus (`RECREDIT_SUSPENDED`), la limite de phase, l'indisponibilité et les
|
|
||||||
verrous gardent la tête : ils diraient la même chose en heures creuses.
|
|
||||||
|
|
||||||
**En heures creuses**, le plancher est servi au réseau par la passe éco (`ECO_FLOOR_GRID`),
|
|
||||||
bornée par l'autorisation de soutirage ; le confort ajoute le surplus s'il y en a. *Lot e2 :
|
|
||||||
c'est désormais le plafond, sous `EV_OFF_PEAK_FULL_POWER` — §8, D7.* La borne
|
|
||||||
déclare un plafond écrêté par `evPhaseAllowanceW()` — en heures creuses par
|
|
||||||
`evOffPeakAllowanceW()`, qui lui rend son courant mesuré moins 1 A (§8.2, D8) —, et
|
|
||||||
`verifyOverloadProtection()` garde le dernier mot : l'invariant 2 tient sans rien ajouter.
|
|
||||||
|
|
||||||
**L'échéance** : la puissance nécessaire pour tenir une échéance reste celle du proxy amont, qui
|
|
||||||
force la charge à pleine puissance au point de non-retour. Ce point est calculé sur l'échéance,
|
|
||||||
pas sur la fin des heures creuses : une échéance trop proche peut donc encore se servir en
|
|
||||||
heures pleines. Une échéance est une promesse de l'utilisateur, le tarif ne la rompt pas. Étaler
|
|
||||||
la charge sur les heures creuses demanderait une énergie restante mesurée (LM-1009) : ce sera un
|
|
||||||
lot à part (§8, D3).
|
|
||||||
|
|
||||||
Le refus d'`EV_GRID_START` hors heures creuses est confirmé (§8, D2) : seul le mode boost
|
|
||||||
(`ChargingModeNormal`) charge tout de suite.
|
|
||||||
|
|
||||||
**À 06:00**, le minimum est retiré : une borne qui charge encore s'arrête (au premier cycle où
|
|
||||||
son verrou d'enclenchement le permet, 300 s par défaut).
|
|
||||||
|
|
||||||
## 3. La batterie : décharge bloquée pendant la charge de la voiture en heures creuses
|
|
||||||
|
|
||||||
### 3.1 Ce que le modèle SunSpec 124 dit — et ce que le plugin écrit
|
|
||||||
|
|
||||||
Lu dans le plugin `sunspec` (`integrationpluginsunspec.cpp`, `sunspecstoragelogic.*`,
|
|
||||||
`froniusrules.*`) et dans la définition du modèle 124 de `libnymea-sunspec` :
|
|
||||||
|
|
||||||
- `StorCtl_Mod` est un **champ de bits** — « Activate hold/discharge/charge storage control
|
|
||||||
mode » : bit 0 `CHARGE`, bit 1 `DiSCHARGE`. Le bit **active la limite** correspondante, il
|
|
||||||
n'« autorise » pas l'opération.
|
|
||||||
- `OutWRte` est la limite de décharge, en **pourcentage** de la puissance maximale (`% WDisChaMax`
|
|
||||||
dans le modèle, « % de `WChaMax` » dans la documentation du plugin). **0 % vaut 0 W quelle que
|
|
||||||
soit la référence** : l'unité n'a pas d'incidence sur la valeur écrite ici, ni le facteur
|
|
||||||
d'échelle `InOutWRte_SF` (que le plugin relit avant chaque écriture, AC-12).
|
|
||||||
- L'état `enableDischarging` du plugin **est** ce bit (`readStates()`), et l'action le pose par
|
|
||||||
lecture-modification-écriture de `StorCtl_Mod`. L'état `dischargingRate` **est** `OutWRte`.
|
|
||||||
- Le contrat du plugin (AC-11, `FroniusRules::planBatteryMode()`) le confirme :
|
|
||||||
`NO_DISCHARGE` = `StorCtl_Mod = 2`, `OutWRte = 0` ; `NORMAL` = `StorCtl_Mod = 0`, `OutWRte = 100`.
|
|
||||||
|
|
||||||
Donc **`enableDischarging = false` LÈVE la limite** — il ne bloque rien. La séquence qui bloque
|
|
||||||
réellement la décharge est **`dischargingRate = 0`, puis `enableDischarging = true`**. Le retour
|
|
||||||
à l'automatisme est **`enableDischarging = false`, puis `dischargingRate = 100`** (le maximum de
|
|
||||||
l'état).
|
|
||||||
|
|
||||||
**Le plugin permet de l'écrire, en deux actions.** Chacune est un plan d'écriture complet (ordre
|
|
||||||
imposé, relecture, minuteur de retour réarmé en dernier). Elles ne sont pas atomiques : le garde
|
|
||||||
les **enchaîne** (la seconde part à la confirmation de la première), dans l'ordre qui rend
|
|
||||||
chaque état intermédiaire sûr :
|
|
||||||
|
|
||||||
- pose : le taux d'abord. Tant que la limite est inactive, le taux est inerte ; quand elle
|
|
||||||
s'active, il vaut déjà 0. Si la première écriture échoue, la seconde ne part pas — la limite
|
|
||||||
reste inactive, la batterie en automatique ;
|
|
||||||
- levée : la limite d'abord, l'automatisme revient tout de suite ; le taux est remis au maximum
|
|
||||||
pour qu'une limite réactivée plus tard ne reparte pas à 0 %.
|
|
||||||
|
|
||||||
`FroniusRules::planBatteryMode(NO_DISCHARGE)` écrirait les deux registres en une séquence, mais
|
|
||||||
aucune action du plugin ne l'emprunte. L'exposer est décidé, dans le dépôt du plugin (§8, D6) ;
|
|
||||||
le garde reste compatible avec les deux actions actuelles.
|
|
||||||
|
|
||||||
### 3.2 Quand
|
|
||||||
|
|
||||||
**Heure creuse ET bornes qui tirent réellement ≥ 230 W (mesuré, toutes bornes, mode manuel
|
|
||||||
compris) ET au moins une batterie pilotable.** Sinon la batterie est rendue à son automatisme,
|
|
||||||
et une consigne tenue est **levée explicitement**. La décision est prise par le rule-based à
|
|
||||||
chaque cycle (`RuleBasedScheduler::storageDecision()`), y compris en ombre d'un optimiseur.
|
|
||||||
|
|
||||||
La batterie est bloquée au cycle où la voiture **tire** : au cycle de 22:00 la borne vient d'être
|
|
||||||
commandée et lit encore 0 W, le blocage part à 22:01. Elle est rendue au cycle où la borne ne
|
|
||||||
tire plus (fin de charge) ou à 06:00.
|
|
||||||
|
|
||||||
Ce que le blocage bloque : **toute** décharge, maison comprise. « Ne pas se décharger dans la
|
|
||||||
voiture » ne s'exprime pas autrement sans asservir `OutWRte` à la consommation de la maison —
|
|
||||||
une boucle de régulation que le lot ne fait pas. C'est le geste voulu (§8, D1), comme le
|
|
||||||
blocage par une borne en mode boost (D4) et le seuil de 230 W (D5).
|
|
||||||
|
|
||||||
### 3.3 Pilotable, ou non
|
|
||||||
|
|
||||||
Par l'interface `energystorage` et les **états présents** — aucune ThingClass n'est nommée
|
|
||||||
(`StorageDischargeGuard::capacite()`, question posée à la classe) :
|
|
||||||
|
|
||||||
| cause | condition | effet |
|
|
||||||
|---|---|---|
|
|
||||||
| — | `enableDischarging` (booléen) et `dischargingRate` (0 admis) modifiables, `revertTime` ≥ 120 s | commandée |
|
|
||||||
| `noDischargeControl` | un des deux états absent ou non modifiable | lue comme avant, `STORAGE_NOT_PROTECTED` |
|
|
||||||
| `noRevertTimer` | pas de paramètre `revertTime`, ou 0 | idem — un moteur arrêté la laisserait bloquée sans fin |
|
|
||||||
| `revertTimerTooShort` | `revertTime` < 120 s | idem — elle expirerait entre deux cycles |
|
|
||||||
|
|
||||||
### 3.4 Le minuteur et le rafraîchissement
|
|
||||||
|
|
||||||
Chaque écriture du plugin se termine par `InOutWRte_RvrtTms = revertTime` (300 s sous Fronius,
|
|
||||||
`FroniusRules::DefaultRevertTimeSeconds`). Le garde réécrit la séquence dès que la dernière a
|
|
||||||
**le tiers du minuteur** (100 s) : à un cycle par minute, deux écritures sont espacées de 120 s,
|
|
||||||
180 s si un cycle manque — toujours sous 300 s. Il la réécrit aussi, sans attendre, si la
|
|
||||||
batterie ne rapporte plus la limite (onduleur revenu seul à l'automatisme).
|
|
||||||
|
|
||||||
**La sûreté est le minuteur**, pas le moteur : si `nymead` s'arrête, plus rien ne réécrit, et le
|
|
||||||
GEN24 reprend son automatisme au plus 300 s après la dernière écriture. Sous compteur muet (L2),
|
|
||||||
le garde lève la limite explicitement. Au redémarrage, une limite à 0 % trouvée active est
|
|
||||||
reprise (rafraîchie ou levée).
|
|
||||||
|
|
||||||
⚠️ **Hypothèse non levée, et elle est du plugin** : la sémantique du minuteur est documentée par
|
|
||||||
le constructeur au chapitre général et illustrée sur le modèle 123, pas sur le 124 (AC-10 du
|
|
||||||
plugin, « décision D4 non validée »). Les tests la modélisent ; seule une mesure sur un GEN24
|
|
||||||
réel la prouve.
|
|
||||||
|
|
||||||
### 3.4 bis Un onduleur qui refuse (suite du lot e1)
|
|
||||||
|
|
||||||
Un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute écriture. Avant cette suite, la
|
|
||||||
batterie ne rapportant jamais la limite, le garde reposait la séquence à chaque cycle, avec un
|
|
||||||
`qCWarning` à chaque fois, toute la nuit.
|
|
||||||
|
|
||||||
- **Espacement** : une pose refusée (taux OU limite) ouvre une **série de refus**. La pose
|
|
||||||
suivante attend **1, 2, 5, 10 puis 15 min** au plus, à compter de la pose refusée, avec une
|
|
||||||
marge de 5 s pour la gigue d'un cycle par minute. Quinze minutes au plus : un réglage corrigé
|
|
||||||
en cours de nuit est repris dans le quart d'heure.
|
|
||||||
- **Fin de série** : à la première séquence **acceptée jusqu'au bout** (taux puis limite), le
|
|
||||||
rythme normal reprend (tiers du minuteur) ; ou à la **levée**, qui clôt la série — la pose
|
|
||||||
suivante est une consigne neuve.
|
|
||||||
- **La levée n'est jamais espacée** : rendre l'automatisme part au cycle même où la décision
|
|
||||||
tombe, série en cours ou non. Elle n'est pas répétée si elle est refusée (le minuteur de
|
|
||||||
l'onduleur la fera) ; son refus reste un `qCWarning`.
|
|
||||||
- **Journal** : un seul `qCWarning` par série (« … REFUSÉE (…) : la décharge n'est PAS
|
|
||||||
bloquée … »), les refus suivants et les nouvelles tentatives en `qCDebug`, et un `qCInfo` à
|
|
||||||
l'acceptation qui clôt la série (« … ACCEPTÉE après N refus »).
|
|
||||||
- **Télémétrie** : pendant une série, **`held` est faux** — et `dischargeBlocked` avec lui — et
|
|
||||||
la batterie publie `refusedWrites` (poses refusées d'affilée), `refusedSince` et
|
|
||||||
`nextAttemptAt`. La décision reste `STORAGE_DISCHARGE_BLOCKED` : c'est ce que le moteur a
|
|
||||||
décidé, l'exécution est ce qui échoue.
|
|
||||||
|
|
||||||
**Pourquoi `held` faux plutôt que `held` vrai plus un drapeau** : `held` et `dischargeBlocked`
|
|
||||||
sont lus comme « la batterie ne se décharge pas ». Un client qui ignore un champ nouveau —
|
|
||||||
c'est-à-dire tous les clients d'aujourd'hui — continuerait d'afficher une protection qui
|
|
||||||
n'existe pas. Avec `held` faux, le client existant dit vrai sans changement, et le client à jour
|
|
||||||
lit en plus pourquoi et jusqu'à quand. Le passage de `dischargeBlocked` à faux notifie, ce qu'un
|
|
||||||
drapeau à côté n'aurait pas fait. Un refus se lit au cycle **suivant** la pose : le verdict de
|
|
||||||
l'onduleur arrive après la publication du cycle qui a écrit, comme `observed`.
|
|
||||||
|
|
||||||
### 3.5 Sécurité et invariant 8 (« pas de boucle »)
|
|
||||||
|
|
||||||
La décision ne lit **ni le budget ni la réserve**, et n'y verse rien. Le budget reste compteur +
|
|
||||||
PV mesurés. Les règles de réserve (`rulebasedscheduler.cpp`, « Prise en compte du stockage »)
|
|
||||||
restent justes sous blocage :
|
|
||||||
|
|
||||||
- **la nuit**, batterie bloquée et sans PV : `powerW` ≈ 0, aucune branche ne modifie le budget,
|
|
||||||
qui vaut −(import). Le blocage augmente l'import, le budget le constate : aucun surplus n'est
|
|
||||||
fabriqué (`testBlockedDischargeShowsAtTheMeterAndL4Decides`, `surplusW == −import`) ;
|
|
||||||
- **sous la réserve**, le surplus positif est annulé : il n'y en a pas la nuit, la règle est
|
|
||||||
sans effet ;
|
|
||||||
- **au-dessus de la réserve et en charge** (PV avant 06:00 l'été) : la charge de la batterie
|
|
||||||
est rendue au budget. C'est vrai sous blocage aussi : bloquer la décharge ne touche pas la
|
|
||||||
charge, et le GEN24 réduit sa charge pour couvrir une consommation nouvelle — ce qu'il ne peut
|
|
||||||
plus couvrir vient alors du réseau, pas de la batterie, et le compteur le montre au cycle
|
|
||||||
suivant ;
|
|
||||||
- **en décharge** (`powerW` < 0, si l'onduleur ignorait la consigne) : retranchée, comme avant —
|
|
||||||
le sens prudent.
|
|
||||||
|
|
||||||
**Le soutirage ajouté** — ce que la batterie couvrait passe au réseau — se mesure au cycle
|
|
||||||
suivant : la marge de soutirage du contexte (`houseDrawMargin()`) et `verifyOverloadProtection()`
|
|
||||||
le lisent au compteur, et coupent la borne s'il dépasse (éprouvé avec une limite de 25 A). La
|
|
||||||
borne coupée ne tire plus : la batterie est rendue au cycle d'après.
|
|
||||||
|
|
||||||
**Oscillation possible, signalée** : sur une installation au bord de sa limite de phase, la suite
|
|
||||||
« bloquer → la phase dépasse → L4 coupe la borne → rendre → la batterie couvre → la borne
|
|
||||||
redémarre » peut se répéter. Sa période est bornée par le verrou d'enclenchement de la borne
|
|
||||||
(300 s), et L4 tient la sécurité à chaque tour.
|
|
||||||
|
|
||||||
**Hystérésis de la réserve de 20 %** : non touchée. Effet indirect seulement : la batterie ne se
|
|
||||||
vide plus dans la voiture la nuit, elle passe plus souvent la réserve le matin.
|
|
||||||
|
|
||||||
## 4. Ce qui est publié
|
|
||||||
|
|
||||||
- **Journal** : `[Arbitre] Batterie → Décharge de la batterie bloquée : la voiture charge en
|
|
||||||
heures creuses (1380 W, jusqu'à 06:00)`, à chaque cycle où la question se pose ; `[Batterie] …
|
|
||||||
décharge BLOQUÉE` et `… RENDUE à l'automatisme` aux transitions (`qCInfo`), le rafraîchissement
|
|
||||||
en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d) — une fois par série de refus
|
|
||||||
pour la pose (§3.4 bis).
|
|
||||||
- **Télémétrie** (`GetLoadTelemetry` et `LoadTelemetryChanged`, même charge utile) : un bloc
|
|
||||||
`storage`, **absent** sans heure creuse ou sans batterie — `dischargeBlocked` (ce que le moteur
|
|
||||||
TIENT après le dispatch), `decision` `{code, params}`, et `batteries[]` avec ce qui est LU
|
|
||||||
(`observed.enableDischarging`, `observed.dischargingRate`) ; pendant une série de refus,
|
|
||||||
`held` faux et `refusedWrites`, `refusedSince`, `nextAttemptAt`. Une transition du bloc notifie.
|
|
||||||
- **Motifs** : `EV_WAITING_OFF_PEAK`, `STORAGE_DISCHARGE_BLOCKED`, `STORAGE_AUTOMATIC`
|
|
||||||
(`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par
|
|
||||||
un optimiseur.
|
|
||||||
- **Aucun flux sortant nouveau** (invariant 10) : le garde parle au ThingManager, comme tous les
|
|
||||||
adaptateurs.
|
|
||||||
|
|
||||||
## 5. Jamais
|
|
||||||
|
|
||||||
`gridCharging`, `enableCharging`, `chargingRate` ne sont jamais écrits, ni un `dischargingRate`
|
|
||||||
négatif (charge forcée). La recharge de la batterie sur le réseau est l'offre Pro. Le test de la
|
|
||||||
journée le vérifie sur toutes les écritures exécutées, et la contre-épreuve qui écrit
|
|
||||||
`gridCharging` le fait rougir.
|
|
||||||
|
|
||||||
## 6. Preuves
|
|
||||||
|
|
||||||
| Preuve | Ce qu'elle rejoue |
|
|
||||||
|---|---|
|
|
||||||
| `testOffPeakIsTheCheapestZone` (loadmodel) | la règle ; aucun / unique / prix égaux ; jours de 23 et 25 h |
|
|
||||||
| `testOffPeakDemoDay` | 12 h, attente à 18 h, refus d'`EV_GRID_START`, reprise à 22:00, blocage à 22:01, minuteur tenu 88 cycles, levée à la fin de charge, aucune écriture interdite |
|
|
||||||
| `testOffPeakEndsAtSixAndOnDstDays` | arrêt et levée à 06:00, jours de 23, 24 et 25 h |
|
|
||||||
| `testWithoutOffPeakTheEngineIsEtm74` | trois formes sans heure creuse : minimum à 18 h, ni bloc ni écriture, trames identiques |
|
|
||||||
| `testABatteryWithoutControlIsReadAsBefore` | sans états, sans minuteur, absente |
|
|
||||||
| `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée |
|
|
||||||
| `testAMeterSilenceHandsTheBatteryBack` | L2 lève explicitement |
|
|
||||||
| `testRefusedWritesAreSpacedAndNeverDelayTheRelease` | onduleur qui refuse : poses à 0, 1, 3, 8, 18, 33, 48 min, un avertissement par série, `held` faux ; acceptation → rythme normal ; levée immédiate en pleine série, qui clôt la série |
|
|
||||||
| `decisions` | IDENTIQUE à la référence sans tarif |
|
|
||||||
|
|
||||||
Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est
|
|
||||||
modélisé dans le test (`limiteTenue()`), jamais dans le moteur ni dans le mock.
|
|
||||||
|
|
||||||
## 7. Ce que ce lot ne fait pas
|
|
||||||
|
|
||||||
- La recharge de la batterie sur le réseau ; toute optimisation par le prix (offre Pro).
|
|
||||||
- L'étalement d'une échéance sur les heures creuses (§2) — lot à part (§8, D3).
|
|
||||||
- Une action atomique « limite de décharge » dans le plugin (`planBatteryMode(NO_DISCHARGE)`
|
|
||||||
existe et n'est pas exposée), et un état au nom moins trompeur que `enableDischarging` —
|
|
||||||
décidés, faits dans le dépôt du plugin (§8, D6).
|
|
||||||
- La batterie comme charge rangée du waterfall (lot 3f, `spec_loadmodel.md` §14).
|
|
||||||
|
|
||||||
## 8. Décisions de Patrick (2026-10-24)
|
|
||||||
|
|
||||||
Les questions ouvertes du lot e1 sont tranchées. Chacune confirme le comportement décrit plus haut :
|
|
||||||
aucune ne change le code.
|
|
||||||
|
|
||||||
| # | Question | Décision du 2026-10-24 |
|
|
||||||
|---|---|---|
|
|
||||||
| D1 | Bloquer **toute** la décharge, maison comprise, pendant la charge de la voiture en heures creuses (§3.2) ? | **Oui.** C'est le geste voulu ; aucune régulation de `OutWRte` sur la consommation de la maison. |
|
|
||||||
| D2 | Refuser `EV_GRID_START` hors heures creuses pour les trois modes concernés (§2) ? | **Confirmé.** En mode Éco, la borne attend les heures creuses ; seul le mode boost (`ChargingModeNormal`, piloté par l'utilisateur) charge tout de suite. |
|
|
||||||
| D3 | Étaler une échéance sur les heures creuses (§2) ? | **Plus tard, lot à part.** Il demande une énergie restante mesurée (LM-1009). |
|
|
||||||
| D4 | Une borne en `ChargingModeNormal` qui charge en heures creuses déclenche-t-elle le blocage (§3.2) ? | **Oui, c'est le but** : la mesure porte sur toutes les bornes, mode compris. |
|
|
||||||
| D5 | Seuil de « la voiture charge réellement » : 230 W sur la somme des bornes (§3.2) ? | **Accepté.** |
|
|
||||||
| D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). |
|
|
||||||
| D7 | À quelle puissance la borne charge-t-elle en heures creuses (lot e2) ? | **À la pleine puissance permise**, et non plus au plancher. La session finit plus tôt, et la décharge de la batterie n'est bloquée que pendant ce temps-là : ensuite, elle couvre la maison pour le reste de la nuit. Détail en §8.1. |
|
|
||||||
| D8 | *(2026-10-25)* Près de la limite de phase, la consigne de la borne alterne à chaque cycle (16, 7, 16, 7 A sous 25 A). Corriger avant la démo ? | **Oui, maintenant.** En heures creuses, le plafond de la borne lui rend ce qu'elle tire, **mesuré**, moins 1 A par phase. Détail en §8.2. Le même jour : la borne **tenue au plancher** en surcharge (L4 décide de couper) est **confirmée** ; les écarts 3 et 4 de §8.1 sont reportés. |
|
|
||||||
| D9 | *(2026-10-25, suite)* L'écart 4 n'est pas qu'un défaut de publication : à l'aube, le surplus s'ajoute au plafond, qui porte la limite de phase. Borner la somme avant la démo ? | **Oui, avant la démo.** La somme éco + confort ne dépasse jamais `declared.maxPowerW`, et l'achat publié ne dépasse pas ce qui est réellement acheté. Détail en §8.3. La dérogation sans heure creuse, les phases étiquetées : plus tard, chacune dans son lot. Le compteur en retard sur la borne est accepté tel quel. |
|
|
||||||
|
|
||||||
La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste
|
|
||||||
ouverte : elle relève du plugin et d'un essai sur matériel réel.
|
|
||||||
|
|
||||||
### 8.1 D7 — pleine puissance en heures creuses (lot e2)
|
|
||||||
|
|
||||||
**Pourquoi.** Au plancher (6 A, 1,4 kW en monophasé), une nuit de 22:00 à 06:00 livre au plus
|
|
||||||
11 kWh : une session de 10 à 25 kWh n'est pas finie au départ. Et la décharge de la batterie
|
|
||||||
reste bloquée toute la nuit, puisque la voiture charge toute la nuit : la batterie est encore
|
|
||||||
pleine le matin, et le surplus solaire part au réseau.
|
|
||||||
|
|
||||||
**La règle.** Dans les trois modes qui reportent en heures creuses (§2), le minimum servi au
|
|
||||||
réseau en heures creuses est le **plafond de cycle** de la borne (`declared.maxPowerW`) :
|
|
||||||
|
|
||||||
- il porte le courant maximal de la borne, ses phases, et la limite de phase
|
|
||||||
(`evPhaseAllowanceW()` ; depuis D8, `evOffPeakAllowanceW()`, §8.2) — rien n'est retiré de ces
|
|
||||||
écrêtages ;
|
|
||||||
- il ne descend jamais sous le plancher : la limite de phase **rabaisse** la borne, elle ne la
|
|
||||||
coupe pas ; couper reste le geste de `verifyOverloadProtection()` (L4), qui garde le dernier
|
|
||||||
mot ;
|
|
||||||
- hors heures creuses, et sans heure creuse dans le tarif, rien ne change.
|
|
||||||
|
|
||||||
Par exemple : 3 680 W en monophasé 16 A, 11 040 W en triphasé 16 A.
|
|
||||||
|
|
||||||
**Les phases.** En heures creuses, une borne qui peut charger en triphasé charge en triphasé.
|
|
||||||
La règle de bascule du manager est inchangée ; c'est le courant qu'on lui présente qui change.
|
|
||||||
Hors heures creuses, c'est « courant de la borne + surplus ». En heures creuses, c'est la pleine
|
|
||||||
puissance de la borne. Raison : la nuit, « courant + surplus » bride dans un cas précis — une
|
|
||||||
batterie (non pilotable, ou pas encore bloquée) couvre la maison et la voiture, le compteur lit
|
|
||||||
≈ 0, et la référence vaut le courant que la borne tire déjà. À 6 A en monophasé, la règle garde
|
|
||||||
le monophasé : 3,7 kW au lieu de 11. La limite de phase n'entre pas dans le choix, et c'est sans
|
|
||||||
perte : par phase, elle borne pareil une monophasée et une triphasée, donc trois phases donnent
|
|
||||||
toujours au moins autant.
|
|
||||||
|
|
||||||
**Sécurité.** Le plafond d'une borne porte déjà l'autorisation de soutirage, résolue sur les
|
|
||||||
phases qu'elle occupe et multipliée par leur nombre. La passe éco ne lui oppose donc pas en plus
|
|
||||||
la marge d'**une** phase de la maison (`LM-1500-h`, déjà appliquée en tête de passe et en passe
|
|
||||||
confort). Sans cela, une triphasée serait servie à 7 A au lieu de 16. Une autre charge allumée
|
|
||||||
la nuit réduit le plafond au cycle suivant ; sous le plancher, la borne est tenue au plancher, et
|
|
||||||
L4 coupe au dépassement. Cette exemption ne vaut **qu'en heures creuses** : sans heure creuse, le
|
|
||||||
plancher éco d'une borne reste écrêté par la marge de la maison, comme avant.
|
|
||||||
|
|
||||||
**Surcharge au plancher — confirmé le 2026-10-25.** En heures creuses, quand la limite de phase
|
|
||||||
ne laisse plus le plancher, la stratégie tient la borne à son plancher (`boundBy: floor`) et L4
|
|
||||||
décide de la couper, comme pour la dérogation. On n'est pas revenu au « rien acheté » du lot e1.
|
|
||||||
|
|
||||||
**Écarts ouverts** (rien n'est changé ; décision du 2026-10-25 : plus tard, chacun dans son lot) :
|
|
||||||
|
|
||||||
- *Écart 3 — le double écrêtage hors heures creuses.* Sans heure creuse, le plancher éco d'une
|
|
||||||
borne triphasée est encore comparé à la marge d'**une** phase de la maison (le double
|
|
||||||
écrêtage que `LM-1500-h` refuse ailleurs). L'étendre changerait des décisions sans tarif :
|
|
||||||
**lot à part**.
|
|
||||||
- *Écart 4 — le partage achat/surplus en heures creuses.* **Corrigé pour la pleine puissance
|
|
||||||
d'heures creuses (D9, §8.3).** À l'aube, le surplus s'ajoutait au niveau éco : la consigne
|
|
||||||
(`estimatedPowerW`) pouvait dépasser le plafond de cycle, et donc la limite de phase qu'il porte.
|
|
||||||
La dérogation, elle, ne cumule pas (sa passe confort est sautée) ; son achat publié reste
|
|
||||||
entier quand le soleil la finance en partie — c'est la conséquence comptable déjà écrite dans
|
|
||||||
le code, et elle reste à reprendre avec la dérogation.
|
|
||||||
- *L'oscillation près de la limite de phase* était le troisième point de cette liste. Elle est
|
|
||||||
corrigée : §8.2.
|
|
||||||
|
|
||||||
**Fin de session.** Le blocage de la décharge se lève sur la puissance **mesurée** des bornes,
|
|
||||||
au seuil de 230 W (D5). Une borne commandée à 16 A dont la voiture ne prend plus rien (pleine,
|
|
||||||
ou session arrêtée par le véhicule) ne tient donc pas la batterie bloquée.
|
|
||||||
|
|
||||||
**Motif.** `EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt, o:boundBy}` remplace,
|
|
||||||
en heures creuses, `ECO_FLOOR_GRID` sur la borne. `gridW` est la puissance retenue, `maxW` la
|
|
||||||
pleine puissance du matériel aux phases visées. `boundBy` n'est présent que si la protection de
|
|
||||||
surcharge l'a rabaissée : `phaseLimit` (à ce que la limite laisse) ou `floor` (tenue au
|
|
||||||
plancher, L4 décide de la suite). Rendu : « Borne charge à pleine puissance en heures creuses :
|
|
||||||
11040 W achetés au réseau (3 phases, jusqu'à 06:00) ».
|
|
||||||
|
|
||||||
**Preuves.** `testOffPeakChargesAtFullPower`, `testOffPeakFullPowerSwitchesToThreePhases`,
|
|
||||||
`testOffPeakFullPowerYieldsToThePhaseLimit` ; les tests du lot e1 en heures creuses attendent
|
|
||||||
désormais le plafond. Sans heure creuse, la preuve `decisions` est identique.
|
|
||||||
|
|
||||||
**Lot futur, non implémenté** — *réserver seulement ce dont la maison a besoin jusqu'au soleil,
|
|
||||||
d'après la prévision.* Aujourd'hui la décharge est bloquée entière tant que la voiture charge
|
|
||||||
(D1). Une version plus fine laisserait la batterie se décharger dans la voiture au-delà de ce que
|
|
||||||
la maison consommera d'ici le retour du soleil — consommation de base prévue (`BaseloadLog`) et
|
|
||||||
heure de la première production utile (prévision PV). Elle demande une prévision fiable des deux,
|
|
||||||
et un pilotage de `OutWRte` plus fin qu'un tout-ou-rien : c'est un lot à part.
|
|
||||||
|
|
||||||
### 8.2 D8 — près de la limite de phase, la consigne se pose (2026-10-25)
|
|
||||||
|
|
||||||
**Le défaut.** Le plafond d'une borne était la place libre au compteur, **sans lui rendre ce
|
|
||||||
qu'elle tire** (`evPhaseAllowanceW()`). Avec un compteur qui voit la borne, sa propre
|
|
||||||
consommation lui retirait la place au cycle suivant, puis la lui rendait au cycle d'après. Mesuré
|
|
||||||
en simulation, maison à 2 A par phase, borne 16 A, mono comme tri :
|
|
||||||
|
|
||||||
| Limite | Avant (consigne cycle par cycle) | Après |
|
|
||||||
|---|---|---|
|
|
||||||
| 25 A | 16, 7, 16, 7… (11,5 A en moyenne) | 16 A tenus |
|
|
||||||
| 32 A | 16, 14, 16, 14… | 16 A tenus |
|
|
||||||
| 16 A | 14, 6, 8, 6, 8… (7 A en moyenne) | 13 A tenus, 15 A au compteur |
|
|
||||||
|
|
||||||
**La règle.** En heures creuses, et pour la pleine puissance du lot e2 seulement, le plafond est
|
|
||||||
lu par `EnergyArbitrator::evOffPeakAllowanceW()` :
|
|
||||||
|
|
||||||
> plafond par phase = limite − (tirage de la phase − courant **mesuré** de la borne) − **1 A**,
|
|
||||||
> arrondi à l'ampère inférieur, × phases.
|
|
||||||
|
|
||||||
C'est la règle de l'échéance chez l'amont (`adjustEvChargers()`). Elle converge en **un** cycle :
|
|
||||||
le résultat ne dépend plus de ce que la borne tirait, seulement de ce que tire le reste de la
|
|
||||||
maison.
|
|
||||||
|
|
||||||
- *Mesure, pas consigne.* Le courant rendu est l'état `currentPower` de la borne, sur les phases
|
|
||||||
où elle le mesure. Une voiture qui prend 10 A d'une borne commandée à 16 n'occupe que 10 A :
|
|
||||||
lui rendre 16 lui ouvrirait 6 A de trop. Une borne sans mesure, à l'arrêt, ou qui bascule de
|
|
||||||
phases (mesurées ≠ visées) ne rend rien, et la marge s'applique quand même.
|
|
||||||
- *La marge : 1 A par phase.* C'est celle de l'amont, pour la même raison : laisser à un appareil
|
|
||||||
moyen allumé entre deux cycles la place de démarrer avant que L4 ait à couper. Elle couvre aussi
|
|
||||||
l'écart entre la puissance annoncée par la borne et le courant lu au compteur (tension ≠ 230 V,
|
|
||||||
±3 % à 16 A, soit 0,5 A). L'arrondi à l'ampère **inférieur** garantit qu'elle n'est pas mangée
|
|
||||||
par l'adaptateur, qui arrondit la consigne au plus proche. Plus grande, elle coûterait 230 W
|
|
||||||
par phase toute la nuit, sans raison mesurée ; plus petite, elle ne couvrirait plus l'écart de
|
|
||||||
mesure.
|
|
||||||
- *La sécurité en dépassement tient.* C'était la raison de ne rien rendre. Si une phase tire
|
|
||||||
`I > limite`, dont `E` pour la borne, la place rendue vaut `limite − (I − E) − 1 < E − 1` : la
|
|
||||||
consigne baisse d'au moins 1 A par cycle, jusqu'à sortir du dépassement ou jusqu'au plancher, où
|
|
||||||
la stratégie tient la borne et où L4 décide. Cela suppose que le compteur voie la borne — ce
|
|
||||||
que L4 suppose déjà.
|
|
||||||
- *Un compteur qui ne voit pas la borne* ne la mène pas seule à la limite : le tirage de la maison
|
|
||||||
sans la borne est écrêté à zéro, donc une phase vaut au plus sa limite moins la marge.
|
|
||||||
- *Où.* Seulement là où `EvAdapter` vise la pleine puissance d'heures creuses (trois modes, heure
|
|
||||||
creuse en cours). Hors heures creuses et sans tarif, `evPhaseAllowanceW()` reste la règle, et
|
|
||||||
les décisions sans tarif ne changent pas. En heures creuses, une dérogation sur la borne lit le
|
|
||||||
même plafond (c'est le même `declared.maxPowerW`). Sans heure creuse, la dérogation garde
|
|
||||||
l'ancienne règle : la faire passer par la même voie changerait son comportement sans tarif, et
|
|
||||||
ce n'est pas décidé.
|
|
||||||
|
|
||||||
**Hypothèses.** Les phases de la borne sont celles du compteur (même hypothèse que L4 et que
|
|
||||||
l'amont). Une borne qui annonce plus que ce que le compteur voit d'elle reçoit au plus 1 A sous la
|
|
||||||
limite. Un compteur rafraîchi après la borne (borne qui monte, compteur pas encore) peut
|
|
||||||
surestimer la place d'un cycle, dans la même limite ; L4, lui, lit le compteur en temps réel.
|
|
||||||
|
|
||||||
**Preuves.** `testOffPeakFullPowerSettlesUnderThePhaseLimit` (sept cas, douze cycles : la consigne
|
|
||||||
se pose dès le premier cycle, la phase la plus chargée reste à 1 A au moins sous la limite ; un
|
|
||||||
cas à 2,4 A de maison prouve l'arrondi) ; `testOffPeakFullPowerYieldsToThePhaseLimit`, réécrit
|
|
||||||
avec un compteur qui voit la borne (mesure et non consigne, dépassement, plancher, coupure par
|
|
||||||
L4) ; `testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter` (écrêtage à zéro) ;
|
|
||||||
`testOverloadProtectionEcoMode`, hors heures creuses, inchangé. Dans
|
|
||||||
`testBlockedDischargeShowsAtTheMeterAndL4Decides`, la borne en dépassement finit à son plancher
|
|
||||||
(L4 la ramène de 16 à 13 A, la stratégie laisse 9 A, L4 la ramène à 6 A) au lieu d'être coupée :
|
|
||||||
l'assertion attend désormais « plancher ou coupée ».
|
|
||||||
|
|
||||||
### 8.3 D9 — la somme éco + confort tient dans le plafond (2026-10-25, suite)
|
|
||||||
|
|
||||||
**Le défaut (écart 4).** En heures creuses, le niveau éco d'une borne vaut son plafond de cycle
|
|
||||||
(`declared.maxPowerW`), qui porte la limite de phase. À l'aube, la passe confort lui ajoutait le
|
|
||||||
surplus, et seul le courant maximal de la borne écrêtait la somme. Mesuré en simulation : borne
|
|
||||||
monophasée sur A, limite 16 A, maison à 2 A sur A, phases B et C exportant 1 500 W chacune. Le
|
|
||||||
plafond vaut 13 A ; la consigne montait à 16 A, et la phase A à **18 A pour 16**. Le partage
|
|
||||||
publiait 2 990 W achetés quand le compteur n'en importait que 450.
|
|
||||||
|
|
||||||
**La règle.** Pour une borne servie à pleine puissance en heures creuses
|
|
||||||
(`EV_OFF_PEAK_FULL_POWER`) :
|
|
||||||
|
|
||||||
- la passe confort sert le surplus **dans** le plafond, et non par-dessus ;
|
|
||||||
- l'achat **cède** ce que le surplus finance : achat = plafond − part confort, jamais négatif.
|
|
||||||
La somme reste donc le plafond, et l'achat publié (`gridW`, `draw.committedW`, `counts`) est ce
|
|
||||||
qui reste à acheter ;
|
|
||||||
- la part confort n'a pas de plancher : le plancher porte sur la somme, qui vaut le plafond, donc
|
|
||||||
jamais moins que lui. Sans cela, un surplus sous le plancher serait refusé, ou démarré par
|
|
||||||
`EV_GRID_START` — un second achat en plus du premier.
|
|
||||||
|
|
||||||
Le motif suit la règle de la part majoritaire : à l'aube, quand le surplus finance plus de la
|
|
||||||
moitié du plafond, la décision publiée est celle du confort ; le niveau éco reste lisible dans
|
|
||||||
`levels`. La marge de soutirage du reste de la cascade est décomptée comme avant (prudence
|
|
||||||
inchangée). Hors heures creuses et sans tarif, rien ne change : la règle ne s'applique qu'aux
|
|
||||||
bornes qui portent `EV_OFF_PEAK_FULL_POWER`.
|
|
||||||
|
|
||||||
| Cas (simulation) | Avant | Après |
|
|
||||||
|---|---|---|
|
|
||||||
| Aube, 2 540 W de surplus | 16 A, phase A à 18 A ; 2 990 W achetés | 13 A, phase A à 15 A ; 450 W achetés, 2 540 W de surplus |
|
|
||||||
| Plein soleil, surplus > plafond | 16 A | 13 A ; 0 W acheté, 2 990 W de surplus |
|
|
||||||
| Nuit | 13 A ; 2 990 W achetés | inchangé |
|
|
||||||
| Compteur aveugle aussi au total | 13, 16, 16… A | 13, 15, 15… A |
|
|
||||||
|
|
||||||
Avec un compteur aveugle au total, le surplus est **fabriqué** par le recrédit : la consigne
|
|
||||||
tient le plafond, mais le partage publié, lui, ne peut pas être juste — le compteur ne dit pas ce
|
|
||||||
qui est acheté.
|
|
||||||
|
|
||||||
**Preuves.** `testOffPeakFullPowerAtDawnStaysInTheCeiling` (nuit, aube, plein soleil, quatre
|
|
||||||
cycles chacun : consigne et phase, somme, achat ≤ import du compteur) ;
|
|
||||||
`testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter`, étendu au total aveugle. La garde de
|
|
||||||
bascule de §8.2 est désormais éprouvée : `testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases`
|
|
||||||
(borne en monophasé à 10 A qui bascule en triphasé, maison à 12 A sur B, limite 25 A : 12 A, et B
|
|
||||||
à 24 A ; sans la garde, 15 A et B à 27 A).
|
|
||||||
@ -1679,9 +1679,3 @@ jours de 23 et 25 h (oracles Python `zoneinfo` dans les tests).
|
|||||||
**Pour e1 (plus tard, sans être lu maintenant)** : `TariffConfig::zoneAt(instant, fuseau)` rend la
|
**Pour e1 (plus tard, sans être lu maintenant)** : `TariffConfig::zoneAt(instant, fuseau)` rend la
|
||||||
zone en vigueur ; « HC » n'a pas de code — le libellé est une donnée de l'installateur. Le lot e1
|
zone en vigueur ; « HC » n'a pas de code — le libellé est une donnée de l'installateur. Le lot e1
|
||||||
devra décider d'une règle (par exemple : la ou les zones au prix le plus bas), pas la deviner ici.
|
devra décider d'une règle (par exemple : la ou les zones au prix le plus bas), pas la deviner ici.
|
||||||
|
|
||||||
> **FAIT (lot e1, 2026-10-24)** — la règle retenue est celle-là : les heures creuses sont la ou
|
|
||||||
> les zones au prix le plus bas d'un tarif `tou` dont les prix diffèrent
|
|
||||||
> (`TariffConfig::zonesCreuses()`). Elles décident de deux choses, et le prix d'aucune :
|
|
||||||
> la borne en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Design,
|
|
||||||
> preuves et limites : `docs/DESIGN_HC_BATTERIE.md`.
|
|
||||||
|
|||||||
@ -25,18 +25,11 @@ 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)* | ✅ | ❌ | — |
|
|
||||||
| `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.
|
||||||
@ -235,20 +228,3 @@ qui ne mesure qu'une charge.
|
|||||||
site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2
|
site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2
|
||||||
(la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en
|
(la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en
|
||||||
unitaire, par `lirePlan()`).
|
unitaire, par `lirePlan()`).
|
||||||
|
|
||||||
## 10. Batterie pilotable — `controllableStorage` (lot e1, 2026-10-24)
|
|
||||||
|
|
||||||
| Classe | Porte | Ce qu'elle ouvre |
|
|
||||||
|---|---|---|
|
|
||||||
| `controllableStorage` | interface `energystorage` ; états MODIFIABLES `enableDischarging`, `dischargingRate`, `enableCharging`, `chargingRate`, `gridCharging` (les noms du `sunspecStorage` du plugin SunSpec, modèle 124) ; paramètre `revertTime` (minuteur de retour, 300 s par défaut, 0 = aucun) | la commande de la décharge en heures creuses, ses deux refus (`noRevertTimer` avec `revertTime = 0` ; `noDischargeControl` avec `energyStorage`, qui n'a pas ces états) |
|
|
||||||
|
|
||||||
**Ce que le mock ne fait PAS, à dessein** : il n'émule pas l'onduleur. Il range ce qu'on écrit,
|
|
||||||
et c'est tout — le minuteur de retour n'expire jamais de lui-même. Le modèle de l'onduleur (« la
|
|
||||||
limite tient tant que la dernière écriture a moins de `revertTime` ») vit dans le TEST
|
|
||||||
(`limiteTenue()`), qui lit les écritures à la frontière nymea (`ThingManager::actionExecuted`) et
|
|
||||||
non dans la mémoire du garde : un modèle logé dans le mock ou dans le moteur se donnerait raison.
|
|
||||||
|
|
||||||
**Ce qui reste introuvable ici** : une écriture ACCEPTÉE puis IGNORÉE par l'onduleur (le plugin
|
|
||||||
la relit et publie `controlHealthy = false`, AC-13 du plugin), et le retour SPONTANÉ de l'onduleur
|
|
||||||
à l'automatisme à l'échéance du minuteur. Le premier se lit dans `storage.batteries[].observed` ;
|
|
||||||
le second n'est éprouvé que par le modèle du test.
|
|
||||||
|
|||||||
@ -62,7 +62,7 @@ repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà.
|
|||||||
| EV débranché | Aucune action |
|
| EV débranché | Aucune action |
|
||||||
| ECS | Relais coupé (palier 0, `force=true`) |
|
| ECS | Relais coupé (palier 0, `force=true`) |
|
||||||
| SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. |
|
| SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. |
|
||||||
| Batterie | Rendue à son **automatisme** : une limite de décharge posée en heures creuses (lot e1) est **levée explicitement** à la transition (`enableDischarging = false`, puis `dischargingRate` au maximum). Le moteur ne commande jamais de charge réseau, il n'y a donc rien d'autre à replier. Si la levée échoue, le minuteur de retour de l'onduleur (`revertTime`) la fait tomber seul : c'est pour cela qu'une batterie sans minuteur n'est jamais commandée. |
|
| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) |
|
||||||
|
|
||||||
« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge.
|
« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge.
|
||||||
La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**
|
La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**
|
||||||
|
|||||||
@ -51,8 +51,7 @@ 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` sert à savoir
|
Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval.
|
||||||
// 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");
|
||||||
@ -75,29 +74,7 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
// avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit.
|
// avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit.
|
||||||
// Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le
|
// Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le
|
||||||
// nombre de phases COURANT, sans la voiture.
|
// nombre de phases COURANT, sans la voiture.
|
||||||
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
const double courantReferenceA = m_parent->evReferenceCurrentA(m_charger);
|
||||||
const bool reportable = mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
|
||||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime
|
|
||||||
|| mode == ChargingInfo::ChargingModeEcoWithTargetTime;
|
|
||||||
|
|
||||||
// Lot e2 — EN HEURES CREUSES, la référence est la PLEINE PUISSANCE de la borne, et non plus
|
|
||||||
// « courant + surplus ». La règle de bascule reste celle du manager ; c'est ce qu'on lui
|
|
||||||
// demande qui change.
|
|
||||||
// « Courant + surplus » répond à « combien le soleil donne-t-il ? », et la nuit la réponse
|
|
||||||
// décide mal : la maison importe, la référence est négative et la règle vise le triphasé —
|
|
||||||
// sauf quand une batterie couvre la maison et la voiture. Le compteur lit alors ≈ 0, la
|
|
||||||
// référence vaut le courant que la borne tire déjà (6 A en monophasé), et la règle la GARDE
|
|
||||||
// en monophasé : 3,7 kW au plus pour une borne qui en ferait 11. En heures creuses, la
|
|
||||||
// question n'est plus le surplus mais ce que la borne peut prendre.
|
|
||||||
//
|
|
||||||
// La limite de phase n'entre pas dans le choix, et c'est sans perte : par phase, elle borne
|
|
||||||
// de la même façon une monophasée et une triphasée, donc trois phases donnent toujours au
|
|
||||||
// moins autant (marge × 3 contre marge × 1). Sous le plancher, aucune des deux ne tient, et
|
|
||||||
// c'est la protection de surcharge qui tranche.
|
|
||||||
const bool pleinePuissanceHc = reportable && m_parent->enHeuresCreuses(now);
|
|
||||||
const double courantReferenceA = pleinePuissanceHc
|
|
||||||
? m_charger->maxChargingCurrentMaxValue() * 3.0
|
|
||||||
: m_parent->evReferenceCurrentA(m_charger);
|
|
||||||
m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA);
|
m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA);
|
||||||
if (m_targetPhases == 0)
|
if (m_targetPhases == 0)
|
||||||
m_targetPhases = 1;
|
m_targetPhases = 1;
|
||||||
@ -116,6 +93,7 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
// La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le
|
// La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le
|
||||||
// plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui
|
// plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui
|
||||||
// se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence.
|
// se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence.
|
||||||
|
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
||||||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
||||||
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
|
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
|
||||||
@ -129,13 +107,7 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
// APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme
|
// APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme
|
||||||
// n'importe quelle borne d'adaptateur (règle 4).
|
// n'importe quelle borne d'adaptateur (règle 4).
|
||||||
ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases;
|
ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases;
|
||||||
// Lot e2, suite (2026-10-25) — EN HEURES CREUSES, le plafond rend à la borne ce qu'elle tire,
|
const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases);
|
||||||
// moins 1 A par phase. Sans ce retour, à pleine puissance, sa propre consommation lui
|
|
||||||
// retirait la place au cycle suivant et la lui rendait au cycle d'après : 16, 7, 16, 7 A sous
|
|
||||||
// une limite de 25 A, avec une maison à 2 A. Hors heures creuses, la règle d'avant.
|
|
||||||
const double plafondPhaseW = pleinePuissanceHc
|
|
||||||
? m_parent->evOffPeakAllowanceW(m_charger, m_targetPhases)
|
|
||||||
: m_parent->evPhaseAllowanceW(m_charger, m_targetPhases);
|
|
||||||
if (plafondPhaseW >= 0) {
|
if (plafondPhaseW >= 0) {
|
||||||
// …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot
|
// …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot
|
||||||
// pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition
|
// pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition
|
||||||
@ -148,24 +120,6 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
qMax(plafondPhaseW, ctx.declared.minPowerW));
|
qMax(plafondPhaseW, ctx.declared.minPowerW));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lots e1 et e2 : le besoin RÉSEAU se reporte en heures creuses -----------------
|
|
||||||
//
|
|
||||||
// DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des
|
|
||||||
// heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux
|
|
||||||
// « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal`
|
|
||||||
// est piloté par l'utilisateur.
|
|
||||||
//
|
|
||||||
// Lot e2 (décision du 2026-10-24) — le minimum d'heures creuses est le PLAFOND du cycle, et
|
|
||||||
// non plus le plancher. Au plancher (6 A, 1,4 kW en monophasé), une session de 10 à 25 kWh
|
|
||||||
// n'était pas finie au départ, et la décharge de la batterie restait bloquée toute la nuit.
|
|
||||||
// À pleine puissance, la session finit tôt et la batterie est rendue pour le reste de la nuit.
|
|
||||||
// Le plafond est pris APRÈS l'écrêtage ci-dessus : il porte le courant maximal, les phases et
|
|
||||||
// la limite de phase, et ne descend jamais sous le plancher — la stratégie rabaisse, L4 coupe.
|
|
||||||
if (reportable) {
|
|
||||||
ctx.needs.offPeakShift = true;
|
|
||||||
ctx.needs.offPeakMinPowerW = ctx.declared.maxPowerW;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
||||||
ctx.telemetry.available = m_charger->available();
|
ctx.telemetry.available = m_charger->available();
|
||||||
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
||||||
@ -176,20 +130,9 @@ 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 plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
|
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond
|
||||||
// contexte).
|
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable.
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,10 +241,18 @@ 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. Elle n'est plus posée
|
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de
|
||||||
// ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
|
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de
|
||||||
// proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
|
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est
|
||||||
// elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
|
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce
|
||||||
|
// 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;
|
||||||
|
|||||||
@ -46,20 +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). Il sert à savoir s'il tombe en heures
|
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
|
||||||
* creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
|
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
|
||||||
* 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
|
|
||||||
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
|
|
||||||
* (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le
|
|
||||||
* plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus.
|
|
||||||
* \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
|
|
||||||
* 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;
|
||||||
|
|
||||||
@ -142,9 +131,8 @@ public:
|
|||||||
EvCharger *evCharger() const { return m_charger; }
|
EvCharger *evCharger() const { return m_charger; }
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Dernière commande tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
|
* \brief Dernière commande envoyée au matériel par cet adaptateur.
|
||||||
* PASSÉ ses verrous (\c noterCommandeTenue()).
|
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée.
|
||||||
* \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.
|
||||||
@ -152,30 +140,9 @@ public:
|
|||||||
bool derniereCommande(uint &ere, uint &phases) const
|
bool derniereCommande(uint &ere, 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 tenue par le matériel — écrêtée et passée par les
|
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée.
|
||||||
* 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
|
||||||
@ -186,9 +153,6 @@ 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;
|
||||||
@ -196,7 +160,6 @@ 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;
|
||||||
|
|||||||
@ -1,349 +0,0 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
||||||
|
|
||||||
#include "storagedischargeguard.h"
|
|
||||||
|
|
||||||
#include "plugininfo.h"
|
|
||||||
|
|
||||||
#include <integrations/thing.h>
|
|
||||||
#include <integrations/thingactioninfo.h>
|
|
||||||
#include <integrations/thingmanager.h>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
const QString EtatLimite = QStringLiteral("enableDischarging"); // bit DiSCHARGE de StorCtl_Mod
|
|
||||||
const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, % de la puissance max
|
|
||||||
|
|
||||||
//! Délai minimal entre deux réécritures déclenchées par une batterie qui ne rapporte plus la
|
|
||||||
//! consigne : l'état relu d'un onduleur suit l'écriture d'un poll (≈ un cycle). Réécrire plus
|
|
||||||
//! vite reviendrait à réécrire ce qui est en vol.
|
|
||||||
constexpr int ReecritureMinS = 55;
|
|
||||||
|
|
||||||
//! Après le n-ième refus d'affilée d'une pose, la suivante attend `EspacementRefusS[n - 1]`
|
|
||||||
//! secondes (le dernier palier ensuite) : 1, 2, 5, 10 puis 15 min. Un onduleur qui refuse (GEN24
|
|
||||||
//! sans « Allow Control », exception Modbus 0x04) refusera encore au cycle suivant ; insister à
|
|
||||||
//! chaque minute toute la nuit n'apprend rien et charge le bus. Quinze minutes au plus : un
|
|
||||||
//! réglage corrigé en cours de nuit est repris dans le quart d'heure.
|
|
||||||
constexpr int EspacementRefusS[] = {60, 120, 300, 600, 900};
|
|
||||||
constexpr int PaliersRefus = int(sizeof(EspacementRefusS) / sizeof(EspacementRefusS[0]));
|
|
||||||
//! Un cycle « par minute » arrive à quelques secondes près : sans cette marge, l'attente d'une
|
|
||||||
//! minute en coûterait deux (même raison que \c ReecritureMinS).
|
|
||||||
constexpr int MargeCycleS = 5;
|
|
||||||
|
|
||||||
//! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut.
|
|
||||||
double tauxLibre(Thing *t)
|
|
||||||
{
|
|
||||||
const QVariant max = t->thingClass().stateTypes().findByName(EtatTaux).maxValue();
|
|
||||||
return max.isValid() ? max.toDouble() : 100.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StorageDischargeGuard::StorageDischargeGuard(ThingManager *tm, QObject *parent)
|
|
||||||
: QObject(parent)
|
|
||||||
, m_tm(tm)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
SurplusContext::StorageControl StorageDischargeGuard::capacite(Thing *t)
|
|
||||||
{
|
|
||||||
SurplusContext::StorageControl c;
|
|
||||||
if (!t)
|
|
||||||
return c;
|
|
||||||
c.thingId = t->id().toString();
|
|
||||||
|
|
||||||
// La question est posée à la CLASSE : les deux états existent, sont modifiables, ont leur
|
|
||||||
// action, et la limite admet 0 %. Une valeur d'état ne dit rien de ce qu'on peut écrire.
|
|
||||||
StateTypes etats = t->thingClass().stateTypes();
|
|
||||||
ActionTypes actions = t->thingClass().actionTypes();
|
|
||||||
const StateType limite = etats.findByName(EtatLimite);
|
|
||||||
const StateType taux = etats.findByName(EtatTaux);
|
|
||||||
const bool pilotable = !limite.id().isNull() && limite.writable()
|
|
||||||
&& limite.type() == QMetaType::Bool
|
|
||||||
&& !actions.findByName(EtatLimite).id().isNull()
|
|
||||||
&& !taux.id().isNull() && taux.writable()
|
|
||||||
&& !actions.findByName(EtatTaux).id().isNull()
|
|
||||||
&& (!taux.minValue().isValid() || taux.minValue().toDouble() <= 0.0);
|
|
||||||
if (!pilotable) {
|
|
||||||
c.cause = QStringLiteral("noDischargeControl");
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LE MINUTEUR DE RETOUR, et c'est lui la sûreté : sans lui, un moteur arrêté laisserait la
|
|
||||||
// batterie bloquée indéfiniment. On ne commande pas ce qui ne sait pas revenir seul.
|
|
||||||
const ParamType minuteur = t->thingClass().paramTypes().findByName(QStringLiteral("revertTime"));
|
|
||||||
const uint revertS = minuteur.id().isNull() ? 0 : t->paramValue(minuteur.id()).toUInt();
|
|
||||||
if (revertS == 0) {
|
|
||||||
c.cause = QStringLiteral("noRevertTimer");
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
if (revertS < uint(MinRevertTimeS)) {
|
|
||||||
c.cause = QStringLiteral("revertTimerTooShort");
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
c.controllable = true;
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
QList<SurplusContext::StorageControl> StorageDischargeGuard::capacites() const
|
|
||||||
{
|
|
||||||
QList<SurplusContext::StorageControl> l;
|
|
||||||
if (!m_tm)
|
|
||||||
return l;
|
|
||||||
for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage")))
|
|
||||||
l << capacite(t);
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateTime &now)
|
|
||||||
{
|
|
||||||
if (!m_tm)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Une batterie disparue n'a plus d'état à tenir.
|
|
||||||
for (auto it = m_etats.begin(); it != m_etats.end();) {
|
|
||||||
if (!m_tm->findConfiguredThing(ThingId(it.key())))
|
|
||||||
it = m_etats.erase(it);
|
|
||||||
else
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) {
|
|
||||||
const QString id = t->id().toString();
|
|
||||||
// SANS heures creuses, rien n'est lu ni écrit — sauf une consigne qu'on tiendrait encore.
|
|
||||||
if (!configure && !(m_etats.contains(id) && m_etats.value(id).tenue))
|
|
||||||
continue;
|
|
||||||
const SurplusContext::StorageControl cap = capacite(t);
|
|
||||||
if (!cap.controllable)
|
|
||||||
continue; // lue comme avant le lot ; le motif STORAGE_NOT_PROTECTED le dit
|
|
||||||
|
|
||||||
Etat &e = m_etats[id];
|
|
||||||
const bool limiteActive = t->stateValue(EtatLimite).toBool();
|
|
||||||
const bool aZero = qFuzzyIsNull(t->stateValue(EtatTaux).toDouble());
|
|
||||||
if (!e.vue) {
|
|
||||||
e.vue = true;
|
|
||||||
// ADOPTION — une limite à 0 % déjà active au premier regard est celle qu'un moteur
|
|
||||||
// précédent a laissée (redémarrage de nymead). La laisser expirer contredirait
|
|
||||||
// « consigne levée explicitement » ; la reprendre la fait rafraîchir ou lever.
|
|
||||||
if (configure && limiteActive && aZero) {
|
|
||||||
e.tenue = true;
|
|
||||||
e.depuis = now;
|
|
||||||
qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name()
|
|
||||||
<< "— limite de décharge à 0 % trouvée active au démarrage : reprise.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bloquer) {
|
|
||||||
// SÉRIE DE REFUS : pas de nouvelle pose avant l'échéance de l'espacement. La levée,
|
|
||||||
// elle, n'attend jamais (branche ci-dessous).
|
|
||||||
if (e.refus > 0 && now < e.prochainEssai.addSecs(-MargeCycleS))
|
|
||||||
continue;
|
|
||||||
const qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1;
|
|
||||||
const int revertS = t->paramValue(t->thingClass().paramTypes()
|
|
||||||
.findByName(QStringLiteral("revertTime")).id()).toInt();
|
|
||||||
// Le TIERS du minuteur : à 300 s, une réécriture toutes les 100 s au plus tard ; à un
|
|
||||||
// cycle par minute, l'écart réel entre deux écritures reste sous 180 s, cycle manqué
|
|
||||||
// compris — bien avant l'expiration.
|
|
||||||
const bool echu = age < 0 || age >= revertS / 3;
|
|
||||||
// L'onduleur ne rapporte plus la consigne : il est revenu seul à l'automatisme (un
|
|
||||||
// minuteur expiré, un redémarrage) — on la repose sans attendre l'échéance.
|
|
||||||
const bool perdue = e.tenue && e.enCours == 0 && !(limiteActive && aZero)
|
|
||||||
&& age >= ReecritureMinS;
|
|
||||||
if (!e.tenue || echu || perdue)
|
|
||||||
bloquerUne(t, e, now, e.tenue && !perdue);
|
|
||||||
} else if (e.tenue) {
|
|
||||||
leverUne(t, e, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::leverTout(const QDateTime &now)
|
|
||||||
{
|
|
||||||
if (!m_tm)
|
|
||||||
return;
|
|
||||||
for (auto it = m_etats.begin(); it != m_etats.end(); ++it) {
|
|
||||||
Thing *t = m_tm->findConfiguredThing(ThingId(it.key()));
|
|
||||||
if (t && it.value().tenue)
|
|
||||||
leverUne(t, it.value(), now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool StorageDischargeGuard::tientUneConsigne() const
|
|
||||||
{
|
|
||||||
for (const Etat &e : m_etats)
|
|
||||||
if (e.tenue && e.refus == 0)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVariantList StorageDischargeGuard::vue() const
|
|
||||||
{
|
|
||||||
QVariantList l;
|
|
||||||
if (!m_tm)
|
|
||||||
return l;
|
|
||||||
for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) {
|
|
||||||
const SurplusContext::StorageControl cap = capacite(t);
|
|
||||||
const Etat e = m_etats.value(cap.thingId);
|
|
||||||
QVariantMap m{{QStringLiteral("thingId"), cap.thingId},
|
|
||||||
{QStringLiteral("controllable"), cap.controllable},
|
|
||||||
{QStringLiteral("held"), e.tenue && e.refus == 0}};
|
|
||||||
if (!cap.cause.isEmpty())
|
|
||||||
m.insert(QStringLiteral("cause"), cap.cause);
|
|
||||||
if (e.tenue && e.refus == 0 && e.depuis.isValid())
|
|
||||||
m.insert(QStringLiteral("heldSince"), e.depuis.toUTC().toString(Qt::ISODate));
|
|
||||||
// UNE SÉRIE DE REFUS : `held` est faux — l'onduleur ne tient pas ce qu'il refuse — et ces
|
|
||||||
// trois champs disent pourquoi, et quand le moteur réessaiera.
|
|
||||||
if (e.tenue && e.refus > 0) {
|
|
||||||
m.insert(QStringLiteral("refusedWrites"), e.refus);
|
|
||||||
m.insert(QStringLiteral("refusedSince"), e.refusDepuis.toUTC().toString(Qt::ISODate));
|
|
||||||
m.insert(QStringLiteral("nextAttemptAt"), e.prochainEssai.toUTC().toString(Qt::ISODate));
|
|
||||||
}
|
|
||||||
if (e.derniereEcriture.isValid())
|
|
||||||
m.insert(QStringLiteral("lastWriteAt"), e.derniereEcriture.toUTC().toString(Qt::ISODate));
|
|
||||||
// CE QUI A ÉTÉ LU, pas ce qu'on a écrit : c'est la seule preuve que la batterie a suivi.
|
|
||||||
QVariantMap lu;
|
|
||||||
if (!t->thingClass().stateTypes().findByName(EtatLimite).id().isNull())
|
|
||||||
lu.insert(EtatLimite, t->stateValue(EtatLimite).toBool());
|
|
||||||
if (!t->thingClass().stateTypes().findByName(EtatTaux).id().isNull())
|
|
||||||
lu.insert(EtatTaux, t->stateValue(EtatTaux).toDouble());
|
|
||||||
if (!lu.isEmpty())
|
|
||||||
m.insert(QStringLiteral("observed"), lu);
|
|
||||||
l << m;
|
|
||||||
}
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement)
|
|
||||||
{
|
|
||||||
if (e.tenue && e.refus > 0)
|
|
||||||
qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— nouvelle tentative de pose après"
|
|
||||||
<< e.refus << "refus.";
|
|
||||||
else if (rafraichissement)
|
|
||||||
qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name()
|
|
||||||
<< "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé).";
|
|
||||||
else
|
|
||||||
qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name()
|
|
||||||
<< "— décharge BLOQUÉE : limite de décharge à 0 %"
|
|
||||||
<< "(dischargingRate = 0, puis enableDischarging = true).";
|
|
||||||
if (!e.tenue)
|
|
||||||
e.depuis = now;
|
|
||||||
e.tenue = true;
|
|
||||||
e.derniereEcriture = now;
|
|
||||||
// ORDRE : le taux d'abord, la limite ensuite. Tant que la limite est inactive, le taux est
|
|
||||||
// inerte ; quand elle s'active, il vaut déjà 0. L'inverse activerait une limite au taux
|
|
||||||
// précédent, peut-être 100 %, le temps d'une écriture. Si la première échoue, la seconde ne
|
|
||||||
// part pas : la limite reste inactive, donc la batterie en automatique — le sens sûr.
|
|
||||||
// La séquence est ACCEPTÉE quand la seconde écriture l'est ; un refus de l'une OU de l'autre
|
|
||||||
// compte pour la série.
|
|
||||||
const QString id = t->id().toString();
|
|
||||||
const auto refus = [this, id](const QString &etat, const QString &cause) {
|
|
||||||
noterRefus(id, etat, cause);
|
|
||||||
};
|
|
||||||
ecrire(t, EtatTaux, 0, [this, id, refus]() {
|
|
||||||
if (Thing *suite = m_tm->findConfiguredThing(ThingId(id)))
|
|
||||||
ecrire(suite, EtatLimite, true, [this, id]() { noterAcceptation(id); }, refus);
|
|
||||||
}, refus);
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::noterRefus(const QString &id, const QString &etat, const QString &cause)
|
|
||||||
{
|
|
||||||
if (!m_etats.contains(id) || !m_etats.value(id).tenue)
|
|
||||||
return; // la consigne a été levée entre-temps : ce verdict tardif ne pèse plus
|
|
||||||
Etat &e = m_etats[id];
|
|
||||||
Thing *t = m_tm->findConfiguredThing(ThingId(id));
|
|
||||||
const QString nom = t ? t->name() : id;
|
|
||||||
const int attente = EspacementRefusS[qMin(e.refus + 1, PaliersRefus) - 1];
|
|
||||||
// UN SEUL AVERTISSEMENT PAR SÉRIE : le premier refus est aussi visible que l'application
|
|
||||||
// (règle 7-d) ; les suivants ne disent rien de neuf, et la télémétrie les compte.
|
|
||||||
if (e.refus == 0) {
|
|
||||||
e.refusDepuis = e.derniereEcriture;
|
|
||||||
qCWarning(dcNymeaEnergy()).noquote()
|
|
||||||
<< "[Batterie]" << nom << "— écriture de" << etat
|
|
||||||
<< QStringLiteral("REFUSÉE (%1) : la décharge").arg(cause)
|
|
||||||
<< "n'est PAS bloquée. Nouvelles tentatives espacées (1, 2, 5, 10 puis 15 min) ; ce"
|
|
||||||
<< "refus n'est plus signalé qu'en débogage jusqu'à la première écriture acceptée.";
|
|
||||||
} else {
|
|
||||||
qCDebug(dcNymeaEnergy()).noquote()
|
|
||||||
<< "[Batterie]" << nom << "— écriture de" << etat
|
|
||||||
<< QStringLiteral("encore refusée (%1),").arg(cause)
|
|
||||||
<< e.refus + 1 << "refus d'affilée ; prochain essai dans" << attente << "s.";
|
|
||||||
}
|
|
||||||
++e.refus;
|
|
||||||
e.prochainEssai = e.derniereEcriture.addSecs(attente);
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::noterAcceptation(const QString &id)
|
|
||||||
{
|
|
||||||
if (!m_etats.contains(id))
|
|
||||||
return;
|
|
||||||
Etat &e = m_etats[id];
|
|
||||||
if (e.refus > 0) {
|
|
||||||
Thing *t = m_tm->findConfiguredThing(ThingId(id));
|
|
||||||
qCInfo(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id)
|
|
||||||
<< "— limite de décharge à 0 % ACCEPTÉE après" << e.refus
|
|
||||||
<< "refus : décharge bloquée, retour au rythme normal.";
|
|
||||||
}
|
|
||||||
e.refus = 0;
|
|
||||||
e.refusDepuis = QDateTime();
|
|
||||||
e.prochainEssai = QDateTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now)
|
|
||||||
{
|
|
||||||
qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name()
|
|
||||||
<< "— décharge RENDUE à l'automatisme : limite levée"
|
|
||||||
<< "(enableDischarging = false, puis dischargingRate =" << tauxLibre(t) << ").";
|
|
||||||
e.tenue = false;
|
|
||||||
e.depuis = QDateTime();
|
|
||||||
e.derniereEcriture = now;
|
|
||||||
// La levée CLÔT une série de refus : la prochaine pose est une nouvelle consigne, au rythme
|
|
||||||
// normal, et son premier refus sera de nouveau signalé.
|
|
||||||
e.refus = 0;
|
|
||||||
e.refusDepuis = QDateTime();
|
|
||||||
e.prochainEssai = QDateTime();
|
|
||||||
// ORDRE INVERSE : lever la limite rend l'automatisme TOUT DE SUITE ; le taux suit, pour
|
|
||||||
// qu'une limite réactivée plus tard par quiconque ne reparte pas à 0 %. La seconde part même
|
|
||||||
// si la première échoue : remettre le taux au maximum ne bloque rien.
|
|
||||||
ecrire(t, EtatLimite, false);
|
|
||||||
ecrire(t, EtatTaux, tauxLibre(t));
|
|
||||||
}
|
|
||||||
|
|
||||||
void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur,
|
|
||||||
const std::function<void()> &suite,
|
|
||||||
const std::function<void(const QString &, const QString &)> &refus)
|
|
||||||
{
|
|
||||||
const StateType st = t->thingClass().stateTypes().findByName(etat);
|
|
||||||
Action action(st.id(), t->id(), Action::TriggeredByRule);
|
|
||||||
action.setParams(ParamList() << Param(st.id(), valeur));
|
|
||||||
ThingActionInfo *info = m_tm->executeAction(action);
|
|
||||||
if (!info) {
|
|
||||||
const QString cause = QStringLiteral("aucune réponse du gestionnaire");
|
|
||||||
if (refus)
|
|
||||||
refus(etat, cause);
|
|
||||||
else
|
|
||||||
qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat
|
|
||||||
<< "impossible (" << cause << ").";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const QString id = t->id().toString();
|
|
||||||
++m_etats[id].enCours;
|
|
||||||
// Aucune attente : verdict par signal, `this` en contexte (même modèle qu'ECS-414). Un REFUS
|
|
||||||
// est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose
|
|
||||||
// (\c noterRefus), à chaque fois pour la levée, qui n'est jamais répétée.
|
|
||||||
connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite, refus]() {
|
|
||||||
if (m_etats.contains(id))
|
|
||||||
--m_etats[id].enCours;
|
|
||||||
if (info->status() != Thing::ThingErrorNoError) {
|
|
||||||
if (refus) {
|
|
||||||
QString statut;
|
|
||||||
QDebug(&statut).nospace() << info->status();
|
|
||||||
refus(etat, QStringLiteral("statut ") + statut);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Thing *t = m_tm->findConfiguredThing(ThingId(id));
|
|
||||||
qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de"
|
|
||||||
<< etat << "=" << valeur << "REFUSÉE, statut" << info->status();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (suite)
|
|
||||||
suite();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QObject>
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QHash>
|
|
||||||
#include <QList>
|
|
||||||
#include <QString>
|
|
||||||
#include <QVariantList>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
#include "../types/surpluscontext.h"
|
|
||||||
|
|
||||||
class Thing;
|
|
||||||
class ThingManager;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Bloque la DÉCHARGE d'une batterie, et rien d'autre — lot e1 (2026-10-24).
|
|
||||||
*
|
|
||||||
* Le moteur décide (\c RuleBasedScheduler::storageDecision()), ce garde EXÉCUTE (règle 2) : il
|
|
||||||
* écrit les consignes, les rafraîchit avant que le minuteur de retour de l'onduleur n'expire, et
|
|
||||||
* les LÈVE explicitement quand la décision tombe. Il n'entre pas dans le waterfall et n'a pas de
|
|
||||||
* \c LoadConfig : la batterie n'est pas une charge rangée (\c spec_loadmodel.md §14, lot 3f).
|
|
||||||
*
|
|
||||||
* \par Ce qu'il écrit — modèle SunSpec 124, lu dans le plugin
|
|
||||||
* `StorCtl_Mod` est un champ de bits : le bit \c DiSCHARGE **ACTIVE la limite** de décharge
|
|
||||||
* `OutWRte`, il n'« autorise » pas la décharge. L'état \c enableDischarging du plugin est ce
|
|
||||||
* bit, \c dischargingRate est `OutWRte` (% de la puissance maximale). Bloquer la décharge,
|
|
||||||
* c'est donc `dischargingRate = 0` PUIS `enableDischarging = true` — la séquence
|
|
||||||
* `NO_DISCHARGE` du plugin (`StorCtl_Mod = 2`, `OutWRte = 0`). Écrire
|
|
||||||
* `enableDischarging = false` LÈVE la limite : c'est le geste de RETOUR à l'automatisme, suivi
|
|
||||||
* de `dischargingRate` à son maximum (la séquence `NORMAL`, `OutWRte = 100`).
|
|
||||||
* \par Ce qu'il n'écrit JAMAIS
|
|
||||||
* \c gridCharging, \c enableCharging, \c chargingRate : la recharge depuis le réseau est hors
|
|
||||||
* produit (décision de Patrick, 2026-10-24). Une `dischargingRate` négative (charge FORCÉE)
|
|
||||||
* n'est jamais écrite non plus : seules 0 et le maximum de l'état le sont.
|
|
||||||
* \par La sûreté, c'est le minuteur de l'onduleur
|
|
||||||
* Chaque écriture du plugin réarme `InOutWRte_RvrtTms` (paramètre \c revertTime). Un moteur
|
|
||||||
* arrêté cesse de rafraîchir, et la batterie retrouve son automatisme au bout du minuteur.
|
|
||||||
* D'où le refus de commander une batterie SANS minuteur (\c noRevertTimer) ou avec un minuteur
|
|
||||||
* plus court que deux cycles (\c revertTimerTooShort) : la consigne y survivrait au moteur, ou
|
|
||||||
* expirerait entre deux rafraîchissements.
|
|
||||||
* \par Un onduleur qui REFUSE (suite du lot e1, 2026-10-24)
|
|
||||||
* Un GEN24 dont « Allow Control » est désactivé répond 0x04 à chaque écriture. Une pose refusée
|
|
||||||
* (taux ou limite) ouvre une SÉRIE de refus : la pose suivante attend 1, 2, 5, 10 puis 15 min
|
|
||||||
* au plus ; un seul \c qCWarning par série, les suivants en \c qCDebug. La série se CLÔT à la
|
|
||||||
* première séquence acceptée (retour au rythme du tiers du minuteur) ou à la levée. La levée
|
|
||||||
* n'est JAMAIS espacée. Pendant la série, la consigne n'est pas publiée comme tenue (\c vue()).
|
|
||||||
* \par Générique
|
|
||||||
* Par l'interface \c energystorage et les états PRÉSENTS — jamais par une ThingClass Fronius.
|
|
||||||
* Une batterie sans ces états est lue comme avant le lot.
|
|
||||||
*/
|
|
||||||
class StorageDischargeGuard : public QObject
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
public:
|
|
||||||
//! Minuteur de retour minimal (s) : deux cycles de 60 s, le rafraîchissement venant au tiers.
|
|
||||||
static constexpr int MinRevertTimeS = 120;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Construit le garde. \param tm Registre des Things (non possédé). \param parent Parent Qt.
|
|
||||||
*/
|
|
||||||
explicit StorageDischargeGuard(ThingManager *tm, QObject *parent = nullptr);
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Ce que la batterie PERMET — lu sur sa ThingClass et ses paramètres, jamais sur une
|
|
||||||
* valeur d'état (une limite inactive reste une limite pilotable).
|
|
||||||
* \param t Batterie (\c energystorage). \return Sa capacité, \c cause vide si pilotable.
|
|
||||||
*/
|
|
||||||
static SurplusContext::StorageControl capacite(Thing *t);
|
|
||||||
|
|
||||||
//! \brief Capacités de toutes les batteries configurées. \return Une entrée par batterie.
|
|
||||||
QList<SurplusContext::StorageControl> capacites() const;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Exécute la décision du cycle.
|
|
||||||
* \param configure Le tarif saisi a des heures creuses ce cycle.
|
|
||||||
* \param bloquer Bloquer la décharge (décision du scheduler).
|
|
||||||
* \param now Temps de cycle — date les écritures et décide du rafraîchissement.
|
|
||||||
*
|
|
||||||
* - \p bloquer : sur chaque batterie pilotable, la limite est posée si elle ne l'est pas, ou
|
|
||||||
* RAFRAÎCHIE dès que la dernière écriture a l'âge du tiers du minuteur de retour, ou si la
|
|
||||||
* batterie ne rapporte plus la consigne (onduleur revenu seul à l'automatisme).
|
|
||||||
* Pendant une série de refus, aucune pose avant l'échéance de l'espacement.
|
|
||||||
* - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer), refusée
|
|
||||||
* ou non, et sans attendre l'espacement.
|
|
||||||
*
|
|
||||||
* \note Sans heures creuses (\p configure faux), le garde ne lit et n'écrit RIEN — sauf pour
|
|
||||||
* lever une consigne qu'il tiendrait encore (tarif effacé en pleine charge). C'est ce qui
|
|
||||||
* rend « sans tarif = etm74 » vrai par construction.
|
|
||||||
* \note À la PREMIÈRE vue d'une batterie (démarrage de \c nymead), une limite à 0 % déjà
|
|
||||||
* active est ADOPTÉE comme sienne, si \p configure : c'est la consigne qu'un moteur
|
|
||||||
* précédent a laissée, et elle est alors rafraîchie ou levée comme les autres.
|
|
||||||
*/
|
|
||||||
void appliquer(bool configure, bool bloquer, const QDateTime &now);
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Lève toute consigne tenue, sans condition — repli L2.
|
|
||||||
* \param now Instant de la levée.
|
|
||||||
*/
|
|
||||||
void leverTout(const QDateTime &now);
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Une consigne est-elle tenue sur au moins une batterie ?
|
|
||||||
* \return Vrai si une limite est posée et que sa dernière pose n'a pas été refusée.
|
|
||||||
*/
|
|
||||||
bool tientUneConsigne() const;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Ce qui a été LU et ÉCRIT, par batterie — pour la télémétrie.
|
|
||||||
* \return Une carte par batterie : \c thingId, \c controllable, \c o:cause, \c held (consigne
|
|
||||||
* posée et NON refusée), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC),
|
|
||||||
* \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose ; pendant
|
|
||||||
* une série de refus, \c held est faux et \c o:refusedWrites (poses refusées d'affilée),
|
|
||||||
* \c o:refusedSince, \c o:nextAttemptAt (ISO UTC) sont publiés.
|
|
||||||
* \note Un verdict arrive après le cycle qui a écrit : un refus se lit au cycle suivant.
|
|
||||||
*/
|
|
||||||
QVariantList vue() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct Etat {
|
|
||||||
bool vue = false; //!< La batterie a déjà été examinée (adoption faite).
|
|
||||||
bool tenue = false; //!< Le moteur tient une limite à 0 %.
|
|
||||||
QDateTime depuis; //!< Début de la consigne tenue.
|
|
||||||
QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle).
|
|
||||||
int enCours = 0; //!< Écritures dont l'issue n'est pas encore connue.
|
|
||||||
int refus = 0; //!< Poses refusées d'affilée (0 : pas de série en cours).
|
|
||||||
QDateTime refusDepuis; //!< Pose qui a ouvert la série de refus.
|
|
||||||
QDateTime prochainEssai; //!< Pas de nouvelle pose avant cet instant (série en cours).
|
|
||||||
};
|
|
||||||
|
|
||||||
//! Pose la limite à 0 % : \c dischargingRate puis \c enableDischarging, enchaînés.
|
|
||||||
void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement);
|
|
||||||
//! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum.
|
|
||||||
void leverUne(Thing *t, Etat &e, const QDateTime &now);
|
|
||||||
//! Un refus de la pose de \p id (état \p etat, \p cause lisible) : avertit une fois par
|
|
||||||
//! série, compte, et fixe le prochain essai.
|
|
||||||
void noterRefus(const QString &id, const QString &etat, const QString &cause);
|
|
||||||
//! La pose de \p id est acceptée jusqu'au bout : clôt la série de refus.
|
|
||||||
void noterAcceptation(const QString &id);
|
|
||||||
//! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit, \p refus (état,
|
|
||||||
//! cause) si elle échoue — sans \p refus, l'échec est un \c qCWarning.
|
|
||||||
void ecrire(Thing *t, const QString &etat, const QVariant &valeur,
|
|
||||||
const std::function<void()> &suite = {},
|
|
||||||
const std::function<void(const QString &, const QString &)> &refus = {});
|
|
||||||
|
|
||||||
ThingManager *m_tm = nullptr;
|
|
||||||
QHash<QString, Etat> m_etats; //!< thingId → état de la commande.
|
|
||||||
};
|
|
||||||
@ -8,7 +8,6 @@
|
|||||||
#include "adapters/sgreadyadapter.h"
|
#include "adapters/sgreadyadapter.h"
|
||||||
#include "adapters/etmvariableloadadapter.h"
|
#include "adapters/etmvariableloadadapter.h"
|
||||||
#include "adapters/relayrouter.h"
|
#include "adapters/relayrouter.h"
|
||||||
#include "adapters/storagedischargeguard.h"
|
|
||||||
#include "config/loadconfigstore.h"
|
#include "config/loadconfigstore.h"
|
||||||
#include "types/loadconfig.h"
|
#include "types/loadconfig.h"
|
||||||
#include "scheduler/rulebasedscheduler.h"
|
#include "scheduler/rulebasedscheduler.h"
|
||||||
@ -17,7 +16,6 @@
|
|||||||
#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"
|
||||||
@ -32,7 +30,6 @@
|
|||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <nymeasettings.h>
|
#include <nymeasettings.h>
|
||||||
#include <QScopeGuard>
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@ -61,7 +58,6 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
|||||||
, m_scheduler(new RuleBasedScheduler(this, this))
|
, m_scheduler(new RuleBasedScheduler(this, this))
|
||||||
, m_strategy(m_scheduler)
|
, m_strategy(m_scheduler)
|
||||||
, m_em(em)
|
, m_em(em)
|
||||||
, m_storageGuard(new StorageDischargeGuard(tm, this))
|
|
||||||
, m_tm(tm)
|
, m_tm(tm)
|
||||||
{
|
{
|
||||||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||||||
@ -434,10 +430,6 @@ void EnergyArbitrator::rebuildLoadAdapters()
|
|||||||
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||||
{
|
{
|
||||||
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
|
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
|
||||||
// Un geste de protection fait PENDANT le cycle est publié par le cycle ; hors cycle
|
|
||||||
// (powerBalanceChanged), par executeChargingAction() lui-même.
|
|
||||||
m_dansCycle = true;
|
|
||||||
const auto finDuCycle = qScopeGuard([this]() { m_dansCycle = false; });
|
|
||||||
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
|
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
|
||||||
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
||||||
// 5.planSpot 6.planSurplus 7.adjustEv
|
// 5.planSpot 6.planSurplus 7.adjustEv
|
||||||
@ -533,25 +525,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
// 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
|
||||||
|
|
||||||
// Lot e1 — la batterie, À LA MÊME POSITION que les autres écritures du cycle. La décision est
|
|
||||||
// celle du rule-based, qui tourne aussi en ombre d'un optimiseur. Sans heures creuses le garde
|
|
||||||
// ne lit ni n'écrit rien, sauf pour lever une consigne qu'il tiendrait encore.
|
|
||||||
const StorageDecision batterie = m_scheduler->storageDecision();
|
|
||||||
m_storageGuard->appliquer(ctx.offPeak.configured, batterie.evaluated && batterie.block,
|
|
||||||
currentDateTime);
|
|
||||||
if (batterie.evaluated)
|
|
||||||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Batterie →" << renderFr(batterie.reason);
|
|
||||||
|
|
||||||
// Brief 4 T3 — sous un plan d'OPTIMISEUR seulement : la puissance publiée est celle que les
|
// Brief 4 T3 — sous un plan d'OPTIMISEUR seulement : la puissance publiée est celle que les
|
||||||
// adaptateurs viennent d'APPLIQUER, le financement et le budget se recalculent sur elle (Q3,
|
// adaptateurs viennent d'APPLIQUER, le financement et le budget se recalculent sur elle (Q3,
|
||||||
// Q4), et une action écrêtée est marquée. AVANT la fusion L4, qui remplace ensuite les
|
// Q4), et une action écrêtée est marquée. AVANT la fusion L4, qui remplace ensuite les
|
||||||
@ -570,11 +547,6 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
||||||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||||||
buildTelemetry(plan, slot, currentDateTime);
|
buildTelemetry(plan, slot, currentDateTime);
|
||||||
attacherStockage(m_telemetry, batterie);
|
|
||||||
// Lot délestage visible — APRÈS le dispatch et la fusion L4 : la tenue se juge sur ce qui a
|
|
||||||
// été exécuté. Publication seule, rien ici ne commande.
|
|
||||||
suivreDelestages(slot, currentDateTime);
|
|
||||||
attacherDelestage(m_telemetry);
|
|
||||||
publishTelemetry(false);
|
publishTelemetry(false);
|
||||||
|
|
||||||
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
|
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
|
||||||
@ -825,9 +797,6 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
|||||||
{
|
{
|
||||||
m_shedActions.clear();
|
m_shedActions.clear();
|
||||||
const DrawMargin marge = houseDrawMargin();
|
const DrawMargin marge = houseDrawMargin();
|
||||||
// Lot délestage visible — noté pour le journal seulement : un plafond encore épuisé TIENT les
|
|
||||||
// charges délestées, quel que soit le motif que la cascade leur donne ensuite.
|
|
||||||
m_plafondEpuiseAuCycle = marge.borne && marge.watts <= 0;
|
|
||||||
if (!marge.borne || marge.watts >= 0)
|
if (!marge.borne || marge.watts >= 0)
|
||||||
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
|
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
|
||||||
|
|
||||||
@ -879,11 +848,6 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
|||||||
LoadAction shed;
|
LoadAction shed;
|
||||||
shed.loadId = id;
|
shed.loadId = id;
|
||||||
|
|
||||||
// Lot délestage visible — l'état AVANT le geste, lu comme l'adaptateur le publie. Lecture
|
|
||||||
// seule (runtimeView est const) : le journal n'entre dans aucune des décisions qui suivent.
|
|
||||||
LoadRuntimeView vueAvant = adaptateur->runtimeView(now);
|
|
||||||
vueAvant.mechanism.insert(QStringLiteral("kind"), vueAvant.mechanismKind);
|
|
||||||
|
|
||||||
// DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait
|
// DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait
|
||||||
// redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est
|
// redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est
|
||||||
// pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413).
|
// pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413).
|
||||||
@ -959,22 +923,6 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
|||||||
// REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
|
// REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
|
||||||
m_shedActions.insert(id, publie);
|
m_shedActions.insert(id, publie);
|
||||||
|
|
||||||
// Le JOURNAL, après le geste : ce qui a été rendu, et le mécanisme tel qu'il est laissé.
|
|
||||||
// Aucun type de charge n'est nommé ici — l'adaptateur dit lui-même ce qu'il tient.
|
|
||||||
LoadRuntimeView vueApres = adaptateur->runtimeView(now);
|
|
||||||
vueApres.mechanism.insert(QStringLiteral("kind"), vueApres.mechanismKind);
|
|
||||||
SheddingAct geste;
|
|
||||||
geste.source = drawCapSourceCode(marge.source);
|
|
||||||
if (marge.phase >= 0 && marge.phase <= 2)
|
|
||||||
geste.phase = QString(QChar('A' + marge.phase));
|
|
||||||
geste.overshootW = qMax(0.0, -marge.watts);
|
|
||||||
// Une coupure est une consigne en watts menée à zéro. Une PAC rendue à l'état 2 n'est
|
|
||||||
// pas coupée : elle chauffe sur son propre thermostat (ECS-413).
|
|
||||||
geste.cut = shed.kind == LoadAction::Setpoint && apresW <= 0;
|
|
||||||
geste.before = { engageW, vueAvant.mechanism };
|
|
||||||
geste.after = { apresW, vueApres.mechanism };
|
|
||||||
m_journalDelestage.tenir(id, now, geste);
|
|
||||||
|
|
||||||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id
|
||||||
<< "→" << renderFr(publie.reason, id)
|
<< "→" << renderFr(publie.reason, id)
|
||||||
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W";
|
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W";
|
||||||
@ -986,82 +934,6 @@ 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) {
|
||||||
@ -1078,243 +950,6 @@ void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EnergyArbitrator::suivreDelestages(const Slot &slot, const QDateTime &now)
|
|
||||||
{
|
|
||||||
// Une charge de la cascade reste TENUE tant que le plafond la tient : il est encore épuisé
|
|
||||||
// au compteur, ou son motif est DRAW_CAP — délestée ce cycle (shed: true), ou la cascade
|
|
||||||
// refuse de la rallumer, l'autorisation étant prise par les charges servies avant elle
|
|
||||||
// (shed: false). C'est la suite que shedWaterfallForOverload() annonce lui-même : « le cycle
|
|
||||||
// suivant, la cascade ne les rallume pas non plus ». Le plafond épuisé compte à lui seul :
|
|
||||||
// une PAC rendue à l'état 2 publie ensuite son verrou, pas le plafond, et le dépassement dure.
|
|
||||||
QSet<QString> tenues;
|
|
||||||
for (const LoadAction &a : slot.actions)
|
|
||||||
if (a.reason.code == QLatin1String(DecisionCode::DrawCap))
|
|
||||||
tenues.insert(a.loadId);
|
|
||||||
|
|
||||||
for (const QString &id : m_journalDelestage.ouverts()) {
|
|
||||||
const bool borneVivante = internalEvChargers().contains(ThingId(id));
|
|
||||||
if (borneVivante || m_bornesTenues.contains(id)) {
|
|
||||||
// Une borne : sa tenue suit ses consignes. Seule une borne disparue se relâche ici.
|
|
||||||
if (!borneVivante) {
|
|
||||||
m_bornesTenues.remove(id);
|
|
||||||
m_journalDelestage.relacher(id, now);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tenues.contains(id) || m_plafondEpuiseAuCycle)
|
|
||||||
m_journalDelestage.prolonger(id);
|
|
||||||
else
|
|
||||||
m_journalDelestage.relacher(id, now);
|
|
||||||
}
|
|
||||||
if (m_journalDelestage.finDeCycle())
|
|
||||||
qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] journal — épisode(s) clos ; en cours :"
|
|
||||||
<< (m_journalDelestage.ouverts().isEmpty()
|
|
||||||
? QStringLiteral("aucun")
|
|
||||||
: m_journalDelestage.ouverts().join(QStringLiteral(", ")));
|
|
||||||
}
|
|
||||||
|
|
||||||
void EnergyArbitrator::attacherDelestage(QVariantMap &snap) const
|
|
||||||
{
|
|
||||||
if (snap.isEmpty())
|
|
||||||
return; // aucun cycle encore : rien à compléter, et rien à inventer
|
|
||||||
// ABSENT tant que rien n'a été délesté depuis le démarrage : l'absence dit « aucun
|
|
||||||
// délestage », un bloc à zéro le dirait mal — et la télémétrie d'une maison qui n'a jamais
|
|
||||||
// délesté reste exactement celle d'avant ce lot.
|
|
||||||
const QVariantMap resume = m_journalDelestage.resume();
|
|
||||||
if (resume.isEmpty())
|
|
||||||
snap.remove(QStringLiteral("shedding"));
|
|
||||||
else
|
|
||||||
snap.insert(QStringLiteral("shedding"), resume);
|
|
||||||
|
|
||||||
if (m_bornesTenues.isEmpty())
|
|
||||||
return;
|
|
||||||
QVariantList loads = snap.value(QStringLiteral("loads")).toList();
|
|
||||||
for (QVariant &v : loads) {
|
|
||||||
QVariantMap entry = v.toMap();
|
|
||||||
const auto t = m_bornesTenues.constFind(entry.value(QStringLiteral("loadId")).toString());
|
|
||||||
if (t == m_bornesTenues.constEnd())
|
|
||||||
continue;
|
|
||||||
// Préséance L4 : l'action entière est celle de la protection, sans niveaux (R7 — les
|
|
||||||
// deux absents ensemble), et l'allocation publiée est ce qu'elle laisse à la borne.
|
|
||||||
entry.insert(QStringLiteral("decision"), motifTenue(*t).toMap());
|
|
||||||
entry.remove(QStringLiteral("levels"));
|
|
||||||
entry.insert(QStringLiteral("allocatedW"), t->cut ? 0.0 : t->toA * 230.0 * t->phases);
|
|
||||||
v = entry;
|
|
||||||
}
|
|
||||||
snap.insert(QStringLiteral("loads"), loads);
|
|
||||||
}
|
|
||||||
|
|
||||||
DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const
|
|
||||||
{
|
|
||||||
QVariantMap params{{QStringLiteral("fromA"), t.fromA},
|
|
||||||
{QStringLiteral("toA"), t.toA},
|
|
||||||
{QStringLiteral("cut"), t.cut},
|
|
||||||
{QStringLiteral("overshootW"), qRound(t.overshootW)},
|
|
||||||
{QStringLiteral("limitA"), phasePowerLimit()}};
|
|
||||||
// Lot délestage triphasé — le SEUIL où la protection agit, sous la limite de la marge de
|
|
||||||
// sécurité. Absent quand la marge est nulle : seuil et limite se confondent alors, et la
|
|
||||||
// charge utile reste celle d'avant ce lot.
|
|
||||||
if (phaseSafetyMarginPercent() > 0)
|
|
||||||
params.insert(QStringLiteral("thresholdA"), qRound(effectivePhaseLimit() * 10) / 10.0);
|
|
||||||
// Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée,
|
|
||||||
// aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP.
|
|
||||||
if (!t.phase.isEmpty())
|
|
||||||
params.insert(QStringLiteral("phase"), t.phase);
|
|
||||||
return DecisionReason(DecisionCode::OverloadThrottled, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction,
|
|
||||||
const QDateTime ¤tDateTime)
|
|
||||||
{
|
|
||||||
// AVANT l'écriture : ce que la borne tenait, et ce que ses verrous laisseront passer. Les
|
|
||||||
// verrous sont lus ICI, avec le même instant que les setters amont : c'est ce qui permet de
|
|
||||||
// publier ce que la borne a RÉELLEMENT reçu, et non ce qui lui a été demandé.
|
|
||||||
const bool avantActive = evCharger->chargingEnabled();
|
|
||||||
const uint avantA = evCharger->maxChargingCurrent();
|
|
||||||
const bool courantPasse = chargingAction.force() || !evCharger->chargingCurrentLocked(currentDateTime);
|
|
||||||
const bool activationPasse = chargingAction.force() || !evCharger->chargingEnabledLocked(currentDateTime);
|
|
||||||
const uint demandeA = qBound(evCharger->maxChargingCurrentMinValue(),
|
|
||||||
static_cast<uint>(qMax(0, qRound(chargingAction.maxChargingCurrent()))),
|
|
||||||
evCharger->maxChargingCurrentMaxValue());
|
|
||||||
|
|
||||||
// La consigne part TELLE QUELLE, par le corps amont : la publication vient après, et ne
|
|
||||||
// touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4).
|
|
||||||
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,
|
|
||||||
activationPasse ? chargingAction.chargingEnabled() : avantActive,
|
|
||||||
courantPasse ? demandeA : avantA, currentDateTime))
|
|
||||||
return;
|
|
||||||
// HORS CYCLE — powerBalanceChanged : aucun cycle ne publiera ce geste avant une minute.
|
|
||||||
// L'instantané publié est complété et republié tel quel : son timestamp reste celui du
|
|
||||||
// dernier cycle, le geste est daté par le journal.
|
|
||||||
if (!m_dansCycle && !m_telemetry.isEmpty()) {
|
|
||||||
attacherDelestage(m_telemetry);
|
|
||||||
publishTelemetry(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur,
|
|
||||||
bool avantActive, uint avantA, bool apresActive, uint apresA,
|
|
||||||
const QDateTime &now)
|
|
||||||
{
|
|
||||||
const QString id = evCharger->id().toString();
|
|
||||||
|
|
||||||
// L'émetteur INCONNU n'est émis que par un chemin : la boucle de verifyOverloadProtection()
|
|
||||||
// sur une borne monophasée dont la phase ne dépasse pas, qui reçoit une consigne par défaut.
|
|
||||||
// Ce n'est pas un geste de la protection au sens de son émetteur : il n'ouvre ni ne ferme rien.
|
|
||||||
if (emetteur == ChargingAction::ChargingActionIssuerUnknown)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const bool tenueAvant = m_bornesTenues.contains(id);
|
|
||||||
|
|
||||||
if (emetteur != ChargingAction::ChargingActionIssuerOverloadProtection) {
|
|
||||||
// Le cycle reprend la main : la protection ne tient plus la borne.
|
|
||||||
if (!tenueAvant)
|
|
||||||
return false;
|
|
||||||
m_bornesTenues.remove(id);
|
|
||||||
m_journalDelestage.relacher(id, now);
|
|
||||||
qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name()
|
|
||||||
<< "— le cycle reprend la main, la protection ne la tient plus.";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rien n'a passé les verrous de la borne : la protection a demandé, la borne n'a rien reçu.
|
|
||||||
// Un refus se dit au moins aussi fort qu'un geste (règle 7-d) — mais il ne s'enregistre pas
|
|
||||||
// comme un délestage, qui n'a pas eu lieu.
|
|
||||||
if (apresActive == avantActive && apresA == avantA) {
|
|
||||||
qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name()
|
|
||||||
<< "— geste de la protection REFUSÉ par les verrous de la "
|
|
||||||
"borne : elle garde" << (avantActive ? avantA : 0u) << "A.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint phases = qMax(1u, evCharger->phaseCount());
|
|
||||||
const bool coupe = !apresActive;
|
|
||||||
const uint commandeA = apresA;
|
|
||||||
const uint versA = coupe ? 0u : commandeA;
|
|
||||||
|
|
||||||
if (!coupe && avantActive && versA >= avantA) {
|
|
||||||
// verifyOverloadProtectionRecovery() rend le courant manuel : la tenue est levée.
|
|
||||||
if (!tenueAvant)
|
|
||||||
return false;
|
|
||||||
m_bornesTenues.remove(id);
|
|
||||||
m_journalDelestage.relacher(id, now);
|
|
||||||
qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name()
|
|
||||||
<< "— la protection lui rend" << versA << "A : tenue levée.";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!coupe && !avantActive && !tenueAvant)
|
|
||||||
return false; // une remise en route sans tenue en cours n'a rien à prolonger
|
|
||||||
|
|
||||||
// Le dépassement que la protection a vu — relu sur le MÊME compteur, dans le même appel, et
|
|
||||||
// jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon.
|
|
||||||
QString phase;
|
|
||||||
double depassementW = 0;
|
|
||||||
if (internalRootMeter()) {
|
|
||||||
// Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total,
|
|
||||||
// limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé.
|
|
||||||
const double limiteW = 230.0 * effectivePhaseLimit();
|
|
||||||
const QHash<QString, double> parPhase = phaseConsumptionW();
|
|
||||||
if (evCharger->phaseCount() == 1) {
|
|
||||||
phase = chargerPhaseFor(evCharger);
|
|
||||||
depassementW = qMax(0.0, parPhase.value(phase) - limiteW);
|
|
||||||
} else {
|
|
||||||
for (double w : parPhase)
|
|
||||||
depassementW = qMax(depassementW, w - limiteW);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BorneTenue &t = m_bornesTenues[id];
|
|
||||||
if (!tenueAvant) {
|
|
||||||
// Un épisode encore ouvert — le cycle a repris la borne, la protection la reprend avant
|
|
||||||
// qu'un cycle complet ne soit passé : c'est le MÊME épisode, et « avant » est le sien.
|
|
||||||
const SheddingEvent *ouvert = m_journalDelestage.enCours(id);
|
|
||||||
t.fromA = ouvert ? ouvert->before.mechanism.value(QStringLiteral("currentA")).toUInt()
|
|
||||||
: (avantActive ? avantA : 0);
|
|
||||||
t.phase = ouvert ? ouvert->phase : phase;
|
|
||||||
t.overshootW = ouvert ? ouvert->overshootW : 0;
|
|
||||||
}
|
|
||||||
t.toA = versA;
|
|
||||||
t.cut = coupe;
|
|
||||||
t.phases = phases;
|
|
||||||
if (depassementW > t.overshootW) {
|
|
||||||
t.overshootW = depassementW;
|
|
||||||
t.phase = phase;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto etat = [phases](bool active, uint ampere) {
|
|
||||||
return SheddingState{ active ? ampere * 230.0 * phases : 0.0,
|
|
||||||
QVariantMap{{QStringLiteral("kind"), QStringLiteral("evcharger")},
|
|
||||||
{QStringLiteral("chargingEnabled"), active},
|
|
||||||
{QStringLiteral("currentA"), ampere},
|
|
||||||
{QStringLiteral("phaseCount"), phases}} };
|
|
||||||
};
|
|
||||||
SheddingAct geste;
|
|
||||||
geste.source = drawCapSourceCode(DrawCapSource::Breaker); // la limite par phase du branchement
|
|
||||||
geste.phase = phase;
|
|
||||||
geste.overshootW = depassementW;
|
|
||||||
geste.cut = coupe;
|
|
||||||
geste.before = etat(avantActive, avantA);
|
|
||||||
geste.after = etat(!coupe, commandeA);
|
|
||||||
m_journalDelestage.tenir(id, now, geste);
|
|
||||||
|
|
||||||
qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << id << "→"
|
|
||||||
<< renderFr(motifTenue(t), evCharger->name());
|
|
||||||
return true; // le motif publié suit chaque geste, même quand le journal ne bouge pas
|
|
||||||
}
|
|
||||||
|
|
||||||
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||||||
{
|
{
|
||||||
if (!ev)
|
if (!ev)
|
||||||
@ -1466,10 +1101,7 @@ QList<DrawCap> EnergyArbitrator::drawCaps() const
|
|||||||
if (limiteA > 0) {
|
if (limiteA > 0) {
|
||||||
DrawCap disjoncteur;
|
DrawCap disjoncteur;
|
||||||
disjoncteur.source = DrawCapSource::Breaker;
|
disjoncteur.source = DrawCapSource::Breaker;
|
||||||
// Lot délestage triphasé (décision du 2026-10-27) — le plafond est le SEUIL, la limite
|
disjoncteur.perPhaseW = limiteA * 230.0;
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1492,22 +1124,17 @@ 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 };
|
||||||
|
|
||||||
// Tirage de chaque phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
|
for (Electricity::Phase p : toutes) {
|
||||||
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
|
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
@ -1573,134 +1200,12 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
|
|||||||
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
|
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
|
||||||
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
|
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
|
||||||
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
|
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
|
||||||
// Ce test a un compteur qui ne voit pas la borne baisser : le retour y est un trou. En heures
|
|
||||||
// creuses, evOffPeakAllowanceW() rend le courant MESURÉ moins une marge, et sa propre preuve
|
|
||||||
// de baisse en dépassement suppose que le compteur voie la borne.
|
|
||||||
//
|
//
|
||||||
// L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la
|
// L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la
|
||||||
// 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.
|
||||||
const double libreW = qMax(0.0, marge.watts) * phaseCount;
|
return 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 {
|
|
||||||
//! Marge du plafond d'heures creuses, par phase. Celle de l'amont pour l'échéance
|
|
||||||
//! (`adjustEvChargers()`), pour la même raison : ne pas coller à la limite, et laisser à un
|
|
||||||
//! appareil moyen allumé entre deux cycles la place de démarrer avant que L4 n'ait à couper.
|
|
||||||
constexpr double MargeHeuresCreusesA = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const
|
|
||||||
{
|
|
||||||
return evCeilingGivingBackDraw(ev, phaseCount).watts;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnergyArbitrator::TirageBorne EnergyArbitrator::evAttributableDraw(EvCharger *ev, uint phaseCount) const
|
|
||||||
{
|
|
||||||
TirageBorne tirage;
|
|
||||||
if (!ev || phaseCount == 0)
|
|
||||||
return tirage;
|
|
||||||
|
|
||||||
// Ce que la borne tire, MESURÉ. Pas la consigne : une voiture qui prend 8 A d'une borne
|
|
||||||
// commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 ouvrirait 8 A qu'elle peut
|
|
||||||
// prendre d'un coup. Une borne sans mesure ne rend rien.
|
|
||||||
if (ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull())
|
|
||||||
return tirage;
|
|
||||||
|
|
||||||
const Electricity::Phases mesurees = ev->meteredPhases();
|
|
||||||
if (mesurees != Electricity::PhaseNone) {
|
|
||||||
// Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas —
|
|
||||||
// et la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule
|
|
||||||
// (phases mesurées ≠ phases visées), on ne rend donc rien.
|
|
||||||
if (Electricity::getPhaseCount(mesurees) != phaseCount)
|
|
||||||
return tirage;
|
|
||||||
tirage.phases = mesurees;
|
|
||||||
} else {
|
|
||||||
// Lot délestage V2C — une borne qui ne publie PAS ses courants par phase (le V2C Trydan
|
|
||||||
// publie `currentL1..3`, que l'interface ne connaît pas). meteredPhases() est vide, et
|
|
||||||
// jusqu'ici rien n'était rendu : le plafond lisait la borne comme une charge de la maison
|
|
||||||
// sur sa phase, et la consigne alternait chaque nuit (16, 6, 13, 6 A sous 25 A).
|
|
||||||
//
|
|
||||||
// Ses phases sont celles que le moteur lui suppose DÉJÀ — phasesForCount(), la phase A en
|
|
||||||
// monophasé : c'est le masque de ce plafond, celui de L4 (chargerPhaseKey()) et celui de
|
|
||||||
// l'amont (prepareInformation()). Attribuer son tirage ailleurs ferait deux hypothèses.
|
|
||||||
//
|
|
||||||
// Seulement quand c'est sûr :
|
|
||||||
// - une borne qui publie des états par phase, tous nuls alors qu'elle tire, a une mesure
|
|
||||||
// qui se contredit — elle n'est pas « non mesurée » ;
|
|
||||||
// - elle doit annoncer autant de phases que le cycle en vise. Une triphasée qui charge
|
|
||||||
// une voiture monophasée (le cycle vise 1, elle annonce 3), ou une borne en cours de
|
|
||||||
// bascule, tire sur des phases que rien ne dit ;
|
|
||||||
// - une ou trois phases : la monophasée sur sa phase, la triphasée un tiers par phase.
|
|
||||||
// En biphasé, le masque A+B est une convention de l'amont, pas une observation : rien
|
|
||||||
// n'est rendu (décision du 2026-10-27 : mono et tri seulement).
|
|
||||||
for (const char *etat : {"currentPhaseA", "currentPhaseB", "currentPhaseC",
|
|
||||||
"currentPowerPhaseA", "currentPowerPhaseB", "currentPowerPhaseC"}) {
|
|
||||||
if (ev->thing()->hasState(etat))
|
|
||||||
return tirage;
|
|
||||||
}
|
|
||||||
if ((phaseCount != 1 && phaseCount != 3) || ev->phaseCount() != phaseCount)
|
|
||||||
return tirage;
|
|
||||||
tirage.phases = const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
|
||||||
}
|
|
||||||
tirage.watts = qMax(0.0, ev->currentPower());
|
|
||||||
return tirage;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnergyArbitrator::PlafondRendu EnergyArbitrator::evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const
|
|
||||||
{
|
|
||||||
PlafondRendu plafond;
|
|
||||||
RootMeter *meter = internalRootMeter();
|
|
||||||
if (!ev || phaseCount == 0 || !meter)
|
|
||||||
return plafond;
|
|
||||||
|
|
||||||
const QList<DrawCap> plafonds = drawCaps();
|
|
||||||
if (plafonds.isEmpty())
|
|
||||||
return plafond;
|
|
||||||
|
|
||||||
const TirageBorne tirage = evAttributableDraw(ev, phaseCount);
|
|
||||||
plafond.attribuable = tirage.phases != Electricity::PhaseNone;
|
|
||||||
const Electricity::Phases masque = plafond.attribuable
|
|
||||||
? tirage.phases : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
|
||||||
const double tireW = plafond.attribuable ? tirage.watts : 0.0;
|
|
||||||
|
|
||||||
// Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui
|
|
||||||
// annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la
|
|
||||||
// limite. Au mieux, une phase vaut sa limite.
|
|
||||||
const QList<double> avecLaBorne = phaseDrawW(masque, phasePowerLimit());
|
|
||||||
plafond.depasse = resolveDrawMargin(plafonds, avecLaBorne, meter->currentPower()).watts < 0;
|
|
||||||
QList<double> sansLaBorne = avecLaBorne;
|
|
||||||
for (double &w : sansLaBorne)
|
|
||||||
w = qMax(0.0, w - tireW / phaseCount);
|
|
||||||
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
|
|
||||||
qMax(0.0, meter->currentPower() - tireW));
|
|
||||||
if (!marge.borne)
|
|
||||||
return plafond;
|
|
||||||
|
|
||||||
// Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche,
|
|
||||||
// et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié.
|
|
||||||
//
|
|
||||||
// En dépassement, ce plafond BAISSE, et c'est la raison de sécurité de evPhaseAllowanceW()
|
|
||||||
// qui tient toujours : si la phase tire I > limite, dont E pour la borne, la place rendue
|
|
||||||
// vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à
|
|
||||||
// sortir du dépassement ou jusqu'au plancher, où L4 décide.
|
|
||||||
const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA);
|
|
||||||
plafond.watts = qMax(0.0, parPhaseA) * 230.0 * phaseCount;
|
|
||||||
return plafond;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
||||||
@ -1970,28 +1475,26 @@ 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())
|
||||||
lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
|
return; // aucun écart en cours : rien à qualifier
|
||||||
lc.telemetry.commandRefused = j->refus;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
|
const int ecoule = static_cast<int>(depuis.secsTo(now));
|
||||||
{
|
lc.telemetry.refusedSinceS = qMax(0, ecoule);
|
||||||
// Une seule lecture, partagée par le contexte et par la borne : deux copies divergeraient au
|
|
||||||
// premier changement de règle, et la borne viserait sa pleine puissance hors des heures que
|
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
|
||||||
// le scheduler sert.
|
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
|
||||||
return !m_tariff.zonesCreuses().isEmpty()
|
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
|
||||||
&& m_tariff.enHeuresCreuses(now, QTimeZone::systemTimeZone());
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||||
@ -2017,28 +1520,6 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
|||||||
}
|
}
|
||||||
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
||||||
|
|
||||||
// --- Heures creuses (lot e1) --------------------------------------------------------
|
|
||||||
// La SEULE lecture du tarif par une décision. Le fuseau de la box est relu à chaque cycle :
|
|
||||||
// il peut changer en marche (le banc l'a fait), et une plage tarifaire est un RÉGLAGE mural.
|
|
||||||
// Sans heure creuse (aucun tarif, tarif unique, prix tous égaux), `configured` reste faux et
|
|
||||||
// rien de ce lot ne s'exécute.
|
|
||||||
if (!m_tariff.zonesCreuses().isEmpty()) {
|
|
||||||
const QTimeZone fuseau = QTimeZone::systemTimeZone();
|
|
||||||
ctx.offPeak.configured = true;
|
|
||||||
ctx.offPeak.active = enHeuresCreuses(now);
|
|
||||||
ctx.offPeak.nextChange = m_tariff.prochaineBascule(now, fuseau);
|
|
||||||
if (ctx.offPeak.nextChange.isValid())
|
|
||||||
ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm"));
|
|
||||||
|
|
||||||
// Ce que la décision sur la batterie lit — et seulement quand elle peut se poser. La
|
|
||||||
// puissance des bornes est MESURÉE, toutes bornes confondues (mode manuel compris) :
|
|
||||||
// une voiture qui charge en heures creuses vide la batterie quel que soit son mode.
|
|
||||||
for (EvCharger *ev : internalEvChargers())
|
|
||||||
if (ev->available())
|
|
||||||
ctx.evMeasuredW += qMax(0.0, ev->currentPower());
|
|
||||||
ctx.storageControl = m_storageGuard->capacites();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stockage (3g-1) ------------------------------------------------------------------
|
// --- Stockage (3g-1) ------------------------------------------------------------------
|
||||||
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
|
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
|
||||||
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
|
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
|
||||||
@ -2445,11 +1926,7 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
|
|||||||
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batterie : rendue à son AUTOMATISME, explicitement (lot e1). Sous compteur muet le moteur
|
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
||||||
// cesse de commander ; une limite de décharge tenue serait sinon rafraîchie par personne et
|
|
||||||
// ne tomberait qu'au minuteur de l'onduleur. Aucune charge réseau n'existe à replier : le
|
|
||||||
// moteur n'en commande jamais.
|
|
||||||
m_storageGuard->leverTout(now);
|
|
||||||
|
|
||||||
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
|
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
|
||||||
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
|
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
|
||||||
@ -2550,7 +2027,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, now);
|
attachMeasurements(orphan, action.loadId);
|
||||||
loads.append(orphan);
|
loads.append(orphan);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -2673,7 +2150,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, now);
|
attachMeasurements(entry, action.loadId);
|
||||||
loads.append(entry);
|
loads.append(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2699,119 +2176,14 @@ 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 et arrondi à l'ampère s'y logent. Valeur de conception, à ajuster sur relevé.
|
//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent.
|
||||||
|
//! 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 QString &source, double mesureW) const
|
||||||
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.
|
||||||
@ -2824,34 +2196,58 @@ 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;
|
||||||
|
|
||||||
// Le VERDICT est celui du cycle — le jugement que le recrédit a lu, fait au début du cycle
|
const double ecart = qAbs(attenduW - mesureW);
|
||||||
// sur la commande en vigueur et la mesure fraîche. Il n'est PAS refait ici sur la consigne
|
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW);
|
||||||
// qui vient de partir : c'était le défaut, une marche suivie se lisait comme un refus. Sans
|
|
||||||
// commande en vigueur au début du cycle (première commande), le jugement est fait ici, sur
|
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un
|
||||||
// celle qui vient de partir : récente, donc jamais divergente.
|
// déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence
|
||||||
const std::optional<JugementCommande> j = jugerCommande(loadId, now);
|
// entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait
|
||||||
const bool divergent = j && j->refus;
|
// indéfiniment parce que plus personne ne commandait.
|
||||||
|
// 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"), *commandeeW);
|
commande.insert(QStringLiteral("expectedW"), attenduW);
|
||||||
commande.insert(QStringLiteral("observedW"), mesureW);
|
commande.insert(QStringLiteral("observedW"), mesureW);
|
||||||
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
|
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une
|
||||||
// des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
|
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La
|
||||||
// et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
|
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la
|
||||||
// la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
|
// même chose du même cycle.
|
||||||
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,
|
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
|
||||||
const QDateTime &now) const
|
|
||||||
{
|
{
|
||||||
if (!m_tm)
|
if (!m_tm)
|
||||||
return;
|
return;
|
||||||
@ -2877,7 +2273,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, now);
|
attachCommandDivergence(entry, loadId, source, powerW);
|
||||||
|
|
||||||
// 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
|
||||||
@ -2987,7 +2383,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, now);
|
attachMeasurements(entry, id);
|
||||||
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.
|
||||||
@ -3011,31 +2407,9 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
|
|||||||
// Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément,
|
// Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément,
|
||||||
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
|
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
|
||||||
attacherFinsDerogation(snap);
|
attacherFinsDerogation(snap);
|
||||||
// La batterie, si la question se posait : rendue à son automatisme par le repli.
|
|
||||||
if (!m_tariff.zonesCreuses().isEmpty() && !m_storageGuard->capacites().isEmpty()) {
|
|
||||||
StorageDecision repli;
|
|
||||||
repli.evaluated = true;
|
|
||||||
repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} };
|
|
||||||
attacherStockage(snap, repli);
|
|
||||||
}
|
|
||||||
// La protection de surcharge tourne aussi sous L2 : ce qu'elle tient se publie ici aussi.
|
|
||||||
attacherDelestage(snap);
|
|
||||||
m_telemetry = snap;
|
m_telemetry = snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
void EnergyArbitrator::attacherStockage(QVariantMap &snap, const StorageDecision &d) const
|
|
||||||
{
|
|
||||||
if (!d.evaluated)
|
|
||||||
return; // pas d'heure creuse, ou pas de batterie : la question ne s'est pas posée
|
|
||||||
QVariantMap st;
|
|
||||||
// CE QUE LE MOTEUR TIENT après le dispatch, pas ce que le plan demandait : une batterie non
|
|
||||||
// pilotable sous une décision « bloquer » n'est pas bloquée, et le motif dit pourquoi.
|
|
||||||
st.insert(QStringLiteral("dischargeBlocked"), m_storageGuard->tientUneConsigne());
|
|
||||||
st.insert(QStringLiteral("decision"), d.reason.toMap());
|
|
||||||
st.insert(QStringLiteral("batteries"), m_storageGuard->vue());
|
|
||||||
snap.insert(QStringLiteral("storage"), st);
|
|
||||||
}
|
|
||||||
|
|
||||||
QStringList EnergyArbitrator::forecastSiteCandidates() const
|
QStringList EnergyArbitrator::forecastSiteCandidates() const
|
||||||
{
|
{
|
||||||
QStringList sites;
|
QStringList sites;
|
||||||
@ -3809,11 +3183,8 @@ 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 = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()};
|
fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())};
|
||||||
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)
|
||||||
@ -4012,19 +3383,6 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
|
|||||||
// pris fin — ni pourquoi.
|
// pris fin — ni pourquoi.
|
||||||
if (b.contains(QStringLiteral("derogationEnded")))
|
if (b.contains(QStringLiteral("derogationEnded")))
|
||||||
return true;
|
return true;
|
||||||
// Lot délestage visible — l'OUVERTURE et la FERMETURE d'un épisode se notifient toujours,
|
|
||||||
// même quand rien d'autre n'a bougé : c'est l'alerte que l'app attend.
|
|
||||||
if (a.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision"))
|
|
||||||
!= b.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision")))
|
|
||||||
return true;
|
|
||||||
// Lot e1 — la batterie : apparition du bloc, consigne tenue ou levée, CODE du motif.
|
|
||||||
const QVariantMap sa = a.value(QStringLiteral("storage")).toMap();
|
|
||||||
const QVariantMap sb = b.value(QStringLiteral("storage")).toMap();
|
|
||||||
if (a.contains(QStringLiteral("storage")) != b.contains(QStringLiteral("storage"))
|
|
||||||
|| sa.value(QStringLiteral("dischargeBlocked")) != sb.value(QStringLiteral("dischargeBlocked"))
|
|
||||||
|| sa.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
|
|
||||||
!= sb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")))
|
|
||||||
return true;
|
|
||||||
// Même règle pour la CLÔTURE NON TENUE (correctif b) : un niveau ECO_FLOOR_MISSED ne vit
|
// Même règle pour la CLÔTURE NON TENUE (correctif b) : un niveau ECO_FLOOR_MISSED ne vit
|
||||||
// qu'un cycle. Le code de TÊTE ne suffit pas à le voir passer — sous LOAD_UNAVAILABLE il reste
|
// qu'un cycle. Le code de TÊTE ne suffit pas à le voir passer — sous LOAD_UNAVAILABLE il reste
|
||||||
// en tête, et rien d'autre ne bouge forcément.
|
// en tête, et rien d'autre ne bouge forcément.
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
|
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
|
||||||
#include "types/loadconfig.h"
|
#include "types/loadconfig.h"
|
||||||
#include "types/drawcap.h"
|
#include "types/drawcap.h"
|
||||||
#include "types/sheddinglog.h"
|
|
||||||
#include "types/derogation.h"
|
#include "types/derogation.h"
|
||||||
#include "forecast/forecaststore.h"
|
#include "forecast/forecaststore.h"
|
||||||
#include "forecast/forecastlearninglog.h"
|
#include "forecast/forecastlearninglog.h"
|
||||||
@ -30,7 +29,6 @@ class EvAdapter;
|
|||||||
class SgReadyAdapter;
|
class SgReadyAdapter;
|
||||||
class EtmVariableLoadAdapter;
|
class EtmVariableLoadAdapter;
|
||||||
class RelayRouter;
|
class RelayRouter;
|
||||||
class StorageDischargeGuard;
|
|
||||||
class ILoadAdapter;
|
class ILoadAdapter;
|
||||||
class LoadConfigStore;
|
class LoadConfigStore;
|
||||||
class RuleBasedScheduler;
|
class RuleBasedScheduler;
|
||||||
@ -124,25 +122,10 @@ public:
|
|||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Le tarif saisi. \return Vide si aucun tarif n'est saisi.
|
* \brief Le tarif saisi. \return Vide si aucun tarif n'est saisi.
|
||||||
* \note Une seule décision le lit — les HEURES CREUSES (lot e1, \c SurplusContext::offPeak),
|
* \note EXPOSÉ, jamais lu par une décision : \c SurplusContext::tariff n'est pas rempli (§11).
|
||||||
* et seulement par \c TariffConfig::zonesCreuses() : jamais le prix. \c SurplusContext::tariff
|
|
||||||
* n'est toujours pas rempli, l'optimiseur ne le reçoit pas par là.
|
|
||||||
*/
|
*/
|
||||||
const TariffConfig &tariff() const { return m_tariff; }
|
const TariffConfig &tariff() const { return m_tariff; }
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief L'instant \p now tombe-t-il en heures creuses du tarif saisi ? (lot e2)
|
|
||||||
*
|
|
||||||
* Même lecture que \c SurplusContext::offPeak (\c buildContext()) : zones au prix le plus
|
|
||||||
* bas, heure murale du fuseau de la box relu à l'appel. Elle existe pour la borne, qui doit
|
|
||||||
* le savoir AVANT l'allocation : en heures creuses elle vise sa pleine puissance, donc ses
|
|
||||||
* phases se choisissent sur son plafond et non sur le surplus (\c EvAdapter::toLoadContext()).
|
|
||||||
*
|
|
||||||
* \param now Temps de cycle (injectable). \return Faux sans heure creuse (aucun tarif, tarif
|
|
||||||
* unique, prix tous égaux) — rien du lot ne s'exécute alors.
|
|
||||||
*/
|
|
||||||
bool enHeuresCreuses(const QDateTime &now) const;
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Remplace le tarif saisi, à chaud, et le persiste. Un tarif vide l'efface.
|
* \brief Remplace le tarif saisi, à chaud, et le persiste. Un tarif vide l'efface.
|
||||||
* \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre.
|
* \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre.
|
||||||
@ -523,23 +506,6 @@ public:
|
|||||||
*/
|
*/
|
||||||
QVariantMap loadTelemetry() const { return m_telemetry; }
|
QVariantMap loadTelemetry() const { return m_telemetry; }
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Le journal court des délestages — charge utile de \c NymeaEnergy.GetSheddingLog.
|
|
||||||
*
|
|
||||||
* \return \c revision, \c capacity et \c events : les \c capacity derniers épisodes de
|
|
||||||
* protection, du plus ancien au plus récent. Un épisode en cours n'a pas d'\c endedAt.
|
|
||||||
*
|
|
||||||
* \par Ce qui y entre
|
|
||||||
* Les deux couches de protection, et elles seules : la protection de surcharge de l'amont
|
|
||||||
* sur les bornes (\c executeChargingAction()), et le délestage du waterfall
|
|
||||||
* (\c shedWaterfallForOverload()) pour TOUTES les autres charges, sans liste de types.
|
|
||||||
*
|
|
||||||
* \note En mémoire seulement : perdu au redémarrage de nymead. La télémétrie en porte le
|
|
||||||
* résumé (\c shedding : \c revision et \c open), absent tant que le journal est vide —
|
|
||||||
* c'est lui qui notifie l'ouverture et la fermeture d'un épisode.
|
|
||||||
*/
|
|
||||||
QVariantMap sheddingLog() const { return m_journalDelestage.toMap(); }
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul.
|
* \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul.
|
||||||
*
|
*
|
||||||
@ -803,79 +769,11 @@ 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.
|
||||||
void fusionnerDelestageDansSlot(Slot &slot) const;
|
void fusionnerDelestageDansSlot(Slot &slot) const;
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Fin de cycle du journal des délestages : prolonge, relâche, ferme.
|
|
||||||
* \param slot Actions du cycle, délestage L4 fusionné. Une charge de la cascade reste tenue
|
|
||||||
* tant que son motif est \c DRAW_CAP — délestée ce cycle, ou que la cascade refuse de la
|
|
||||||
* rallumer sous le même plafond.
|
|
||||||
* \param now Temps de cycle.
|
|
||||||
* \note Les bornes ne passent pas par ici : leur tenue suit les consignes elles-mêmes
|
|
||||||
* (\c executeChargingAction()). Une borne qui a disparu est relâchée.
|
|
||||||
*/
|
|
||||||
void suivreDelestages(const Slot &slot, const QDateTime &now);
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Pose dans \p snap ce que la protection tient : le résumé du journal, et le motif
|
|
||||||
* \c OVERLOAD_THROTTLED des bornes tenues.
|
|
||||||
* \param[in,out] snap Instantané de télémétrie. Rien n'est ajouté à un instantané vide.
|
|
||||||
* \note Idempotent : rejouée hors cycle sur l'instantané publié, elle ne double rien. Une
|
|
||||||
* borne tenue perd ses niveaux, comme une charge délestée par L4 — l'action entière est
|
|
||||||
* celle de la protection, et \c allocatedW dit ce qu'elle laisse à la borne.
|
|
||||||
*/
|
|
||||||
void attacherDelestage(QVariantMap &snap) const;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Classe une consigne de borne qui vient de partir, et note ce qu'elle dit de la protection.
|
|
||||||
* \param evCharger Borne. \param emetteur Émetteur de la consigne.
|
|
||||||
* \param avantActive La borne était-elle enclenchée avant la consigne ?
|
|
||||||
* \param avantA Son courant avant la consigne.
|
|
||||||
* \param apresActive Enclenchement que la borne a REÇU — l'ancien si son verrou a refusé.
|
|
||||||
* \param apresA Courant que la borne a REÇU, borné à ses limites — l'ancien si son verrou a refusé.
|
|
||||||
* \param now Temps de cycle.
|
|
||||||
* \return Vrai si la tenue ou le journal ont changé.
|
|
||||||
*/
|
|
||||||
bool noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur,
|
|
||||||
bool avantActive, uint avantA, bool apresActive, uint apresA,
|
|
||||||
const QDateTime &now);
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Marge de soutirage de la MAISON, toutes phases confondues.
|
* \brief Marge de soutirage de la MAISON, toutes phases confondues.
|
||||||
*
|
*
|
||||||
@ -917,61 +815,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 EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la
|
* \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà.
|
||||||
* borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la
|
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
|
||||||
* marge est négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
|
* 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`, dont le compteur ne voit pas la borne baisser).
|
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
|
||||||
* \note HORS DÉPASSEMENT (lot délestage V2C, question 5), le plafond vaut au moins celui qui
|
* fausse : le code ne l'a jamais fait.
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const;
|
double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const;
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Lot e2, suite — plafond d'une borne À PLEINE PUISSANCE EN HEURES CREUSES : la place
|
|
||||||
* libre, plus ce que la borne tire, moins une marge de 1 A par phase.
|
|
||||||
*
|
|
||||||
* \param ev Borne. \param phaseCount Nombre de phases VISÉ.
|
|
||||||
* \return Plafond en watts, multiple de 230 W par phase, jamais négatif ; ou **−1 si aucune
|
|
||||||
* limite** (même convention que \c evPhaseAllowanceW()).
|
|
||||||
*
|
|
||||||
* \par Pourquoi
|
|
||||||
* \c evPhaseAllowanceW() ne rend pas à la borne ce qu'elle tire. À pleine puissance, et avec
|
|
||||||
* un compteur qui voit la borne, sa propre consommation lui retire donc la place au cycle
|
|
||||||
* suivant, puis la lui rend au cycle d'après : 16, 7, 16, 7 A sous une limite de 25 A. C'est
|
|
||||||
* la règle de l'échéance chez l'amont (\c adjustEvChargers() : marge − 1 A + courant de la
|
|
||||||
* borne) qui la fait se poser.
|
|
||||||
*
|
|
||||||
* \par Contrat
|
|
||||||
* - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais
|
|
||||||
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est
|
|
||||||
* sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou,
|
|
||||||
* pour une borne qui ne publie pas ses phases, celles que le moteur lui suppose (lot
|
|
||||||
* délestage V2C). Sinon — borne sans mesure, à l'arrêt, ou qui bascule — rien n'est rendu,
|
|
||||||
* et la marge s'applique quand même.
|
|
||||||
* - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire
|
|
||||||
* sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur
|
|
||||||
* voit ne se fabrique donc pas de place au-delà de la limite.
|
|
||||||
* - **En dépassement, le plafond baisse** : sur une phase au-dessus de la limite, il vaut au
|
|
||||||
* plus le courant mesuré de la borne moins 1 A. Il redescend donc à chaque cycle, jusqu'à
|
|
||||||
* sortir du dépassement ou jusqu'au plancher — où la stratégie tient la borne et où
|
|
||||||
* \c verifyOverloadProtection() (L4) décide. Cela suppose que le compteur voie la borne,
|
|
||||||
* comme L4 le suppose déjà.
|
|
||||||
* - Arrondi à l'ampère INFÉRIEUR : la consigne, arrondie au plus proche par l'adaptateur, ne
|
|
||||||
* mange jamais la marge.
|
|
||||||
*
|
|
||||||
* \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là,
|
|
||||||
* \c evPhaseAllowanceW() reste la règle ; depuis le lot délestage V2C, elle rend elle aussi
|
|
||||||
* le tirage hors dépassement.
|
|
||||||
*/
|
|
||||||
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
|
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
|
||||||
*
|
*
|
||||||
@ -1031,73 +885,7 @@ protected:
|
|||||||
*/
|
*/
|
||||||
void update(const QDateTime ¤tDateTime) override;
|
void update(const QDateTime ¤tDateTime) override;
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Exécute une consigne de borne par le corps amont, puis PUBLIE un geste de protection.
|
|
||||||
*
|
|
||||||
* \param evCharger Borne commandée. \param chargingAction Consigne, transmise TELLE QUELLE.
|
|
||||||
* \param currentDateTime Temps de cycle.
|
|
||||||
*
|
|
||||||
* \par Ce qui est publié
|
|
||||||
* Une consigne émise par la protection de surcharge (\c ChargingActionIssuerOverloadProtection)
|
|
||||||
* qui baisse ou coupe la borne ouvre ou prolonge son épisode au journal, et la borne publie
|
|
||||||
* \c OVERLOAD_THROTTLED. La remise au minimum après coupure prolonge la tenue. La reprise au
|
|
||||||
* courant manuel, ou toute consigne d'un autre émetteur, la lève.
|
|
||||||
*
|
|
||||||
* \par Ce qui ne change pas
|
|
||||||
* La consigne part d'abord, inchangée ; la lecture vient après. Hors d'un cycle — la
|
|
||||||
* protection tourne aussi sur \c powerBalanceChanged — la télémétrie est republiée sur-le-champ.
|
|
||||||
*/
|
|
||||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction,
|
|
||||||
const QDateTime ¤tDateTime) 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.
|
||||||
*
|
*
|
||||||
@ -1241,14 +1029,6 @@ private:
|
|||||||
*/
|
*/
|
||||||
void buildDegradedTelemetry(const QDateTime &now);
|
void buildDegradedTelemetry(const QDateTime &now);
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Ajoute à la trame l'état de la DÉCHARGE de la batterie (lot e1), si la question se pose.
|
|
||||||
* \param[in,out] snap Trame en construction. \param d Décision du cycle.
|
|
||||||
* \note Rien n'est ajouté quand \c d.evaluated est faux : sans heure creuse ou sans batterie,
|
|
||||||
* la trame est celle d'avant le lot, au bit près.
|
|
||||||
*/
|
|
||||||
void attacherStockage(QVariantMap &snap, const StorageDecision &d) const;
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare.
|
* \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare.
|
||||||
*
|
*
|
||||||
@ -1264,9 +1044,8 @@ 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 QDateTime &now) const;
|
void attachMeasurements(QVariantMap &entry, const QString &loadId) 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 {
|
||||||
@ -1294,49 +1073,9 @@ 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.
|
||||||
@ -1403,15 +1142,6 @@ 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 |
|
||||||
@ -1428,8 +1158,7 @@ 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 QString &source, double mesureW) const;
|
||||||
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.
|
||||||
@ -1505,8 +1234,6 @@ private:
|
|||||||
QString m_dossierTrace;
|
QString m_dossierTrace;
|
||||||
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
||||||
EnergyManager *m_em = nullptr;
|
EnergyManager *m_em = nullptr;
|
||||||
//! Exécute la décision sur la décharge de la batterie (lot e1). Possédé.
|
|
||||||
StorageDischargeGuard *m_storageGuard = nullptr;
|
|
||||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||||
/*!
|
/*!
|
||||||
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
|
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
|
||||||
@ -1569,26 +1296,6 @@ private:
|
|||||||
//! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges,
|
//! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges,
|
||||||
//! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4.
|
//! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4.
|
||||||
QHash<QString, LoadAction> m_shedActions;
|
QHash<QString, LoadAction> m_shedActions;
|
||||||
//! Journal court des délestages (lot délestage visible). AUCUNE DÉCISION N'EN DÉPEND.
|
|
||||||
SheddingLog m_journalDelestage;
|
|
||||||
//! Ce que la protection de surcharge tient d'une borne : la base du motif OVERLOAD_THROTTLED.
|
|
||||||
struct BorneTenue {
|
|
||||||
uint fromA = 0; //!< Courant avant le premier geste.
|
|
||||||
uint toA = 0; //!< Courant tenu ; 0 si coupée.
|
|
||||||
uint phases = 1; //!< Phases de la borne, pour la puissance laissée.
|
|
||||||
bool cut = false; //!< Coupée par le dernier geste.
|
|
||||||
QString phase; //!< Phase jugée ; vide pour une borne triphasée.
|
|
||||||
double overshootW = 0; //!< Plus fort dépassement de la tenue.
|
|
||||||
};
|
|
||||||
//! loadId de borne → tenue en cours. Vide dans le cas normal.
|
|
||||||
QHash<QString, BorneTenue> m_bornesTenues;
|
|
||||||
//! Le motif OVERLOAD_THROTTLED d'une tenue — construit ICI, en un seul endroit.
|
|
||||||
DecisionReason motifTenue(const BorneTenue &t) const;
|
|
||||||
//! Vrai pendant update() : un geste de protection y est publié par le cycle lui-même.
|
|
||||||
bool m_dansCycle = false;
|
|
||||||
//! Le plafond de soutirage était-il épuisé au compteur, à l'évaluation L4 du cycle ? Lu par
|
|
||||||
//! le seul journal des délestages : une charge délestée reste tenue tant qu'il l'est.
|
|
||||||
bool m_plafondEpuiseAuCycle = false;
|
|
||||||
//! loadId → action APPLIQUÉE AU CYCLE : ce que l'adaptateur a rendu au dispatch
|
//! loadId → action APPLIQUÉE AU CYCLE : ce que l'adaptateur a rendu au dispatch
|
||||||
//! (\c applyActionsToAdapters()), après écrêtage et verrous — pas ce que le plan demandait.
|
//! (\c applyActionsToAdapters()), après écrêtage et verrous — pas ce que le plan demandait.
|
||||||
//! Vidée à chaque dispatch ; les délestages L4 et le repli L2 n'y entrent pas (ils ont leur
|
//! Vidée à chaque dispatch ; les délestages L4 et le repli L2 n'y entrent pas (ils ont leur
|
||||||
@ -1622,15 +1329,7 @@ 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;
|
||||||
|
|||||||
@ -5,7 +5,6 @@ HEADERS += \
|
|||||||
$$PWD/types/surpluscontext.h \
|
$$PWD/types/surpluscontext.h \
|
||||||
$$PWD/types/plan.h \
|
$$PWD/types/plan.h \
|
||||||
$$PWD/types/drawcap.h \
|
$$PWD/types/drawcap.h \
|
||||||
$$PWD/types/sheddinglog.h \
|
|
||||||
$$PWD/types/ecoprogress.h \
|
$$PWD/types/ecoprogress.h \
|
||||||
$$PWD/types/ecotarget.h \
|
$$PWD/types/ecotarget.h \
|
||||||
$$PWD/types/loadconfig.h \
|
$$PWD/types/loadconfig.h \
|
||||||
@ -17,7 +16,6 @@ HEADERS += \
|
|||||||
$$PWD/adapters/sgreadyadapter.h \
|
$$PWD/adapters/sgreadyadapter.h \
|
||||||
$$PWD/adapters/etmvariableloadadapter.h \
|
$$PWD/adapters/etmvariableloadadapter.h \
|
||||||
$$PWD/adapters/relayrouter.h \
|
$$PWD/adapters/relayrouter.h \
|
||||||
$$PWD/adapters/storagedischargeguard.h \
|
|
||||||
$$PWD/scheduler/rulebasedscheduler.h \
|
$$PWD/scheduler/rulebasedscheduler.h \
|
||||||
$$PWD/scheduler/optimizerprotocol.h \
|
$$PWD/scheduler/optimizerprotocol.h \
|
||||||
$$PWD/scheduler/optimizerclient.h \
|
$$PWD/scheduler/optimizerclient.h \
|
||||||
@ -34,7 +32,6 @@ HEADERS += \
|
|||||||
SOURCES += \
|
SOURCES += \
|
||||||
$$PWD/types/decisionreason.cpp \
|
$$PWD/types/decisionreason.cpp \
|
||||||
$$PWD/types/drawcap.cpp \
|
$$PWD/types/drawcap.cpp \
|
||||||
$$PWD/types/sheddinglog.cpp \
|
|
||||||
$$PWD/types/ecotarget.cpp \
|
$$PWD/types/ecotarget.cpp \
|
||||||
$$PWD/types/loadconfig.cpp \
|
$$PWD/types/loadconfig.cpp \
|
||||||
$$PWD/tariff/tariffconfig.cpp \
|
$$PWD/tariff/tariffconfig.cpp \
|
||||||
@ -44,7 +41,6 @@ SOURCES += \
|
|||||||
$$PWD/adapters/sgreadyadapter.cpp \
|
$$PWD/adapters/sgreadyadapter.cpp \
|
||||||
$$PWD/adapters/etmvariableloadadapter.cpp \
|
$$PWD/adapters/etmvariableloadadapter.cpp \
|
||||||
$$PWD/adapters/relayrouter.cpp \
|
$$PWD/adapters/relayrouter.cpp \
|
||||||
$$PWD/adapters/storagedischargeguard.cpp \
|
|
||||||
$$PWD/scheduler/rulebasedscheduler.cpp \
|
$$PWD/scheduler/rulebasedscheduler.cpp \
|
||||||
$$PWD/scheduler/optimizerprotocol.cpp \
|
$$PWD/scheduler/optimizerprotocol.cpp \
|
||||||
$$PWD/scheduler/optimizerclient.cpp \
|
$$PWD/scheduler/optimizerclient.cpp \
|
||||||
|
|||||||
@ -555,9 +555,7 @@ 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};
|
||||||
|
|
||||||
// Déjà écrêtée par la garde de soutirage de l'arbitre, avant le dispatch : la demande
|
a.clamped = !applique || differe(demande, appliquees.value(a.loadId), adapter);
|
||||||
// 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")});
|
||||||
|
|||||||
@ -246,31 +246,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
continue;
|
continue;
|
||||||
nonEvLoads.append(lc);
|
nonEvLoads.append(lc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Heures creuses (lot e1) ---------------------------------------------------------
|
|
||||||
// Le minimum RÉSEAU d'une charge qui le déclare reportable (\c offPeakShift) se sert en
|
|
||||||
// heures creuses, et seulement là. Hors heures creuses il est RETIRÉ — surplus seulement — et
|
|
||||||
// la charge est notée « en attente » pour que son motif le dise. Sans heures creuses dans le
|
|
||||||
// tarif saisi, rien de ceci ne s'exécute : c'est la garantie « sans tarif = etm74 ».
|
|
||||||
m_attendCreuses.clear();
|
|
||||||
m_creusesAt.clear();
|
|
||||||
m_creusesStartsAt.clear();
|
|
||||||
if (ctx.offPeak.configured) {
|
|
||||||
if (!ctx.offPeak.active) {
|
|
||||||
m_creusesAt = ctx.offPeak.nextChangeWall;
|
|
||||||
m_creusesStartsAt = ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate);
|
|
||||||
}
|
|
||||||
for (LoadContext &lc : nonEvLoads) {
|
|
||||||
if (!lc.needs.offPeakShift)
|
|
||||||
continue;
|
|
||||||
if (ctx.offPeak.active) {
|
|
||||||
lc.needs.minPowerWAlways = qMax(lc.needs.minPowerWAlways, lc.needs.offPeakMinPowerW);
|
|
||||||
} else {
|
|
||||||
lc.needs.minPowerWAlways = 0;
|
|
||||||
m_attendCreuses.insert(lc.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang
|
// 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang
|
||||||
// laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est
|
// laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est
|
||||||
// un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à
|
// un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à
|
||||||
@ -301,9 +276,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
// donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme
|
// donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme
|
||||||
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
|
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
|
||||||
QHash<QString, LoadAction::Level> niveauxEco;
|
QHash<QString, LoadAction::Level> niveauxEco;
|
||||||
//! Lot e2, suite (écart 4) — les bornes servies à leur plafond de cycle en heures creuses. En
|
|
||||||
//! passe confort, leur surplus PREND la place de l'achat au lieu de s'y ajouter.
|
|
||||||
QSet<QString> plafondPartage;
|
|
||||||
|
|
||||||
// --- TÊTE DE PASSE ÉCO — les DÉROGATIONS --------------------------------------------------
|
// --- TÊTE DE PASSE ÉCO — les DÉROGATIONS --------------------------------------------------
|
||||||
//
|
//
|
||||||
@ -460,24 +432,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
// un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et
|
// un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et
|
||||||
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
|
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
|
||||||
double achatW = cible.watts;
|
double achatW = cible.watts;
|
||||||
|
const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts);
|
||||||
// Lot e2 — LA BORNE À PLEINE PUISSANCE EN HEURES CREUSES. Son minimum y est son plafond de
|
if (margeRestante.borne)
|
||||||
// cycle, et ce plafond porte DÉJÀ l'autorisation de soutirage : `evPhaseAllowanceW()` la
|
|
||||||
// résout sur les phases qu'elle occupe et la multiplie par leur nombre. \rule{LM-1500-h}
|
|
||||||
// s'applique donc ici comme en tête de passe et en passe confort : lui opposer en plus
|
|
||||||
// `margeRestante`, la marge d'UNE phase, diviserait une borne triphasée par trois — 7 A
|
|
||||||
// par phase au lieu de 16 quand la maison tire 4 A sur une limite de 25 A, la nuit même
|
|
||||||
// où on veut qu'elle finisse tôt.
|
|
||||||
//
|
|
||||||
// Restreint à ce cas, et c'est délibéré : sans heures creuses, le plancher éco d'une borne
|
|
||||||
// reste écrêté comme avant (engagement « sans tarif, rien ne change »). Cet écart-là reste
|
|
||||||
// à trancher (DESIGN_HC_BATTERIE §8.1) ; il n'est pas corrigé en passant.
|
|
||||||
const bool pleinePuissanceHc = ctx.offPeak.active && lc.needs.offPeakShift
|
|
||||||
&& lc.adapter == QStringLiteral("evcharger")
|
|
||||||
&& cible.borneParLeMinimum;
|
|
||||||
const bool bridePlafond = !pleinePuissanceHc && margeRestante.borne
|
|
||||||
&& achatW > qMax(0.0, margeRestante.watts);
|
|
||||||
if (!pleinePuissanceHc && margeRestante.borne)
|
|
||||||
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
|
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
|
||||||
|
|
||||||
// La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui
|
// La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui
|
||||||
@ -519,31 +475,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
}
|
}
|
||||||
eco.reason = { DecisionCode::EcoFloorGrid, motif };
|
eco.reason = { DecisionCode::EcoFloorGrid, motif };
|
||||||
|
|
||||||
// Lot e2 — le motif dit « pleine puissance », la puissance RETENUE, et ce qui la bride.
|
|
||||||
// ECO_FLOOR_GRID parlerait d'une obligation et d'un reste à livrer qu'une borne n'a pas ;
|
|
||||||
// et une borne servie sous sa pleine puissance se lirait comme une borne qui ne suit pas.
|
|
||||||
if (pleinePuissanceHc) {
|
|
||||||
// La pleine puissance du MATÉRIEL, aux phases visées : ce que la borne prendrait sans
|
|
||||||
// limite de phase. L'écart au plafond du cycle est ce que la protection a retiré.
|
|
||||||
const double materielW = lc.declared.maxA * 230.0 * qMax(1, lc.declared.phases);
|
|
||||||
QVariantMap pp{{"gridW", qRound(achatW)},
|
|
||||||
{"maxW", qRound(materielW)},
|
|
||||||
{"phases", qMax(1, lc.declared.phases)},
|
|
||||||
{"until", ctx.offPeak.nextChangeWall},
|
|
||||||
{"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}};
|
|
||||||
if (achatW < materielW - 0.5) {
|
|
||||||
// La limite de phase rabaisse la borne. Jusqu'au plancher, c'est la stratégie qui
|
|
||||||
// la tient (`phaseLimit`) ; au plancher, la stratégie ne descend plus — couper est
|
|
||||||
// le geste de L4, et le motif le dit (`floor`).
|
|
||||||
const bool auPlancher = lc.declared.minPowerW > 0
|
|
||||||
&& achatW <= lc.declared.minPowerW + 0.5;
|
|
||||||
pp.insert(QStringLiteral("boundBy"), auPlancher ? QStringLiteral("floor")
|
|
||||||
: QStringLiteral("phaseLimit"));
|
|
||||||
}
|
|
||||||
eco.reason = { DecisionCode::EvOffPeakFullPower, pp };
|
|
||||||
plafondPartage.insert(lc.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses
|
// SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses
|
||||||
// paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de
|
// paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de
|
||||||
// 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid.
|
// 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid.
|
||||||
@ -602,43 +533,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
waterfallEvIds.insert(lc.id);
|
waterfallEvIds.insert(lc.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Lot e2, suite (écart 4, décision du 2026-10-25) — LA SOMME ÉCO + CONFORT EST BORNÉE AU
|
|
||||||
// PLAFOND DE CYCLE. Le niveau éco vaut déjà ce plafond, qui porte la limite de phase ; à
|
|
||||||
// l'aube, le surplus s'y ajoutait, et seul le courant maximal de la borne écrêtait la
|
|
||||||
// somme : 13 A de plafond plus 2 540 W de surplus faisaient 16 A sous une limite de 16 A.
|
|
||||||
// Le confort est donc servi DANS le plafond, et l'achat cède ce que le surplus finance
|
|
||||||
// (plus bas, à la fusion). Le plancher ne s'applique qu'à la somme, qui vaut le plafond,
|
|
||||||
// donc jamais moins que lui : la part confort n'en a pas — sans quoi un surplus sous le
|
|
||||||
// plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier.
|
|
||||||
LoadContext lcConfort = lc;
|
|
||||||
if (plafondPartage.contains(lc.id))
|
|
||||||
lcConfort.declared.minPowerW = 0;
|
|
||||||
// Lot délestage V2C (question 5) — HORS HEURES CREUSES AUSSI, la somme éco + confort d'une
|
|
||||||
// BORNE tient dans son plafond de cycle. En « Éco avec minimum », le niveau éco (le
|
|
||||||
// plancher) s'ajoutait au confort, borné seul par le plafond : sous une limite de 25 A, une
|
|
||||||
// maison à 10 A sur A, le plafond de 12,5 A plus 6 A d'éco faisaient 16 A, la phase à 26 A,
|
|
||||||
// et L4 coupait — puis la borne repartait au cycle suivant. Le confort est donc servi dans
|
|
||||||
// ce que l'éco laisse du plafond, et le plancher porte sur la somme : la part confort n'a
|
|
||||||
// que ce qui manque à l'éco pour l'atteindre. Seule la BORNE de D9 est reprise ici ; le
|
|
||||||
// partage de l'achat (l'achat cède ce que le surplus finance) reste aux heures creuses.
|
|
||||||
else if (lc.adapter == QStringLiteral("evcharger") && niveauxEco.contains(lc.id)
|
|
||||||
&& niveauxEco.value(lc.id).targetW > 0 && lc.declared.maxPowerW > 0) {
|
|
||||||
const double ecoW = niveauxEco.value(lc.id).targetW;
|
|
||||||
lcConfort.declared.maxPowerW = qMax(0.0, lc.declared.maxPowerW - ecoW);
|
|
||||||
lcConfort.declared.minPowerW = qMax(0.0, lc.declared.minPowerW - ecoW);
|
|
||||||
}
|
|
||||||
// Même règle pour la puissance ENGAGÉE par un verrou : elle borne la SOMME éco + confort,
|
|
||||||
// qui est la consigne. Relevée en entier sur la part confort, elle s'ajoutait à un achat
|
|
||||||
// éco qui la couvrait déjà : une borne tenue à 6 A par son verrou d'arrêt, servie à 6 A
|
|
||||||
// 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
|
||||||
slot.actions.append(emitSetpointAction(
|
slot.actions.append(emitSetpointAction(
|
||||||
decideSetpoint(lcConfort, remainingSurplusW, margeRestante, compta, draw)));
|
decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw)));
|
||||||
// etmvariableload + relay-router + evcharger
|
// etmvariableload + relay-router + evcharger
|
||||||
|
|
||||||
// Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles
|
// Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles
|
||||||
@ -650,19 +549,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
// court-cycling fabriqué par l'arbitre lui-même.
|
// court-cycling fabriqué par l'arbitre lui-même.
|
||||||
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
||||||
LoadAction &la = slot.actions.last();
|
LoadAction &la = slot.actions.last();
|
||||||
LoadAction::Level eco = niveauxEco.take(lc.id);
|
const LoadAction::Level eco = niveauxEco.take(lc.id);
|
||||||
const double confortW = la.estimatedPowerW; // la part confort, avant l'addition
|
const double confortW = la.estimatedPowerW; // la part confort, avant l'addition
|
||||||
if (plafondPartage.contains(lc.id)) {
|
|
||||||
// L'ACHAT CÈDE CE QUE LE SURPLUS FINANCE : la somme reste le plafond, et l'achat
|
|
||||||
// publié (`gridW`, `drawCommittedW`, `draw.committedW`) est ce qui reste à acheter
|
|
||||||
// — jamais plus que le compteur n'importe. La marge de soutirage n'est pas rendue :
|
|
||||||
// la ligne qui suit la fusion décompte la consigne entière, prudence inchangée.
|
|
||||||
const double financeW = qBound(0.0, confortW, eco.targetW);
|
|
||||||
eco.targetW -= financeW;
|
|
||||||
eco.counts[CountKey::DrawCommitted] = eco.targetW;
|
|
||||||
eco.reason.params.insert(QStringLiteral("gridW"), qRound(eco.targetW));
|
|
||||||
draw.committedW -= financeW;
|
|
||||||
}
|
|
||||||
la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
|
la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
|
||||||
la.powerW += eco.targetW;
|
la.powerW += eco.targetW;
|
||||||
la.estimatedPowerW += eco.targetW;
|
la.estimatedPowerW += eco.targetW;
|
||||||
@ -689,39 +577,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
|
|
||||||
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
|
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
|
||||||
|
|
||||||
// ---- La DÉCHARGE de la batterie en heures creuses (lot e1) -----------------------------
|
|
||||||
// En heures creuses, pendant qu'une voiture tire RÉELLEMENT de la puissance, la batterie ne
|
|
||||||
// se décharge pas dans la voiture. Rien d'autre : sa charge, et sa charge sur le réseau, ne
|
|
||||||
// sont jamais touchées. La décision ne lit ni le budget ni la réserve, et elle n'y verse
|
|
||||||
// rien : bloquer la décharge augmente le SOUTIRAGE, mesuré au cycle suivant par le compteur,
|
|
||||||
// et ne fabrique aucun surplus (invariant 8). Hors heures creuses, sans voiture, la batterie
|
|
||||||
// est rendue à son automatisme — et le garde lève ce qu'il tenait.
|
|
||||||
m_storage = StorageDecision();
|
|
||||||
if (ctx.offPeak.configured && ctx.battery.present) {
|
|
||||||
m_storage.evaluated = true;
|
|
||||||
QString cause;
|
|
||||||
bool pilotable = false;
|
|
||||||
for (const SurplusContext::StorageControl &c : ctx.storageControl) {
|
|
||||||
if (c.controllable)
|
|
||||||
pilotable = true;
|
|
||||||
else if (cause.isEmpty())
|
|
||||||
cause = c.cause;
|
|
||||||
}
|
|
||||||
if (!ctx.offPeak.active) {
|
|
||||||
m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("peak")}} };
|
|
||||||
} else if (ctx.evMeasuredW < EvChargingThresholdW) {
|
|
||||||
m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("evNotCharging")}} };
|
|
||||||
} else if (!pilotable) {
|
|
||||||
m_storage.reason = { DecisionCode::StorageNotProtected, {{"cause", cause}} };
|
|
||||||
} else {
|
|
||||||
m_storage.block = true;
|
|
||||||
m_storage.reason = { DecisionCode::StorageDischargeBlocked,
|
|
||||||
{{"evPowerW", qRound(ctx.evMeasuredW)},
|
|
||||||
{"until", ctx.offPeak.nextChangeWall},
|
|
||||||
{"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Plan plan;
|
Plan plan;
|
||||||
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
plan.strategy = QStringLiteral("rules");
|
plan.strategy = QStringLiteral("rules");
|
||||||
@ -788,14 +643,6 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
|
|||||||
return { DecisionCode::SurplusSetpoint,
|
return { DecisionCode::SurplusSetpoint,
|
||||||
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
|
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
|
||||||
{"stepped", !e.dynamic}} };
|
{"stepped", !e.dynamic}} };
|
||||||
else if (e.attendCreuses && !e.capEpuise && !(e.refuse && e.retenuW > 0))
|
|
||||||
// Lot e1 — la consigne est nulle parce que le minimum réseau est REPORTÉ aux heures
|
|
||||||
// creuses, pas parce que le surplus manque : sans le tarif, le minimum aurait été servi.
|
|
||||||
// Le plafond de soutirage et le refus gardent la tête : ils diraient la même chose en
|
|
||||||
// heures creuses, et ce sont eux qu'il faudrait lire.
|
|
||||||
return { DecisionCode::EvWaitingOffPeak,
|
|
||||||
{{"at", m_creusesAt}, {"startsAt", m_creusesStartsAt},
|
|
||||||
{"budgetW", qRound(e.budgetW)}} };
|
|
||||||
else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0)
|
else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0)
|
||||||
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
|
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
|
||||||
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
|
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
|
||||||
@ -950,10 +797,7 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
|
|||||||
// suivante trouve un budget nul et repart sur son motif ordinaire ;
|
// suivante trouve un budget nul et repart sur son motif ordinaire ;
|
||||||
// · il reste sous le plafond de la protection de surcharge, contrôlé plus haut.
|
// · il reste sous le plafond de la protection de surcharge, contrôlé plus haut.
|
||||||
const double tolerance = m_arbitrator->acquisitionTolerance();
|
const double tolerance = m_arbitrator->acquisitionTolerance();
|
||||||
// Lot e1 — hors heures creuses, une borne qui attend les heures creuses n'est servie
|
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) {
|
||||||
// qu'au SURPLUS : démarrer en soutirant serait le minimum réseau par une autre porte.
|
|
||||||
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance
|
|
||||||
&& !m_attendCreuses.contains(lc.id)) {
|
|
||||||
const double soutireW = plancherW - budgetW;
|
const double soutireW = plancherW - budgetW;
|
||||||
|
|
||||||
SetpointTarget t;
|
SetpointTarget t;
|
||||||
@ -1143,7 +987,6 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
|
|||||||
t.entrees.refuse = refuse;
|
t.entrees.refuse = refuse;
|
||||||
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
|
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
|
||||||
t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0;
|
t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0;
|
||||||
t.entrees.attendCreuses = m_attendCreuses.contains(lc.id);
|
|
||||||
|
|
||||||
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
|
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
|
||||||
remainingSurplusW = budgetW - setpointW;
|
remainingSurplusW = budgetW - setpointW;
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QSet>
|
|
||||||
#include "ischeduler.h"
|
#include "ischeduler.h"
|
||||||
|
|
||||||
class EvCharger;
|
class EvCharger;
|
||||||
@ -62,18 +61,6 @@ public:
|
|||||||
*/
|
*/
|
||||||
Plan getPlan(const SurplusContext &ctx) override;
|
Plan getPlan(const SurplusContext &ctx) override;
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief La décision du dernier \c getPlan() sur la décharge de la batterie (lot e1).
|
|
||||||
* \return \c evaluated faux sans heures creuses ou sans batterie.
|
|
||||||
* \note Un état du rule-based, comme la réserve : il est calculé à CHAQUE cycle, y compris en
|
|
||||||
* ombre d'un optimiseur — c'est donc lui qui commande la batterie dans les deux stratégies.
|
|
||||||
*/
|
|
||||||
StorageDecision storageDecision() const { return m_storage; }
|
|
||||||
|
|
||||||
//! Puissance mesurée des bornes (W) au-delà de laquelle « une voiture charge » : un ampère
|
|
||||||
//! sur une phase. Sous le plancher de 6 A d'une borne, au-dessus du bruit d'un compteur.
|
|
||||||
static constexpr double EvChargingThresholdW = 230.0;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/*!
|
/*!
|
||||||
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
|
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
|
||||||
@ -132,8 +119,6 @@ private:
|
|||||||
bool refuse = false;
|
bool refuse = false;
|
||||||
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
|
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
|
||||||
double retenuW = 0; //!< Watts NON rendus au budget du fait du refus.
|
double retenuW = 0; //!< Watts NON rendus au budget du fait du refus.
|
||||||
//! Lot e1 — le minimum réseau de la charge est REPORTÉ aux heures creuses ce cycle.
|
|
||||||
bool attendCreuses = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
@ -231,13 +216,4 @@ private:
|
|||||||
//! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le
|
//! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le
|
||||||
//! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0.
|
//! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0.
|
||||||
double m_reserveWithheldW = 0;
|
double m_reserveWithheldW = 0;
|
||||||
|
|
||||||
// --- Heures creuses : retenu d'un cycle pour le MOTIF (lot e1) -----------------------
|
|
||||||
//! Charges dont le minimum réseau est reporté aux heures creuses CE cycle (hors heures creuses).
|
|
||||||
QSet<QString> m_attendCreuses;
|
|
||||||
QString m_creusesAt; //!< Début des heures creuses, heure murale \c "HH:MM".
|
|
||||||
QString m_creusesStartsAt; //!< Le même début, instant ISO UTC.
|
|
||||||
|
|
||||||
//! Décision du dernier cycle sur la décharge de la batterie (lot e1).
|
|
||||||
StorageDecision m_storage;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -205,48 +205,6 @@ int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) cons
|
|||||||
return m_parMinute.at(murale.hour() * 60 + murale.minute());
|
return m_parMinute.at(murale.hour() * 60 + murale.minute());
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<int> TariffConfig::zonesCreuses() const
|
|
||||||
{
|
|
||||||
QList<int> creuses;
|
|
||||||
if (m_zones.count() < 2)
|
|
||||||
return creuses; // aucun tarif, ou tarif unique
|
|
||||||
double bas = m_zones.first().priceCtkWh;
|
|
||||||
double haut = bas;
|
|
||||||
for (const TariffZone &z : m_zones) {
|
|
||||||
bas = qMin(bas, z.priceCtkWh);
|
|
||||||
haut = qMax(haut, z.priceCtkWh);
|
|
||||||
}
|
|
||||||
if (!(bas < haut))
|
|
||||||
return creuses; // tous au même prix : aucune heure n'est meilleure
|
|
||||||
for (int i = 0; i < m_zones.count(); ++i)
|
|
||||||
if (m_zones.at(i).priceCtkWh == bas)
|
|
||||||
creuses << i;
|
|
||||||
return creuses;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool TariffConfig::enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const
|
|
||||||
{
|
|
||||||
const QList<int> creuses = zonesCreuses();
|
|
||||||
return !creuses.isEmpty() && creuses.contains(zoneAt(instant, fuseau));
|
|
||||||
}
|
|
||||||
|
|
||||||
QDateTime TariffConfig::prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const
|
|
||||||
{
|
|
||||||
const QList<int> creuses = zonesCreuses();
|
|
||||||
if (creuses.isEmpty() || !instant.isValid())
|
|
||||||
return {};
|
|
||||||
const bool depart = creuses.contains(zoneAt(instant, fuseau));
|
|
||||||
// Minute ABSOLUE suivante, puis pas d'une minute : chaque minute prend la zone de son heure
|
|
||||||
// murale (même parcours qu'importSeries()).
|
|
||||||
const qint64 debut = (instant.toSecsSinceEpoch() / 60 + 1) * 60;
|
|
||||||
for (qint64 a = debut; a <= debut + 50 * 3600; a += 60) {
|
|
||||||
const QDateTime t = QDateTime::fromSecsSinceEpoch(a, QTimeZone::utc());
|
|
||||||
if (creuses.contains(zoneAt(t, fuseau)) != depart)
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
QList<double> TariffConfig::importSeries(const QDateTime &start, int stepS, int steps,
|
QList<double> TariffConfig::importSeries(const QDateTime &start, int stepS, int steps,
|
||||||
const QTimeZone &fuseau) const
|
const QTimeZone &fuseau) const
|
||||||
{
|
{
|
||||||
|
|||||||
@ -21,12 +21,10 @@
|
|||||||
* instants absolus. Le même réglage donne donc 23 ou 25 créneaux horaires selon le jour —
|
* instants absolus. Le même réglage donne donc 23 ou 25 créneaux horaires selon le jour —
|
||||||
* c'est voulu (AGENTS.md, « un RÉGLAGE … un INSTANT »).
|
* c'est voulu (AGENTS.md, « un RÉGLAGE … un INSTANT »).
|
||||||
*
|
*
|
||||||
* \par Une seule décision le lit : les HEURES CREUSES (lot e1, 2026-10-24)
|
* \par Aucune décision ne le lit (§11)
|
||||||
* Ce type est EXPOSÉ (\c GetTariff, \c GetForecastSnapshot), jamais versé dans
|
* Ce type est EXPOSÉ (\c GetTariff, \c GetForecastSnapshot), jamais versé dans
|
||||||
* \c SurplusContext::tariff — l'optimiseur ne le reçoit pas par là. Le moteur n'en tire
|
* \c SurplusContext::tariff. Son usage par une décision (rattrapage éco en HC, e1) est un lot
|
||||||
* qu'une chose : quelles minutes sont « creuses » (\c zonesCreuses(), \c enHeuresCreuses()),
|
* séparé ; \c zoneAt() existe pour lui, et rien ne l'appelle aujourd'hui depuis le plan.
|
||||||
* et rien d'autre ne lit le prix. Sans tarif ou avec un tarif unique, il n'y a pas d'heure
|
|
||||||
* creuse, et le moteur se comporte exactement comme avant ce lot.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
//! \brief Une plage horaire locale `[from, to)`, en minutes depuis minuit ; `from > to` passe minuit.
|
//! \brief Une plage horaire locale `[from, to)`, en minutes depuis minuit ; `from > to` passe minuit.
|
||||||
@ -108,47 +106,11 @@ public:
|
|||||||
* \param instant Instant absolu.
|
* \param instant Instant absolu.
|
||||||
* \param fuseau Fuseau de la box.
|
* \param fuseau Fuseau de la box.
|
||||||
* \return Index dans \c zones() ; -1 si aucun tarif.
|
* \return Index dans \c zones() ; -1 si aucun tarif.
|
||||||
|
* \note Réservé au lot qui branchera une décision dessus (e1) — aucun appelant aujourd'hui
|
||||||
|
* hors des tests et de \c importSeries().
|
||||||
*/
|
*/
|
||||||
int zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const;
|
int zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const;
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Les zones « creuses » : celles au prix LE PLUS BAS d'un tarif à zones horaires.
|
|
||||||
* \return Index dans \c zones(), croissants ; VIDE sans tarif, avec un tarif unique, ou quand
|
|
||||||
* toutes les zones ont le même prix.
|
|
||||||
*
|
|
||||||
* \par Pourquoi le prix, et pas le libellé
|
|
||||||
* « HC » n'a pas de code : le libellé est une donnée de l'installateur, qui peut écrire
|
|
||||||
* « Creuses », « Nuit » ou « Off-peak ». Le prix, lui, se compare. Plusieurs zones au même
|
|
||||||
* prix minimal sont TOUTES creuses — deux fenêtres saisies comme deux zones (nuit et début
|
|
||||||
* d'après-midi) ne changent pas de nature parce qu'on les a nommées séparément.
|
|
||||||
* \par Pourquoi « des prix distincts » est exigé
|
|
||||||
* Un tarif dont toutes les zones coûtent le même prix n'a pas d'heure meilleure qu'une
|
|
||||||
* autre : le dire « creux » partout reviendrait à servir le minimum réseau à toute heure,
|
|
||||||
* c'est-à-dire à changer le comportement d'un tarif qui, de fait, est unique.
|
|
||||||
*/
|
|
||||||
QList<int> zonesCreuses() const;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief L'instant tombe-t-il en heures creuses ?
|
|
||||||
* \param instant Instant absolu. \param fuseau Fuseau de la box.
|
|
||||||
* \return Vrai si la zone en vigueur est dans \c zonesCreuses() ; faux sans heure creuse.
|
|
||||||
*/
|
|
||||||
bool enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const;
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief La prochaine BASCULE creuse ↔ pleine après \p instant.
|
|
||||||
* \param instant Instant absolu. \param fuseau Fuseau de la box.
|
|
||||||
* \return Premier instant (UTC, à la minute) où \c enHeuresCreuses() change de valeur ;
|
|
||||||
* invalide sans heure creuse.
|
|
||||||
* \par Calculée minute ABSOLUE par minute
|
|
||||||
* Comme \c importSeries() : chaque minute prend la zone de son heure murale. Un jour de
|
|
||||||
* 23 h saute l'heure qui n'existe pas, un jour de 25 h parcourt deux fois l'heure répétée —
|
|
||||||
* jamais de soustraction de dates, qui rendrait la durée d'horloge murale (AGENTS.md,
|
|
||||||
* « l'arithmétique de dates »). Recherche bornée à 50 h : toute zone saisie revient chaque
|
|
||||||
* jour, donc une bascule existe toujours dans cette fenêtre quand il y a des heures creuses.
|
|
||||||
*/
|
|
||||||
QDateTime prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const;
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Le prix d'import sur une grille d'instants absolus.
|
* \brief Le prix d'import sur une grille d'instants absolus.
|
||||||
* \param start Début du premier créneau.
|
* \param start Début du premier créneau.
|
||||||
|
|||||||
@ -123,29 +123,6 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
"%3 n'est pas servie")
|
"%3 n'est pas servie")
|
||||||
.arg(ou).arg(origine).arg(label);
|
.arg(ou).arg(origine).arg(label);
|
||||||
}
|
}
|
||||||
// La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne
|
|
||||||
// reçoit, puis pourquoi : le branchement, la phase, sa limite.
|
|
||||||
if (c == QLatin1String(DecisionCode::OverloadThrottled)) {
|
|
||||||
// Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite :
|
|
||||||
// le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu.
|
|
||||||
const QString ou = p.contains(QStringLiteral("phase"))
|
|
||||||
? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString())
|
|
||||||
: QString();
|
|
||||||
const QString pourquoi = p.contains(QStringLiteral("thresholdA"))
|
|
||||||
? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)")
|
|
||||||
.arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble()))
|
|
||||||
.arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString())
|
|
||||||
: p.contains(QStringLiteral("phase"))
|
|
||||||
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
|
|
||||||
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
|
|
||||||
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));
|
|
||||||
const QString qui = label.isEmpty() ? QStringLiteral("Borne") : label;
|
|
||||||
if (p.value(QStringLiteral("cut")).toBool())
|
|
||||||
return QStringLiteral("%1 coupée : %2, dépassement de %3 W")
|
|
||||||
.arg(qui, pourquoi).arg(w("overshootW"));
|
|
||||||
return QStringLiteral("%1 abaissée à %2 A : %3, dépassement de %4 W")
|
|
||||||
.arg(qui).arg(w("toA")).arg(pourquoi).arg(w("overshootW"));
|
|
||||||
}
|
|
||||||
// La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable
|
// La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable
|
||||||
// là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
|
// là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
|
||||||
auto delaiEco = [&p]() {
|
auto delaiEco = [&p]() {
|
||||||
@ -351,55 +328,6 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 servie sur ce créneau").arg(prix, label);
|
return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 servie sur ce créneau").arg(prix, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Heures creuses et stockage (lot e1) --------------------------------
|
|
||||||
if (c == QLatin1String(DecisionCode::EvWaitingOffPeak))
|
|
||||||
return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus "
|
|
||||||
"seulement (%3 W)")
|
|
||||||
.arg(label, p.value(QStringLiteral("at")).toString()).arg(w("budgetW"));
|
|
||||||
// Lot e2 — la puissance RETENUE d'abord ; et si elle n'est pas la pleine puissance, QUI l'a
|
|
||||||
// retirée. Sans la seconde phrase, une borne bridée se lirait comme une borne qui ne suit pas.
|
|
||||||
if (c == QLatin1String(DecisionCode::EvOffPeakFullPower)) {
|
|
||||||
const QString jusqua = p.value(QStringLiteral("until")).toString();
|
|
||||||
const QString bride = p.value(QStringLiteral("boundBy")).toString();
|
|
||||||
if (bride == QLatin1String("floor"))
|
|
||||||
return QStringLiteral("%1 charge en heures creuses, tenue à son plancher (%2 W) par la "
|
|
||||||
"protection de surcharge du branchement : la limite de phase "
|
|
||||||
"est atteinte, la protection décide de la suite (pleine "
|
|
||||||
"puissance : %3 W, jusqu'à %4)")
|
|
||||||
.arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua);
|
|
||||||
if (bride == QLatin1String("phaseLimit"))
|
|
||||||
return QStringLiteral("%1 charge en heures creuses à %2 W, bridée par la protection de "
|
|
||||||
"surcharge du branchement : la limite de phase ne laisse pas "
|
|
||||||
"plus (pleine puissance : %3 W, jusqu'à %4)")
|
|
||||||
.arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua);
|
|
||||||
return QStringLiteral("%1 charge à pleine puissance en heures creuses : %2 W achetés au "
|
|
||||||
"réseau (%3 phase%4, jusqu'à %5)")
|
|
||||||
.arg(label).arg(w("gridW")).arg(w("phases"))
|
|
||||||
.arg(w("phases") > 1 ? QStringLiteral("s") : QString()).arg(jusqua);
|
|
||||||
}
|
|
||||||
if (c == QLatin1String(DecisionCode::StorageDischargeBlocked))
|
|
||||||
return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures "
|
|
||||||
"creuses (%1 W, jusqu'à %2)")
|
|
||||||
.arg(w("evPowerW")).arg(p.value(QStringLiteral("until")).toString());
|
|
||||||
if (c == QLatin1String(DecisionCode::StorageAutomatic)) {
|
|
||||||
const QString cause = p.value(QStringLiteral("cause")).toString();
|
|
||||||
QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause);
|
|
||||||
if (cause == QLatin1String("peak")) pourquoi = QStringLiteral("hors heures creuses");
|
|
||||||
else if (cause == QLatin1String("evNotCharging")) pourquoi = QStringLiteral("aucune voiture ne charge");
|
|
||||||
else if (cause == QLatin1String("degraded")) pourquoi = QStringLiteral("mode dégradé L2");
|
|
||||||
return QStringLiteral("Batterie en automatique — %1").arg(pourquoi);
|
|
||||||
}
|
|
||||||
if (c == QLatin1String(DecisionCode::StorageNotProtected)) {
|
|
||||||
const QString cause = p.value(QStringLiteral("cause")).toString();
|
|
||||||
QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause);
|
|
||||||
if (cause == QLatin1String("noDischargeControl"))
|
|
||||||
pourquoi = QStringLiteral("elle n'expose pas de limite de décharge pilotable");
|
|
||||||
else if (cause == QLatin1String("noRevertTimer"))
|
|
||||||
pourquoi = QStringLiteral("elle n'a pas de minuteur de retour, le moteur ne la commande pas");
|
|
||||||
return QStringLiteral("Batterie non protégée : la voiture charge en heures creuses, mais %1")
|
|
||||||
.arg(pourquoi);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sécurité et cycle de vie ------------------------------------------
|
// --- Sécurité et cycle de vie ------------------------------------------
|
||||||
if (c == QLatin1String(DecisionCode::DegradedL2))
|
if (c == QLatin1String(DecisionCode::DegradedL2))
|
||||||
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
||||||
@ -456,14 +384,13 @@ QStringList DecisionCode::knownCodes()
|
|||||||
return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle,
|
return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle,
|
||||||
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
|
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
|
||||||
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
|
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
|
||||||
OverloadThrottled, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
||||||
DerogationActive,
|
DerogationActive,
|
||||||
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
||||||
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
||||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||||
OptimizerDecision, PriceSlot,
|
OptimizerDecision, PriceSlot,
|
||||||
EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic,
|
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||||
StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList DecisionCode::optimizerEmittableCodes()
|
QStringList DecisionCode::optimizerEmittableCodes()
|
||||||
|
|||||||
@ -176,31 +176,6 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT";
|
|||||||
*/
|
*/
|
||||||
constexpr const char *DrawCap = "DRAW_CAP";
|
constexpr const char *DrawCap = "DRAW_CAP";
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief La PROTECTION DE SURCHARGE de l'amont tient la borne : abaissée ou coupée.
|
|
||||||
*
|
|
||||||
* params : \c fromA (courant avant le premier geste), \c toA (courant tenu, 0 si coupée),
|
|
||||||
* \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue),
|
|
||||||
* \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque
|
|
||||||
* \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur
|
|
||||||
* la phase la plus chargée, sans qu'une phase soit à elle seule coupable), \c thresholdA (le seuil
|
|
||||||
* où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent**
|
|
||||||
* quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui).
|
|
||||||
*
|
|
||||||
* \par Ce qu'il publie, et ce qu'il ne décide pas
|
|
||||||
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi
|
|
||||||
* à l'ampère supérieur, et la coupe sous son plancher. Ce geste ne laissait ni motif, ni
|
|
||||||
* télémétrie. Ce code le PUBLIE, après coup : la protection reste celle de l'amont, à l'identique.
|
|
||||||
*
|
|
||||||
* \par Quand il tient, quand il se lève
|
|
||||||
* Il tient tant que la dernière commande appliquée à la borne vient de la protection — la baisse,
|
|
||||||
* la coupure, et la remise au courant minimal par \c verifyOverloadProtectionRecovery(). Il se lève
|
|
||||||
* quand le cycle commande à nouveau la borne, ou quand la reprise lui rend son courant manuel.
|
|
||||||
*
|
|
||||||
* \note Préséance : celle du délestage L4 — l'action entière est remplacée, sans niveaux.
|
|
||||||
*/
|
|
||||||
constexpr const char *OverloadThrottled = "OVERLOAD_THROTTLED";
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief L'obligation éco est servie en ACHETANT au réseau.
|
* \brief L'obligation éco est servie en ACHETANT au réseau.
|
||||||
*
|
*
|
||||||
@ -453,70 +428,6 @@ constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION";
|
|||||||
*/
|
*/
|
||||||
constexpr const char *PriceSlot = "PRICE_SLOT";
|
constexpr const char *PriceSlot = "PRICE_SLOT";
|
||||||
|
|
||||||
// --- Heures creuses et stockage (lot e1) -----------------------------------
|
|
||||||
/*!
|
|
||||||
* \brief params: \c at, \c startsAt, \c budgetW — la borne ATTEND LES HEURES CREUSES.
|
|
||||||
*
|
|
||||||
* Hors heures creuses, une borne en mode « avec minimum » ou « avec échéance » n'est servie
|
|
||||||
* qu'au surplus : son minimum réseau est REPORTÉ aux heures creuses. Ce motif remplace alors
|
|
||||||
* \c SURPLUS_INSUFFICIENT, \c BELOW_MIN_POWER et \c BATTERY_RESERVE, parce que la cause de la
|
|
||||||
* consigne nulle n'est plus le manque de surplus : sans le tarif, le minimum aurait été servi.
|
|
||||||
*
|
|
||||||
* \c at est l'heure MURALE de la box (\c "HH:MM", le réglage tel qu'il a été saisi),
|
|
||||||
* \c startsAt le même début en INSTANT (ISO UTC) — un réglage et un instant ne se rendent pas
|
|
||||||
* au même endroit (AGENTS.md). \c budgetW est le budget de surplus vu par la borne.
|
|
||||||
*
|
|
||||||
* \note N'est émis QUE si le tarif saisi a des heures creuses. Un motif de limite (phase,
|
|
||||||
* plafond de soutirage, verrou, indisponibilité) garde la tête : il dit ce qui empêcherait
|
|
||||||
* la borne de charger même en heures creuses.
|
|
||||||
*/
|
|
||||||
constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK";
|
|
||||||
/*!
|
|
||||||
* \brief params: \c gridW, \c maxW, \c phases, \c until, \c endsAt, \c o:boundBy — la borne
|
|
||||||
* charge à PLEINE PUISSANCE en heures creuses (lot e2).
|
|
||||||
*
|
|
||||||
* Dans les mêmes trois modes que \c EV_WAITING_OFF_PEAK, le minimum servi au réseau en heures
|
|
||||||
* creuses est le plafond du cycle de la borne. \c gridW est la puissance RETENUE, achetée ;
|
|
||||||
* \c maxW la pleine puissance du matériel aux \c phases visées (courant maximal × 230 V ×
|
|
||||||
* phases). \c until (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures
|
|
||||||
* creuses finissent.
|
|
||||||
*
|
|
||||||
* \c boundBy n'est présent que si \c gridW < \c maxW : la protection de surcharge du
|
|
||||||
* branchement a rabaissé la borne. \c phaseLimit — à ce que la limite de phase laisse ;
|
|
||||||
* \c floor — au plancher de la borne, que la stratégie ne franchit jamais : couper revient
|
|
||||||
* alors à la protection de surcharge (L4), qui garde le dernier mot.
|
|
||||||
*
|
|
||||||
* \note N'est émis QUE si le tarif saisi a des heures creuses. Aucun optimiseur ne l'émet.
|
|
||||||
*/
|
|
||||||
constexpr const char *EvOffPeakFullPower = "EV_OFF_PEAK_FULL_POWER";
|
|
||||||
/*!
|
|
||||||
* \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE.
|
|
||||||
*
|
|
||||||
* En heures creuses, pendant qu'une voiture tire réellement de la puissance (\c evPowerW,
|
|
||||||
* mesurée), la batterie ne se décharge pas dans la voiture : le moteur active sa limite de
|
|
||||||
* décharge à 0 %. Il ne touche jamais à sa charge, ni à la charge sur le réseau. \c until
|
|
||||||
* (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures creuses finissent —
|
|
||||||
* au plus tard, la batterie retrouve alors son automatisme.
|
|
||||||
*/
|
|
||||||
constexpr const char *StorageDischargeBlocked = "STORAGE_DISCHARGE_BLOCKED";
|
|
||||||
/*!
|
|
||||||
* \brief params: \c cause — la batterie est rendue à son AUTOMATISME.
|
|
||||||
*
|
|
||||||
* \c cause ∈ \c peak (hors heures creuses), \c evNotCharging (aucune voiture ne tire de
|
|
||||||
* puissance), \c degraded (mode dégradé L2 : le moteur ne commande plus rien). Le moteur ne
|
|
||||||
* pose alors aucune consigne ; s'il en tenait une, il l'a LEVÉE explicitement.
|
|
||||||
*/
|
|
||||||
constexpr const char *StorageAutomatic = "STORAGE_AUTOMATIC";
|
|
||||||
/*!
|
|
||||||
* \brief params: \c cause — la décharge AURAIT dû être bloquée, et la batterie ne le permet pas.
|
|
||||||
*
|
|
||||||
* \c cause ∈ \c noDischargeControl (pas d'états \c enableDischarging et \c dischargingRate
|
|
||||||
* modifiables), \c noRevertTimer (aucun minuteur de retour : un moteur arrêté laisserait la
|
|
||||||
* batterie bloquée sans limite de temps, et le moteur refuse donc de la commander). La batterie
|
|
||||||
* est lue comme avant le lot, et se décharge dans la voiture.
|
|
||||||
*/
|
|
||||||
constexpr const char *StorageNotProtected = "STORAGE_NOT_PROTECTED";
|
|
||||||
|
|
||||||
// --- Sécurité et cycle de vie ----------------------------------------------
|
// --- Sécurité et cycle de vie ----------------------------------------------
|
||||||
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
|
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
|
||||||
constexpr const char *DegradedL2 = "DEGRADED_L2";
|
constexpr const char *DegradedL2 = "DEGRADED_L2";
|
||||||
|
|||||||
@ -85,24 +85,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -111,30 +111,6 @@ 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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -109,22 +109,6 @@ struct LoadNeeds {
|
|||||||
* L'écrire en configuration créerait une copie qui se périme au premier recâblage.
|
* L'écrire en configuration créerait une copie qui se périme au premier recâblage.
|
||||||
*/
|
*/
|
||||||
double minPowerWAlways = 0;
|
double minPowerWAlways = 0;
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Le besoin RÉSEAU de cette charge se reporte-t-il en heures creuses ? (lot e1)
|
|
||||||
*
|
|
||||||
* Déclaré par l'adaptateur depuis le mode de l'utilisateur, comme \c minPowerWAlways ; c'est
|
|
||||||
* le SCHEDULER qui en fait une décision, et seulement si le tarif saisi a des heures creuses.
|
|
||||||
* Sans heures creuses, ce champ n'a aucun effet.
|
|
||||||
*
|
|
||||||
* Avec : hors heures creuses, \c minPowerWAlways est retiré (surplus seulement) ; en heures
|
|
||||||
* creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur.
|
|
||||||
*/
|
|
||||||
bool offPeakShift = false;
|
|
||||||
//! Minimum servi en heures creuses quand \c offPeakShift est vrai (W), dérivé comme
|
|
||||||
//! \c minPowerWAlways. 0 = aucun. Pour une borne, c'est son PLAFOND de cycle depuis le lot e2
|
|
||||||
//! (le plancher au lot e1) : elle y charge à pleine puissance, limite de phase comprise.
|
|
||||||
double offPeakMinPowerW = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
|
|||||||
@ -148,21 +148,6 @@ struct PlanDraw {
|
|||||||
bool perPhaseBound = false;
|
bool perPhaseBound = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief La décision du cycle sur la DÉCHARGE de la batterie (lot e1) — interne au moteur.
|
|
||||||
*
|
|
||||||
* Prise par le rule-based à chaque cycle (y compris en ombre d'un optimiseur), exécutée par
|
|
||||||
* \c StorageDischargeGuard. Jamais sérialisée vers l'optimiseur.
|
|
||||||
*
|
|
||||||
* \invariant \c evaluated faux ⇔ il n'y a pas d'heure creuse dans le tarif saisi, ou aucune
|
|
||||||
* batterie : rien n'est publié, rien n'est écrit (sauf la levée d'une consigne encore tenue).
|
|
||||||
*/
|
|
||||||
struct StorageDecision {
|
|
||||||
bool evaluated = false; //!< La question s'est posée ce cycle.
|
|
||||||
bool block = false; //!< Bloquer la décharge des batteries pilotables.
|
|
||||||
DecisionReason reason; //!< Pourquoi — non vide quand \c evaluated.
|
|
||||||
};
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
|
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,184 +0,0 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
||||||
|
|
||||||
#include "sheddinglog.h"
|
|
||||||
|
|
||||||
QVariantMap SheddingState::toMap() const
|
|
||||||
{
|
|
||||||
return QVariantMap{{QStringLiteral("powerW"), qRound(powerW)},
|
|
||||||
{QStringLiteral("mechanism"), mechanism}};
|
|
||||||
}
|
|
||||||
|
|
||||||
QVariantMap SheddingEvent::toMap() const
|
|
||||||
{
|
|
||||||
QVariantMap m{{QStringLiteral("id"), id},
|
|
||||||
{QStringLiteral("loadId"), loadId},
|
|
||||||
{QStringLiteral("startedAt"), startedAt.toUTC().toString(Qt::ISODate)},
|
|
||||||
{QStringLiteral("source"), source},
|
|
||||||
{QStringLiteral("overshootW"), qRound(overshootW)},
|
|
||||||
{QStringLiteral("cut"), cut},
|
|
||||||
{QStringLiteral("before"), before.toMap()},
|
|
||||||
{QStringLiteral("after"), after.toMap()}};
|
|
||||||
// Absents quand ils n'existent pas : un épisode en cours n'a pas de fin, et un plafond total
|
|
||||||
// n'a pas de phase coupable. Une valeur par défaut dirait le contraire.
|
|
||||||
if (endedAt.isValid())
|
|
||||||
m.insert(QStringLiteral("endedAt"), endedAt.toUTC().toString(Qt::ISODate));
|
|
||||||
if (!phase.isEmpty())
|
|
||||||
m.insert(QStringLiteral("phase"), phase);
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
SheddingLog::SheddingLog(int capacity)
|
|
||||||
: m_capacity(qMax(1, capacity))
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
SheddingEvent *SheddingLog::trouver(int id)
|
|
||||||
{
|
|
||||||
for (SheddingEvent &e : m_evenements)
|
|
||||||
if (e.id == id)
|
|
||||||
return &e;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SheddingLog::evincer()
|
|
||||||
{
|
|
||||||
while (m_evenements.size() > m_capacity) {
|
|
||||||
// Le plus ancien épisode CLOS d'abord : un épisode en cours est ce que l'écran affiche
|
|
||||||
// en ce moment, l'évincer effacerait l'alerte pendant qu'elle a lieu.
|
|
||||||
int victime = 0;
|
|
||||||
for (int i = 0; i < m_evenements.size(); ++i) {
|
|
||||||
if (m_evenements.at(i).endedAt.isValid()) {
|
|
||||||
victime = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Le suivi n'est retiré que si l'évincé est en cours : un épisode CLOS de la même charge
|
|
||||||
// n'a plus de suivi, et retirer par loadId emporterait celui de l'épisode qui s'ouvre.
|
|
||||||
const SheddingEvent &evince = m_evenements.at(victime);
|
|
||||||
const auto suivi = m_ouverts.constFind(evince.loadId);
|
|
||||||
if (suivi != m_ouverts.constEnd() && suivi->id == evince.id)
|
|
||||||
m_ouverts.remove(evince.loadId);
|
|
||||||
m_evenements.removeAt(victime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void SheddingLog::tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act)
|
|
||||||
{
|
|
||||||
auto it = m_ouverts.find(loadId);
|
|
||||||
SheddingEvent *e = it != m_ouverts.end() ? trouver(it->id) : nullptr;
|
|
||||||
if (!e) {
|
|
||||||
SheddingEvent n;
|
|
||||||
n.id = m_prochainId++;
|
|
||||||
n.loadId = loadId;
|
|
||||||
n.startedAt = now.toUTC(); // ancré à la capture (AGENTS, piège du fuseau)
|
|
||||||
n.source = act.source;
|
|
||||||
n.phase = act.phase;
|
|
||||||
n.overshootW = act.overshootW;
|
|
||||||
n.cut = act.cut;
|
|
||||||
n.before = act.before;
|
|
||||||
n.after = act.after;
|
|
||||||
m_evenements.append(n);
|
|
||||||
m_ouverts.insert(loadId, Ouvert{n.id, m_cycle, QDateTime()});
|
|
||||||
++m_revision;
|
|
||||||
evincer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
it->dernierCycleTenu = m_cycle;
|
|
||||||
it->libreDepuis = QDateTime();
|
|
||||||
|
|
||||||
// Ce qui est PUBLIÉ peut changer : la révision ne bouge que dans ce cas.
|
|
||||||
const QVariantMap avant = e->toMap();
|
|
||||||
if (act.overshootW > e->overshootW) {
|
|
||||||
e->overshootW = act.overshootW;
|
|
||||||
e->phase = act.phase;
|
|
||||||
e->source = act.source;
|
|
||||||
}
|
|
||||||
e->cut = e->cut || act.cut;
|
|
||||||
// « Après » garde le plus BAS : c'est ce que la protection a fait de plus fort. À égalité,
|
|
||||||
// le plus récent, qui dit l'état tenu.
|
|
||||||
if (act.after.powerW <= e->after.powerW)
|
|
||||||
e->after = act.after;
|
|
||||||
if (e->toMap() != avant)
|
|
||||||
++m_revision;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SheddingLog::prolonger(const QString &loadId)
|
|
||||||
{
|
|
||||||
auto it = m_ouverts.find(loadId);
|
|
||||||
if (it == m_ouverts.end())
|
|
||||||
return;
|
|
||||||
it->dernierCycleTenu = m_cycle;
|
|
||||||
it->libreDepuis = QDateTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
void SheddingLog::relacher(const QString &loadId, const QDateTime &now)
|
|
||||||
{
|
|
||||||
auto it = m_ouverts.find(loadId);
|
|
||||||
if (it == m_ouverts.end() || it->libreDepuis.isValid())
|
|
||||||
return; // la fin se date du PREMIER instant libre, pas du dernier
|
|
||||||
it->libreDepuis = now.toUTC();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SheddingLog::finDeCycle()
|
|
||||||
{
|
|
||||||
bool ferme = false;
|
|
||||||
for (auto it = m_ouverts.begin(); it != m_ouverts.end();) {
|
|
||||||
// Libre, et pas tenue pendant le cycle qui s'achève : un cycle complet sans protection.
|
|
||||||
if (it->libreDepuis.isValid() && it->dernierCycleTenu < m_cycle) {
|
|
||||||
if (SheddingEvent *e = trouver(it->id))
|
|
||||||
e->endedAt = it->libreDepuis;
|
|
||||||
it = m_ouverts.erase(it);
|
|
||||||
ferme = true;
|
|
||||||
} else {
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ferme)
|
|
||||||
++m_revision;
|
|
||||||
++m_cycle;
|
|
||||||
return ferme;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SheddingEvent *SheddingLog::enCours(const QString &loadId) const
|
|
||||||
{
|
|
||||||
const auto it = m_ouverts.constFind(loadId);
|
|
||||||
if (it == m_ouverts.constEnd())
|
|
||||||
return nullptr;
|
|
||||||
for (const SheddingEvent &e : m_evenements)
|
|
||||||
if (e.id == it->id)
|
|
||||||
return &e;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
QStringList SheddingLog::ouverts() const
|
|
||||||
{
|
|
||||||
QStringList ids = m_ouverts.keys();
|
|
||||||
ids.sort();
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVariantMap SheddingLog::toMap() const
|
|
||||||
{
|
|
||||||
QVariantList evts;
|
|
||||||
for (const SheddingEvent &e : m_evenements)
|
|
||||||
evts.append(e.toMap());
|
|
||||||
return QVariantMap{{QStringLiteral("revision"), m_revision},
|
|
||||||
{QStringLiteral("capacity"), m_capacity},
|
|
||||||
{QStringLiteral("events"), evts}};
|
|
||||||
}
|
|
||||||
|
|
||||||
QVariantMap SheddingLog::resume() const
|
|
||||||
{
|
|
||||||
if (m_revision == 0)
|
|
||||||
return QVariantMap();
|
|
||||||
// Une QVariantList de chaînes, comme le reste de la télémétrie — pas une QStringList : le JSON
|
|
||||||
// est le même, mais un consommateur qui parcourt la carte par type (l'enregistreur de
|
|
||||||
// `decisions` anonymise les ThingId des listes) ne reconnaîtrait pas la seconde.
|
|
||||||
QVariantList ids;
|
|
||||||
for (const QString &id : ouverts())
|
|
||||||
ids.append(id);
|
|
||||||
return QVariantMap{{QStringLiteral("revision"), m_revision},
|
|
||||||
{QStringLiteral("open"), ids}};
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QHash>
|
|
||||||
#include <QList>
|
|
||||||
#include <QString>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <QVariantMap>
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \file sheddinglog.h
|
|
||||||
* \brief Journal COURT des délestages : un événement par ÉPISODE, en mémoire.
|
|
||||||
*
|
|
||||||
* \par Pourquoi il existe
|
|
||||||
* Un délestage ne vivait qu'un cycle dans la télémétrie (\c DRAW_CAP, \c shed: true), puis
|
|
||||||
* disparaissait. Une borne coupée la nuit par la protection de surcharge ne laissait aucune
|
|
||||||
* trace hors du journal système. Ce journal garde les N derniers épisodes, de quoi répondre
|
|
||||||
* le matin à « pourquoi ma borne s'est-elle arrêtée ? ».
|
|
||||||
*
|
|
||||||
* \par Ce qu'il ne fait pas
|
|
||||||
* Il ne décide rien. Il est alimenté par les deux couches de protection, après leur geste,
|
|
||||||
* et aucune décision ne le lit. Il est perdu au redémarrage de nymead : c'est accepté.
|
|
||||||
*
|
|
||||||
* \par Un épisode, pas un cycle
|
|
||||||
* Une charge tenue 20 minutes par la protection fait UNE entrée, avec un début et une fin.
|
|
||||||
* L'épisode s'ouvre au premier geste de protection, et se ferme à la fin du premier cycle
|
|
||||||
* COMPLET où la protection ne tient plus la charge. La fin est datée du premier instant où
|
|
||||||
* elle ne la tenait plus (\c relacher()). Un geste repris entre-temps prolonge l'épisode.
|
|
||||||
*
|
|
||||||
* \par Le mécanisme, sans liste de types
|
|
||||||
* « Avant » et « après » portent la charge utile de mécanisme TELLE QUE L'ADAPTATEUR LA
|
|
||||||
* PUBLIE (\c ILoadAdapter::runtimeView()) : un palier pour des relais, une consigne pour une
|
|
||||||
* charge continue, un état pour une PAC SG-Ready, un courant pour une borne. Un adaptateur
|
|
||||||
* ajouté demain est couvert sans rien changer ici.
|
|
||||||
*/
|
|
||||||
|
|
||||||
//! \brief L'état d'une charge d'un côté du délestage.
|
|
||||||
struct SheddingState {
|
|
||||||
//! Puissance engagée, en watts — ce que la charge tenait (avant) ou tient (après).
|
|
||||||
double powerW = 0;
|
|
||||||
//! Charge utile de mécanisme, \c kind compris. Celle que \c GetLoadTelemetry publie.
|
|
||||||
QVariantMap mechanism;
|
|
||||||
|
|
||||||
//! \return Forme sérialisée \c {powerW, mechanism}.
|
|
||||||
QVariantMap toMap() const;
|
|
||||||
};
|
|
||||||
|
|
||||||
//! \brief Ce qu'un geste de protection constate, au moment où il agit.
|
|
||||||
struct SheddingAct {
|
|
||||||
//! Source du plafond, code opaque : \c connection, \c gridOperator ou \c selfImposed.
|
|
||||||
QString source;
|
|
||||||
//! Phase qui dépasse (\c A, \c B, \c C), vide si aucune phase n'est coupable.
|
|
||||||
QString phase;
|
|
||||||
//! Dépassement mesuré au geste, en watts.
|
|
||||||
double overshootW = 0;
|
|
||||||
//! Vrai si le geste COUPE la charge. Un changement d'état SG-Ready n'est pas une coupure.
|
|
||||||
bool cut = false;
|
|
||||||
//! L'état avant le geste.
|
|
||||||
SheddingState before;
|
|
||||||
//! L'état laissé par le geste.
|
|
||||||
SheddingState after;
|
|
||||||
};
|
|
||||||
|
|
||||||
//! \brief Un épisode de délestage, tel que \c GetSheddingLog le publie.
|
|
||||||
struct SheddingEvent {
|
|
||||||
int id = 0; //!< Identifiant croissant, jamais réutilisé pendant la vie du processus.
|
|
||||||
QString loadId; //!< Charge délestée (identifiant de \c GetLoadConfig).
|
|
||||||
QDateTime startedAt; //!< Premier geste de l'épisode, en UTC.
|
|
||||||
QDateTime endedAt; //!< Fin de l'épisode, en UTC. Invalide = épisode en cours.
|
|
||||||
QString source; //!< Source du plafond au plus fort du dépassement.
|
|
||||||
QString phase; //!< Phase au plus fort du dépassement ; vide si aucune.
|
|
||||||
double overshootW = 0; //!< Le plus fort dépassement vu pendant l'épisode, en watts.
|
|
||||||
bool cut = false; //!< Vrai si la charge a été coupée au moins une fois.
|
|
||||||
SheddingState before; //!< L'état avant le premier geste.
|
|
||||||
SheddingState after; //!< L'état le plus bas tenu pendant l'épisode.
|
|
||||||
|
|
||||||
//! \return Forme sérialisée. \c endedAt et \c phase sont absents quand ils n'existent pas.
|
|
||||||
QVariantMap toMap() const;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Journal circulaire des épisodes de délestage.
|
|
||||||
*
|
|
||||||
* \invariant Au plus \c capacity() événements. Le plus ancien épisode CLOS sort le premier ;
|
|
||||||
* un épisode en cours n'est évincé que si tous le sont.
|
|
||||||
* \invariant \c revision() change à chaque ouverture, fermeture, ou changement d'un contenu
|
|
||||||
* publié. Prolonger ou relâcher un épisode, sans le fermer, ne la change pas.
|
|
||||||
*/
|
|
||||||
class SheddingLog
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
//! Taille par défaut du journal.
|
|
||||||
static constexpr int DefaultCapacity = 20;
|
|
||||||
|
|
||||||
//! \param capacity Nombre d'épisodes gardés, au moins 1.
|
|
||||||
explicit SheddingLog(int capacity = DefaultCapacity);
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Un geste de protection sur \p loadId : ouvre l'épisode, ou le prolonge.
|
|
||||||
* \param loadId Charge délestée.
|
|
||||||
* \param now Instant du geste.
|
|
||||||
* \param act Ce que le geste constate.
|
|
||||||
* \note Dans un épisode en cours, l'état « après » garde le plus bas, \c cut s'accumule, et
|
|
||||||
* le dépassement garde le plus fort, avec sa phase et sa source.
|
|
||||||
*/
|
|
||||||
void tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act);
|
|
||||||
|
|
||||||
//! \brief La protection tient encore \p loadId ce cycle, sans nouveau geste.
|
|
||||||
//! \param loadId Charge visée. Sans épisode en cours, rien ne se passe.
|
|
||||||
void prolonger(const QString &loadId);
|
|
||||||
|
|
||||||
//! \brief La protection ne tient plus \p loadId : la fin sera datée de \p now.
|
|
||||||
//! \param loadId Charge visée. \param now Premier instant libre. Sans épisode, rien.
|
|
||||||
void relacher(const QString &loadId, const QDateTime &now);
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Fin d'un cycle d'arbitrage : ferme les épisodes libres depuis un cycle complet.
|
|
||||||
* \return Vrai si un épisode s'est fermé.
|
|
||||||
*/
|
|
||||||
bool finDeCycle();
|
|
||||||
|
|
||||||
//! \param loadId Charge visée. \return Vrai si un épisode est en cours sur elle.
|
|
||||||
bool estOuvert(const QString &loadId) const { return m_ouverts.contains(loadId); }
|
|
||||||
//! \param loadId Charge visée. \return Son épisode en cours, ou nul s'il n'y en a pas.
|
|
||||||
const SheddingEvent *enCours(const QString &loadId) const;
|
|
||||||
//! \return Les charges dont un épisode est en cours, triées.
|
|
||||||
QStringList ouverts() const;
|
|
||||||
//! \return Les épisodes gardés, du plus ancien au plus récent.
|
|
||||||
const QList<SheddingEvent> &evenements() const { return m_evenements; }
|
|
||||||
//! \return Le compteur de révision ; 0 tant que rien n'a été noté.
|
|
||||||
int revision() const { return m_revision; }
|
|
||||||
//! \return Le nombre maximal d'épisodes gardés.
|
|
||||||
int capacity() const { return m_capacity; }
|
|
||||||
|
|
||||||
//! \return La charge utile de \c GetSheddingLog : \c revision, \c capacity, \c events.
|
|
||||||
QVariantMap toMap() const;
|
|
||||||
/*!
|
|
||||||
* \brief Le résumé publié dans la télémétrie : \c revision et \c open.
|
|
||||||
* \return Vide tant qu'aucun épisode n'a été noté depuis le démarrage : l'absence du bloc
|
|
||||||
* dit « aucun délestage », un bloc à zéro le dirait mal.
|
|
||||||
*/
|
|
||||||
QVariantMap resume() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct Ouvert {
|
|
||||||
int id = 0;
|
|
||||||
int dernierCycleTenu = 0;
|
|
||||||
QDateTime libreDepuis; //!< Invalide tant que la protection tient la charge.
|
|
||||||
};
|
|
||||||
|
|
||||||
SheddingEvent *trouver(int id);
|
|
||||||
void evincer();
|
|
||||||
|
|
||||||
int m_capacity;
|
|
||||||
int m_revision = 0;
|
|
||||||
int m_prochainId = 1;
|
|
||||||
int m_cycle = 0;
|
|
||||||
QList<SheddingEvent> m_evenements;
|
|
||||||
QHash<QString, Ouvert> m_ouverts;
|
|
||||||
};
|
|
||||||
@ -39,27 +39,6 @@ struct SurplusBattery {
|
|||||||
double maxDischargeW = 0; //!< Puissance de décharge maximale admissible (W).
|
double maxDischargeW = 0; //!< Puissance de décharge maximale admissible (W).
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief Les HEURES CREUSES du cycle, tirées du tarif saisi (lot e1) — INTERNE au moteur.
|
|
||||||
*
|
|
||||||
* Jamais sérialisé vers l'optimiseur (il n'est pas dans le §5 du protocole, comme \c PlanBudget) :
|
|
||||||
* c'est la lecture que le rule-based fait du tarif, pas une donnée d'entrée du contrat.
|
|
||||||
*
|
|
||||||
* \invariant \c configured faux ⇔ aucun tarif, tarif unique, ou zones toutes au même prix
|
|
||||||
* (\c TariffConfig::zonesCreuses() vide). Les autres champs sont alors sans objet, et
|
|
||||||
* AUCUNE décision ne change : c'est la garantie « sans tarif = etm74 ».
|
|
||||||
*/
|
|
||||||
struct SurplusOffPeak {
|
|
||||||
bool configured = false; //!< Il existe des heures creuses dans le tarif saisi.
|
|
||||||
bool active = false; //!< \c SurplusContext::timestamp tombe en heures creuses.
|
|
||||||
//! Prochaine bascule (UTC, à la minute) : DÉBUT des heures creuses si \c active est faux,
|
|
||||||
//! leur FIN sinon. Un INSTANT, ancré à la capture.
|
|
||||||
QDateTime nextChange;
|
|
||||||
//! La même bascule en heure MURALE de la box, \c "HH:MM" — le réglage tel que l'installateur
|
|
||||||
//! l'a saisi (« 22:00 »), pour le motif publié.
|
|
||||||
QString nextChangeWall;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
|
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
|
||||||
struct TariffEntry {
|
struct TariffEntry {
|
||||||
QDateTime from; //!< Début du créneau ; il court jusqu'au \c from du suivant.
|
QDateTime from; //!< Début du créneau ; il court jusqu'au \c from du suivant.
|
||||||
@ -278,29 +257,4 @@ struct SurplusContext {
|
|||||||
QList<LoadContext> loads; //!< Une entrée par charge pilotable, tous types confondus.
|
QList<LoadContext> loads; //!< Une entrée par charge pilotable, tous types confondus.
|
||||||
SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants).
|
SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants).
|
||||||
// forecast : opaque, transmis tel quel si présent — réservé V2
|
// forecast : opaque, transmis tel quel si présent — réservé V2
|
||||||
|
|
||||||
//! Heures creuses du cycle (lot e1). Interne, non sérialisé — voir \c SurplusOffPeak.
|
|
||||||
SurplusOffPeak offPeak;
|
|
||||||
/*!
|
|
||||||
* \brief Puissance MESURÉE tirée par l'ensemble des bornes (W), toutes, y compris celles
|
|
||||||
* hors waterfall (mode manuel). Interne, non sérialisé.
|
|
||||||
* \note Lue au compteur de chaque borne (\c currentPower), jamais déduite d'une commande :
|
|
||||||
* la décharge de la batterie n'est bloquée que pour une voiture qui tire RÉELLEMENT.
|
|
||||||
*/
|
|
||||||
double evMeasuredW = 0;
|
|
||||||
/*!
|
|
||||||
* \brief Le stockage EXPOSE-t-il une limite de décharge pilotable ? Interne, non sérialisé.
|
|
||||||
*
|
|
||||||
* Vide = aucune batterie, ou aucune n'est pilotable. Sinon, une entrée par batterie,
|
|
||||||
* déclarée par \c StorageDischargeGuard (capacité de la ThingClass et du minuteur de retour,
|
|
||||||
* jamais une valeur d'état).
|
|
||||||
*/
|
|
||||||
struct StorageControl {
|
|
||||||
QString thingId; //!< ThingId de la batterie.
|
|
||||||
bool controllable = false; //!< Peut-on bloquer sa décharge en sûreté ?
|
|
||||||
//! Sinon, pourquoi : \c noDischargeControl (états absents ou non modifiables),
|
|
||||||
//! \c noRevertTimer (aucun minuteur de retour : un moteur arrêté la laisserait bloquée).
|
|
||||||
QString cause;
|
|
||||||
};
|
|
||||||
QList<StorageControl> storageControl; //!< Une entrée par batterie \c energystorage.
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -30,7 +30,6 @@
|
|||||||
#include <QTimeZone>
|
#include <QTimeZone>
|
||||||
#endif
|
#endif
|
||||||
#include "etm/types/decisionreason.h" // hors ETM_ARBITRATOR aussi : SPOT_MARKET_NOT_IN_BUILD
|
#include "etm/types/decisionreason.h" // hors ETM_ARBITRATOR aussi : SPOT_MARKET_NOT_IN_BUILD
|
||||||
#include "etm/types/sheddinglog.h" // hors ETM_ARBITRATOR aussi : GetSheddingLog rend un journal vide
|
|
||||||
#include "types/charginginfo.h"
|
#include "types/charginginfo.h"
|
||||||
#include "smartchargingmanager.h"
|
#include "smartchargingmanager.h"
|
||||||
#include "spotmarket/spotmarketmanager.h"
|
#include "spotmarket/spotmarketmanager.h"
|
||||||
@ -81,18 +80,13 @@ 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.";
|
||||||
"[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): "
|
returns.insert("phasePowerLimit", enumValueName(Uint));
|
||||||
"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);
|
||||||
|
|
||||||
@ -337,39 +331,6 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
returns = loadTelemetrySchema();
|
returns = loadTelemetrySchema();
|
||||||
registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeNone);
|
registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeNone);
|
||||||
|
|
||||||
// [ETM] Journal court des délestages. Hors de la télémétrie, parce qu'il la doublerait : vingt
|
|
||||||
// épisodes portant chacun deux charges utiles de mécanisme, émis à chaque battement. La
|
|
||||||
// télémétrie n'en porte que le résumé (shedding.revision, shedding.open) — c'est lui qui
|
|
||||||
// notifie. Même droit que GetLoadTelemetry : c'est de la lecture d'état, un compte sans droit la lit.
|
|
||||||
params.clear(); returns.clear();
|
|
||||||
description = "Get the short log of load-shedding episodes: the last 'capacity' episodes of "
|
|
||||||
"overload protection, oldest first, EV chargers and cascade loads alike. ONE "
|
|
||||||
"event per episode, not per cycle: 'endedAt' is omitted while the episode is "
|
|
||||||
"running. 'before' and 'after' carry the mechanism payload as the adapter "
|
|
||||||
"publishes it (stage for relays, setpoint, SG-Ready state, charger current); "
|
|
||||||
"'after' is the lowest state held during the episode. In memory only: lost "
|
|
||||||
"when nymead restarts. No own notification: GetLoadTelemetry carries "
|
|
||||||
"'shedding.revision', and LoadTelemetryChanged is emitted when an episode opens "
|
|
||||||
"or closes.";
|
|
||||||
QVariantMap etatItem;
|
|
||||||
etatItem.insert("powerW", enumValueName(Int));
|
|
||||||
etatItem.insert("mechanism", enumValueName(Variant));
|
|
||||||
QVariantMap evenementItem;
|
|
||||||
evenementItem.insert("id", enumValueName(Int));
|
|
||||||
evenementItem.insert("loadId", enumValueName(String));
|
|
||||||
evenementItem.insert("startedAt", enumValueName(String)); // ISO 8601 UTC
|
|
||||||
evenementItem.insert("o:endedAt", enumValueName(String)); // absent = en cours
|
|
||||||
evenementItem.insert("source", enumValueName(String)); // connection | gridOperator | selfImposed
|
|
||||||
evenementItem.insert("o:phase", enumValueName(String)); // A | B | C ; absent = aucune
|
|
||||||
evenementItem.insert("overshootW", enumValueName(Int));
|
|
||||||
evenementItem.insert("cut", enumValueName(Bool));
|
|
||||||
evenementItem.insert("before", etatItem);
|
|
||||||
evenementItem.insert("after", etatItem);
|
|
||||||
returns.insert("revision", enumValueName(Int));
|
|
||||||
returns.insert("capacity", enumValueName(Int));
|
|
||||||
returns.insert("events", QVariantList() << evenementItem);
|
|
||||||
registerMethod("GetSheddingLog", description, params, returns, Types::PermissionScopeNone);
|
|
||||||
|
|
||||||
// --- Prévision PV ------------------------------------------------------------------------
|
// --- Prévision PV ------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE
|
// UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE
|
||||||
@ -730,15 +691,11 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
params.clear();
|
params.clear();
|
||||||
description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
|
description = "Emitted whenever the phase power limit configuration changes.";
|
||||||
params = phasePowerLimitSchema();
|
params.insert("phasePowerLimit", enumValueName(Uint));
|
||||||
registerNotification("PhasePowerLimitChanged", description, params);
|
registerNotification("PhasePowerLimitChanged", description, params);
|
||||||
// [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
|
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
|
||||||
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
|
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
|
||||||
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
|
||||||
});
|
|
||||||
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
|
|
||||||
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
params.clear();
|
params.clear();
|
||||||
@ -943,38 +900,14 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
|
|||||||
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
Q_UNUSED(params)
|
Q_UNUSED(params)
|
||||||
return createReply(phasePowerLimitPayload());
|
QVariantMap returns;
|
||||||
}
|
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 ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
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));
|
||||||
@ -1493,35 +1426,6 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
|||||||
derogEndItem.insert("o:restoredMode", enumValueName(Int));
|
derogEndItem.insert("o:restoredMode", enumValueName(Int));
|
||||||
schema.insert("o:derogationEnded", QVariantList() << derogEndItem);
|
schema.insert("o:derogationEnded", QVariantList() << derogEndItem);
|
||||||
|
|
||||||
// Lot e1 — la DÉCHARGE DE LA BATTERIE en heures creuses. ABSENT tant que le tarif saisi n'a
|
|
||||||
// pas d'heure creuse ou qu'aucune batterie n'existe : la question ne se pose pas, et un
|
|
||||||
// « non bloquée » publié sans elle se lirait comme une décision. Hors de « loads » : la
|
|
||||||
// batterie n'est pas une charge rangée, et « loads » reste l'ensemble de GetLoadConfig.
|
|
||||||
// « dischargeBlocked » est ce que le moteur TIENT après le dispatch ; « observed » ce que la
|
|
||||||
// batterie RAPPORTE — les deux, parce qu'un onduleur peut accepter une écriture et l'ignorer.
|
|
||||||
QVariantMap observedItem;
|
|
||||||
observedItem.insert("o:enableDischarging", enumValueName(Bool));
|
|
||||||
observedItem.insert("o:dischargingRate", enumValueName(Double));
|
|
||||||
QVariantMap batteryItem;
|
|
||||||
batteryItem.insert("thingId", enumValueName(Uuid));
|
|
||||||
batteryItem.insert("controllable", enumValueName(Bool));
|
|
||||||
batteryItem.insert("o:cause", enumValueName(String)); // noDischargeControl | noRevertTimer |
|
|
||||||
// revertTimerTooShort
|
|
||||||
batteryItem.insert("held", enumValueName(Bool));
|
|
||||||
batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC
|
|
||||||
batteryItem.insert("o:lastWriteAt", enumValueName(String)); // ISO 8601 UTC
|
|
||||||
// Série de refus de l'onduleur : « held » est alors FAUX, et ces trois champs disent depuis
|
|
||||||
// quand, combien de poses d'affilée, et quand le moteur réessaiera (espacement 1 → 15 min).
|
|
||||||
batteryItem.insert("o:refusedWrites", enumValueName(Int));
|
|
||||||
batteryItem.insert("o:refusedSince", enumValueName(String)); // ISO 8601 UTC
|
|
||||||
batteryItem.insert("o:nextAttemptAt", enumValueName(String)); // ISO 8601 UTC
|
|
||||||
batteryItem.insert("o:observed", observedItem);
|
|
||||||
QVariantMap storageItem;
|
|
||||||
storageItem.insert("dischargeBlocked", enumValueName(Bool));
|
|
||||||
storageItem.insert("decision", decisionItem);
|
|
||||||
storageItem.insert("batteries", QVariantList() << batteryItem);
|
|
||||||
schema.insert("o:storage", storageItem);
|
|
||||||
|
|
||||||
schema.insert("loads", QVariantList() << loadItem);
|
schema.insert("loads", QVariantList() << loadItem);
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
@ -1550,17 +1454,6 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms)
|
|||||||
return createReply(currentLoadTelemetry());
|
return createReply(currentLoadTelemetry());
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap ¶ms)
|
|
||||||
{
|
|
||||||
Q_UNUSED(params)
|
|
||||||
#ifdef ETM_ARBITRATOR
|
|
||||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
|
|
||||||
return createReply(arb->sheddingLog());
|
|
||||||
#endif
|
|
||||||
// Sans arbitre, aucun délestage ne peut être noté : le journal est vide, et le dit.
|
|
||||||
return createReply(SheddingLog().toMap());
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
const QVariantMap succes{{"forecastPoints", QVariantList()},
|
const QVariantMap succes{{"forecastPoints", QVariantList()},
|
||||||
|
|||||||
@ -119,17 +119,6 @@ public:
|
|||||||
* notification seule le laisse dans un silence ambigu (famille ECS-411).
|
* notification seule le laisse dans un silence ambigu (famille ECS-411).
|
||||||
*/
|
*/
|
||||||
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms);
|
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms);
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief [ETM] Journal court des délestages : les derniers épisodes de protection.
|
|
||||||
* \param params Aucun.
|
|
||||||
* \return \c revision, \c capacity, \c events — un événement par ÉPISODE, borne et charges
|
|
||||||
* de la cascade confondues. Rendu tel que tenu en mémoire, jamais recalculé.
|
|
||||||
* \note Pas de notification propre : la télémétrie porte \c shedding.revision, et
|
|
||||||
* \c LoadTelemetryChanged part à chaque ouverture et à chaque fermeture. Même droit que
|
|
||||||
* \c GetLoadTelemetry. Perdu au redémarrage de nymead.
|
|
||||||
*/
|
|
||||||
Q_INVOKABLE JsonReply *GetSheddingLog(const QVariantMap ¶ms);
|
|
||||||
//! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco).
|
//! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco).
|
||||||
//! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel).
|
//! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel).
|
||||||
//! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes.
|
//! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes.
|
||||||
@ -239,14 +228,6 @@ 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.
|
||||||
|
|||||||
@ -23,7 +23,6 @@
|
|||||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||||
|
|
||||||
#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"
|
||||||
@ -44,11 +43,6 @@ 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.
|
||||||
//
|
//
|
||||||
@ -192,39 +186,6 @@ 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;
|
||||||
@ -407,13 +368,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
|
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
||||||
// marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
|
{"B", m_rootMeter->currentPowerPhaseB()},
|
||||||
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
{"C", m_rootMeter->currentPowerPhaseC()}};
|
||||||
|
|
||||||
bool limitExceeded = false;
|
bool limitExceeded = false;
|
||||||
|
|
||||||
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
||||||
|
|
||||||
// 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
|
||||||
@ -433,7 +394,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
|||||||
|
|
||||||
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 (" << effectivePhaseLimit() << "A)";
|
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)";
|
||||||
limitExceeded = true;
|
limitExceeded = true;
|
||||||
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
||||||
requiredThrottlePower[phase] = overshot;
|
requiredThrottlePower[phase] = overshot;
|
||||||
@ -515,10 +476,11 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
|||||||
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
||||||
|
|
||||||
|
|
||||||
// [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
|
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
||||||
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
{"B", m_rootMeter->currentPowerPhaseB()},
|
||||||
|
{"C", m_rootMeter->currentPowerPhaseC()}};
|
||||||
|
|
||||||
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
||||||
|
|
||||||
QHash<QString, double> availablePhasePower = {{"A", 0},
|
QHash<QString, double> availablePhasePower = {{"A", 0},
|
||||||
{"B", 0},
|
{"B", 0},
|
||||||
@ -621,7 +583,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
|||||||
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
||||||
{
|
{
|
||||||
// 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 = effectivePhaseLimit() * 230; // [ETM] marge de sécurité
|
double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230;
|
||||||
|
|
||||||
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
||||||
|
|
||||||
@ -675,12 +637,6 @@ void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
|||||||
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) {
|
||||||
|
|
||||||
@ -1358,9 +1314,7 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime)
|
|||||||
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
||||||
|
|
||||||
// Adjust down to not overload any phases.
|
// Adjust down to not overload any phases.
|
||||||
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit);
|
||||||
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;
|
||||||
@ -1442,9 +1396,7 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime)
|
|||||||
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";
|
||||||
|
|
||||||
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit);
|
||||||
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
|
||||||
|
|||||||
@ -27,7 +27,6 @@
|
|||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QSet>
|
|
||||||
|
|
||||||
#include "energymanagerconfiguration.h"
|
#include "energymanagerconfiguration.h"
|
||||||
#include "types/charginginfo.h"
|
#include "types/charginginfo.h"
|
||||||
@ -64,30 +63,6 @@ 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);
|
||||||
|
|
||||||
@ -124,7 +99,6 @@ 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);
|
||||||
@ -139,9 +113,7 @@ 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 — EXCEPT items 9 to 11 of the inventory (lot délestage triphasé,
|
// Zero logic change. Revert by deleting this block and restoring private slots.
|
||||||
// 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
|
||||||
@ -159,27 +131,10 @@ signals:
|
|||||||
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
|
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
|
||||||
// internalProcessInfo(), internalHasProcessInfo()
|
// internalProcessInfo(), internalHasProcessInfo()
|
||||||
// — accesseurs ETM neufs, documentés ci-dessous.
|
// — accesseurs ETM neufs, documentés ci-dessous.
|
||||||
// 5. bestPhaseCountFor(), phasesForCount(), chargerPhaseFor()
|
// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont
|
||||||
// — passe-plats ETM vers trois règles amont
|
|
||||||
// privées, documentés ci-dessous.
|
// privées, documentés ci-dessous.
|
||||||
// 8. executeChargingAction() — virtual ajouté (le corps reste amont), pour
|
|
||||||
// que l'arbitre PUBLIE les gestes de la
|
|
||||||
// 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
|
||||||
@ -199,28 +154,7 @@ protected slots:
|
|||||||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
/*!
|
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||||
* \brief [ETM] Exécute une consigne de borne — corps amont, inchangé.
|
|
||||||
* \param evCharger Borne commandée. \param chargingAction Consigne et émetteur.
|
|
||||||
* \param currentDateTime Temps de cycle, passé aux verrous de la borne.
|
|
||||||
* \note `private → protected`, puis `virtual` ajouté (lot délestage visible). C'est le seul
|
|
||||||
* point par où passent TOUS les gestes de la protection de surcharge — y compris ceux de
|
|
||||||
* \c verifyOverloadProtection() déclenchés par \c powerBalanceChanged, hors cycle.
|
|
||||||
* \c EnergyArbitrator le surcharge pour les PUBLIER : il appelle ce corps tel quel, puis
|
|
||||||
* lit ce qui a été commandé. Une surcharge qui modifierait la consigne avant de l'appeler
|
|
||||||
* ferait décider la publication à la place de la protection (règle 4).
|
|
||||||
*/
|
|
||||||
virtual void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected, virtual added
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* \brief [ETM] Phase de raccordement d'une borne monophasée, selon la protection de surcharge.
|
|
||||||
* \param evCharger Borne. \return \c "A", \c "B" ou \c "C".
|
|
||||||
* \note `private → protected`, même raison que \c bestPhaseCountFor : l'arbitre publie la
|
|
||||||
* phase sur laquelle la protection a jugé la borne. La redériver en ferait deux versions,
|
|
||||||
* et le motif nommerait une autre phase que celle qui a décidé.
|
|
||||||
*/
|
|
||||||
QString chargerPhaseFor(EvCharger *evCharger) const
|
|
||||||
{ return chargerPhaseKey(evCharger); }
|
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné.
|
* \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné.
|
||||||
@ -352,27 +286,8 @@ 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:
|
||||||
@ -404,7 +319,6 @@ 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;
|
||||||
@ -413,7 +327,6 @@ 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;
|
||||||
|
|||||||
@ -332,8 +332,7 @@ void TestCharging::addChargingInfo()
|
|||||||
|
|
||||||
// Set phase power limit
|
// Set phase power limit
|
||||||
uint phasePowerLimit = 25000;
|
uint phasePowerLimit = 25000;
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
|
||||||
verifyEnergyError(response);
|
verifyEnergyError(response);
|
||||||
|
|
||||||
removeDevices();
|
removeDevices();
|
||||||
@ -692,8 +691,7 @@ 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();
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
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
|
||||||
@ -1071,8 +1069,7 @@ 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();
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
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
|
||||||
@ -1384,8 +1381,7 @@ 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();
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
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
|
||||||
@ -1606,8 +1602,7 @@ 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();
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
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
|
||||||
@ -1845,8 +1840,7 @@ 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();
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
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
|
||||||
@ -2017,8 +2011,7 @@ 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();
|
||||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||||
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
|
||||||
@ -2209,7 +2202,6 @@ void TestCharging::testNymeaEnergyPermissionScopes()
|
|||||||
{"GetSpotMarketScoreEntries", none},
|
{"GetSpotMarketScoreEntries", none},
|
||||||
{"GetLoadConfig", none},
|
{"GetLoadConfig", none},
|
||||||
{"GetLoadTelemetry", none},
|
{"GetLoadTelemetry", none},
|
||||||
{"GetSheddingLog", none}, // même droit que la télémétrie : un compte sans droit le lit
|
|
||||||
{"GetEnergyRatios", none},
|
{"GetEnergyRatios", none},
|
||||||
{"GetPvForecast", none},
|
{"GetPvForecast", none},
|
||||||
{"GetForecastSite", none},
|
{"GetForecastSite", none},
|
||||||
@ -2330,15 +2322,12 @@ void TestCharging::testNymeaEnergyPermissionsEnforced()
|
|||||||
// Le transport du harnais EXIGE l'authentification. Sans elle, nymea ne vérifie aucun droit
|
// Le transport du harnais EXIGE l'authentification. Sans elle, nymea ne vérifie aucun droit
|
||||||
// et rien de ce qui suit ne prouverait quoi que ce soit.
|
// et rien de ce qui suit ne prouverait quoi que ce soit.
|
||||||
QVERIFY(lecteur.authentificationExigee);
|
QVERIFY(lecteur.authentificationExigee);
|
||||||
for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetSheddingLog"),
|
for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"),
|
||||||
QStringLiteral("GetTariff"), QStringLiteral("GetBatteryLevelConsideration")}) {
|
QStringLiteral("GetBatteryLevelConsideration")}) {
|
||||||
const QVariantMap r = appeler(lecteur, m);
|
const QVariantMap r = appeler(lecteur, m);
|
||||||
QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact)));
|
QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact)));
|
||||||
}
|
}
|
||||||
QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille
|
QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille
|
||||||
// Le journal des délestages : un compte sans aucun droit le lit en entier.
|
|
||||||
QCOMPARE(appeler(lecteur, "GetSheddingLog").value("params"), lireAdmin("GetSheddingLog"));
|
|
||||||
QVERIFY(appeler(lecteur, "GetSheddingLog").value("params").toMap().contains("events"));
|
|
||||||
QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}})));
|
QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}})));
|
||||||
QCOMPARE(lireAdmin("GetTariff"), tarifAvant);
|
QCOMPARE(lireAdmin("GetTariff"), tarifAvant);
|
||||||
QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}})));
|
QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}})));
|
||||||
|
|||||||
@ -451,23 +451,6 @@ 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;
|
||||||
|
|||||||
@ -51,9 +51,6 @@ 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");
|
||||||
@ -113,9 +110,6 @@ 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);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -53,7 +53,6 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
|||||||
for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
|
for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
|
||||||
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady,
|
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady,
|
||||||
DecisionCode::LoadUnavailable, DecisionCode::DrawCap,
|
DecisionCode::LoadUnavailable, DecisionCode::DrawCap,
|
||||||
DecisionCode::OverloadThrottled, // la protection de surcharge de l'amont
|
|
||||||
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
|
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
|
||||||
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
|
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
|
||||||
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)
|
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)
|
||||||
|
|||||||
@ -2656,91 +2656,6 @@ void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays()
|
|||||||
QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0);
|
QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TestLoadModel::testOffPeakIsTheCheapestZone()
|
|
||||||
{
|
|
||||||
// Lot e1 (2026-10-24) — les heures creuses sont LA OU LES ZONES AU PRIX LE PLUS BAS d'un
|
|
||||||
// tarif à zones horaires. Ni tarif, ni tarif unique, ni zones toutes au même prix : aucune
|
|
||||||
// heure creuse, et le moteur ne change rien (preuve `decisions`).
|
|
||||||
QString cause;
|
|
||||||
QVariantMap ou;
|
|
||||||
const QTimeZone paris("Europe/Paris");
|
|
||||||
const auto lire = [&](const QVariantMap &saisie) {
|
|
||||||
const TariffConfig t = TariffConfig::fromVariant(saisie, cause, ou);
|
|
||||||
if (!cause.isEmpty())
|
|
||||||
qWarning() << "tarif refusé" << cause << ou;
|
|
||||||
return t;
|
|
||||||
};
|
|
||||||
const QDateTime midi = QDateTime(QDate(2026, 6, 8), QTime(12, 0), paris);
|
|
||||||
|
|
||||||
// SANS HEURE CREUSE — trois formes, une seule réponse.
|
|
||||||
QVERIFY(TariffConfig().zonesCreuses().isEmpty());
|
|
||||||
QVERIFY(!TariffConfig().enHeuresCreuses(midi, paris));
|
|
||||||
QVERIFY(!TariffConfig().prochaineBascule(midi, paris).isValid());
|
|
||||||
const TariffConfig unique = lire(saisieTarif({zoneTarif("Base", 20.16, {{"00:00", "24:00"}})}));
|
|
||||||
QVERIFY(cause.isEmpty());
|
|
||||||
QCOMPARE(unique.source(), QStringLiteral("fixed"));
|
|
||||||
QVERIFY(unique.zonesCreuses().isEmpty());
|
|
||||||
QVERIFY(!unique.prochaineBascule(midi, paris).isValid());
|
|
||||||
const TariffConfig egaux = lire(saisieTarif({zoneTarif("Jour", 20.0, {{"06:00", "22:00"}}),
|
|
||||||
zoneTarif("Nuit", 20.0, {{"22:00", "06:00"}})}));
|
|
||||||
QVERIFY(cause.isEmpty());
|
|
||||||
QCOMPARE(egaux.source(), QStringLiteral("tou"));
|
|
||||||
QVERIFY2(egaux.zonesCreuses().isEmpty(), "zones au même prix : aucune n'est creuse");
|
|
||||||
|
|
||||||
// LE LIBELLÉ NE DÉCIDE PAS : la zone nommée « HC » mais plus chère n'est pas creuse.
|
|
||||||
const TariffConfig trompeur = lire(saisieTarif({zoneTarif("HC", 30.0, {{"22:00", "06:00"}}),
|
|
||||||
zoneTarif("HP", 18.0, {{"06:00", "22:00"}})}));
|
|
||||||
QVERIFY(cause.isEmpty());
|
|
||||||
QCOMPARE(trompeur.zonesCreuses(), QList<int>{1});
|
|
||||||
|
|
||||||
// PLUSIEURS ZONES AU PRIX MINIMAL : toutes creuses. La plus chère (HP) ne l'est pas, ni la
|
|
||||||
// zone intermédiaire.
|
|
||||||
const TariffConfig trois = lire(saisieTarif({zoneTarif("Nuit", 12.0, {{"22:00", "06:00"}}),
|
|
||||||
zoneTarif("Pointe", 30.0, {{"06:00", "12:00"}, {"16:00", "22:00"}}),
|
|
||||||
zoneTarif("Midi", 12.0, {{"12:00", "14:00"}}),
|
|
||||||
zoneTarif("Après-midi", 20.0, {{"14:00", "16:00"}})}));
|
|
||||||
QVERIFY(cause.isEmpty());
|
|
||||||
QCOMPARE(trois.zonesCreuses(), (QList<int>{0, 2}));
|
|
||||||
QVERIFY(trois.enHeuresCreuses(QDateTime(QDate(2026, 6, 8), QTime(13, 0), paris), paris));
|
|
||||||
QVERIFY(!trois.enHeuresCreuses(QDateTime(QDate(2026, 6, 8), QTime(15, 0), paris), paris));
|
|
||||||
|
|
||||||
// LE CAS DE LA DÉMO — 22:00-06:00. Bascules lues à la minute ; époques = ORACLES hors de Qt
|
|
||||||
// (Python zoneinfo, cf. rapport du lot) — jamais recalculées avec QDateTime.
|
|
||||||
const TariffConfig t = lire(hphc());
|
|
||||||
QVERIFY(cause.isEmpty());
|
|
||||||
QCOMPARE(t.zonesCreuses(), QList<int>{0});
|
|
||||||
const auto bascule = [&](qint64 depuis) {
|
|
||||||
return t.prochaineBascule(QDateTime::fromSecsSinceEpoch(depuis, QTimeZone::utc()), paris)
|
|
||||||
.toSecsSinceEpoch();
|
|
||||||
};
|
|
||||||
// Jour ordinaire : 18:00 → entrée à 22:00 (1780948800) ; 21:59:59 HP, 22:00 HC.
|
|
||||||
QCOMPARE(bascule(1780934400), qint64(1780948800));
|
|
||||||
QVERIFY(!t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1780948799, QTimeZone::utc()), paris));
|
|
||||||
QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1780948800, QTimeZone::utc()), paris));
|
|
||||||
// …puis sortie à 06:00 (1780977600) : la nuit dure 8 h.
|
|
||||||
QCOMPARE(bascule(1780948800), qint64(1780977600));
|
|
||||||
// Une seconde avant l'entrée : la bascule est la minute suivante, pas celle d'après.
|
|
||||||
QCOMPARE(bascule(1780948799), qint64(1780948800));
|
|
||||||
|
|
||||||
// JOUR DE 23 H (2026-03-29) : entrée 22:00 CET (1774731600), sortie 06:00 CEST (1774756800)
|
|
||||||
// — 7 h réelles, pas 8. Une soustraction de dates dirait 8.
|
|
||||||
QCOMPARE(bascule(1774731600 - 3600), qint64(1774731600));
|
|
||||||
QCOMPARE(bascule(1774731600), qint64(1774756800));
|
|
||||||
// JOUR DE 25 H (2026-10-25) : entrée 22:00 CEST (1792872000), sortie 06:00 CET (1792904400)
|
|
||||||
// — 9 h réelles. L'heure murale 02:30 existe deux fois ; les deux sont creuses.
|
|
||||||
QCOMPARE(bascule(1792872000), qint64(1792904400));
|
|
||||||
QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1792888200, QTimeZone::utc()), paris));
|
|
||||||
QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1792888200 + 3600, QTimeZone::utc()), paris));
|
|
||||||
// …et le soir du 25 : entrée à 22:00 CET (1792962000).
|
|
||||||
QCOMPARE(bascule(1792904400), qint64(1792962000));
|
|
||||||
|
|
||||||
// UN RÉGLAGE MURAL : le même instant, lu à Londres, n'est pas dans la même zone.
|
|
||||||
const QDateTime z2130 = QDateTime::fromSecsSinceEpoch(1780948800 - 30 * 60, QTimeZone::utc()); // 21:30 Paris
|
|
||||||
QVERIFY(!t.enHeuresCreuses(z2130, paris));
|
|
||||||
QVERIFY(!t.enHeuresCreuses(z2130, QTimeZone("Europe/London"))); // 20:30 Londres
|
|
||||||
QVERIFY(t.enHeuresCreuses(z2130, QTimeZone("Asia/Tokyo"))); // 04:30 Tokyo
|
|
||||||
}
|
|
||||||
|
|
||||||
void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean()
|
void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean()
|
||||||
{
|
{
|
||||||
QString cause;
|
QString cause;
|
||||||
@ -3070,32 +2985,3 @@ 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());
|
|
||||||
}
|
|
||||||
|
|||||||
@ -108,8 +108,6 @@ 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();
|
||||||
@ -144,8 +142,6 @@ private slots:
|
|||||||
void testTheTariffIsValidatedStrictly();
|
void testTheTariffIsValidatedStrictly();
|
||||||
//! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt.
|
//! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt.
|
||||||
void testTheTariffFollowsLocalTimeAcrossDstDays();
|
void testTheTariffFollowsLocalTimeAcrossDstDays();
|
||||||
//! Lot e1 : heures creuses = zones au prix le plus bas ; bascules exactes les jours de 23 et 25 h.
|
|
||||||
void testOffPeakIsTheCheapestZone();
|
|
||||||
//! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée.
|
//! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée.
|
||||||
void testAStraddlingSlotGetsTheTimeWeightedMean();
|
void testAStraddlingSlotGetsTheTimeWeightedMean();
|
||||||
|
|
||||||
|
|||||||
@ -223,7 +223,6 @@ private slots:
|
|||||||
void testAProtocol10OptimizerIsAccepted();
|
void testAProtocol10OptimizerIsAccepted();
|
||||||
void testTracesReplayWithZeroDivergence();
|
void testTracesReplayWithZeroDivergence();
|
||||||
void testSafetyWinsOverTheOptimizer();
|
void testSafetyWinsOverTheOptimizer();
|
||||||
void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum();
|
|
||||||
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
||||||
void cleanupTestCase();
|
void cleanupTestCase();
|
||||||
|
|
||||||
@ -937,116 +936,6 @@ 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
@ -27,12 +27,6 @@
|
|||||||
|
|
||||||
#include "simulationbase.h"
|
#include "simulationbase.h"
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
class EnergyArbitrator;
|
|
||||||
struct EcritureBatterie;
|
|
||||||
|
|
||||||
using namespace nymeaserver;
|
using namespace nymeaserver;
|
||||||
|
|
||||||
class ChargerPlugEvent
|
class ChargerPlugEvent
|
||||||
@ -58,69 +52,6 @@ public:
|
|||||||
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
|
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
|
||||||
typedef QList<int> DetailsStepList;
|
typedef QList<int> DetailsStepList;
|
||||||
|
|
||||||
private:
|
|
||||||
// ── Lot e1 — scénario commun ──────────────────────────────────────────────────────────
|
|
||||||
//! Quelle batterie pose le scénario.
|
|
||||||
enum class BatterieHc { Aucune, Pilotable, SansEtats, SansMinuteur };
|
|
||||||
//! Le banc d'un scénario d'heures creuses, et le journal des écritures sur la batterie.
|
|
||||||
struct ScenarioHc {
|
|
||||||
EnergyArbitrator *arb = nullptr;
|
|
||||||
Thing *meter = nullptr;
|
|
||||||
Thing *borne = nullptr;
|
|
||||||
Thing *batterie = nullptr;
|
|
||||||
QUuid borneId;
|
|
||||||
std::shared_ptr<QList<struct EcritureBatterie>> ecrites; //!< Lues à la frontière nymea.
|
|
||||||
std::shared_ptr<QDateTime> cycleCourant; //!< Date les écritures.
|
|
||||||
};
|
|
||||||
//! 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.
|
|
||||||
//! Lot délestage V2C : les mêmes, monophasée sur A et triphasée, SANS courants par phase
|
|
||||||
//! lisibles par le moteur (`currentL1..3`, façon V2C Trydan).
|
|
||||||
enum class BorneHc { Monophasee, Triphasee, Bascule, MonophaseeNonMesuree, TriphaseeNonMesuree };
|
|
||||||
//! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
|
|
||||||
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
|
|
||||||
BorneHc borne = BorneHc::Monophasee);
|
|
||||||
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
|
|
||||||
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
|
|
||||||
|
|
||||||
// ── Lot délestage triphasé — une borne SANS voiture assignée, limite de phase 30 A ────────
|
|
||||||
struct BancTriphase {
|
|
||||||
EnergyArbitrator *arb = nullptr;
|
|
||||||
Thing *compteur = nullptr;
|
|
||||||
Thing *borne = nullptr;
|
|
||||||
QString borneId;
|
|
||||||
};
|
|
||||||
//! Serveur neuf, compteur, borne 16 A « Éco » branchée et sans voiture (le cas de l'invité,
|
|
||||||
//! LM-1208-b). \p phases : câblage du mock ; \p bascule : la borne porte `desiredPhaseCount`
|
|
||||||
//! (mise en triphasé si elle est câblée sur trois).
|
|
||||||
BancTriphase preparerBancTriphase(const QString &nom, const QString &phases, bool bascule);
|
|
||||||
|
|
||||||
// ── Recrédit — une borne mesurée par un compteur dédié, cycle par cycle ──────────────────
|
|
||||||
//! Ce que la télémétrie dit de la borne à un cycle, et ce qu'elle a reçu.
|
|
||||||
struct CycleBorne {
|
|
||||||
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();
|
||||||
@ -281,18 +212,6 @@ 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();
|
||||||
@ -313,60 +232,6 @@ private slots:
|
|||||||
void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid();
|
void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid();
|
||||||
//! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan.
|
//! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan.
|
||||||
void testNoDecisionReadsBaseloadOrPrice();
|
void testNoDecisionReadsBaseloadOrPrice();
|
||||||
|
|
||||||
// ── Lot e1 — heures creuses pour la borne, batterie protégée pendant sa charge ────────
|
|
||||||
//! La journée de la démo : surplus à midi, attente des HC à 18 h, reprise à 22:00, décharge
|
|
||||||
//! bloquée tenue (minuteur réarmé) puis LEVÉE à la fin de charge ; jamais la charge réseau.
|
|
||||||
void testOffPeakDemoDay();
|
|
||||||
//! 06:00 : la borne s'arrête et la batterie est rendue — jour de 24, 23 et 25 heures.
|
|
||||||
void testOffPeakEndsAtSixAndOnDstDays();
|
|
||||||
//! Sans tarif, tarif unique, zones au même prix : le moteur est etm74 (minimum, ni bloc ni écriture).
|
|
||||||
void testWithoutOffPeakTheEngineIsEtm74();
|
|
||||||
//! Batterie sans états de commande, sans minuteur, absente : lue comme avant, motif publié.
|
|
||||||
void testABatteryWithoutControlIsReadAsBefore();
|
|
||||||
//! Le soutirage que le blocage ajoute se MESURE au cycle suivant ; L4 et le plafond décident.
|
|
||||||
void testBlockedDischargeShowsAtTheMeterAndL4Decides();
|
|
||||||
//! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit.
|
|
||||||
void testAMeterSilenceHandsTheBatteryBack();
|
|
||||||
//! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ;
|
|
||||||
//! une pose acceptée rend le rythme normal ; la levée n'attend jamais.
|
|
||||||
void testRefusedWritesAreSpacedAndNeverDelayTheRelease();
|
|
||||||
//! Lot e2 — en heures creuses, la borne reçoit son PLAFOND (3 680 W en mono, 11 040 W en tri) ;
|
|
||||||
//! hors heures creuses inchangé ; voiture pleine sous 230 W : la batterie est rendue.
|
|
||||||
void testOffPeakChargesAtFullPower();
|
|
||||||
//! Lot e2 — borne à bascule : en heures creuses, trois phases même quand le compteur lit 0.
|
|
||||||
void testOffPeakFullPowerSwitchesToThreePhases();
|
|
||||||
//! Lot e2 — limite de phase, compteur qui voit la borne : la borne est réduite, jamais sous son
|
|
||||||
//! plancher par la stratégie ; la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; le
|
|
||||||
//! courant rendu est le MESURÉ ; en dépassement la consigne baisse ; L4 coupe au dépassement.
|
|
||||||
void testOffPeakFullPowerYieldsToThePhaseLimit();
|
|
||||||
//! Lot e2, suite — compteur qui voit la borne, maison à 2 A : la consigne se pose sans
|
|
||||||
//! alterner (mono et tri, limites 25 et 32 A), et sous une limite de 16 A elle se pose dessous,
|
|
||||||
//! à l'ampère inférieur.
|
|
||||||
void testOffPeakFullPowerSettlesUnderThePhaseLimit();
|
|
||||||
//! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas
|
|
||||||
//! seule à la limite (maison sans la borne écrêtée à zéro) ; total aveugle aussi : le surplus
|
|
||||||
//! fabriqué ne la porte plus au-delà du plafond (15 A, jamais 16).
|
|
||||||
void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter();
|
|
||||||
//! Lot e2, suite (écart 4) — à l'aube en heures creuses, le surplus prend la place de l'achat
|
|
||||||
//! dans le plafond, au lieu de s'y ajouter : consigne ≤ plafond, achat ≤ import du compteur.
|
|
||||||
void testOffPeakFullPowerAtDawnStaysInTheCeiling();
|
|
||||||
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
|
|
||||||
void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases();
|
|
||||||
//! Lot délestage V2C — borne SANS courants par phase lisibles (façon V2C Trydan), limite 25 A,
|
|
||||||
//! marge 10 % : la consigne se pose au plafond qui tient compte de la borne, sans alterner
|
|
||||||
//! (monophasée sur A, triphasée ; maison seule, puis lave-vaisselle sur A).
|
|
||||||
void testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit();
|
|
||||||
//! Lot délestage V2C — quand l'attribution n'est pas sûre, rien n'est rendu : triphasée avec
|
|
||||||
//! une voiture monophasée (phases visées ≠ phases annoncées), borne qui publie des courants
|
|
||||||
//! par phase nuls alors qu'elle tire.
|
|
||||||
void testUnmeteredChargerGivesNothingBackWhenUnsure();
|
|
||||||
//! Lot délestage V2C (question 5) — hors heures creuses, sous le rule-based, une borne limitée
|
|
||||||
//! par sa phase se pose sous la limite sans alterner, maison légère ou chargée.
|
|
||||||
void testWaterfallChargerSettlesUnderThePhaseLimit();
|
|
||||||
//! Lot délestage V2C (question 5) — EN DÉPASSEMENT, la règle de jour ne rend rien : phases du
|
|
||||||
//! compteur aveugles à la borne, la stratégie la tient à son plancher.
|
|
||||||
void testWaterfallChargerGetsNothingBackInOverload();
|
|
||||||
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
//! 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.
|
||||||
@ -400,36 +265,6 @@ private slots:
|
|||||||
// relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget.
|
// relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget.
|
||||||
void testVanishedRelayFreezesTheLoad();
|
void testVanishedRelayFreezesTheLoad();
|
||||||
|
|
||||||
// ── Lot délestage visible (2026-10-25) ──────────────────────────────────
|
|
||||||
//! La borne abaissée puis coupée par la protection de surcharge le DIT (OVERLOAD_THROTTLED),
|
|
||||||
//! le motif se lève au retour du cycle, et l'épisode fait UNE entrée du journal.
|
|
||||||
void testOverloadProtectionReasonOnTheCharger();
|
|
||||||
//! Une borne en mode manuel, hors de loads[] : l'épisode se lit au journal, s'ouvre et se
|
|
||||||
//! ferme par une notification qui n'a pas d'autre cause, et la reprise amont le referme.
|
|
||||||
void testOverloadProtectionOnAManualChargerIsLogged();
|
|
||||||
//! Deux mécanismes délestés par L4 (relais, SG-Ready) : un épisode de 20 min = une entrée
|
|
||||||
//! par charge, avant / après dans le langage de chaque mécanisme.
|
|
||||||
void testSheddingLogCoversEveryMechanism();
|
|
||||||
//! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés.
|
|
||||||
void testSheddingLogKeepsTheLastEpisodes();
|
|
||||||
|
|
||||||
// ── Lot délestage triphasé (2026-10-27) ─────────────────────────────────
|
|
||||||
//! Après une surcharge, la borne garde ses phases dans les comptes : allocation, commande et
|
|
||||||
//! matériel disent le même nombre de phases — triphasée sans bascule, à bascule, monophasée.
|
|
||||||
void testOverloadLeavesTheChargerItsPhases_data();
|
|
||||||
void testOverloadLeavesTheChargerItsPhases();
|
|
||||||
//! Une voiture monophasée désassignée ne laisse pas « 1 phase » à une borne triphasée.
|
|
||||||
void testAnUnassignedCarTakesItsPhasesAway();
|
|
||||||
//! Une borne qui tire sur trois phases ce que le moteur croit commandé sur une : divergente.
|
|
||||||
void testCommandDivergenceSeesAPhaseCountError();
|
|
||||||
//! Des courants par phase en retard sur le total ne retardent pas la protection.
|
|
||||||
void testOverloadReactsToTheTotalWhenPhasesLag();
|
|
||||||
//! Délesté sous son minOn, le chauffe-eau franchit le verrou ET ses relais s'ouvrent.
|
|
||||||
void testSheddingOpensTheRelaysUnderMinOn();
|
|
||||||
//! La marge de sécurité : 10 % par défaut, réglable et bornée, persistée, et c'est à 90 %
|
|
||||||
//! de la limite que la protection agit.
|
|
||||||
void testPhaseSafetyMargin();
|
|
||||||
|
|
||||||
void printStates(Thing *thing);
|
void printStates(Thing *thing);
|
||||||
void updateChargerMeter(Thing *thing);
|
void updateChargerMeter(Thing *thing);
|
||||||
|
|
||||||
|
|||||||
@ -187,43 +187,6 @@ 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");
|
||||||
@ -467,16 +430,6 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} else if (thing->thingClassId() == controllableStorageThingClassId) {
|
|
||||||
// BATTERIE À LIMITE DE DÉCHARGE PILOTABLE (lot e1) — les états modifiables d'un
|
|
||||||
// `sunspecStorage` (modèle 124) : `enableDischarging` ACTIVE la limite, `dischargingRate`
|
|
||||||
// la fixe en % (OutWRte), et le paramètre `revertTime` porte le minuteur de retour. Le
|
|
||||||
// mock n'émule PAS l'onduleur : il range ce qu'on écrit, et le test lit les écritures
|
|
||||||
// au fil des actions exécutées (ThingManager::actionExecuted). Pas de contrôleur HTTP :
|
|
||||||
// les tests posent les états directement.
|
|
||||||
info->finish(Thing::ThingErrorNoError);
|
|
||||||
return;
|
|
||||||
|
|
||||||
} else if (thing->thingClassId() == dryRelayThingClassId) {
|
} else if (thing->thingClassId() == dryRelayThingClassId) {
|
||||||
// RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne
|
// RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne
|
||||||
// savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non
|
// savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non
|
||||||
@ -640,22 +593,6 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
|||||||
c->logActionExecuted(actionType, action);
|
c->logActionExecuted(actionType, action);
|
||||||
|
|
||||||
// Update states
|
// Update states
|
||||||
if (thing->thingClassId() == controllableStorageThingClassId) {
|
|
||||||
// Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui
|
|
||||||
// obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe
|
|
||||||
// aussi — c'est au TEST de dire qu'elle n'aurait jamais dû partir.
|
|
||||||
// Propriété dynamique `refuserEcritures`, posée par le test : l'onduleur qui REFUSE
|
|
||||||
// (GEN24 sans « Allow Control », exception Modbus 0x04) — rien n'est appliqué.
|
|
||||||
if (thing->property("refuserEcritures").toBool()) {
|
|
||||||
info->finish(Thing::ThingErrorHardwareFailure);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const QString nom = actionType.name();
|
|
||||||
thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id()));
|
|
||||||
info->finish(Thing::ThingErrorNoError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thing->thingClassId() == dryRelayThingClassId) {
|
if (thing->thingClassId() == dryRelayThingClassId) {
|
||||||
if (actionType.name() == "power") {
|
if (actionType.name() == "power") {
|
||||||
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est
|
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est
|
||||||
@ -736,15 +673,6 @@ 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();
|
||||||
@ -797,24 +725,6 @@ 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());
|
||||||
|
|||||||
@ -78,7 +78,6 @@ 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);
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -717,146 +717,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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",
|
||||||
@ -1153,124 +1013,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "controllableStorage",
|
|
||||||
"displayName": "Mocked energy storage with a discharge limit",
|
|
||||||
"id": "648e1a54-96c9-4f0f-9fc8-4326ab7c643e",
|
|
||||||
"createMethods": [
|
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"interfaces": [
|
|
||||||
"energystorage"
|
|
||||||
],
|
|
||||||
"paramTypes": [
|
|
||||||
{
|
|
||||||
"id": "6250973b-c0f5-4f2d-9bc9-b077898025de",
|
|
||||||
"name": "port",
|
|
||||||
"displayName": "Port",
|
|
||||||
"type": "uint",
|
|
||||||
"defaultValue": 26690
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "13b73424-5076-46b9-8eb8-38ee0078236e",
|
|
||||||
"name": "revertTime",
|
|
||||||
"displayName": "Revert time",
|
|
||||||
"type": "uint",
|
|
||||||
"unit": "Seconds",
|
|
||||||
"minValue": 0,
|
|
||||||
"maxValue": 28800,
|
|
||||||
"defaultValue": 300
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stateTypes": [
|
|
||||||
{
|
|
||||||
"id": "f288ff92-0404-496b-a26b-5590b6896d76",
|
|
||||||
"name": "capacity",
|
|
||||||
"displayName": "Capacity",
|
|
||||||
"type": "double",
|
|
||||||
"unit": "KiloWattHour",
|
|
||||||
"defaultValue": 7.68
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ca7fcade-1599-4e7d-a0d7-a70461c16493",
|
|
||||||
"name": "batteryLevel",
|
|
||||||
"displayName": "Battery level",
|
|
||||||
"displayNameAction": "Set battery level",
|
|
||||||
"type": "int",
|
|
||||||
"unit": "Percentage",
|
|
||||||
"minValue": 0,
|
|
||||||
"maxValue": 100,
|
|
||||||
"writable": true,
|
|
||||||
"defaultValue": 60
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1f74f29f-55fa-420d-a2b3-4d2a53018dad",
|
|
||||||
"name": "currentPower",
|
|
||||||
"displayName": "Current power consumption",
|
|
||||||
"type": "double",
|
|
||||||
"unit": "Watt",
|
|
||||||
"defaultValue": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "e1381991-e137-43c7-a75a-62ea97271dca",
|
|
||||||
"name": "batteryCritical",
|
|
||||||
"displayName": "Battery level critical",
|
|
||||||
"type": "bool",
|
|
||||||
"defaultValue": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ad1597dd-b1ff-454e-99e7-293425c64c6b",
|
|
||||||
"name": "enableCharging",
|
|
||||||
"displayName": "Charge limit active",
|
|
||||||
"displayNameAction": "Set charge limit active",
|
|
||||||
"type": "bool",
|
|
||||||
"writable": true,
|
|
||||||
"defaultValue": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "11ba1548-03be-4232-a615-b8edbc564437",
|
|
||||||
"name": "enableDischarging",
|
|
||||||
"displayName": "Discharge limit active",
|
|
||||||
"displayNameAction": "Set discharge limit active",
|
|
||||||
"type": "bool",
|
|
||||||
"writable": true,
|
|
||||||
"defaultValue": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0c2d52b4-c117-477f-a13c-173bce8816a6",
|
|
||||||
"name": "chargingRate",
|
|
||||||
"displayName": "Charging rate",
|
|
||||||
"displayNameAction": "Set charging rate",
|
|
||||||
"type": "int",
|
|
||||||
"unit": "Percentage",
|
|
||||||
"minValue": -100,
|
|
||||||
"maxValue": 100,
|
|
||||||
"writable": true,
|
|
||||||
"defaultValue": 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "55ac7803-9fb4-4f4e-b38a-4af2b45eeb2e",
|
|
||||||
"name": "dischargingRate",
|
|
||||||
"displayName": "Discharging rate",
|
|
||||||
"displayNameAction": "Set discharging rate",
|
|
||||||
"type": "int",
|
|
||||||
"unit": "Percentage",
|
|
||||||
"minValue": -100,
|
|
||||||
"maxValue": 100,
|
|
||||||
"writable": true,
|
|
||||||
"defaultValue": 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "26c0e94e-fdb0-485d-afa3-ab157052d365",
|
|
||||||
"name": "gridCharging",
|
|
||||||
"displayName": "Grid charging",
|
|
||||||
"displayNameAction": "Set grid charging",
|
|
||||||
"type": "bool",
|
|
||||||
"writable": true,
|
|
||||||
"defaultValue": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "powerSwitch",
|
"name": "powerSwitch",
|
||||||
"displayName": "Mocked Power Switch (relais ECS)",
|
"displayName": "Mocked Power Switch (relais ECS)",
|
||||||
|
|||||||
@ -65,8 +65,6 @@ 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
|
||||||
|
|||||||
@ -83,8 +83,7 @@ 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) · `over-limit` (toute consigne en watts portée à 50 kW, au-delà des bornes
|
jugée avant le token).
|
||||||
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é
|
||||||
|
|||||||
@ -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', 'over-limit']
|
'old-data', 'echo-value', 'major-first']
|
||||||
|
|
||||||
# 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,13 +389,6 @@ 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}'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user