Compare commits
No commits in common. "main" and "feature/protocole-1.3" have entirely different histories.
main
...
feature/pr
@ -1,49 +1,3 @@
|
||||
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
|
||||
|
||||
* 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
|
||||
concerne pas.
|
||||
- **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
|
||||
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.
|
||||
|
||||
> **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`.
|
||||
|
||||
@ -28,7 +28,6 @@ rien sur les cas qu'il ne sait pas produire.
|
||||
| `powerSwitch` | ✅ | ❌ | — |
|
||||
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
|
||||
| `energyStorage` | ✅ | ❌ | — |
|
||||
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
|
||||
| `car`, `notification` | ❌ | ❌ | — |
|
||||
|
||||
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
|
||||
@ -229,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
|
||||
(la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en
|
||||
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 |
|
||||
| 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. |
|
||||
| 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.
|
||||
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
|
||||
{
|
||||
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` sert à savoir
|
||||
// si l'instant tombe en heures creuses (lot e2), et si l'arrêt est verrouillé (P2, plus bas).
|
||||
Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval.
|
||||
LoadContext ctx;
|
||||
ctx.id = m_charger->thing()->id().toString();
|
||||
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.
|
||||
// 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.
|
||||
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
||||
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);
|
||||
const double courantReferenceA = m_parent->evReferenceCurrentA(m_charger);
|
||||
m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA);
|
||||
if (m_targetPhases == 0)
|
||||
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
|
||||
// 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.
|
||||
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
||||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
||||
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
|
||||
// n'importe quelle borne d'adaptateur (règle 4).
|
||||
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,
|
||||
// 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);
|
||||
const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases);
|
||||
if (plafondPhaseW >= 0) {
|
||||
// …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
|
||||
@ -148,24 +120,6 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
||||
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.available = m_charger->available();
|
||||
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.sessionWh = m_charger->sessionEnergyWh();
|
||||
|
||||
// Pas de plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
|
||||
// contexte).
|
||||
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond
|
||||
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable.
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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_lastActionAt = now;
|
||||
|
||||
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Elle n'est plus posée
|
||||
// ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
|
||||
// proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
|
||||
// elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
|
||||
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de
|
||||
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de
|
||||
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est
|
||||
// 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;
|
||||
applied.currentA = clampedA;
|
||||
|
||||
@ -46,20 +46,9 @@ public:
|
||||
|
||||
/*!
|
||||
* \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
|
||||
* creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
|
||||
* et si l'ARRÊT est verrouillé (voir la note P2).
|
||||
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
|
||||
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
|
||||
* \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;
|
||||
|
||||
@ -142,9 +131,8 @@ public:
|
||||
EvCharger *evCharger() const { return m_charger; }
|
||||
|
||||
/*!
|
||||
* \brief Dernière commande tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
|
||||
* PASSÉ ses verrous (\c noterCommandeTenue()).
|
||||
* \param[out] ampere Courant tenu (A), 0 si la charge a été coupée.
|
||||
* \brief Dernière commande envoyée au matériel par cet adaptateur.
|
||||
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée.
|
||||
* \param[out] phases Nombre de phases commandé.
|
||||
* \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.
|
||||
@ -152,30 +140,9 @@ public:
|
||||
bool derniereCommande(uint &ere, uint &phases) const
|
||||
{ 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:
|
||||
/*!
|
||||
* \brief Dernière commande RÉELLEMENT tenue par le matériel — écrêtée et passée par les
|
||||
* verrous de la borne, pas demandée.
|
||||
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée.
|
||||
*
|
||||
* \par Pourquoi ce souvenir n'existait pas
|
||||
* 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.
|
||||
* 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_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
|
||||
//! consomme, ce qui est le cas NORMAL du mode manuel.
|
||||
bool m_commandeValide = false;
|
||||
QDateTime m_commandeAt; //!< Instant de la dernière commande.
|
||||
|
||||
EvCharger *m_charger;
|
||||
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/etmvariableloadadapter.h"
|
||||
#include "adapters/relayrouter.h"
|
||||
#include "adapters/storagedischargeguard.h"
|
||||
#include "config/loadconfigstore.h"
|
||||
#include "types/loadconfig.h"
|
||||
#include "scheduler/rulebasedscheduler.h"
|
||||
@ -31,7 +30,6 @@
|
||||
#include <QJsonDocument>
|
||||
#include <QFileInfo>
|
||||
#include <nymeasettings.h>
|
||||
#include <QScopeGuard>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
@ -60,7 +58,6 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
, m_scheduler(new RuleBasedScheduler(this, this))
|
||||
, m_strategy(m_scheduler)
|
||||
, m_em(em)
|
||||
, m_storageGuard(new StorageDischargeGuard(tm, this))
|
||||
, m_tm(tm)
|
||||
{
|
||||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||||
@ -433,10 +430,6 @@ void EnergyArbitrator::rebuildLoadAdapters()
|
||||
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
{
|
||||
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.
|
||||
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
||||
// 5.planSpot 6.planSurplus 7.adjustEv
|
||||
@ -536,15 +529,6 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
||||
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
|
||||
// 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
|
||||
@ -563,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
|
||||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||||
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);
|
||||
|
||||
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
|
||||
@ -818,9 +797,6 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
||||
{
|
||||
m_shedActions.clear();
|
||||
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)
|
||||
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
|
||||
|
||||
@ -872,11 +848,6 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
||||
LoadAction shed;
|
||||
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
|
||||
// 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).
|
||||
@ -952,22 +923,6 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
||||
// REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
|
||||
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
|
||||
<< "→" << renderFr(publie.reason, id)
|
||||
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W";
|
||||
@ -995,238 +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()}};
|
||||
// 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 (RootMeter *compteur = internalRootMeter()) {
|
||||
const double limiteW = 230.0 * phasePowerLimit();
|
||||
const QHash<QString, double> parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()},
|
||||
{QStringLiteral("B"), compteur->currentPowerPhaseB()},
|
||||
{QStringLiteral("C"), compteur->currentPowerPhaseC()}};
|
||||
if (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
|
||||
{
|
||||
if (!ev)
|
||||
@ -1477,9 +1200,6 @@ 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
|
||||
// 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.)
|
||||
// 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
|
||||
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
|
||||
@ -1488,59 +1208,6 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
|
||||
return qMax(0.0, marge.watts) * phaseCount;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
RootMeter *meter = internalRootMeter();
|
||||
if (!ev || phaseCount == 0 || !meter)
|
||||
return -1;
|
||||
|
||||
const QList<DrawCap> plafonds = drawCaps();
|
||||
if (plafonds.isEmpty())
|
||||
return -1;
|
||||
|
||||
// Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui
|
||||
// prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16
|
||||
// ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien.
|
||||
const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull();
|
||||
const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone;
|
||||
// Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et
|
||||
// la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases
|
||||
// mesurées ≠ phases visées), on ne rend donc rien.
|
||||
const bool attribuable = tirees != Electricity::PhaseNone
|
||||
&& Electricity::getPhaseCount(tirees) == phaseCount;
|
||||
const Electricity::Phases masque = attribuable
|
||||
? tirees : const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
||||
const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0;
|
||||
|
||||
// Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui
|
||||
// annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la
|
||||
// limite. Au mieux, une phase vaut sa limite.
|
||||
QList<double> sansLaBorne = phaseDrawW(masque, phasePowerLimit());
|
||||
for (double &w : sansLaBorne)
|
||||
w = qMax(0.0, w - tireW / phaseCount);
|
||||
const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne,
|
||||
qMax(0.0, meter->currentPower() - tireW));
|
||||
if (!marge.borne)
|
||||
return -1;
|
||||
|
||||
// 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);
|
||||
return qMax(0.0, parPhaseA) * 230.0 * phaseCount;
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
||||
{
|
||||
return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString());
|
||||
@ -1808,28 +1475,26 @@ void EnergyArbitrator::alimenterAvancement(LoadContext &lc) 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);
|
||||
if (depuis.isValid())
|
||||
lc.telemetry.refusedSinceS = qMax(0, static_cast<int>(depuis.secsTo(now)));
|
||||
lc.telemetry.commandRefused = j->refus;
|
||||
}
|
||||
if (!depuis.isValid())
|
||||
return; // aucun écart en cours : rien à qualifier
|
||||
|
||||
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
|
||||
{
|
||||
// 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 scheduler sert.
|
||||
return !m_tariff.zonesCreuses().isEmpty()
|
||||
&& m_tariff.enHeuresCreuses(now, QTimeZone::systemTimeZone());
|
||||
const int ecoule = static_cast<int>(depuis.secsTo(now));
|
||||
lc.telemetry.refusedSinceS = qMax(0, ecoule);
|
||||
|
||||
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
|
||||
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
|
||||
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
|
||||
ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr);
|
||||
if (!a)
|
||||
a = m_adapters.value(lc.id, nullptr);
|
||||
if (!a)
|
||||
return;
|
||||
|
||||
// Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux —
|
||||
// commander la baisse sans être suivi — est justement celui qui se constate le plus vite.
|
||||
const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0;
|
||||
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
|
||||
}
|
||||
|
||||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
@ -1855,28 +1520,6 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
}
|
||||
// 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) ------------------------------------------------------------------
|
||||
// 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
|
||||
@ -2283,11 +1926,7 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
|
||||
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
||||
}
|
||||
|
||||
// Batterie : rendue à son AUTOMATISME, explicitement (lot e1). Sous compteur muet le moteur
|
||||
// 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);
|
||||
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
||||
|
||||
// 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
|
||||
@ -2388,7 +2027,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
QVariantMap mech;
|
||||
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
|
||||
orphan.insert(QStringLiteral("mechanism"), mech);
|
||||
attachMeasurements(orphan, action.loadId, now);
|
||||
attachMeasurements(orphan, action.loadId);
|
||||
loads.append(orphan);
|
||||
continue;
|
||||
}
|
||||
@ -2511,7 +2150,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||||
entry.insert(QStringLiteral("mechanism"), mech);
|
||||
}
|
||||
attachMeasurements(entry, action.loadId, now);
|
||||
attachMeasurements(entry, action.loadId);
|
||||
loads.append(entry);
|
||||
}
|
||||
|
||||
@ -2537,119 +2176,14 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
|
||||
namespace {
|
||||
//! 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 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,
|
||||
const QString &source, double mesureW,
|
||||
const QDateTime &now) const
|
||||
const QString &source, double mesureW) const
|
||||
{
|
||||
// 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.
|
||||
@ -2662,34 +2196,58 @@ void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString
|
||||
const std::optional<double> commandeeW = puissanceCommandee(loadId);
|
||||
if (!commandeeW)
|
||||
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
|
||||
// sur la commande en vigueur et la mesure fraîche. Il n'est PAS refait ici sur la consigne
|
||||
// qui vient de partir : c'était le défaut, une marche suivie se lisait comme un refus. Sans
|
||||
// commande en vigueur au début du cycle (première commande), le jugement est fait ici, sur
|
||||
// celle qui vient de partir : récente, donc jamais divergente.
|
||||
const std::optional<JugementCommande> j = jugerCommande(loadId, now);
|
||||
const bool divergent = j && j->refus;
|
||||
const double ecart = qAbs(attenduW - mesureW);
|
||||
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW);
|
||||
|
||||
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un
|
||||
// déphasage d'un cycle entre l'écriture et sa mesure. 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.
|
||||
// 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;
|
||||
commande.insert(QStringLiteral("expectedW"), *commandeeW);
|
||||
commande.insert(QStringLiteral("expectedW"), attenduW);
|
||||
commande.insert(QStringLiteral("observedW"), mesureW);
|
||||
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance et la latence sont
|
||||
// des connaissances du moteur. Les laisser au client ferait réinventer un seuil par écran,
|
||||
// et deux écrans ne diraient pas la même chose du même cycle. Les deux nombres sont ceux de
|
||||
// la publication : la commande qui vient de partir peut ne pas être celle qui a été jugée.
|
||||
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une
|
||||
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La
|
||||
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la
|
||||
// même chose du même cycle.
|
||||
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)
|
||||
commande.insert(QStringLiteral("since"),
|
||||
m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate));
|
||||
entry.insert(QStringLiteral("command"), commande);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId,
|
||||
const QDateTime &now) const
|
||||
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
|
||||
{
|
||||
if (!m_tm)
|
||||
return;
|
||||
@ -2715,7 +2273,7 @@ void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loa
|
||||
mesure.insert(QStringLiteral("powerW"), powerW);
|
||||
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
|
||||
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
|
||||
@ -2825,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
|
||||
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
|
||||
attachMeasurements(entry, id, now);
|
||||
attachMeasurements(entry, id);
|
||||
loads.append(entry);
|
||||
};
|
||||
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
|
||||
@ -2849,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,
|
||||
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
|
||||
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;
|
||||
}
|
||||
|
||||
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 sites;
|
||||
@ -3847,19 +3383,6 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
|
||||
// pris fin — ni pourquoi.
|
||||
if (b.contains(QStringLiteral("derogationEnded")))
|
||||
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
|
||||
// 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.
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
|
||||
#include "types/loadconfig.h"
|
||||
#include "types/drawcap.h"
|
||||
#include "types/sheddinglog.h"
|
||||
#include "types/derogation.h"
|
||||
#include "forecast/forecaststore.h"
|
||||
#include "forecast/forecastlearninglog.h"
|
||||
@ -30,7 +29,6 @@ class EvAdapter;
|
||||
class SgReadyAdapter;
|
||||
class EtmVariableLoadAdapter;
|
||||
class RelayRouter;
|
||||
class StorageDischargeGuard;
|
||||
class ILoadAdapter;
|
||||
class LoadConfigStore;
|
||||
class RuleBasedScheduler;
|
||||
@ -124,25 +122,10 @@ public:
|
||||
|
||||
/*!
|
||||
* \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),
|
||||
* 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à.
|
||||
* \note EXPOSÉ, jamais lu par une décision : \c SurplusContext::tariff n'est pas rempli (§11).
|
||||
*/
|
||||
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.
|
||||
* \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; }
|
||||
|
||||
/*!
|
||||
* \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.
|
||||
*
|
||||
@ -808,41 +774,6 @@ public:
|
||||
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
|
||||
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.
|
||||
*
|
||||
@ -895,42 +826,6 @@ public:
|
||||
*/
|
||||
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 mesure, et
|
||||
* seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans
|
||||
* mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même.
|
||||
* - 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, et les décisions sans tarif ne changent pas.
|
||||
*/
|
||||
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
|
||||
|
||||
/*!
|
||||
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
|
||||
*
|
||||
@ -990,25 +885,6 @@ protected:
|
||||
*/
|
||||
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:
|
||||
/*!
|
||||
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
|
||||
@ -1153,14 +1029,6 @@ private:
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@ -1176,9 +1044,8 @@ private:
|
||||
*
|
||||
* \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 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.
|
||||
struct MesureCharge {
|
||||
@ -1206,49 +1073,9 @@ private:
|
||||
* \c refusedSinceS. \param now Temps de cycle.
|
||||
* \note Le seuil vient de \c ILoadAdapter::commandSettleS, jamais d'une constante : trop
|
||||
* 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;
|
||||
|
||||
//! \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é.
|
||||
* \param[in,out] lc Charge du cycle.
|
||||
@ -1315,15 +1142,6 @@ private:
|
||||
*
|
||||
* \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 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
|
||||
* | | concordance | divergence |
|
||||
@ -1340,8 +1158,7 @@ private:
|
||||
* publie, il ne se diagnostique pas — la cause n'est pas dans les watts.
|
||||
*/
|
||||
void attachCommandDivergence(QVariantMap &entry, const QString &loadId,
|
||||
const QString &source, double mesureW,
|
||||
const QDateTime &now) const;
|
||||
const QString &source, double mesureW) const;
|
||||
|
||||
/*!
|
||||
* \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée.
|
||||
@ -1417,8 +1234,6 @@ private:
|
||||
QString m_dossierTrace;
|
||||
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
||||
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*.
|
||||
/*!
|
||||
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
|
||||
@ -1481,26 +1296,6 @@ private:
|
||||
//! 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.
|
||||
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
|
||||
//! (\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
|
||||
@ -1534,15 +1329,7 @@ private:
|
||||
//! \brief Mode mémorisé rendu, publié avec l'événement de fin.
|
||||
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;
|
||||
//! 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 :
|
||||
//! énergie MESURÉE × part éco de l'action appliquée (\c noterAchatEco()).
|
||||
mutable QHash<QString, double> m_ecoAchatWh;
|
||||
|
||||
@ -5,7 +5,6 @@ HEADERS += \
|
||||
$$PWD/types/surpluscontext.h \
|
||||
$$PWD/types/plan.h \
|
||||
$$PWD/types/drawcap.h \
|
||||
$$PWD/types/sheddinglog.h \
|
||||
$$PWD/types/ecoprogress.h \
|
||||
$$PWD/types/ecotarget.h \
|
||||
$$PWD/types/loadconfig.h \
|
||||
@ -17,7 +16,6 @@ HEADERS += \
|
||||
$$PWD/adapters/sgreadyadapter.h \
|
||||
$$PWD/adapters/etmvariableloadadapter.h \
|
||||
$$PWD/adapters/relayrouter.h \
|
||||
$$PWD/adapters/storagedischargeguard.h \
|
||||
$$PWD/scheduler/rulebasedscheduler.h \
|
||||
$$PWD/scheduler/optimizerprotocol.h \
|
||||
$$PWD/scheduler/optimizerclient.h \
|
||||
@ -34,7 +32,6 @@ HEADERS += \
|
||||
SOURCES += \
|
||||
$$PWD/types/decisionreason.cpp \
|
||||
$$PWD/types/drawcap.cpp \
|
||||
$$PWD/types/sheddinglog.cpp \
|
||||
$$PWD/types/ecotarget.cpp \
|
||||
$$PWD/types/loadconfig.cpp \
|
||||
$$PWD/tariff/tariffconfig.cpp \
|
||||
@ -44,7 +41,6 @@ SOURCES += \
|
||||
$$PWD/adapters/sgreadyadapter.cpp \
|
||||
$$PWD/adapters/etmvariableloadadapter.cpp \
|
||||
$$PWD/adapters/relayrouter.cpp \
|
||||
$$PWD/adapters/storagedischargeguard.cpp \
|
||||
$$PWD/scheduler/rulebasedscheduler.cpp \
|
||||
$$PWD/scheduler/optimizerprotocol.cpp \
|
||||
$$PWD/scheduler/optimizerclient.cpp \
|
||||
|
||||
@ -246,31 +246,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
continue;
|
||||
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
|
||||
// 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 à
|
||||
@ -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
|
||||
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
|
||||
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 --------------------------------------------------
|
||||
//
|
||||
@ -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
|
||||
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
|
||||
double achatW = cible.watts;
|
||||
|
||||
// Lot e2 — LA BORNE À PLEINE PUISSANCE EN HEURES CREUSES. Son minimum y est son plafond de
|
||||
// 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)
|
||||
const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts);
|
||||
if (margeRestante.borne)
|
||||
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
|
||||
@ -519,31 +475,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
@ -602,29 +533,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
waterfallEvIds.insert(lc.id);
|
||||
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;
|
||||
// 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"))
|
||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
||||
else
|
||||
slot.actions.append(emitSetpointAction(
|
||||
decideSetpoint(lcConfort, remainingSurplusW, margeRestante, compta, draw)));
|
||||
decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw)));
|
||||
// etmvariableload + relay-router + evcharger
|
||||
|
||||
// Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles
|
||||
@ -636,19 +549,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
// court-cycling fabriqué par l'arbitre lui-même.
|
||||
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
||||
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
|
||||
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.powerW += eco.targetW;
|
||||
la.estimatedPowerW += eco.targetW;
|
||||
@ -675,39 +577,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
|
||||
// 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.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
plan.strategy = QStringLiteral("rules");
|
||||
@ -774,14 +643,6 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
|
||||
return { DecisionCode::SurplusSetpoint,
|
||||
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
|
||||
{"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)
|
||||
// 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
|
||||
@ -936,10 +797,7 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
|
||||
// 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.
|
||||
const double tolerance = m_arbitrator->acquisitionTolerance();
|
||||
// Lot e1 — hors heures creuses, une borne qui attend les heures creuses n'est servie
|
||||
// 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)) {
|
||||
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) {
|
||||
const double soutireW = plancherW - budgetW;
|
||||
|
||||
SetpointTarget t;
|
||||
@ -1129,7 +987,6 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
|
||||
t.entrees.refuse = refuse;
|
||||
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
|
||||
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).
|
||||
remainingSurplusW = budgetW - setpointW;
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include "ischeduler.h"
|
||||
|
||||
class EvCharger;
|
||||
@ -62,18 +61,6 @@ public:
|
||||
*/
|
||||
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:
|
||||
/*!
|
||||
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
|
||||
@ -132,8 +119,6 @@ private:
|
||||
bool refuse = false;
|
||||
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
|
||||
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
|
||||
//! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 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());
|
||||
}
|
||||
|
||||
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,
|
||||
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 —
|
||||
* 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
|
||||
* \c SurplusContext::tariff — l'optimiseur ne le reçoit pas par là. Le moteur n'en tire
|
||||
* qu'une chose : quelles minutes sont « creuses » (\c zonesCreuses(), \c enHeuresCreuses()),
|
||||
* 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.
|
||||
* \c SurplusContext::tariff. Son usage par une décision (rattrapage éco en HC, e1) est un lot
|
||||
* séparé ; \c zoneAt() existe pour lui, et rien ne l'appelle aujourd'hui depuis le plan.
|
||||
*/
|
||||
|
||||
//! \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 fuseau Fuseau de la box.
|
||||
* \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;
|
||||
|
||||
/*!
|
||||
* \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.
|
||||
* \param start Début du premier créneau.
|
||||
|
||||
@ -123,20 +123,6 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
||||
"%3 n'est pas servie")
|
||||
.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)) {
|
||||
const QString pourquoi = 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
|
||||
// là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
|
||||
auto delaiEco = [&p]() {
|
||||
@ -342,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);
|
||||
}
|
||||
|
||||
// --- 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 ------------------------------------------
|
||||
if (c == QLatin1String(DecisionCode::DegradedL2))
|
||||
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
||||
@ -447,14 +384,13 @@ QStringList DecisionCode::knownCodes()
|
||||
return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle,
|
||||
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
|
||||
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
|
||||
OverloadThrottled, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
||||
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
||||
DerogationActive,
|
||||
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
||||
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||
OptimizerDecision, PriceSlot,
|
||||
EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic,
|
||||
StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||
}
|
||||
|
||||
QStringList DecisionCode::optimizerEmittableCodes()
|
||||
|
||||
@ -176,29 +176,6 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT";
|
||||
*/
|
||||
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).
|
||||
*
|
||||
* \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.
|
||||
*
|
||||
@ -451,70 +428,6 @@ constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION";
|
||||
*/
|
||||
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 ----------------------------------------------
|
||||
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
|
||||
constexpr const char *DegradedL2 = "DEGRADED_L2";
|
||||
|
||||
@ -109,22 +109,6 @@ struct LoadNeeds {
|
||||
* L'écrire en configuration créerait une copie qui se périme au premier recâblage.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \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().
|
||||
*
|
||||
|
||||
@ -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).
|
||||
};
|
||||
|
||||
/*!
|
||||
* \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). */
|
||||
struct TariffEntry {
|
||||
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.
|
||||
SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants).
|
||||
// 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>
|
||||
#endif
|
||||
#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 "smartchargingmanager.h"
|
||||
#include "spotmarket/spotmarketmanager.h"
|
||||
@ -332,39 +331,6 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
returns = loadTelemetrySchema();
|
||||
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 ------------------------------------------------------------------------
|
||||
//
|
||||
// UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE
|
||||
@ -1460,35 +1426,6 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
||||
derogEndItem.insert("o:restoredMode", enumValueName(Int));
|
||||
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);
|
||||
return schema;
|
||||
}
|
||||
@ -1517,17 +1454,6 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms)
|
||||
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)
|
||||
{
|
||||
const QVariantMap succes{{"forecastPoints", QVariantList()},
|
||||
|
||||
@ -119,17 +119,6 @@ public:
|
||||
* notification seule le laisse dans un silence ambigu (famille ECS-411).
|
||||
*/
|
||||
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).
|
||||
//! \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.
|
||||
|
||||
@ -131,12 +131,8 @@ signals:
|
||||
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
|
||||
// internalProcessInfo(), internalHasProcessInfo()
|
||||
// — accesseurs ETM neufs, documentés ci-dessous.
|
||||
// 5. bestPhaseCountFor(), phasesForCount(), chargerPhaseFor()
|
||||
// — passe-plats ETM vers trois règles amont
|
||||
// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont
|
||||
// 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
|
||||
// ci-dessous. Pas un réglage : jamais persistée.
|
||||
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
|
||||
@ -158,28 +154,7 @@ protected slots:
|
||||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
|
||||
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); }
|
||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
|
||||
/*!
|
||||
* \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné.
|
||||
|
||||
@ -2202,7 +2202,6 @@ void TestCharging::testNymeaEnergyPermissionScopes()
|
||||
{"GetSpotMarketScoreEntries", none},
|
||||
{"GetLoadConfig", none},
|
||||
{"GetLoadTelemetry", none},
|
||||
{"GetSheddingLog", none}, // même droit que la télémétrie : un compte sans droit le lit
|
||||
{"GetEnergyRatios", none},
|
||||
{"GetPvForecast", none},
|
||||
{"GetForecastSite", none},
|
||||
@ -2323,15 +2322,12 @@ void TestCharging::testNymeaEnergyPermissionsEnforced()
|
||||
// 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.
|
||||
QVERIFY(lecteur.authentificationExigee);
|
||||
for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetSheddingLog"),
|
||||
QStringLiteral("GetTariff"), QStringLiteral("GetBatteryLevelConsideration")}) {
|
||||
for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"),
|
||||
QStringLiteral("GetBatteryLevelConsideration")}) {
|
||||
const QVariantMap r = appeler(lecteur, m);
|
||||
QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact)));
|
||||
}
|
||||
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()}})));
|
||||
QCOMPARE(lireAdmin("GetTariff"), tarifAvant);
|
||||
QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}})));
|
||||
|
||||
Binary file not shown.
@ -53,7 +53,6 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
||||
for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay,
|
||||
DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady,
|
||||
DecisionCode::LoadUnavailable, DecisionCode::DrawCap,
|
||||
DecisionCode::OverloadThrottled, // la protection de surcharge de l'amont
|
||||
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
|
||||
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
|
||||
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)
|
||||
|
||||
@ -2656,91 +2656,6 @@ void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays()
|
||||
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()
|
||||
{
|
||||
QString cause;
|
||||
|
||||
@ -142,8 +142,6 @@ private slots:
|
||||
void testTheTariffIsValidatedStrictly();
|
||||
//! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt.
|
||||
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.
|
||||
void testAStraddlingSlotGetsTheTimeWeightedMean();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -27,12 +27,6 @@
|
||||
|
||||
#include "simulationbase.h"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class EnergyArbitrator;
|
||||
struct EcritureBatterie;
|
||||
|
||||
using namespace nymeaserver;
|
||||
|
||||
class ChargerPlugEvent
|
||||
@ -58,55 +52,6 @@ public:
|
||||
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
|
||||
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.
|
||||
enum class BorneHc { Monophasee, Triphasee, Bascule };
|
||||
//! 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);
|
||||
|
||||
// ── 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:
|
||||
void run_data();
|
||||
void run();
|
||||
@ -267,18 +212,6 @@ private slots:
|
||||
void testPhaseVoltageDoesNotLeakBetweenPhases();
|
||||
void testMeterEnergyActuallyAccumulates();
|
||||
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 testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
void testDerogationTakesTheHeadButNotTheSafety();
|
||||
@ -299,46 +232,6 @@ private slots:
|
||||
void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid();
|
||||
//! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan.
|
||||
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();
|
||||
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||
void testEveryGetterReturnsWhatItDeclares();
|
||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
||||
@ -372,19 +265,6 @@ private slots:
|
||||
// relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget.
|
||||
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();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
@ -430,16 +430,6 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
||||
|
||||
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) {
|
||||
// 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
|
||||
@ -603,22 +593,6 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
||||
c->logActionExecuted(actionType, action);
|
||||
|
||||
// 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 (actionType.name() == "power") {
|
||||
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est
|
||||
|
||||
@ -1013,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",
|
||||
"displayName": "Mocked Power Switch (relais ECS)",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user