Compare commits
No commits in common. "main" and "fix/instants-decalage" have entirely different histories.
main
...
fix/instan
@ -1,98 +1,3 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm77) trixie; urgency=medium
|
||||
|
||||
* Délestage : marge de sécurité réglable sur la limite de phase (10 % par
|
||||
défaut, 0 à 25 %, phaseSafetyMarginPercent) ; le moteur vise la limite
|
||||
effective (effectivePhaseLimit) pour le plafond, l'allocation, la
|
||||
protection et la limite transmise à l'optimiseur.
|
||||
* Une borne triphasée qui ne peut pas basculer n'est plus comptée en
|
||||
monophasé après un délestage (allocation et consigne justes).
|
||||
* Les courants par phase sont rendus cohérents avec le total du compteur
|
||||
(relevés, jamais abaissés) : la protection ne réagit plus en retard.
|
||||
* Sous un optimiseur, une hausse de consigne est bornée par ce que le
|
||||
soutirage permet ; une consigne écrêtée publie DRAW_CAP.
|
||||
* Une borne qui ne publie pas ses courants par phase (V2C Trydan) rend son
|
||||
tirage mesuré à la phase que le moteur lui suppose : la consigne ne
|
||||
bascule plus sous la limite de phase, en heures creuses comme de jour.
|
||||
* Hors heures creuses aussi, la somme éco + confort d'une borne tient dans
|
||||
son plafond de cycle.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 27 Oct 2026 17:17:43 +0100
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm76) trixie; urgency=medium
|
||||
|
||||
* Borne au surplus : la consigne n'oscille plus sous un surplus constant.
|
||||
L'écart commande/mesure est jugé au début du cycle, sur la commande en
|
||||
vigueur et la mesure fraîche, daté à l'instant de la commande ; une montée
|
||||
qui progresse est une latence, une descente jamais. Une marche suivie ne
|
||||
suspend plus le recrédit ; un vrai refus reste vu au cycle suivant.
|
||||
* La borne retient ce qu'elle tient, quel que soit l'émetteur (répartition,
|
||||
échéance, protection L4) ; un arrêt refusé par le verrou de marche/arrêt
|
||||
gèle son courant minimal (LOCK_MIN_ON) au lieu de le prêter. Le verrou
|
||||
borne la somme éco + confort.
|
||||
* command.divergent publie ce verdict (même cycle que RECREDIT_SUSPENDED).
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 25 Oct 2026 20:07:00 +0100
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm75) trixie; urgency=medium
|
||||
|
||||
* Heures creuses : zones au prix le plus bas du tarif. En mode éco, la borne
|
||||
attend les heures creuses (EV_WAITING_OFF_PEAK) et y charge à la pleine
|
||||
puissance permise (EV_OFF_PEAK_FULL_POWER), sous la limite de phase
|
||||
mesurée moins 1 A ; la somme éco + confort tient dans le plafond de la
|
||||
borne. Sans tarif à heures creuses, aucune décision ne change.
|
||||
* Batterie : la décharge est bloquée pendant que la voiture charge en heures
|
||||
creuses (limite de décharge SunSpec, rafraîchie au tiers du minuteur,
|
||||
levée inverse) ; une consigne refusée est réessayée en s'espaçant, la
|
||||
levée jamais. Bloc storage dans la télémétrie.
|
||||
* Délestage visible : la protection de surcharge publie ce qu'elle fait à la
|
||||
borne (OVERLOAD_THROTTLED : abaissée ou coupée, phase, limite,
|
||||
dépassement) ; journal court des 20 derniers épisodes de délestage, toutes
|
||||
charges, par NymeaEnergy.GetSheddingLog (lecture sans droit, en mémoire) ;
|
||||
télémétrie shedding (revision, open), notifiée à l'ouverture et à la
|
||||
fermeture. La protection décide exactement comme avant.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 25 Oct 2026 13:00:00 +0100
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm74) trixie; urgency=medium
|
||||
|
||||
* Protocole optimiseur 1.3 : le moteur annonce 1.3. Forme normée de
|
||||
error.data d'un contexte GetPlan invalide (problems : chemin et code d'une
|
||||
liste fermée, 50 au plus), token jugé avant la majeure au handshake ; le
|
||||
moteur ne lit pas error.data, son pilotage ne change pas.
|
||||
* Validateur du contrat, suite de conformité et optimiseur de référence
|
||||
alignés sur 1.3 (forme exigée à partir de 1.3, avertie pour un 1.2).
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 23 Oct 2026 14:00:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm73) trixie; urgency=medium
|
||||
|
||||
* SetLoadConfig : un refus publie son motif (LOAD_CONFIG_REFUSED, cause et
|
||||
endroit) ; identifiant et échéance quotidienne bornés à l'écriture.
|
||||
* Une borne ajoutée d'office au nom trop long garde un libellé coupé à 256
|
||||
caractères.
|
||||
* Journal : un refus ne recopie plus la saisie du client ; les valeurs
|
||||
héritées citées au chargement sont échappées, les libellés cités dans les
|
||||
refus d'ensemble bornés à 64 caractères.
|
||||
* Trace optimiseur : une requête non envoyée n'est tracée que par sa taille
|
||||
(requestBytes).
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 23 Oct 2026 09:00:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm72) trixie; urgency=medium
|
||||
|
||||
* Protocole optimiseur 1.2 : règles Q2 à Q9 (codes d'erreur normés, lots,
|
||||
fermeture, longueur de ligne), le moteur annonce 1.2 ; optimiseur de
|
||||
référence et sonde de conformance alignés.
|
||||
* Q7 : le moteur n'émet jamais de ligne de plus de 1 Mio ; une requête
|
||||
GetPlan trop longue n'est pas envoyée, échoue en request-too-long et compte
|
||||
comme un échec ; journal une fois par connexion, sans contenu.
|
||||
* SetLoadConfig : libellé de charge borné à 256 caractères à l'écriture ; une
|
||||
configuration enregistrée plus longue est chargée telle quelle.
|
||||
* Protocole optimiseur : tout instant émis porte son décalage UTC (fuseaux
|
||||
non UTC, changement d'heure).
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 22 Oct 2026 20:00:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm71) trixie; urgency=medium
|
||||
|
||||
* Éco sous le premier palier : quand l'obligation demande moins que le
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -25,18 +25,11 @@ rien sur les cas qu'il ne sait pas produire.
|
||||
| `charger` | ✅ | ✅ | ❌ |
|
||||
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
|
||||
| `simpleCharger` | ❌ | ❌ | ❌ |
|
||||
| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ |
|
||||
| `powerSwitch` | ✅ | ❌ | — |
|
||||
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
|
||||
| `energyStorage` | ✅ | ❌ | — |
|
||||
| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — |
|
||||
| `car`, `notification` | ❌ | ❌ | — |
|
||||
|
||||
`chargerUnmetered` mesure sa puissance mais publie ses courants sous `currentL1..3`, comme le
|
||||
plugin V2C Trydan, et jamais sous `currentPhaseX` : `EvCharger::meteredPhases()` y est vide. Les
|
||||
autres bornes du mock publiaient toutes leurs phases, si bien qu'une borne **non mesurée par
|
||||
phase** — le cas d'un V2C Trydan — était introuvable par la suite.
|
||||
|
||||
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
|
||||
sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` /
|
||||
`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.
|
||||
@ -235,20 +228,3 @@ qui ne mesure qu'une charge.
|
||||
site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2
|
||||
(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.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# OPTIMIZER_PROTOCOL — v1.3
|
||||
# OPTIMIZER_PROTOCOL — v1.2
|
||||
|
||||
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
|
||||
et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce).
|
||||
@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages :
|
||||
2. Spécification du protocole socket pour un optimiseur en process séparé.
|
||||
3. Documentation publique : n'importe qui peut implémenter son propre optimiseur.
|
||||
|
||||
Statut : **v1.3** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||||
Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||||
|
||||
> ### La forme est un contrat MACHINE
|
||||
>
|
||||
@ -42,47 +42,19 @@ Statut : **v1.3** — figé à la fin de la beta. Versionné en semver (`protoco
|
||||
|
||||
## 0. Changelog
|
||||
|
||||
### 1.3 (2026-10)
|
||||
Mineure. Norme la **forme d'`error.data`** de l'erreur `-32602` à un `GetPlan` dont le contexte est
|
||||
invalide, et écrit deux règles que la 1.2 laissait à déduire. **Côté moteur, seule la version
|
||||
annoncée change** : il n'émet aucun message nouveau, juge les réponses exactement comme en 1.2, et
|
||||
ne lit pas `error.data` (§7).
|
||||
|
||||
**Compatibilité.** Même majeure : un moteur 1.3 accepte un optimiseur 1.0 à 1.2, et un optimiseur
|
||||
1.3 accepte un moteur 1.0 à 1.2 (seule la majeure est comparée, §3). Les deux règles nouvelles
|
||||
portent sur des situations que le moteur ne produit pas — un contexte invalide, un handshake d'une
|
||||
autre majeure. Un optimiseur 1.2 qui renvoie un autre `data`, ou qui juge la majeure avant le token,
|
||||
**continue de fonctionner avec ce moteur** : le moteur ne lit pas `error.data`, et traite toute
|
||||
réponse `error` au handshake comme un refus, quel que soit son code. Il n'est pas conforme 1.3 sur
|
||||
ces points, et s'aligne avant de s'annoncer 1.3 — la suite de conformité l'exige d'un optimiseur qui
|
||||
s'annonce 1.3 ou plus, et se contente d'un avertissement pour un optimiseur 1.2 (annexe G).
|
||||
|
||||
- §7 **`error.data` d'un contexte invalide** : `{"problems":[{"path","problem"}]}` — liste non vide
|
||||
d'au plus 50 problèmes (`truncated: true` à côté s'il y en avait davantage) ; `path` depuis
|
||||
`params`, points et crochets ; `problem` dans une liste fermée (`missing`, `type`, `range`,
|
||||
`format`, `enum`, `constraint`). Jamais la valeur reçue, dans aucun champ. Le moteur ignore un
|
||||
code qu'il ne connaît pas (§9) et ne lit pas `error.data` pour décider.
|
||||
- §3 **Token faux et autre majeure dans le même handshake** : le **token est jugé d'abord** —
|
||||
`-32001`, puis fermeture.
|
||||
- §2 **Un lot reçu par le moteur reste sans réponse** : c'est l'intention, désormais écrite (le
|
||||
comportement est celui de 1.2).
|
||||
- Contrat machine : composants `InvalidContextData`, `ContextProblem`, `ContextProblemCode` ;
|
||||
`x-etm-data-schema` et `x-etm-data-required-from` sur l'erreur `-32602` de `GetPlan` ; ordre
|
||||
token puis majeure dans les erreurs du `Handshake`.
|
||||
|
||||
### 1.2 (2026-10)
|
||||
Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par
|
||||
l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne,
|
||||
lots, propriétaire de `/run/powersync`. **Côté moteur, la version annoncée change, et il borne
|
||||
ce qu'il émet** (§2 : une requête de plus de 1 Mio n'est pas envoyée) ; il n'émet aucun message
|
||||
nouveau et juge les réponses exactement comme en 1.1.
|
||||
lots, propriétaire de `/run/powersync`. **Côté moteur, seule la version annoncée change** : il
|
||||
n'émet aucun message nouveau et juge les réponses exactement comme en 1.1.
|
||||
|
||||
**Compatibilité.** Même majeure : un moteur 1.2 accepte un optimiseur 1.0 ou 1.1, et un
|
||||
optimiseur 1.2 accepte un moteur 1.0 ou 1.1 (seule la majeure est comparée, §3). Les règles
|
||||
nouvelles portent sur ce que fait l'optimiseur dans des situations que le moteur ne produit pas —
|
||||
contexte invalide, `GetPlan` avant handshake, autre majeure, lot, ligne de plus de 1 Mio (le
|
||||
moteur ne l'émet pas, §2). Un optimiseur 1.1 qui y répond autrement continue de fonctionner avec
|
||||
ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de s'annoncer 1.2.
|
||||
contexte invalide, `GetPlan` avant handshake, autre majeure, lot — et sur une qu'il peut produire,
|
||||
la ligne de plus de 1 Mio (§2, écart connu). Un optimiseur 1.1 qui y répond autrement continue de
|
||||
fonctionner avec ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de
|
||||
s'annoncer 1.2.
|
||||
|
||||
- §7 **Contexte `GetPlan` invalide** : l'optimiseur répond une **erreur `-32602`**, pas une
|
||||
abstention ; `error.data` liste les chemins fautifs et la nature de chaque problème, jamais les
|
||||
@ -96,8 +68,7 @@ ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de s'ann
|
||||
- §8 **`UpdateLearned`** : sens optimiseur → moteur uniquement ; reçu par un optimiseur,
|
||||
notification ignorée, requête avec `id` → `-32601`. Sa **forme reste non normée en 1.2**.
|
||||
- §2 **`maxLineBytes` (1 Mio) dans les deux sens** ; ligne trop longue reçue par l'optimiseur →
|
||||
`-32600`, connexion gardée. Le moteur n'en émet jamais : une requête trop longue n'est pas
|
||||
envoyée et compte comme un échec (`request-too-long`).
|
||||
`-32600`, connexion gardée.
|
||||
- §2 **Lots JSON-RPC interdits** dans les deux sens : un message par ligne ; un lot reçu → `-32600`.
|
||||
- §2 **`/run/powersync` appartient au paquet de l'optimiseur** ; le moteur ne le crée ni ne le
|
||||
suppose.
|
||||
@ -184,19 +155,17 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr
|
||||
(`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun
|
||||
message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée
|
||||
(`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue
|
||||
dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). **Le moteur
|
||||
n'y répond pas, et c'est voulu** (écrit en 1.3) : client du protocole, il ne renvoie jamais de
|
||||
réponse sur ce socket — une erreur à `id: null` n'aurait personne pour l'attendre. Le délai
|
||||
dépassé de la requête visée est le seul effet visible, et il est compté (§7).
|
||||
dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4).
|
||||
- **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux
|
||||
sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur
|
||||
`-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée
|
||||
(`line-too-long`), comptée, connexion conservée ; la requête en vol continue d'attendre — si sa
|
||||
réponse était cette ligne, elle finit en délai dépassé (10 s, échec compté, §4).
|
||||
**Le moteur n'émet jamais de ligne de plus de 1 Mio** : une requête trop longue n'est pas
|
||||
envoyée et compte comme un échec (`request-too-long`, règle des échecs consécutifs du §7),
|
||||
connexion conservée. Éprouvé par `testARequestOverTheLineLimitIsNeverSent` (suite
|
||||
`optimizerclient`) et `testARequestTooLongCountsAsAFailure` (suite `socketscheduler`).
|
||||
> **Écart connu du moteur (1.2)** : il n'applique pas encore la borne à ce qu'il **émet**.
|
||||
> Rien ne borne la taille d'un contexte `GetPlan` (un `label` de charge très long suffit) ; une
|
||||
> telle requête part sur une seule ligne de plus de 1 Mio, un optimiseur conforme la refuse, et
|
||||
> la requête finit en délai dépassé. Éprouvé par `testAContextOverTheLineLimitIsStillSent`
|
||||
> (suite `optimizerclient`, échec attendu tant que l'écart vit).
|
||||
- Le contrat est transport-agnostique : mêmes messages sur unix et tcp.
|
||||
- Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion :
|
||||
stratégie de repli active, `optimizerAlive = false`.
|
||||
@ -207,7 +176,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
|
||||
"protocolVersion":"1.3",
|
||||
"protocolVersion":"1.2",
|
||||
"engine":"etm-powersync-energy-plugin-etm/<version>",
|
||||
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
|
||||
}}
|
||||
@ -217,7 +186,7 @@ Réponse attendue :
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{
|
||||
"protocolVersion":"1.3",
|
||||
"protocolVersion":"1.2",
|
||||
"name":"etm-optimizer",
|
||||
"planning":true,
|
||||
"capabilities":{
|
||||
@ -255,11 +224,6 @@ Réponse attendue :
|
||||
est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa
|
||||
réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit
|
||||
son code (`handshake-refused`), et retente au backoff (§2).
|
||||
- **Le token d'abord** (1.3). Un handshake qui porte à la fois un token faux (ou absent alors
|
||||
qu'il est exigé) et une autre majeure reçoit **`-32001`**, puis la connexion est fermée :
|
||||
l'optimiseur juge le token avant la version. Un pair qui ne présente pas le secret n'apprend
|
||||
rien d'autre — pas même quelle majeure l'optimiseur parle. `-32602` ne répond donc qu'à un
|
||||
handshake dont le token est accepté, ou qui n'en exige pas.
|
||||
- **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la
|
||||
connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il
|
||||
n'appelle `GetPlan` qu'une fois le handshake accepté.
|
||||
@ -272,7 +236,7 @@ Réponse attendue :
|
||||
|
||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
||||
|---|---|---|---|---|
|
||||
| `protocolVersion` | `ProtocolVersion` | oui | `"1.3"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3). |
|
||||
| `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). |
|
||||
| `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. |
|
||||
| `token` | string | non | ≥ 16 car. | Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé. |
|
||||
|
||||
@ -1263,81 +1227,27 @@ une abstention, à ce qu'il ne peut pas traiter. En particulier :
|
||||
protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond
|
||||
(`-32603` erreur interne, par exemple).
|
||||
|
||||
**Forme d'`error.data` pour un contexte invalide** (1.3). Exigée d'un optimiseur qui s'annonce 1.3 ou
|
||||
plus :
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":42,"error":{"code":-32602,"message":"contexte invalide","data":{
|
||||
"problems":[{"path":"params.loads[0].id","problem":"missing"},
|
||||
{"path":"params.forecast.pv[3].from","problem":"format"}]}}}
|
||||
```
|
||||
|
||||
- `problems` : liste **non vide**, de **50 entrées au plus**. S'il y a davantage de problèmes, les 50
|
||||
premiers, et **`"truncated": true`** à côté de `problems` — champ absent sinon.
|
||||
- `path` : chemin du champ fautif **depuis `params`**, noms séparés par des points, indices de liste
|
||||
entre crochets (`params.forecast.pv[3].from`, `params.loads[0].declared.estimatedPowerW.3`). Pour
|
||||
un champ absent, le chemin où il était attendu.
|
||||
- `problem` : un code d'une **liste fermée** — `missing` (obligatoire absent), `type` (type faux),
|
||||
`range` (hors bornes), `format` (chaîne mal formée : instant, durée…), `enum` (valeur hors liste),
|
||||
`constraint` (règle entre champs, ordre des créneaux…).
|
||||
- **Jamais la valeur reçue**, dans aucun champ : aucun autre champ que ceux-là, un chemin qui suit son
|
||||
motif, une nature prise dans la liste. La forme fermée rend la recopie inexprimable — c'est le but :
|
||||
une valeur du site n'a rien à faire dans une erreur qu'un journal peut garder.
|
||||
- **Ce que le moteur en fait : rien.** Il ne lit pas `error.data` pour décider — l'erreur reste un
|
||||
échec `rpc-error`, règle des deux échecs inchangée —, ne le journalise pas (le code JSON-RPC seul),
|
||||
et ne le recopie pas dans la trace (annexe D : la ligne porte `rpc-error`). Un code `problem` qu'il
|
||||
ne connaît pas, ou un `data` d'une autre forme, n'y change donc rien (§9). La forme sert celui qui
|
||||
lit l'échange : le validateur, la suite de conformité, l'installateur qui capture une session.
|
||||
|
||||
<!-- BEGIN GENERATED table:InvalidContextData -->
|
||||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||
|
||||
**`InvalidContextData`** — error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée.
|
||||
|
||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
||||
|---|---|---|---|---|
|
||||
| `problems` | [`ContextProblem`] | oui | | Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated. |
|
||||
| `truncated` | boolean | non | `true` | Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon. |
|
||||
|
||||
Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser).
|
||||
|
||||
<!-- END GENERATED -->
|
||||
|
||||
<!-- BEGIN GENERATED table:ContextProblem -->
|
||||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||
|
||||
**`ContextProblem`** — Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue.
|
||||
|
||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
||||
|---|---|---|---|---|
|
||||
| `path` | string | oui | motif `^params(\.[A-Za-z0-9_]+\|\[[0-9]+\])*$` | Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu. |
|
||||
| `problem` | string | oui | `missing` · `type` · `range` · `format` · `enum` · `constraint` | Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9). |
|
||||
|
||||
Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser).
|
||||
|
||||
<!-- END GENERATED -->
|
||||
|
||||
<!-- BEGIN GENERATED errors -->
|
||||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||
|
||||
| Code | Méthode | `message` | Connexion après l'erreur | Quand |
|
||||
|---|---|---|---|---|
|
||||
| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. |
|
||||
| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord). |
|
||||
| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. |
|
||||
| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). |
|
||||
| `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. |
|
||||
| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. |
|
||||
| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. |
|
||||
| `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. |
|
||||
| `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. |
|
||||
| `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. |
|
||||
| `-32600` | toute | ligne trop longue | conservée | Ligne de plus de maxLineBytes octets, '\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null. |
|
||||
| `-32601` | toute | méthode inconnue | conservée | Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse. |
|
||||
|
||||
Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune — pas même la ligne trop longue, qu'il n'émet jamais (§2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.
|
||||
Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.
|
||||
|
||||
Ce que fait le moteur d'une réponse error :
|
||||
|
||||
- **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff.
|
||||
- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error).
|
||||
- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7).
|
||||
|
||||
<!-- END GENERATED -->
|
||||
|
||||
@ -1368,7 +1278,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt
|
||||
| `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. |
|
||||
| `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. |
|
||||
| `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). |
|
||||
| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. |
|
||||
| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. |
|
||||
| `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. |
|
||||
| `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. |
|
||||
|
||||
@ -1388,7 +1298,7 @@ qu'elle affiche de ces trois champs.
|
||||
- **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par
|
||||
un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`),
|
||||
erreur `-32601` (méthode inconnue).
|
||||
- **La forme d'`UpdateLearned` n'est pas normée** (ni en 1.2, ni en 1.3). Le tableau ci-dessous dit ce que le
|
||||
- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le
|
||||
contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui
|
||||
apprend.
|
||||
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
|
||||
@ -1402,7 +1312,7 @@ qu'elle affiche de ces trois champs.
|
||||
|
||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
||||
|---|---|---|---|---|
|
||||
| `loads` | [object] | non | | Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge). |
|
||||
| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). |
|
||||
|
||||
Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
|
||||
|
||||
@ -1416,7 +1326,7 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
|
||||
majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et
|
||||
l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée.
|
||||
- Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre
|
||||
côté conforme ne produit pas (1.2 et 1.3 en sont des exemples, §0). Elle se lit comme une liste de choses
|
||||
côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses
|
||||
à aligner avant de s'annoncer de cette mineure, pas comme une rupture.
|
||||
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
|
||||
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
|
||||
@ -1426,13 +1336,13 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
|
||||
## Annexe A — Session minimale
|
||||
|
||||
```
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.3","engine":"etm-powersync-energy-plugin-etm/<version>"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.3","name":"mon-optimiseur","planning":false}}
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}}
|
||||
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }}
|
||||
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
|
||||
```
|
||||
|
||||
Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, 1.3 ; §2, §3, §7) — c'est ce
|
||||
Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce
|
||||
qu'éprouve la suite de conformité, par une connexion de sonde :
|
||||
|
||||
```
|
||||
@ -1444,26 +1354,6 @@ qu'éprouve la suite de conformité, par une connexion de sonde :
|
||||
← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture
|
||||
```
|
||||
|
||||
Sur une session ouverte, un contexte invalide (1.3) — ici un `timestamp` sans décalage et rien
|
||||
d'autre :
|
||||
|
||||
```
|
||||
→ {"jsonrpc":"2.0","id":4,"method":"GetPlan","params":{"timestamp":"2026-10-25T02:30:00"}}
|
||||
← {"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"contexte invalide","data":{"problems":[
|
||||
{"path":"params.timestamp","problem":"format"},{"path":"params.trigger","problem":"missing"},…]}}}
|
||||
```
|
||||
|
||||
L'optimiseur qui ne lit que l'instant s'arrête au premier problème ; celui qui valide tout le
|
||||
contexte les liste tous, 50 au plus. Le chemin dit où ; la valeur `2026-10-25T02:30:00` n'apparaît
|
||||
nulle part.
|
||||
|
||||
Token faux **et** autre majeure (1.3, si l'optimiseur exige un token) : le token d'abord.
|
||||
|
||||
```
|
||||
→ {"jsonrpc":"2.0","id":5,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…","token":"<faux>"}}
|
||||
← {"jsonrpc":"2.0","id":5,"error":{"code":-32001,"message":"token refusé"}} puis fermeture
|
||||
```
|
||||
|
||||
Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake,
|
||||
renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7.
|
||||
|
||||
@ -1519,14 +1409,9 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden
|
||||
"powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12}
|
||||
```
|
||||
|
||||
- `request` : les params `GetPlan` envoyés (ou qui l'auraient été) — **ou, à leur place,
|
||||
`requestBytes`**, la taille en octets de la ligne `GetPlan` quand elle dépasse 1 Mio (§2) : une
|
||||
telle requête n'est pas envoyée (soumise au client, elle a pour réponse `request-too-long`),
|
||||
elle n'est pas recopiée, et le rejeu passe la ligne ; `response` : ce qui a
|
||||
- `request` : les params `GetPlan` envoyés (ou qui l'auraient été) ; `response` : ce qui a
|
||||
répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été
|
||||
remplacée sans réponse) ; `error.code` est le code d'échec du **moteur** (`rpc-error` pour une
|
||||
réponse `error` de l'optimiseur, `timeout`…), jamais le code JSON-RPC, et `error.data` n'est
|
||||
jamais recopié (§7) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur
|
||||
remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur
|
||||
seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend.
|
||||
- Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué).
|
||||
- **Rejeu** : `tools/etm-powersync-trace-replay` relit `request`, l'envoie à un optimiseur
|
||||
@ -1538,7 +1423,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden
|
||||
## Annexe E — Optimiseur de référence
|
||||
|
||||
`tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule)
|
||||
conforme 1.3, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
|
||||
conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
|
||||
plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du
|
||||
`SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple
|
||||
communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`.
|
||||
@ -1589,8 +1474,5 @@ Ils deviendront normatifs avec le lot qui les implémente.
|
||||
- **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance`
|
||||
(`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que
|
||||
lance `ETM_POWERSYNC_OPTIMIZER_CMD`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde,
|
||||
les erreurs du §7 que le moteur ne provoque pas (annexe A). Depuis 1.3, la forme d'`error.data`
|
||||
d'un contexte invalide et l'ordre token puis majeure : **exigés** d'un optimiseur qui s'annonce
|
||||
1.3 ou plus, **avertis** seulement pour un optimiseur 1.2 ; une valeur reçue recopiée dans
|
||||
`error.data` est refusée quelle que soit la version (la règle date de 1.2).
|
||||
les erreurs du §7 que le moteur ne provoque pas (annexe A).
|
||||
- **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).
|
||||
|
||||
@ -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**
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
"openrpc": "1.3.2",
|
||||
"info": {
|
||||
"title": "etm-powersync — protocole moteur ↔ optimiseur",
|
||||
"version": "1.3",
|
||||
"version": "1.2",
|
||||
"license": {
|
||||
"name": "GPL-3.0-or-later",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
},
|
||||
"description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ; forme de error.data d'une erreur, quand elle est normée (1.3) : x-etm-data-schema de l'erreur, exigée à partir de x-etm-data-required-from ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus."
|
||||
"description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -33,7 +33,7 @@
|
||||
{
|
||||
"name": "Handshake",
|
||||
"summary": "Ouverture de session, à chaque (re)connexion (§3).",
|
||||
"description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; les deux fautes dans le même handshake → -32001, le token est jugé AVANT la majeure (1.3) ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.",
|
||||
"description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
@ -42,7 +42,7 @@
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProtocolVersion"
|
||||
},
|
||||
"description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3)."
|
||||
"description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme)."
|
||||
},
|
||||
{
|
||||
"name": "engine",
|
||||
@ -71,13 +71,13 @@
|
||||
{
|
||||
"code": -32001,
|
||||
"message": "token refusé",
|
||||
"data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.",
|
||||
"data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.",
|
||||
"x-etm-connection": "fermée"
|
||||
},
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "majeure incompatible",
|
||||
"data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord).",
|
||||
"data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).",
|
||||
"x-etm-connection": "fermée"
|
||||
}
|
||||
],
|
||||
@ -193,11 +193,7 @@
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "contexte invalide",
|
||||
"data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.",
|
||||
"x-etm-data-schema": {
|
||||
"$ref": "#/components/schemas/InvalidContextData"
|
||||
},
|
||||
"x-etm-data-required-from": "1.3",
|
||||
"data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.",
|
||||
"x-etm-connection": "conservée"
|
||||
},
|
||||
{
|
||||
@ -209,12 +205,12 @@
|
||||
],
|
||||
"x-etm-direction": "engine→optimizer",
|
||||
"x-etm-timeout-s": 10,
|
||||
"x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error)."
|
||||
"x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7)."
|
||||
},
|
||||
{
|
||||
"name": "UpdateLearned",
|
||||
"summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.",
|
||||
"description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE (1.2, 1.3) : elle sera fixée avec la première stratégie qui apprend.",
|
||||
"description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE en 1.2 : elle sera fixée avec la première stratégie qui apprend.",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
@ -227,7 +223,7 @@
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"description": "Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge)."
|
||||
"description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)."
|
||||
}
|
||||
],
|
||||
"x-etm-direction": "optimizer→engine",
|
||||
@ -238,7 +234,7 @@
|
||||
"schemas": {
|
||||
"ProtocolVersion": {
|
||||
"description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
|
||||
"const": "1.3"
|
||||
"const": "1.2"
|
||||
},
|
||||
"Trigger": {
|
||||
"description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
|
||||
@ -341,7 +337,6 @@
|
||||
"rpc-error",
|
||||
"invalid-response",
|
||||
"disconnected",
|
||||
"request-too-long",
|
||||
"malformed-plan",
|
||||
"bad-time",
|
||||
"slots-unordered",
|
||||
@ -1808,62 +1803,6 @@
|
||||
"$ref": "#/components/schemas/Plan"
|
||||
}
|
||||
]
|
||||
},
|
||||
"InvalidContextData": {
|
||||
"description": "error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"problems": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ContextProblem"
|
||||
},
|
||||
"description": "Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated."
|
||||
},
|
||||
"truncated": {
|
||||
"const": true,
|
||||
"description": "Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"problems"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser"
|
||||
},
|
||||
"ContextProblem": {
|
||||
"description": "Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^params(\\.[A-Za-z0-9_]+|\\[[0-9]+\\])*$",
|
||||
"description": "Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu."
|
||||
},
|
||||
"problem": {
|
||||
"$ref": "#/components/schemas/ContextProblemCode"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"problem"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser"
|
||||
},
|
||||
"ContextProblemCode": {
|
||||
"description": "Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"missing",
|
||||
"type",
|
||||
"range",
|
||||
"format",
|
||||
"enum",
|
||||
"constraint"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -1882,7 +1821,7 @@
|
||||
"server": "optimizer"
|
||||
},
|
||||
"maxLineBytes": 1048576,
|
||||
"maxLineBytesScope": "les deux sens (1.2) : octets de la ligne, '\\n' non compté. Reçue trop longue : côté optimiseur -32600 (id null), connexion conservée ; côté moteur, ligne écartée (line-too-long), connexion conservée. Émise : le moteur n'émet jamais de ligne de plus de maxLineBytes ; une requête trop longue n'est pas envoyée et compte comme un échec (request-too-long, §7), connexion conservée.",
|
||||
"maxLineBytesScope": "les deux sens (1.2) : octets de la ligne, '\\n' non compté. Reçue trop longue : côté optimiseur -32600 (id null), connexion conservée ; côté moteur, ligne écartée (line-too-long), connexion conservée.",
|
||||
"batches": "interdits dans les deux sens (1.2) : un message JSON-RPC, un objet, par ligne. Un lot reçu : côté optimiseur -32600 (id null), aucun de ses messages traité ; côté moteur, ligne écartée (invalid-json), connexion conservée.",
|
||||
"handshakeTimeoutS": 5,
|
||||
"requestTimeoutS": 10,
|
||||
@ -1898,7 +1837,7 @@
|
||||
"runtimeDirectory": "/run/powersync appartient au paquet de l'OPTIMISEUR (RuntimeDirectory=powersync de son unité, groupe powersync) (1.2). Le moteur ne le crée ni ne le suppose : sans optimiseur, il n'en a pas besoin."
|
||||
},
|
||||
"x-etm-message-errors": {
|
||||
"description": "Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune — pas même la ligne trop longue, qu'il n'émet jamais (§2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.",
|
||||
"description": "Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.",
|
||||
"items": [
|
||||
{
|
||||
"code": -32700,
|
||||
@ -2165,7 +2104,7 @@
|
||||
"countsAsFailure": false,
|
||||
"needs": [],
|
||||
"schemaAlsoCatches": false,
|
||||
"description": "réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff."
|
||||
"description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff."
|
||||
},
|
||||
{
|
||||
"name": "invalid-handshake",
|
||||
@ -2200,8 +2139,7 @@
|
||||
"diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.",
|
||||
"abstain : toute valeur « vraie » au sens de QVariant::toBool (true, chaîne non vide autre que « false »/« 0 », nombre non nul).",
|
||||
"champs inconnus (params et result) : ignorés (§9) ; force et champs réservés (annexe F) : ignorés et signalés une fois.",
|
||||
"state : 3.0 accepté comme 3 (entier au sens JSON Schema).",
|
||||
"error.data d'une réponse error (1.3) : jamais lu — un code problem inconnu, un data d'une autre forme (optimiseur 1.2) ou absent ne changent rien : l'erreur reste un échec rpc-error."
|
||||
"state : 3.0 accepté comme 3 (entier au sens JSON Schema)."
|
||||
]
|
||||
},
|
||||
"x-etm-reason-params": {
|
||||
|
||||
@ -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.
|
||||
};
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
#include <nymeasettings.h>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
@ -14,52 +13,6 @@
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonParseError>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace {
|
||||
//! L'identifiant tel qu'il entre dans un motif : entier s'il est bien formé (donc borné), sinon
|
||||
//! ses 64 premiers caractères — une configuration héritée peut porter n'importe quoi.
|
||||
QString idPourMotif(const QString &id)
|
||||
{
|
||||
if (LoadConfigStore::idIsWellFormed(id) || id.toUcs4().size() <= 64)
|
||||
return id;
|
||||
return LoadConfigStore::leftChars(id, 64) + QStringLiteral("…");
|
||||
}
|
||||
|
||||
//! Une valeur saisie citée dans un motif : assez pour la reconnaître, jamais en entier.
|
||||
QString valeurPourMotif(const QString &valeur)
|
||||
{
|
||||
return valeur.toUcs4().size() <= 16 ? valeur
|
||||
: LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…");
|
||||
}
|
||||
|
||||
//! Un libellé cité dans un motif : ses 64 premiers caractères. Celui d'une entrée héritée n'est
|
||||
//! pas borné, et l'ajout automatique d'une borne journalise ce motif (relecture q7-suite, m-1).
|
||||
QString libellePourMotif(const QString &libelle)
|
||||
{
|
||||
return libelle.toUcs4().size() <= 64 ? libelle
|
||||
: LoadConfigStore::leftChars(libelle, 64) + QStringLiteral("…");
|
||||
}
|
||||
|
||||
//! Une valeur citée au journal, entre guillemets et ÉCHAPPÉE par Qt (\c "\n" pour un saut de
|
||||
//! ligne) : une valeur persistée par un client ne fabrique pas de fausse ligne de journal.
|
||||
QString citee(const QString &valeur)
|
||||
{
|
||||
QString s;
|
||||
QDebug(&s).nospace() << valeur;
|
||||
return s;
|
||||
}
|
||||
|
||||
//! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il
|
||||
//! est bien formé — un identifiant refusé ne se recopie pas.
|
||||
QVariantMap motifEntree(const char *cause, int index, const QString &id)
|
||||
{
|
||||
QVariantMap m{{QStringLiteral("cause"), QLatin1String(cause)}, {QStringLiteral("index"), index}};
|
||||
if (LoadConfigStore::idIsWellFormed(id))
|
||||
m.insert(QStringLiteral("id"), id);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
LoadConfigStore::LoadConfigStore(QObject *parent)
|
||||
: QObject{parent}
|
||||
@ -136,64 +89,24 @@ bool LoadConfigStore::load()
|
||||
<< "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à"
|
||||
<< "des identifiants, donc à personne. À réparer par un SetLoadConfig.";
|
||||
}
|
||||
// Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le
|
||||
// client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet.
|
||||
// Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations.
|
||||
// Les valeurs persistées sont citées ÉCHAPPÉES (citee(), ou `<<` sans noquote()) : avant Q7,
|
||||
// un client pouvait écrire un identifiant ou une échéance portant un saut de ligne.
|
||||
for (int i = 0; i < loaded.count(); ++i) {
|
||||
QString pourquoi;
|
||||
if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi))
|
||||
qCWarning(dcNymeaEnergy())
|
||||
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
|
||||
<< "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci.";
|
||||
if (!idIsWellFormed(loaded.at(i).id()))
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : "
|
||||
"charge %1 (%2 caractères ; attendu 1 à %3 caractères "
|
||||
"[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente "
|
||||
"sera refusée tant qu'elle porte cet identifiant.")
|
||||
.arg(citee(idPourMotif(loaded.at(i).id()))).arg(loaded.at(i).id().toUcs4().size())
|
||||
.arg(MaxIdChars);
|
||||
// L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible
|
||||
// ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où
|
||||
// l'optimiseur devrait deviner ce qu'elle veut dire. Sans elle, une obligation en énergie
|
||||
// n'a plus de cadence et rien n'est acheté pour elle : le côté sûr. Le fichier n'est pas
|
||||
// réécrit ici ; la première écriture cliente le remplacera.
|
||||
const QString echeance = loaded.at(i).needs().dailyDeadline();
|
||||
if (!deadlineIsWellFormed(echeance)) {
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : "
|
||||
"charge %1, needs.dailyDeadline %2 (attendu HH:MM, de 00:00 "
|
||||
"à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, "
|
||||
"l'obligation n'a plus d'échéance.")
|
||||
.arg(citee(idPourMotif(loaded.at(i).id())), citee(valeurPourMotif(echeance)));
|
||||
LoadConfigNeeds besoins = loaded.at(i).needs();
|
||||
besoins.setDailyDeadline(QString());
|
||||
loaded[i].setNeeds(besoins);
|
||||
}
|
||||
}
|
||||
|
||||
m_configs = loaded;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
||||
bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error)
|
||||
{
|
||||
auto fail = [error, fault](const QString &why, const QVariantMap &motif) {
|
||||
auto fail = [error](const QString &why) {
|
||||
if (error) *error = why;
|
||||
if (fault) *fault = motif;
|
||||
return false;
|
||||
};
|
||||
|
||||
QSet<QString> ids;
|
||||
QHash<QString, int> proprietaire; // ThingId normalisé → index de la charge
|
||||
QHash<QString, QString> proprietaire; // ThingId normalisé → libellé de la charge
|
||||
|
||||
for (int i = 0; i < configs.count(); ++i) {
|
||||
const LoadConfig &c = configs.at(i);
|
||||
for (const LoadConfig &c : configs) {
|
||||
if (ids.contains(c.id()))
|
||||
return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(idPourMotif(c.id())),
|
||||
motifEntree("ID_DUPLICATE", i, c.id()));
|
||||
return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(c.id()));
|
||||
ids.insert(c.id());
|
||||
|
||||
if (!c.enabled())
|
||||
@ -206,57 +119,45 @@ bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error, QV
|
||||
// les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de
|
||||
// isValid().
|
||||
for (const QString &thing : c.claimedThingIds()) {
|
||||
if (proprietaire.contains(thing)) {
|
||||
const int j = proprietaire.value(thing);
|
||||
const QString deja = proprietaire.value(thing);
|
||||
if (!deja.isEmpty())
|
||||
return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, "
|
||||
"\"%2\" et \"%3\" : deux commandes contradictoires et "
|
||||
"une puissance comptée deux fois")
|
||||
.arg(thing, libellePourMotif(configs.at(j).label()), libellePourMotif(c.label())),
|
||||
QVariantMap{{QStringLiteral("cause"), QStringLiteral("THING_SHARED")},
|
||||
{QStringLiteral("indexes"), QVariantList{j, i}},
|
||||
{QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}});
|
||||
}
|
||||
proprietaire.insert(thing, i);
|
||||
.arg(thing, deja, c.label()));
|
||||
proprietaire.insert(thing, c.label());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
||||
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error)
|
||||
{
|
||||
QHash<int, int> parRang; // rang → index de la première charge qui le porte
|
||||
for (int i = 0; i < configs.count(); ++i) {
|
||||
const LoadConfig &c = configs.at(i);
|
||||
if (parRang.contains(c.priority())) {
|
||||
const int j = parRang.value(c.priority());
|
||||
QHash<int, QString> parRang;
|
||||
for (const LoadConfig &c : configs) {
|
||||
const QString deja = parRang.value(c.priority());
|
||||
if (!deja.isEmpty()) {
|
||||
if (error)
|
||||
*error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang "
|
||||
"%3 : leur ordre de service serait stable mais NON "
|
||||
"RÉGLABLE — il se déciderait sur leurs identifiants")
|
||||
.arg(libellePourMotif(configs.at(j).label()), libellePourMotif(c.label()))
|
||||
.arg(c.priority());
|
||||
if (fault)
|
||||
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("RANK_DUPLICATE")},
|
||||
{QStringLiteral("indexes"), QVariantList{j, i}},
|
||||
{QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}},
|
||||
{QStringLiteral("rank"), c.priority()}};
|
||||
.arg(deja, c.label()).arg(c.priority());
|
||||
return false;
|
||||
}
|
||||
parRang.insert(c.priority(), i);
|
||||
parRang.insert(c.priority(), c.label());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
||||
QString *error, QVariantMap *fault) const
|
||||
QString *error) const
|
||||
{
|
||||
QHash<QString, const LoadConfig *> stocke;
|
||||
for (const LoadConfig &c : m_configs)
|
||||
stocke.insert(c.id(), &c);
|
||||
|
||||
LoadConfigs resultat;
|
||||
for (int i = 0; i < incoming.count(); ++i) {
|
||||
LoadConfig c = incoming.at(i);
|
||||
for (LoadConfig c : incoming) {
|
||||
const LoadConfig *avant = stocke.value(c.id(), nullptr);
|
||||
const QString marqueAvant = avant ? avant->rankOrigin() : QString();
|
||||
|
||||
@ -270,8 +171,6 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
|
||||
"moteur n'a pas posé : c'est une affirmation sur un geste "
|
||||
"du moteur, elle ne s'écrit pas depuis un client")
|
||||
.arg(c.label());
|
||||
if (fault)
|
||||
*fault = motifEntree("RANK_ORIGIN_AUTO", i, c.id());
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -297,170 +196,30 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error, QVariantMap *fault,
|
||||
int from)
|
||||
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
|
||||
{
|
||||
for (int i = qMax(0, from); i < configs.count(); ++i) {
|
||||
const LoadConfig &c = configs.at(i);
|
||||
const qsizetype caracteres = c.label().toUcs4().size();
|
||||
if (caracteres > MaxLabelChars) {
|
||||
if (error)
|
||||
*error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus")
|
||||
.arg(idPourMotif(c.id())).arg(caracteres).arg(MaxLabelChars);
|
||||
if (fault) {
|
||||
*fault = motifEntree("LABEL", i, c.id());
|
||||
fault->insert(QStringLiteral("length"), caracteres);
|
||||
fault->insert(QStringLiteral("max"), MaxLabelChars);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::idIsWellFormed(const QString &id)
|
||||
{
|
||||
// Le jeu est ASCII : un caractère y vaut une unité UTF-16, et size() compte des caractères.
|
||||
static const QRegularExpression forme(
|
||||
QRegularExpression::anchoredPattern(QStringLiteral("[A-Za-z0-9._{}-]+")));
|
||||
return id.size() <= MaxIdChars && forme.match(id).hasMatch();
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateIds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from)
|
||||
{
|
||||
static const QRegularExpression horsJeu(QStringLiteral("[^A-Za-z0-9._{}-]"));
|
||||
for (int i = qMax(0, from); i < configs.count(); ++i) {
|
||||
const QString id = configs.at(i).id();
|
||||
if (idIsWellFormed(id))
|
||||
continue;
|
||||
const qsizetype caracteres = id.toUcs4().size();
|
||||
// La position du premier caractère hors jeu, en caractères — pas en unités UTF-16.
|
||||
const QRegularExpressionMatch m = horsJeu.match(id);
|
||||
const qsizetype position = m.hasMatch() ? id.left(m.capturedStart()).toUcs4().size() : -1;
|
||||
if (error) {
|
||||
*error = caracteres == 0
|
||||
? QStringLiteral("l'identifiant de la charge en position %1 est vide").arg(i + 1)
|
||||
: position >= 0
|
||||
? QStringLiteral("l'identifiant de la charge en position %1 contient un caractère hors "
|
||||
"[A-Za-z0-9._{}-] (caractère %2)").arg(i + 1).arg(position + 1)
|
||||
: QStringLiteral("l'identifiant de la charge en position %1 compte %2 caractères, %3 au plus")
|
||||
.arg(i + 1).arg(caracteres).arg(MaxIdChars);
|
||||
}
|
||||
if (fault) {
|
||||
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("ID")},
|
||||
{QStringLiteral("index"), i},
|
||||
{QStringLiteral("length"), caracteres},
|
||||
{QStringLiteral("max"), MaxIdChars}};
|
||||
if (position >= 0)
|
||||
fault->insert(QStringLiteral("position"), position);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::deadlineIsWellFormed(const QString &deadline)
|
||||
{
|
||||
// anchoredPattern, et non ^…$ : « $ » accepterait un saut de ligne final.
|
||||
static const QRegularExpression forme(
|
||||
QRegularExpression::anchoredPattern(QStringLiteral("([01][0-9]|2[0-3]):[0-5][0-9]")));
|
||||
return deadline.isEmpty() || forme.match(deadline).hasMatch();
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateDeadlines(const LoadConfigs &configs, QString *error, QVariantMap *fault,
|
||||
int from)
|
||||
{
|
||||
for (int i = qMax(0, from); i < configs.count(); ++i) {
|
||||
const LoadConfig &c = configs.at(i);
|
||||
if (deadlineIsWellFormed(c.needs().dailyDeadline()))
|
||||
continue;
|
||||
if (error)
|
||||
*error = QStringLiteral("l'échéance quotidienne de la charge \"%1\" est illisible (\"%2\") : "
|
||||
"attendu HH:MM, de 00:00 à 23:59, ou absente")
|
||||
.arg(idPourMotif(c.id()), valeurPourMotif(c.needs().dailyDeadline()));
|
||||
if (fault)
|
||||
*fault = motifEntree("DEADLINE", i, c.id());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LoadConfigStore::validateBounds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from)
|
||||
{
|
||||
// L'identifiant D'ABORD : tous les motifs suivants le recopient.
|
||||
return validateIds(configs, error, fault, from)
|
||||
&& validateLabels(configs, error, fault, from)
|
||||
&& validateDeadlines(configs, error, fault, from);
|
||||
}
|
||||
|
||||
QString LoadConfigStore::leftChars(const QString &texte, int n)
|
||||
{
|
||||
// Couper en points de code puis recomposer : une paire de substitution reste entière.
|
||||
const QList<uint> points = texte.toUcs4();
|
||||
if (points.size() <= n)
|
||||
return texte;
|
||||
return QString::fromUcs4(reinterpret_cast<const char32_t *>(points.constData()), qMax(0, n));
|
||||
}
|
||||
|
||||
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
||||
{
|
||||
return commit(configs, 0, error, fault);
|
||||
}
|
||||
|
||||
bool LoadConfigStore::appendConfig(const LoadConfig &config, QString *error, QVariantMap *fault)
|
||||
{
|
||||
LoadConfigs configs = m_configs;
|
||||
configs.append(config);
|
||||
// Les bornes de saisie ne portent que sur l'entrée APPORTÉE — voir la déclaration.
|
||||
return commit(configs, configs.count() - 1, error, fault);
|
||||
}
|
||||
|
||||
bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault)
|
||||
{
|
||||
// Les bornes de saisie D'ABORD : les motifs de refus suivants citent l'identifiant et le
|
||||
// libellé, et une saisie démesurée ne doit entrer ni dans la configuration ni dans le
|
||||
// journal (Q7).
|
||||
if (!validateBounds(configs, error, fault, premierBorne))
|
||||
return false;
|
||||
|
||||
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
|
||||
for (int i = 0; i < configs.count(); ++i) {
|
||||
const LoadConfig &c = configs.at(i);
|
||||
for (const LoadConfig &c : configs) {
|
||||
QString why;
|
||||
QVariantMap motif;
|
||||
if (!c.isValid(&why, &motif)) {
|
||||
if (!c.isValid(&why)) {
|
||||
if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why);
|
||||
if (fault) {
|
||||
*fault = motif;
|
||||
fault->insert(QStringLiteral("index"), i);
|
||||
if (idIsWellFormed(c.id()))
|
||||
fault->insert(QStringLiteral("id"), c.id());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule.
|
||||
if (!validateSet(configs, error, fault))
|
||||
if (!validateSet(configs, error))
|
||||
return false;
|
||||
|
||||
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() n'écarte rien sur sa foi : elle
|
||||
// refuse une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
|
||||
if (!validateRanks(configs, error, fault))
|
||||
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse
|
||||
// une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
|
||||
if (!validateRanks(configs, error))
|
||||
return false;
|
||||
|
||||
const LoadConfigs previous = m_configs;
|
||||
m_configs = configs;
|
||||
|
||||
QString echec;
|
||||
if (!save(&echec)) {
|
||||
if (!save(error)) {
|
||||
m_configs = previous; // rollback mémoire si l'écriture échoue
|
||||
// La cause système est dite ICI : le refus de SetLoadConfig ne journalise que son motif,
|
||||
// et WRITE_FAILED ne dit pas pourquoi le disque a refusé. Aucune saisie n'y entre.
|
||||
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Écriture de la configuration échouée :"
|
||||
<< filePath() << echec;
|
||||
if (error) *error = echec;
|
||||
if (fault)
|
||||
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}};
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -41,32 +41,9 @@ public:
|
||||
* \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed().
|
||||
* \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète).
|
||||
* \param[out] error Message FR si rejet (aucune écriture dans ce cas).
|
||||
* \param[out] fault Si rejet, les \c faultParams du motif \c LOAD_CONFIG_REFUSED : \c cause
|
||||
* et l'endroit (\c index, \c id, \c field, …) — liste des causes : \c DecisionCode::LoadConfigRefused.
|
||||
* \return true si validé + persisté ; false si invalide ou échec d'écriture.
|
||||
*/
|
||||
bool setConfigs(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief AJOUT d'une entrée par le MOTEUR (auto-provisionnement d'une borne, LM-1209).
|
||||
*
|
||||
* Mêmes contrôles que \c setConfigs(), à une différence près : les bornes de saisie
|
||||
* (\c validateBounds() — identifiant, libellé, échéance) ne portent que sur l'entrée AJOUTÉE.
|
||||
* Un client renvoie l'ensemble entier : il l'apporte, il en répond, et une entrée héritée hors
|
||||
* règle bloque son écriture jusqu'à ce qu'il la répare. Le moteur n'apporte qu'une entrée ; il
|
||||
* ne sait pas réparer les autres (raccourcir un libellé humain n'est pas son geste), et en être
|
||||
* bloqué laisserait la borne arbitrée sans rang réglable — le trou refermé en 3g-2.
|
||||
*
|
||||
* \param config Entrée à ajouter ; son \c id ne doit pas déjà figurer dans l'ensemble.
|
||||
* \param[out] error Message FR si rejet (aucune écriture dans ce cas).
|
||||
* \param[out] fault Comme pour \c setConfigs().
|
||||
* \return true si validé + persisté.
|
||||
*
|
||||
* \note La cohérence d'ensemble (\c validateSet()) et l'unicité des rangs (\c validateRanks())
|
||||
* restent contrôlées sur l'ensemble complet : la première protège un organe de deux
|
||||
* commandeurs, et un rang en double hérité bloque encore l'ajout, comme avant ce lot.
|
||||
*/
|
||||
bool appendConfig(const LoadConfig &config, QString *error = nullptr, QVariantMap *fault = nullptr);
|
||||
bool setConfigs(const LoadConfigs &configs, QString *error = nullptr);
|
||||
|
||||
/*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule.
|
||||
*
|
||||
@ -83,21 +60,15 @@ public:
|
||||
* \c setConfigs(), donc par cette vérification.
|
||||
*
|
||||
* \param configs Ensemble complet à confronter (les charges désactivées sont ignorées).
|
||||
* \param[out] error Motif de refus, en français, exploitable tel quel ; un libellé n'y est
|
||||
* cité que sur ses 64 premiers caractères (celui d'une entrée héritée n'est pas borné).
|
||||
* \param[out] fault Si refus : \c ID_DUPLICATE (\c index, \c id) ou \c THING_SHARED
|
||||
* (\c indexes, \c ids — la paire de charges).
|
||||
* \param[out] error Motif de refus, en français, exploitable tel quel.
|
||||
* \return true si l'ensemble est cohérent ; false au PREMIER conflit rencontré.
|
||||
*/
|
||||
static bool validateSet(const LoadConfigs &configs, QString *error = nullptr,
|
||||
QVariantMap *fault = nullptr);
|
||||
static bool validateSet(const LoadConfigs &configs, QString *error = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Contrôle de BONNE FORME, appliqué aux ÉCRITURES et à elles seules. §12 / LM-1209-a.
|
||||
*
|
||||
* \param configs Ensemble candidat. \param error Motif du refus, si refus ; un libellé n'y
|
||||
* est cité que sur ses 64 premiers caractères.
|
||||
* \param[out] fault Si refus : \c RANK_DUPLICATE (\c indexes, \c ids, \c rank).
|
||||
* \param configs Ensemble candidat. \param error Motif du refus, si refus.
|
||||
* \return Faux si deux charges portent le même rang.
|
||||
*
|
||||
* \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté**
|
||||
@ -110,118 +81,16 @@ public:
|
||||
* héritée à rangs doublés : des charges disparaîtraient de la configuration ET de
|
||||
* l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de
|
||||
* configuration silencieuse.
|
||||
* \note \c load() n'écarte donc rien sur sa foi. Un doublon hérité est **signalé
|
||||
* \note N'est donc JAMAIS appelée par \c load(). Un doublon hérité est **signalé
|
||||
* bruyamment et CONSERVÉ** ; la première écriture cliente le répare.
|
||||
*/
|
||||
static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr,
|
||||
QVariantMap *fault = nullptr);
|
||||
|
||||
//! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode).
|
||||
static constexpr int MaxLabelChars = 256;
|
||||
|
||||
/*!
|
||||
* \brief Borne la longueur des libellés, à l'ÉCRITURE seulement (protocole optimiseur 1.2, Q7).
|
||||
*
|
||||
* Le libellé part tel quel dans chaque \c GetPlan (§5.3). Sans borne, une saisie démesurée
|
||||
* ferait dépasser la limite de 1 Mio par ligne du protocole, et l'optimiseur ne recevrait plus
|
||||
* rien. Le client d'optimiseur refuse d'émettre une telle ligne (\c request-too-long) : cette
|
||||
* borne-ci arrête la saisie avant qu'elle n'y arrive.
|
||||
*
|
||||
* \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme
|
||||
* la charge par son identifiant et donne la longueur, jamais le libellé.
|
||||
* \param[out] fault Si refus : \c LABEL (\c index, \c id, \c length, \c max).
|
||||
* \param from Premier index contrôlé (les précédents ne le sont pas) ; voir \c appendConfig().
|
||||
* \return Faux si un libellé compte plus de \c MaxLabelChars caractères.
|
||||
*
|
||||
* \note Même famille que \c validateRanks() (\rule{LM-1209-a-bis}) : un refus d'écriture, jamais
|
||||
* un écart au chargement. \c load() n'écarte rien sur sa foi — un libellé plus long déjà
|
||||
* persisté est **chargé tel quel, signalé, jamais tronqué** ; la première écriture cliente
|
||||
* devra le raccourcir.
|
||||
*/
|
||||
static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr,
|
||||
QVariantMap *fault = nullptr, int from = 0);
|
||||
|
||||
//! Plus long identifiant de charge accepté à l'écriture, en caractères.
|
||||
static constexpr int MaxIdChars = 128;
|
||||
|
||||
/*!
|
||||
* \brief \return Vrai si \p id est un identifiant de charge bien formé : 1 à \c MaxIdChars
|
||||
* caractères, tous dans \c [A-Za-z0-9._{}-].
|
||||
* \param id Identifiant à juger.
|
||||
*
|
||||
* L'identifiant est une CLÉ, pas un texte : il part dans chaque \c GetPlan (\c loads[].id, et
|
||||
* clé des actions), il est recopié dans les motifs et le journal. Le jeu couvre les deux formes
|
||||
* réelles — un ThingId entre accolades (38 caractères) et un nom court à tirets.
|
||||
*/
|
||||
static bool idIsWellFormed(const QString &id);
|
||||
|
||||
/*!
|
||||
* \brief Borne les identifiants, à l'ÉCRITURE seulement (Q7).
|
||||
* \param configs Ensemble candidat.
|
||||
* \param[out] error Motif du refus, en français ; il donne la position de l'entrée et la
|
||||
* longueur, jamais l'identifiant refusé.
|
||||
* \param[out] fault Si refus : \c ID (\c index, \c length, \c max, et \c position, premier
|
||||
* caractère hors jeu, s'il y en a un). Jamais \c id : c'est lui qui est refusé.
|
||||
* \param from Premier index contrôlé ; voir \c appendConfig().
|
||||
* \return Faux si un identifiant n'est pas \c idIsWellFormed().
|
||||
*
|
||||
* \note Même partage que \c validateLabels() : un identifiant hors règle déjà persisté est
|
||||
* **chargé tel quel et signalé** — le changer reviendrait à retirer une charge et en créer
|
||||
* une autre, ce qu'une lecture n'a pas à décider.
|
||||
*/
|
||||
static bool validateIds(const LoadConfigs &configs, QString *error = nullptr,
|
||||
QVariantMap *fault = nullptr, int from = 0);
|
||||
|
||||
/*!
|
||||
* \brief \return Vrai si \p deadline est vide ou de la forme \c HH:MM, de 00:00 à 23:59, deux
|
||||
* chiffres de chaque côté. \param deadline Valeur de \c needs.dailyDeadline.
|
||||
*/
|
||||
static bool deadlineIsWellFormed(const QString &deadline);
|
||||
|
||||
/*!
|
||||
* \brief Contrôle l'échéance quotidienne (\c needs.dailyDeadline), à l'ÉCRITURE seulement.
|
||||
* \param configs Ensemble candidat.
|
||||
* \param[out] error Motif du refus, en français ; la valeur refusée n'y est citée que tronquée.
|
||||
* \param[out] fault Si refus : \c DEADLINE (\c index, \c id).
|
||||
* \param from Premier index contrôlé ; voir \c appendConfig().
|
||||
* \return Faux si une échéance n'est pas \c deadlineIsWellFormed().
|
||||
*
|
||||
* \note Au chargement, l'entrée est conservée mais l'échéance illisible est **retirée de la
|
||||
* mémoire** (le fichier n'est pas réécrit) : elle n'atteint ni l'arbitre ni l'optimiseur.
|
||||
* Sans échéance, une obligation en énergie n'a pas de cadence et rien n'est acheté pour
|
||||
* elle — le côté sûr pour un mécanisme qui achète. Une échéance illisible n'était de toute
|
||||
* façon résolue en aucun instant ; elle partait seulement, brute, dans \c GetPlan.
|
||||
*/
|
||||
static bool validateDeadlines(const LoadConfigs &configs, QString *error = nullptr,
|
||||
QVariantMap *fault = nullptr, int from = 0);
|
||||
|
||||
/*!
|
||||
* \brief Les bornes de SAISIE, dans l'ordre : identifiants, libellés, échéances (Q7).
|
||||
*
|
||||
* Avant tout autre contrôle, et avant \c reconcileRankOrigin() : les motifs suivants citent
|
||||
* l'identifiant et le libellé, qui doivent donc déjà être bornés pour entrer au journal.
|
||||
*
|
||||
* \param configs Ensemble candidat. \param[out] error Motif du refus, en français.
|
||||
* \param[out] fault Si refus, le motif de \c validateIds(), \c validateLabels() ou
|
||||
* \c validateDeadlines(). \param from Premier index contrôlé ; voir \c appendConfig().
|
||||
* \return Faux au premier manquement.
|
||||
*/
|
||||
static bool validateBounds(const LoadConfigs &configs, QString *error = nullptr,
|
||||
QVariantMap *fault = nullptr, int from = 0);
|
||||
|
||||
/*!
|
||||
* \brief \return Les \p n premiers caractères de \p texte — en points de code, jamais au milieu
|
||||
* d'une paire de substitution UTF-16 (à l'inverse de \c QString::left()).
|
||||
* \param texte Texte à couper. \param n Nombre de caractères gardés.
|
||||
*/
|
||||
static QString leftChars(const QString &texte, int n);
|
||||
static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c.
|
||||
*
|
||||
* \param incoming Ensemble reçu du client. \param error Motif du refus, si refus.
|
||||
* \param[out] out Ensemble à persister, \c rankOrigin réconcilié.
|
||||
* \param[out] fault Si refus : \c RANK_ORIGIN_AUTO (\c index, \c id).
|
||||
* \return Faux si le client tente de FABRIQUER un \c "auto".
|
||||
*
|
||||
* Trois règles, et la deuxième est celle qu'on se serait trompé à écrire :
|
||||
@ -239,7 +108,7 @@ public:
|
||||
* seule voie, et elle est explicite. L'omission ne lève rien (règle 2).
|
||||
*/
|
||||
bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
||||
QString *error = nullptr, QVariantMap *fault = nullptr) const;
|
||||
QString *error = nullptr) const;
|
||||
|
||||
signals:
|
||||
/*! \brief Émis après une persistance réussie de \c setConfigs(). */
|
||||
@ -249,8 +118,6 @@ private:
|
||||
QString filePath() const;
|
||||
bool load();
|
||||
bool save(QString *error) const;
|
||||
// Valide puis persiste ; les bornes de saisie ne portent qu'à partir de l'index premierBorne.
|
||||
bool commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault);
|
||||
|
||||
LoadConfigs m_configs;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
*
|
||||
@ -803,79 +769,11 @@ public:
|
||||
*/
|
||||
void shedWaterfallForOverload(const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Garde de soutirage : une consigne d'OPTIMISEUR ne fait monter une charge que de la
|
||||
* marge que le compteur laisse sous le seuil (lot délestage triphasé, défauts 4 et 5).
|
||||
*
|
||||
* \param slot Créneau du cycle, modifié en place AVANT le dispatch. Seules les actions
|
||||
* d'optimiseur (niveau unique \c optimizer) sont lues ; celles que L4 revendique ce cycle
|
||||
* (\c m_shedActions) sont laissées au délestage.
|
||||
* \param ctx Contexte du cycle : \c drawMargin (seuil, phase la plus chargée, signée) et
|
||||
* la télémétrie des charges.
|
||||
*
|
||||
* \par Pourquoi
|
||||
* La marge de soutirage bornait la cascade du rule-based, et RIEN d'autre : un optimiseur qui
|
||||
* demande le maximum voyait ses consignes écrêtées au matériel seulement. Relevé le 2026-10-27 :
|
||||
* la protection déleste, le compteur repasse juste sous la limite, le délestage se relâche,
|
||||
* l'optimiseur redemande 3 kW, et la surcharge reprend une minute. La règle de sécurité était
|
||||
* celle du disjoncteur ; elle devient celle du moteur, quel que soit le décideur.
|
||||
*
|
||||
* \par Contrat
|
||||
* - Seule une HAUSSE est bornée, jamais une baisse : réduire est le geste de la protection, et
|
||||
* la garde ne réduit rien sous ce que la charge tient déjà. Sous un compteur en dépassement
|
||||
* (marge négative), la charge garde ce qu'elle tient.
|
||||
* - Plafond = engagé + marge libre × phases de la charge. L'engagé d'une borne est sa dernière
|
||||
* commande TENUE (la protection l'a peut-être rabaissée dans ce même cycle) ; sinon sa mesure.
|
||||
* Celui des autres charges est leur télémétrie.
|
||||
* - La marge est la pire phase, et chaque hausse servie la consomme pour les suivantes, dans
|
||||
* l'ordre du créneau.
|
||||
* - Une consigne écrêtée porte \c DRAW_CAP (le mécanisme qui a produit l'issue, règle 7-b) et
|
||||
* \c clamped ; une borne est écrêtée à l'ampère entier inférieur, une PAC à l'état le plus haut
|
||||
* qui tient (jamais 1).
|
||||
* - Sans plafond en vigueur (limite non réglée), rien n'est borné.
|
||||
*/
|
||||
void bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx);
|
||||
|
||||
//! \brief Replace dans \c slot les décisions des charges délestées par L4.
|
||||
//! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE :
|
||||
//! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu.
|
||||
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.
|
||||
*
|
||||
@ -917,61 +815,17 @@ public:
|
||||
* waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
|
||||
* chose qui rattraperait serait \c verifyOverloadProtection() **après coup**.
|
||||
*
|
||||
* \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la
|
||||
* borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la
|
||||
* marge est négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
|
||||
* \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà.
|
||||
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
|
||||
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
|
||||
* consommation lui rendrait exactement la puissance qui cause le dépassement
|
||||
* (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser).
|
||||
* \note HORS DÉPASSEMENT (lot délestage V2C, question 5), le plafond vaut au moins celui qui
|
||||
* rend à la borne son tirage attribuable, moins 1 A (\c evCeilingGivingBackDraw()). Sans
|
||||
* lui, une borne qui tient une bonne part de sa phase alternait : pleine, la place libre
|
||||
* tombait ; au plancher, elle remontait (16, 9, 16, 9 A sous 25 A et 10 % de marge). Le
|
||||
* maximum des deux garde la règle d'avant quand rien n'est attribuable, ou quand la borne
|
||||
* tire moins d'un ampère.
|
||||
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
|
||||
* fausse : le code ne l'a jamais fait.
|
||||
* \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet
|
||||
* instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
|
||||
*/
|
||||
double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const;
|
||||
|
||||
/*!
|
||||
* \brief Lot e2, suite — plafond d'une borne À PLEINE PUISSANCE EN HEURES CREUSES : la place
|
||||
* libre, plus ce que la borne tire, moins une marge de 1 A par phase.
|
||||
*
|
||||
* \param ev Borne. \param phaseCount Nombre de phases VISÉ.
|
||||
* \return Plafond en watts, multiple de 230 W par phase, jamais négatif ; ou **−1 si aucune
|
||||
* limite** (même convention que \c evPhaseAllowanceW()).
|
||||
*
|
||||
* \par Pourquoi
|
||||
* \c evPhaseAllowanceW() ne rend pas à la borne ce qu'elle tire. À pleine puissance, et avec
|
||||
* un compteur qui voit la borne, sa propre consommation lui retire donc la place au cycle
|
||||
* suivant, puis la lui rend au cycle d'après : 16, 7, 16, 7 A sous une limite de 25 A. C'est
|
||||
* la règle de l'échéance chez l'amont (\c adjustEvChargers() : marge − 1 A + courant de la
|
||||
* borne) qui la fait se poser.
|
||||
*
|
||||
* \par Contrat
|
||||
* - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais
|
||||
* déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est
|
||||
* sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou,
|
||||
* pour une borne qui ne publie pas ses phases, celles que le moteur lui suppose (lot
|
||||
* délestage V2C). Sinon — borne sans mesure, à l'arrêt, ou qui bascule — rien n'est rendu,
|
||||
* et la marge s'applique quand même.
|
||||
* - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire
|
||||
* sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur
|
||||
* voit ne se fabrique donc pas de place au-delà de la limite.
|
||||
* - **En dépassement, le plafond baisse** : sur une phase au-dessus de la limite, il vaut au
|
||||
* plus le courant mesuré de la borne moins 1 A. Il redescend donc à chaque cycle, jusqu'à
|
||||
* sortir du dépassement ou jusqu'au plancher — où la stratégie tient la borne et où
|
||||
* \c verifyOverloadProtection() (L4) décide. Cela suppose que le compteur voie la borne,
|
||||
* comme L4 le suppose déjà.
|
||||
* - Arrondi à l'ampère INFÉRIEUR : la consigne, arrondie au plus proche par l'adaptateur, ne
|
||||
* mange jamais la marge.
|
||||
*
|
||||
* \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là,
|
||||
* \c evPhaseAllowanceW() reste la règle ; depuis le lot délestage V2C, elle rend elle aussi
|
||||
* le tirage hors dépassement.
|
||||
*/
|
||||
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
|
||||
|
||||
/*!
|
||||
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
|
||||
*
|
||||
@ -1031,73 +885,7 @@ 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:
|
||||
//! Lot délestage V2C — ce qu'une borne tire, et sur quelles phases. \c phases vaut
|
||||
//! \c PhaseNone quand l'attribution n'est pas sûre : rien n'est alors rendu.
|
||||
struct TirageBorne {
|
||||
Electricity::Phases phases = Electricity::PhaseNone;
|
||||
double watts = 0;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Lot délestage V2C — le tirage MESURÉ d'une borne, attribué à ses phases quand c'est sûr.
|
||||
*
|
||||
* \param ev Borne. \param phaseCount Nombre de phases VISÉ par le cycle.
|
||||
* \return Les phases et la puissance (\c currentPower de la borne, jamais sa consigne) ; un
|
||||
* tirage vide si la borne n'a pas de \c currentPower ou si l'attribution n'est pas sûre.
|
||||
*
|
||||
* \par Deux cas
|
||||
* - Borne MESURÉE par phase (\c meteredPhases() non vide) : ses phases mesurées, si elles
|
||||
* sont aussi nombreuses que les phases visées. Sinon elle bascule, et rien n'est attribué.
|
||||
* C'est la règle du lot e2, inchangée.
|
||||
* - Borne NON MESURÉE par phase (aucun état \c currentPhaseX ni \c currentPowerPhaseX : le
|
||||
* V2C Trydan publie \c currentL1..3) : les phases que le moteur lui suppose déjà
|
||||
* (\c phasesForCount() — la phase A en monophasé, comme \c chargerPhaseKey() pour L4 et
|
||||
* \c prepareInformation() pour l'amont), à condition que la borne annonce autant de phases
|
||||
* que le cycle en vise (\c EvCharger::phaseCount()), et que ce soit une ou trois. Une
|
||||
* triphasée qui charge une voiture monophasée, ou une borne en cours de bascule, n'est donc
|
||||
* pas attribuée.
|
||||
* \note Une borne qui PUBLIE des courants par phase, tous nuls alors qu'elle tire, n'est pas
|
||||
* « non mesurée » : sa mesure se contredit, et rien n'est attribué.
|
||||
* \note L'hypothèse de la phase A est celle de tout le moteur. Une monophasée câblée sur une
|
||||
* autre phase la met en défaut, ici comme dans L4 : c'est le plugin qui peut la lever, en
|
||||
* publiant ses courants par phase.
|
||||
*/
|
||||
TirageBorne evAttributableDraw(EvCharger *ev, uint phaseCount) const;
|
||||
|
||||
//! Lot délestage V2C — le plafond d'une borne qui lui rend ce qu'elle tire (règle du lot e2).
|
||||
struct PlafondRendu {
|
||||
double watts = -1; //!< Plafond en W, multiple de 230 W par phase ; −1 sans limite.
|
||||
bool depasse = false; //!< Une phase de la borne, borne comprise, est au-dessus du seuil.
|
||||
bool attribuable = false;//!< Le tirage de la borne a été rendu (\c evAttributableDraw()).
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Lot délestage V2C — limite − (tirage de la phase − tirage attribué à la borne) − 1 A,
|
||||
* à l'ampère inférieur, × phases. Le calcul d'\c evOffPeakAllowanceW(), mis en commun avec
|
||||
* \c evPhaseAllowanceW() ; son contrat est écrit sur la première.
|
||||
*/
|
||||
PlafondRendu evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const;
|
||||
|
||||
/*!
|
||||
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
|
||||
*
|
||||
@ -1241,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.
|
||||
*
|
||||
@ -1264,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 {
|
||||
@ -1294,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.
|
||||
@ -1403,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 |
|
||||
@ -1428,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.
|
||||
@ -1505,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.
|
||||
@ -1569,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
|
||||
@ -1622,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 \
|
||||
|
||||
@ -209,7 +209,7 @@ QStringList OptimizerClient::knownErrorCodes()
|
||||
QStringLiteral("major-mismatch"), QStringLiteral("line-too-long"),
|
||||
QStringLiteral("invalid-json"), QStringLiteral("missing-id"), QStringLiteral("unknown-id"),
|
||||
QStringLiteral("timeout"), QStringLiteral("rpc-error"), QStringLiteral("invalid-response"),
|
||||
QStringLiteral("disconnected"), QStringLiteral("request-too-long")};
|
||||
QStringLiteral("disconnected")};
|
||||
}
|
||||
|
||||
int OptimizerClient::tokenLength(const QByteArray &token)
|
||||
@ -254,40 +254,13 @@ quint64 OptimizerClient::requestPlan(const QVariantMap ¶ms)
|
||||
|
||||
const quint64 id = ++m_lastId;
|
||||
m_pendingId = id;
|
||||
const QByteArray line = getPlanLine(id, params);
|
||||
|
||||
// §2 (1.2, Q7) : le moteur n'émet jamais de ligne de plus de maxLineBytes. Un optimiseur
|
||||
// conforme la jetterait (-32600 à id null) et la requête finirait en délai dépassé, la cause
|
||||
// perdue. Rien n'est écrit ; la requête échoue tout de suite, et l'échec COMPTE (règle des
|
||||
// échecs consécutifs, §7) : un contexte hors protocole est un défaut du moteur.
|
||||
if (line.size() > kMaxLineBytes) {
|
||||
m_requestDeadline = -1;
|
||||
rearm();
|
||||
// Une ligne par connexion, avec la taille — jamais le contenu : il est volumineux, et le
|
||||
// champ en cause est une saisie de l'installateur.
|
||||
if (!m_errorsLoggedThisConnection.contains(QStringLiteral("request-too-long"))) {
|
||||
m_errorsLoggedThisConnection.append(QStringLiteral("request-too-long"));
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[Optimiseur] GetPlan %1 NON ENVOYÉ : %2 octets, au-delà de la limite "
|
||||
"de %3 octets par ligne (§2) — échec de la requête (request-too-long), "
|
||||
"repli rule-based ; les suivants sur cette connexion ne sont pas "
|
||||
"journalisés").arg(id).arg(line.size()).arg(kMaxLineBytes);
|
||||
}
|
||||
// En différé : l'appelant n'a pas encore l'identifiant, et un échec émis avant le retour
|
||||
// serait inattribuable. Une requête remplacée, abandonnée par stop() ou déjà signalée par
|
||||
// une perte de connexion entre-temps n'émet rien de plus.
|
||||
QMetaObject::invokeMethod(this, [this, id] {
|
||||
if (m_pendingId != id)
|
||||
return;
|
||||
m_pendingId = 0;
|
||||
m_lastErrorCode = QStringLiteral("request-too-long");
|
||||
emit planFailed(id, QStringLiteral("request-too-long"));
|
||||
}, Qt::QueuedConnection);
|
||||
return id;
|
||||
}
|
||||
|
||||
m_requestDeadline = m_clock->nowMs() + kRequestTimeoutMs;
|
||||
send(line);
|
||||
send(QVariantMap{
|
||||
{QStringLiteral("jsonrpc"), QStringLiteral("2.0")},
|
||||
{QStringLiteral("id"), id},
|
||||
{QStringLiteral("method"), QStringLiteral("GetPlan")},
|
||||
{QStringLiteral("params"), params},
|
||||
});
|
||||
// Un échec d'écriture n'est pas traité ici : le socket signale sa perte (→ planFailed
|
||||
// « disconnected »), et à défaut les 10 s bornent l'attente. Émettre planFailed() avant
|
||||
// d'avoir rendu l'identifiant le rendrait inattribuable par l'appelant.
|
||||
@ -370,15 +343,12 @@ void OptimizerClient::onConnected()
|
||||
|
||||
m_handshakeId = ++m_lastId;
|
||||
// Ce message porte le secret : il n'est JAMAIS journalisé, à aucun niveau.
|
||||
// Pas de contrôle de maxLineBytes ici : la ligne est bornée par construction, loin sous
|
||||
// 1 Mio — version fixe, version du moteur fixée à la compilation, token lu sur au plus
|
||||
// kMaxTokenBytes octets (au pire ×6 une fois échappé en JSON : 24 Kio).
|
||||
send(serialize(QVariantMap{
|
||||
send(QVariantMap{
|
||||
{QStringLiteral("jsonrpc"), QStringLiteral("2.0")},
|
||||
{QStringLiteral("id"), m_handshakeId},
|
||||
{QStringLiteral("method"), QStringLiteral("Handshake")},
|
||||
{QStringLiteral("params"), params},
|
||||
}));
|
||||
});
|
||||
qCDebug(dcNymeaEnergy()) << "[Optimiseur] connecté, handshake envoyé ( id" << m_handshakeId
|
||||
<< ", token" << (m_tokenSent ? "configuré" : "absent") << ")";
|
||||
}
|
||||
@ -699,26 +669,13 @@ void OptimizerClient::countProtocolError(const QString &code, const QString &det
|
||||
emit protocolError(code);
|
||||
}
|
||||
|
||||
QByteArray OptimizerClient::getPlanLine(quint64 id, const QVariantMap ¶ms)
|
||||
{
|
||||
return serialize(QVariantMap{
|
||||
{QStringLiteral("jsonrpc"), QStringLiteral("2.0")},
|
||||
{QStringLiteral("id"), id},
|
||||
{QStringLiteral("method"), QStringLiteral("GetPlan")},
|
||||
{QStringLiteral("params"), params},
|
||||
});
|
||||
}
|
||||
|
||||
QByteArray OptimizerClient::serialize(const QVariantMap &message)
|
||||
{
|
||||
return QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
bool OptimizerClient::send(const QByteArray &line)
|
||||
bool OptimizerClient::send(const QVariantMap &message)
|
||||
{
|
||||
if (!m_socket)
|
||||
return false;
|
||||
return m_socket->write(line + '\n') == line.size() + 1;
|
||||
QByteArray line = QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact);
|
||||
line.append('\n');
|
||||
return m_socket->write(line) == line.size();
|
||||
}
|
||||
|
||||
QByteArray OptimizerClient::readToken()
|
||||
|
||||
@ -144,12 +144,12 @@ public:
|
||||
Q_ENUM(State)
|
||||
|
||||
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
|
||||
static constexpr const char *kProtocolVersion = "1.3";
|
||||
static constexpr const char *kProtocolVersion = "1.2";
|
||||
static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
|
||||
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
|
||||
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
|
||||
static constexpr qint64 kBackoffMaxMs = 60000; //!< §2, plafond du backoff.
|
||||
/*! \brief Plus longue ligne acceptée OU émise, octets hors \c '\\n' (1 Mio, §2 ; écart E24). */
|
||||
/*! \brief Plus longue ligne acceptée, octets hors \c '\\n' (1 Mio, proposition T0 ; écart E24). */
|
||||
static constexpr int kMaxLineBytes = 1 << 20;
|
||||
|
||||
/*! \brief Nom du fichier de token, sous \c NymeaSettings::settingsPath() (décision Q2). */
|
||||
@ -226,24 +226,9 @@ public:
|
||||
* \param params Les \c params de la requête, tels quels (\c SurplusContext sérialisé, T1).
|
||||
* \return L'identifiant JSON-RPC de la requête, ou 0 si le client n'est pas \c Ready (rien
|
||||
* n'est envoyé : l'appelant reste sur sa stratégie de repli).
|
||||
*
|
||||
* **Ligne trop longue** (§2, 1.2) : si la requête sérialisée dépasse \c kMaxLineBytes, RIEN
|
||||
* n'est écrit sur le socket. L'identifiant est rendu quand même, et \c planFailed() suit au
|
||||
* tour de boucle suivant avec le code \c request-too-long — un échec de requête comme un
|
||||
* autre. La connexion est conservée. Journalisé une fois par connexion, avec la taille,
|
||||
* jamais le contenu.
|
||||
*/
|
||||
quint64 requestPlan(const QVariantMap ¶ms);
|
||||
|
||||
/*!
|
||||
* \brief La ligne \c GetPlan exacte que \c requestPlan() écrirait, sans son \c '\\n'.
|
||||
* \param id Identifiant JSON-RPC de la requête. \param params Ses \c params.
|
||||
* \return La ligne sérialisée. Sa taille comparée à \c kMaxLineBytes dit si la requête part :
|
||||
* c'est la règle de \c requestPlan(), qui l'appelle — la trace de décision la lit aussi,
|
||||
* pour ne pas recopier une requête qui n'est pas partie.
|
||||
*/
|
||||
static QByteArray getPlanLine(quint64 id, const QVariantMap ¶ms);
|
||||
|
||||
/*! \brief \return Identifiant de la requête attendue, 0 si aucune. */
|
||||
quint64 pendingRequestId() const { return m_pendingId; }
|
||||
|
||||
@ -290,8 +275,7 @@ signals:
|
||||
* \brief La requête attendue n'aura pas de réponse utilisable.
|
||||
* \param id Son identifiant.
|
||||
* \param code \c timeout (10 s), \c disconnected (connexion perdue), \c rpc-error (réponse
|
||||
* \c error), \c invalid-response (ni \c result objet ni \c error), \c request-too-long
|
||||
* (requête de plus de \c kMaxLineBytes, jamais envoyée).
|
||||
* \c error), \c invalid-response (ni \c result objet ni \c error).
|
||||
* \note Une requête REMPLACÉE n'émet rien : son abandon est le fait de l'appelant.
|
||||
*/
|
||||
void planFailed(quint64 id, const QString &code);
|
||||
@ -319,8 +303,7 @@ private:
|
||||
void scheduleReconnect();
|
||||
void rearm();
|
||||
void countProtocolError(const QString &code, const QString &detail);
|
||||
static QByteArray serialize(const QVariantMap &message); // une ligne, sans son '\n'
|
||||
bool send(const QByteArray &line);
|
||||
bool send(const QVariantMap &message);
|
||||
QByteArray readToken();
|
||||
|
||||
OptimizerEndpoint m_endpoint;
|
||||
|
||||
@ -555,9 +555,7 @@ QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadActio
|
||||
a.counts = niveau.counts;
|
||||
a.levels = {niveau};
|
||||
|
||||
// Déjà écrêtée par la garde de soutirage de l'arbitre, avant le dispatch : la demande
|
||||
// reçue ici est la demande bornée, l'écrêtage reste un écrêtage.
|
||||
a.clamped = demande.clamped || !applique || differe(demande, appliquees.value(a.loadId), adapter);
|
||||
a.clamped = !applique || differe(demande, appliquees.value(a.loadId), adapter);
|
||||
if (a.clamped)
|
||||
ecretages.append({a.loadId, decrire(demande, adapter),
|
||||
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
namespace OptimizerProtocol {
|
||||
|
||||
//! Version du protocole parlée par ce sérialiseur (§3, §9).
|
||||
constexpr const char *ProtocolVersion = "1.3";
|
||||
constexpr const char *ProtocolVersion = "1.2";
|
||||
|
||||
//! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json).
|
||||
constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json";
|
||||
|
||||
@ -30,9 +30,7 @@
|
||||
*
|
||||
* \par Ce qu'une ligne porte (annexe D)
|
||||
* \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan
|
||||
* envoyés, ou ceux qui l'auraient été) — ou, à sa place, \c requestBytes (taille de la ligne,
|
||||
* en octets) pour une requête trop longue pour partir (\c request-too-long, Q7), \c response
|
||||
* (ce qui a répondu à CETTE requête),
|
||||
* envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête),
|
||||
* \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none),
|
||||
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake ;
|
||||
* le client masque toute réponse qui le recopierait (\c OptimizerSecret), et l'écrivain masque
|
||||
@ -197,8 +195,7 @@ public:
|
||||
|
||||
/*!
|
||||
* \brief Ligne d'un cycle.
|
||||
* \param ligne \c t, \c request (ou \c requestBytes), \c response (si connue), \c executed,
|
||||
* \c strategy.
|
||||
* \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy.
|
||||
* \param t Instant du cycle (choisit le fichier).
|
||||
* \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part).
|
||||
*/
|
||||
|
||||
@ -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,43 +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;
|
||||
// Lot délestage V2C (question 5) — HORS HEURES CREUSES AUSSI, la somme éco + confort d'une
|
||||
// BORNE tient dans son plafond de cycle. En « Éco avec minimum », le niveau éco (le
|
||||
// plancher) s'ajoutait au confort, borné seul par le plafond : sous une limite de 25 A, une
|
||||
// maison à 10 A sur A, le plafond de 12,5 A plus 6 A d'éco faisaient 16 A, la phase à 26 A,
|
||||
// et L4 coupait — puis la borne repartait au cycle suivant. Le confort est donc servi dans
|
||||
// ce que l'éco laisse du plafond, et le plancher porte sur la somme : la part confort n'a
|
||||
// que ce qui manque à l'éco pour l'atteindre. Seule la BORNE de D9 est reprise ici ; le
|
||||
// partage de l'achat (l'achat cède ce que le surplus finance) reste aux heures creuses.
|
||||
else if (lc.adapter == QStringLiteral("evcharger") && niveauxEco.contains(lc.id)
|
||||
&& niveauxEco.value(lc.id).targetW > 0 && lc.declared.maxPowerW > 0) {
|
||||
const double ecoW = niveauxEco.value(lc.id).targetW;
|
||||
lcConfort.declared.maxPowerW = qMax(0.0, lc.declared.maxPowerW - ecoW);
|
||||
lcConfort.declared.minPowerW = qMax(0.0, lc.declared.minPowerW - ecoW);
|
||||
}
|
||||
// Même règle pour la puissance ENGAGÉE par un verrou : elle borne la SOMME éco + confort,
|
||||
// qui est la consigne. Relevée en entier sur la part confort, elle s'ajoutait à un achat
|
||||
// éco qui la couvrait déjà : une borne tenue à 6 A par son verrou d'arrêt, servie à 6 A
|
||||
// en éco, recevait 12 A.
|
||||
if (niveauxEco.contains(lc.id))
|
||||
lcConfort.telemetry.lockMinPowerW =
|
||||
qMax(0.0, lc.telemetry.lockMinPowerW - niveauxEco.value(lc.id).targetW);
|
||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||
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
|
||||
@ -650,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;
|
||||
@ -689,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");
|
||||
@ -788,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
|
||||
@ -950,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;
|
||||
@ -1143,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;
|
||||
};
|
||||
|
||||
@ -160,7 +160,7 @@ signals:
|
||||
void reponseJugee(const QString &issue, const QString &code);
|
||||
|
||||
/*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client
|
||||
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response, \c request-too-long). */
|
||||
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */
|
||||
void echecRequete(const QString &code);
|
||||
|
||||
/*!
|
||||
|
||||
@ -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,29 +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)) {
|
||||
// Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite :
|
||||
// le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu.
|
||||
const QString ou = p.contains(QStringLiteral("phase"))
|
||||
? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString())
|
||||
: QString();
|
||||
const QString pourquoi = p.contains(QStringLiteral("thresholdA"))
|
||||
? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)")
|
||||
.arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble()))
|
||||
.arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString())
|
||||
: p.contains(QStringLiteral("phase"))
|
||||
? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)")
|
||||
.arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA"))
|
||||
: QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA"));
|
||||
const QString qui = label.isEmpty() ? QStringLiteral("Borne") : label;
|
||||
if (p.value(QStringLiteral("cut")).toBool())
|
||||
return QStringLiteral("%1 coupée : %2, dépassement de %3 W")
|
||||
.arg(qui, pourquoi).arg(w("overshootW"));
|
||||
return QStringLiteral("%1 abaissée à %2 A : %3, dépassement de %4 W")
|
||||
.arg(qui).arg(w("toA")).arg(pourquoi).arg(w("overshootW"));
|
||||
}
|
||||
// La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable
|
||||
// là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
|
||||
auto delaiEco = [&p]() {
|
||||
@ -295,39 +272,6 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
||||
ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", ")));
|
||||
}
|
||||
|
||||
if (c == QLatin1String(DecisionCode::LoadConfigRefused)) {
|
||||
// L'ENDROIT, comme pour le tarif : une liste de charges ne se corrige pas à l'aveugle.
|
||||
// Charges numérotées à partir de 1 pour la lecture humaine ; jamais le libellé.
|
||||
QStringList ou;
|
||||
if (p.contains(QStringLiteral("index"))) {
|
||||
const QString id = p.value(QStringLiteral("id")).toString();
|
||||
ou << (id.isEmpty() ? QStringLiteral("charge %1").arg(p.value(QStringLiteral("index")).toInt() + 1)
|
||||
: QStringLiteral("charge %1 « %2 »")
|
||||
.arg(p.value(QStringLiteral("index")).toInt() + 1).arg(id));
|
||||
}
|
||||
if (p.contains(QStringLiteral("indexes"))) {
|
||||
const QVariantList n = p.value(QStringLiteral("indexes")).toList();
|
||||
if (n.count() == 2)
|
||||
ou << QStringLiteral("charges %1 et %2").arg(n.at(0).toInt() + 1).arg(n.at(1).toInt() + 1);
|
||||
}
|
||||
if (p.contains(QStringLiteral("field")))
|
||||
ou << QStringLiteral("champ %1").arg(p.value(QStringLiteral("field")).toString());
|
||||
if (p.contains(QStringLiteral("state")))
|
||||
ou << QStringLiteral("état %1").arg(p.value(QStringLiteral("state")).toInt());
|
||||
if (p.contains(QStringLiteral("rank")))
|
||||
ou << QStringLiteral("rang %1").arg(p.value(QStringLiteral("rank")).toInt());
|
||||
if (p.contains(QStringLiteral("length")))
|
||||
ou << QStringLiteral("%1 caractères sur %2 au plus")
|
||||
.arg(p.value(QStringLiteral("length")).toLongLong())
|
||||
.arg(p.value(QStringLiteral("max")).toInt());
|
||||
if (p.contains(QStringLiteral("position")))
|
||||
ou << QStringLiteral("caractère %1 hors [A-Za-z0-9._{}-]")
|
||||
.arg(p.value(QStringLiteral("position")).toLongLong() + 1);
|
||||
return QStringLiteral("Configuration des charges refusée (%1)%2 — rien n'est modifié")
|
||||
.arg(p.value(QStringLiteral("cause")).toString(),
|
||||
ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", ")));
|
||||
}
|
||||
|
||||
if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild))
|
||||
// LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas.
|
||||
return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur "
|
||||
@ -351,55 +295,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)");
|
||||
@ -456,14 +351,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,
|
||||
ForecastDateUnreadable, TariffRefused,
|
||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||
OptimizerDecision, PriceSlot,
|
||||
EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic,
|
||||
StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||
}
|
||||
|
||||
QStringList DecisionCode::optimizerEmittableCodes()
|
||||
|
||||
@ -176,31 +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), \c thresholdA (le seuil
|
||||
* où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent**
|
||||
* quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui).
|
||||
*
|
||||
* \par Ce qu'il publie, et ce qu'il ne décide pas
|
||||
* \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi
|
||||
* à l'ampère supérieur, et la coupe sous son plancher. Ce geste ne laissait ni motif, ni
|
||||
* télémétrie. Ce code le PUBLIE, après coup : la protection reste celle de l'amont, à l'identique.
|
||||
*
|
||||
* \par Quand il tient, quand il se lève
|
||||
* Il tient tant que la dernière commande appliquée à la borne vient de la protection — la baisse,
|
||||
* la coupure, et la remise au courant minimal par \c verifyOverloadProtectionRecovery(). Il se lève
|
||||
* quand le cycle commande à nouveau la borne, ou quand la reprise lui rend son courant manuel.
|
||||
*
|
||||
* \note Préséance : celle du délestage L4 — l'action entière est remplacée, sans niveaux.
|
||||
*/
|
||||
constexpr const char *OverloadThrottled = "OVERLOAD_THROTTLED";
|
||||
|
||||
/*!
|
||||
* \brief L'obligation éco est servie en ACHETANT au réseau.
|
||||
*
|
||||
@ -399,27 +374,6 @@ constexpr const char *ForecastDateUnreadable = "FORECAST_DATE_UNREADABLE";
|
||||
*/
|
||||
constexpr const char *TariffRefused = "TARIFF_REFUSED";
|
||||
|
||||
/*!
|
||||
* \brief Une configuration des charges est REFUSÉE par \c SetLoadConfig — rien n'est modifié.
|
||||
* params : \c cause, et OÙ.
|
||||
* - Par entrée — \c index (position dans \c loadConfigs, à partir de 0) et \c id quand
|
||||
* l'identifiant est bien formé : \c ID (identifiant vide, trop long ou hors jeu ; \c length,
|
||||
* \c max, \c position du premier caractère hors jeu — jamais \c id), \c LABEL (\c length,
|
||||
* \c max), \c DEADLINE, \c MODE, \c DOMAIN, \c RANK_ORIGIN (hors énumération),
|
||||
* \c RANK_ORIGIN_AUTO (\c "auto" posé par un client), \c ADAPTER, \c FIELD_FORBIDDEN,
|
||||
* \c FIELD_REQUIRED, \c FIELD_RANGE, \c FIELD_DUPLICATE (\c field, et \c state pour un état
|
||||
* SG-Ready), \c SG_READY_STATE_2, \c ID_DUPLICATE.
|
||||
* - Entre deux entrées — \c indexes et \c ids (la paire) : \c THING_SHARED, \c RANK_DUPLICATE
|
||||
* (avec \c rank).
|
||||
* - Sans entrée : \c WRITE_FAILED (persistance impossible), \c STORE_UNAVAILABLE.
|
||||
* Liste faisant foi : \c LoadConfig::isValid() et \c LoadConfigStore.
|
||||
*
|
||||
* \note **Pas un motif de décision** : \c fault de \c SetLoadConfig à côté de
|
||||
* \c EnergyErrorInvalidParameter, inchangé. Jamais le libellé : le client nomme la charge avec
|
||||
* celui qu'il vient lui-même d'envoyer, à la position donnée.
|
||||
*/
|
||||
constexpr const char *LoadConfigRefused = "LOAD_CONFIG_REFUSED";
|
||||
|
||||
// --- SG-Ready (PAC) ---------------------------------------------------------
|
||||
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
||||
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";
|
||||
@ -453,70 +407,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";
|
||||
|
||||
@ -85,24 +85,3 @@ QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW
|
||||
params.insert(QStringLiteral("minOnBypassed"), true);
|
||||
return params;
|
||||
}
|
||||
|
||||
QList<double> tiragesCoherentsAvecLeTotal(const QList<double> &parPhaseW, double totalW)
|
||||
{
|
||||
QList<double> tirages = parPhaseW;
|
||||
if (tirages.isEmpty())
|
||||
return tirages;
|
||||
double somme = 0;
|
||||
double importe = 0;
|
||||
for (double w : tirages) {
|
||||
somme += w;
|
||||
importe += qMax(0.0, w);
|
||||
}
|
||||
const double manque = totalW - somme;
|
||||
// Sous le demi-watt, c'est l'arrondi d'une somme de flottants, pas une lecture en retard : la
|
||||
// corriger changerait des décisions au douzième chiffre sans rien protéger.
|
||||
if (manque <= 0.5)
|
||||
return tirages; // les phases disent au moins ce que dit le total : rien à corriger
|
||||
for (double &w : tirages)
|
||||
w += importe > 0 ? manque * qMax(0.0, w) / importe : manque / tirages.size();
|
||||
return tirages;
|
||||
}
|
||||
|
||||
@ -111,30 +111,6 @@ DrawMargin resolveDrawMargin(const QList<DrawCap> &caps,
|
||||
//! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed".
|
||||
QString drawCapSourceCode(DrawCapSource s);
|
||||
|
||||
/*!
|
||||
* \brief Tirages par phase rendus COHÉRENTS avec le total du compteur — jamais abaissés.
|
||||
*
|
||||
* \param parPhaseW Tirage lu sur chaque phase, en watts (import > 0, export < 0).
|
||||
* \param totalW Puissance totale du même compteur, en watts (import > 0).
|
||||
* \return Les mêmes tirages si leur somme atteint le total, à un demi-watt près. Sinon, le
|
||||
* MANQUE (total − somme) est ajouté aux phases qui importent, au prorata de ce qu'elles lisent
|
||||
* — ou à parts égales si aucune n'importe. Aucune valeur n'est jamais diminuée. Liste vide :
|
||||
* rendue telle quelle.
|
||||
*
|
||||
* \par Pourquoi
|
||||
* Les phases d'un compteur triphasé somment à son total : la phase la plus chargée porte AU MOINS
|
||||
* le tiers de ce que la maison importe. Une somme des phases sous le total ne peut donc venir que
|
||||
* de lectures en retard, et c'est le total qu'il faut croire pour la sécurité. Relevé le
|
||||
* 2026-10-27 sur une installation d'essai : 22,3 kW importés (32,3 A par phase) pendant que les
|
||||
* phases publiaient 29,1 A, puis 30,4 A — la protection a attendu 1 min 45 qu'elles franchissent
|
||||
* la limite. Le sens de l'erreur commande la règle : relever une phase ne peut que faire agir la
|
||||
* protection plus tôt ou plus fort ; c'est le seul sens admissible dans une couche de sécurité.
|
||||
*
|
||||
* \note Le prorata est une HYPOTHÈSE de répartition ; la seule garantie est sur le maximum, qui ne
|
||||
* tombe jamais sous le tiers du total importé.
|
||||
*/
|
||||
QList<double> tiragesCoherentsAvecLeTotal(const QList<double> &parPhaseW, double totalW);
|
||||
|
||||
/*!
|
||||
* \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit.
|
||||
*
|
||||
|
||||
@ -194,31 +194,17 @@ QSet<QString> LoadConfig::claimedThingIds() const
|
||||
return ids;
|
||||
}
|
||||
|
||||
bool LoadConfig::isValid(QString *error, QVariantMap *fault) const
|
||||
bool LoadConfig::isValid(QString *error) const
|
||||
{
|
||||
// Chaque refus porte DEUX choses : la phrase française du journal, inchangée, et le motif
|
||||
// publiable de SetLoadConfig — une cause, et l'endroit (champ, état). Les deux sont posées au
|
||||
// même endroit, refus par refus : une table séparée des causes divergerait de ces branches.
|
||||
auto fail = [&](const char *cause, const QString &msg, const QString &field = QString(),
|
||||
int state = 0) {
|
||||
if (error) *error = msg;
|
||||
if (fault) {
|
||||
*fault = QVariantMap{{QStringLiteral("cause"), QLatin1String(cause)}};
|
||||
if (!field.isEmpty())
|
||||
fault->insert(QStringLiteral("field"), field);
|
||||
if (state > 0)
|
||||
fault->insert(QStringLiteral("state"), state);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
auto fail = [&](const QString &msg) { if (error) *error = msg; return false; };
|
||||
|
||||
// Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis".
|
||||
// Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma
|
||||
// JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche.
|
||||
if (m_id.isEmpty())
|
||||
return fail("ID", QStringLiteral("id de charge vide"));
|
||||
return fail(QStringLiteral("id de charge vide"));
|
||||
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
|
||||
return fail("MODE", QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
|
||||
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
|
||||
|
||||
// Domaine : métadonnée d'intention, OPTIONNELLE. Absent = charge non classée — le plugin ne
|
||||
// la range pas d'office, parce qu'il n'a aucun moyen de deviner juste et qu'un classement
|
||||
@ -226,8 +212,8 @@ bool LoadConfig::isValid(QString *error, QVariantMap *fault) const
|
||||
// l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter
|
||||
// le jour où la couche domaine en fera son discriminant.
|
||||
if (!m_domain.isEmpty() && !knownDomains().contains(m_domain))
|
||||
return fail("DOMAIN", QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent "
|
||||
"pour une charge non classée)")
|
||||
return fail(QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent "
|
||||
"pour une charge non classée)")
|
||||
.arg(m_domain, knownDomains().join(QStringLiteral(", "))));
|
||||
|
||||
// Origine du rang (LM-1209-c) : même règle d'énumération fermée. Le vide reste licite — il
|
||||
@ -235,8 +221,8 @@ bool LoadConfig::isValid(QString *error, QVariantMap *fault) const
|
||||
// question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la
|
||||
// frontière d'écriture (LoadConfigStore::reconcileRankOrigin).
|
||||
if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin))
|
||||
return fail("RANK_ORIGIN", QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent "
|
||||
"— l'absence n'affirme rien)")
|
||||
return fail(QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent "
|
||||
"— l'absence n'affirme rien)")
|
||||
.arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", "))));
|
||||
|
||||
// LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque
|
||||
@ -246,50 +232,38 @@ bool LoadConfig::isValid(QString *error, QVariantMap *fault) const
|
||||
const bool aRelays = !m_relays.isEmpty();
|
||||
const bool aSgReady = !m_sgReady.states.isEmpty();
|
||||
const bool aLevels = !m_powerLevels.isEmpty() || m_maxPowerW > 0;
|
||||
// Le champ NOMMÉ par un refus groupé : celui qui est effectivement posé.
|
||||
const QString champNiveaux = !m_powerLevels.isEmpty() ? QStringLiteral("powerLevels")
|
||||
: QStringLiteral("maxPowerW");
|
||||
const QString champVerrous = m_minOnS != 0 ? QStringLiteral("minOnS") : QStringLiteral("minOffS");
|
||||
|
||||
if (m_adapter == QStringLiteral("sg-ready")) {
|
||||
if (aRelays)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)"),
|
||||
QStringLiteral("relays"));
|
||||
return fail(QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)"));
|
||||
if (aLevels)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)"),
|
||||
champNiveaux);
|
||||
return fail(QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)"));
|
||||
if (!aSgReady)
|
||||
return fail("FIELD_REQUIRED", QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)"),
|
||||
QStringLiteral("sgReady.states"));
|
||||
return fail(QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)"));
|
||||
if (m_mode != QStringLiteral("fixed"))
|
||||
return fail("MODE", QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)"));
|
||||
return fail(QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)"));
|
||||
if (m_sgReady.minStateHoldS < 0)
|
||||
return fail("FIELD_RANGE", QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0"),
|
||||
QStringLiteral("sgReady.minStateHoldS"));
|
||||
return fail(QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0"));
|
||||
|
||||
QList<int> vus;
|
||||
for (const LoadConfigSgReadyState &e : m_sgReady.states) {
|
||||
if (e.state < 1 || e.state > 4)
|
||||
return fail("FIELD_RANGE", QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state),
|
||||
QStringLiteral("sgReady.states.state"));
|
||||
return fail(QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state));
|
||||
if (vus.contains(e.state))
|
||||
return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state),
|
||||
QStringLiteral("sgReady.states.state"), e.state);
|
||||
return fail(QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state));
|
||||
vus.append(e.state);
|
||||
if (e.estimatedPowerW < 0)
|
||||
return fail("FIELD_RANGE", QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state),
|
||||
QStringLiteral("sgReady.states.estimatedPowerW"), e.state);
|
||||
return fail(QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state));
|
||||
for (const QString &r : e.relays)
|
||||
if (r.isEmpty())
|
||||
return fail("FIELD_REQUIRED", QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state),
|
||||
QStringLiteral("sgReady.states.relays"), e.state);
|
||||
return fail(QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state));
|
||||
QSet<QString> vusEtat;
|
||||
for (const QString &r : e.relays) {
|
||||
// Un même relais dans PLUSIEURS états est normal (état 4 = état 1 + état 3) ;
|
||||
// deux fois dans le MÊME état ne l'est pas.
|
||||
if (vusEtat.contains(normalizedThingId(r)))
|
||||
return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2")
|
||||
.arg(e.state).arg(r), QStringLiteral("sgReady.states.relays"), e.state);
|
||||
return fail(QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2")
|
||||
.arg(e.state).arg(r));
|
||||
vusEtat.insert(normalizedThingId(r));
|
||||
}
|
||||
}
|
||||
@ -298,109 +272,96 @@ bool LoadConfig::isValid(QString *error, QVariantMap *fault) const
|
||||
// L2. Une PAC dont la configuration ne permet pas de l'exprimer est REFUSÉE, jamais
|
||||
// construite : c'est le garde-fou qui remplace le Q_ASSERT disparu en release.
|
||||
if (!m_sgReady.hasState(2))
|
||||
return fail("SG_READY_STATE_2", QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le "
|
||||
"repli sûr du mode dégradé L2, sans lui la charge ne peut "
|
||||
"pas être mise en sécurité"));
|
||||
return fail(QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le "
|
||||
"repli sûr du mode dégradé L2, sans lui la charge ne peut "
|
||||
"pas être mise en sécurité"));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (aSgReady)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter),
|
||||
QStringLiteral("sgReady"));
|
||||
return fail(QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter));
|
||||
|
||||
if (m_adapter == QStringLiteral("evcharger")) {
|
||||
// 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases
|
||||
// et sa disponibilité viennent du Thing et changent en continu ; les déclarer ici en
|
||||
// ferait une seconde source de vérité, périmée dès la première voiture branchée.
|
||||
if (aRelays)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)"),
|
||||
QStringLiteral("relays"));
|
||||
return fail(QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)"));
|
||||
if (aLevels)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites "
|
||||
"de la borne viennent du Thing, pas de la configuration"), champNiveaux);
|
||||
return fail(QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites "
|
||||
"de la borne viennent du Thing, pas de la configuration"));
|
||||
if (m_minPowerW > 0)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du "
|
||||
"courant minimal de la borne et du véhicule branché"),
|
||||
QStringLiteral("minPowerW"));
|
||||
return fail(QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du "
|
||||
"courant minimal de la borne et du véhicule branché"));
|
||||
if (m_minOnS != 0 || m_minOffS != 0)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une "
|
||||
"borne vivent dans le manager amont"), champVerrous);
|
||||
return fail(QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une "
|
||||
"borne vivent dans le manager amont"));
|
||||
// Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On
|
||||
// garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE.
|
||||
if (m_mode != QStringLiteral("dynamic"))
|
||||
return fail("MODE", QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, "
|
||||
"elle n'a pas de paliers)"));
|
||||
return fail(QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, "
|
||||
"elle n'a pas de paliers)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_adapter == QStringLiteral("relay-router")) {
|
||||
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
|
||||
if (m_mode != QStringLiteral("fixed"))
|
||||
return fail("MODE", QStringLiteral("relay-router : mode doit être \"fixed\""));
|
||||
return fail(QStringLiteral("relay-router : mode doit être \"fixed\""));
|
||||
if (m_relays.isEmpty())
|
||||
return fail("FIELD_REQUIRED", QStringLiteral("relay-router : relays[] requis (non vide)"),
|
||||
QStringLiteral("relays"));
|
||||
return fail(QStringLiteral("relay-router : relays[] requis (non vide)"));
|
||||
QSet<QString> vus;
|
||||
for (const LoadConfigRelay &r : m_relays) {
|
||||
if (r.thingId.isEmpty())
|
||||
return fail("FIELD_REQUIRED", QStringLiteral("relay-router : un relais a un thingId vide"),
|
||||
QStringLiteral("relays.thingId"));
|
||||
return fail(QStringLiteral("relay-router : un relais a un thingId vide"));
|
||||
if (r.powerW <= 0)
|
||||
return fail("FIELD_RANGE", QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId),
|
||||
QStringLiteral("relays.powerW"));
|
||||
return fail(QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId));
|
||||
// ECS-110 — deux étages sur le même Thing : le routeur les fusionnerait
|
||||
// silencieusement, et la puissance annoncée serait le double de la réelle.
|
||||
if (vus.contains(normalizedThingId(r.thingId)))
|
||||
return fail("FIELD_DUPLICATE", QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages")
|
||||
.arg(r.thingId), QStringLiteral("relays.thingId"));
|
||||
return fail(QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages")
|
||||
.arg(r.thingId));
|
||||
vus.insert(normalizedThingId(r.thingId));
|
||||
}
|
||||
if (m_minOnS < 0 || m_minOffS < 0)
|
||||
return fail("FIELD_RANGE", QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"),
|
||||
m_minOnS < 0 ? QStringLiteral("minOnS") : QStringLiteral("minOffS"));
|
||||
return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"));
|
||||
// §12 / LM-1202 — même raison qu'en etmvariableload fixed : ici le plancher est le
|
||||
// plus petit palier atteignable, donc il se dérive de relays[].
|
||||
if (m_minPowerW > 0)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("relay-router : minPowerW interdit — le plancher est le "
|
||||
"plus petit palier dérivé des relais"), QStringLiteral("minPowerW"));
|
||||
return fail(QStringLiteral("relay-router : minPowerW interdit — le plancher est le "
|
||||
"plus petit palier dérivé des relais"));
|
||||
|
||||
} else if (m_adapter == QStringLiteral("etmvariableload")) {
|
||||
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
|
||||
if (!m_relays.isEmpty())
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"),
|
||||
QStringLiteral("relays"));
|
||||
return fail(QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"));
|
||||
if (m_mode == QStringLiteral("fixed")) {
|
||||
if (m_powerLevels.isEmpty())
|
||||
return fail("FIELD_REQUIRED", QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"),
|
||||
QStringLiteral("powerLevels"));
|
||||
return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"));
|
||||
if (m_powerLevels.first() != 0)
|
||||
return fail("FIELD_RANGE", QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"),
|
||||
QStringLiteral("powerLevels"));
|
||||
return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"));
|
||||
// §12 / LM-1202 — en fixed le plancher est le plus petit palier non nul : il se
|
||||
// DÉRIVE. Le déclarer en plus ouvrirait deux sources d'une même vérité, et rien
|
||||
// ne dirait laquelle fait foi le jour où elles divergent.
|
||||
if (m_minPowerW > 0)
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload fixed : minPowerW interdit — le "
|
||||
"plancher est le plus petit palier non nul"),
|
||||
QStringLiteral("minPowerW"));
|
||||
return fail(QStringLiteral("etmvariableload fixed : minPowerW interdit — le "
|
||||
"plancher est le plus petit palier non nul"));
|
||||
} else { // dynamic
|
||||
if (m_maxPowerW <= 0)
|
||||
return fail("FIELD_REQUIRED", QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"),
|
||||
QStringLiteral("maxPowerW"));
|
||||
return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"));
|
||||
if (!m_powerLevels.isEmpty())
|
||||
return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"),
|
||||
QStringLiteral("powerLevels"));
|
||||
return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"));
|
||||
// Un plancher au-dessus du plafond rendrait la charge inatteignable : le domaine
|
||||
// {0} ∪ [min, max] serait réduit à {0}, et la charge ne démarrerait jamais.
|
||||
if (m_minPowerW > static_cast<uint>(m_maxPowerW))
|
||||
return fail("FIELD_RANGE", QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse "
|
||||
"maxPowerW (%2 W) — la charge ne pourrait jamais démarrer")
|
||||
.arg(m_minPowerW).arg(m_maxPowerW), QStringLiteral("minPowerW"));
|
||||
return fail(QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse "
|
||||
"maxPowerW (%2 W) — la charge ne pourrait jamais démarrer")
|
||||
.arg(m_minPowerW).arg(m_maxPowerW));
|
||||
}
|
||||
|
||||
} else {
|
||||
return fail("ADAPTER", QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
|
||||
"\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter));
|
||||
return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
|
||||
"\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -379,14 +379,9 @@ public:
|
||||
/*!
|
||||
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
|
||||
* \param[out] error Message FR si invalide.
|
||||
* \param[out] fault Si invalide, le motif publiable de \c SetLoadConfig : \c cause (\c MODE,
|
||||
* \c DOMAIN, \c RANK_ORIGIN, \c ADAPTER, \c FIELD_FORBIDDEN, \c FIELD_REQUIRED,
|
||||
* \c FIELD_RANGE, \c FIELD_DUPLICATE, \c SG_READY_STATE_2, ou \c ID pour un id vide), et
|
||||
* selon le cas \c field (chemin du champ en cause) et \c state (état SG-Ready). Des codes,
|
||||
* jamais une phrase ; l'appelant y ajoute la position de l'entrée.
|
||||
* \return true si la config est exploitable.
|
||||
*/
|
||||
bool isValid(QString *error = nullptr, QVariantMap *fault = nullptr) const;
|
||||
bool isValid(QString *error = nullptr) const;
|
||||
|
||||
//! \return Forme sérialisée §4. Les champs \c relays/\c minOnS/\c minOffS ne sont émis
|
||||
//! que s'il existe au moins un relais ; \c powerLevels que s'il est non vide.
|
||||
|
||||
@ -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"
|
||||
@ -81,18 +80,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
// Table figée par TestCharging::testNymeaEnergyPermissionScopes.
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. "
|
||||
"[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): "
|
||||
"the engine allocates under the effective limit and its overload protection acts above it.";
|
||||
returns = phasePowerLimitSchema();
|
||||
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything.";
|
||||
returns.insert("phasePowerLimit", enumValueName(Uint));
|
||||
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone);
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled. "
|
||||
"[ETM] Optional phaseSafetyMarginPercent, 0 to 25: absent, the margin is kept; out of range, "
|
||||
"EnergyErrorInvalidParameter and nothing changes.";
|
||||
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled.";
|
||||
params.insert("phasePowerLimit", enumValueName(Uint));
|
||||
params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint));
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin);
|
||||
|
||||
@ -201,13 +195,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Replace the full set of controllable load configs. Rejected as a whole "
|
||||
"(energyError) if any entry is invalid; persisted and applied atomically. "
|
||||
"'id' is 1 to 128 characters in [A-Za-z0-9._{}-]; 'label' at most 256 "
|
||||
"characters (Unicode code points); 'needs.dailyDeadline' empty or HH:MM "
|
||||
"(00:00-23:59). On refusal: EnergyErrorInvalidParameter, 'fault' "
|
||||
"LOAD_CONFIG_REFUSED and 'faultParams' {cause, index?, id?, indexes?, ids?, "
|
||||
"field?, state?, rank?, length?, max?, position?} - codes and positions, never "
|
||||
"a sentence - and NOTHING is changed.";
|
||||
"(energyError) if any entry is invalid; persisted and applied atomically.";
|
||||
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
|
||||
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
|
||||
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
|
||||
@ -296,10 +284,6 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
loadItem.insert("o:minOffS", enumValueName(Uint));
|
||||
params.insert("loadConfigs", QVariantList() << loadItem);
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
// Ajoutés le 2026-10-22 (Q7, suite) : OPTIONNELS, présents sur refus seulement. energyError
|
||||
// garde sa valeur ; un succès rend exactement ce qu'il rendait.
|
||||
returns.insert("o:fault", enumValueName(String)); // LOAD_CONFIG_REFUSED
|
||||
returns.insert("o:faultParams", enumValueName(Variant));
|
||||
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeConfigureThings);
|
||||
|
||||
// [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis
|
||||
@ -337,39 +321,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
|
||||
@ -730,15 +681,11 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
|
||||
// Notifications
|
||||
params.clear();
|
||||
description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin.";
|
||||
params = phasePowerLimitSchema();
|
||||
description = "Emitted whenever the phase power limit configuration changes.";
|
||||
params.insert("phasePowerLimit", enumValueName(Uint));
|
||||
registerNotification("PhasePowerLimitChanged", description, params);
|
||||
// [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs.
|
||||
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){
|
||||
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
||||
});
|
||||
connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){
|
||||
emit PhasePowerLimitChanged(phasePowerLimitPayload());
|
||||
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
|
||||
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
|
||||
});
|
||||
|
||||
params.clear();
|
||||
@ -943,38 +890,14 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString &
|
||||
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
return createReply(phasePowerLimitPayload());
|
||||
}
|
||||
|
||||
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitSchema()
|
||||
{
|
||||
// [ETM] UNE déclaration pour GetPhasePowerLimit et PhasePowerLimitChanged (règle 11).
|
||||
QVariantMap schema;
|
||||
schema.insert("phasePowerLimit", enumValueName(Uint));
|
||||
schema.insert("phaseSafetyMarginPercent", enumValueName(Uint));
|
||||
schema.insert("effectivePhaseLimit", enumValueName(Double));
|
||||
return schema;
|
||||
}
|
||||
|
||||
QVariantMap NymeaEnergyJsonHandler::phasePowerLimitPayload() const
|
||||
{
|
||||
return {{"phasePowerLimit", m_smartChargingManager->phasePowerLimit()},
|
||||
{"phaseSafetyMarginPercent", m_smartChargingManager->phaseSafetyMarginPercent()},
|
||||
{"effectivePhaseLimit", m_smartChargingManager->effectivePhaseLimit()}};
|
||||
QVariantMap returns;
|
||||
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit());
|
||||
return createReply(returns);
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms)
|
||||
{
|
||||
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
|
||||
// [ETM] La marge est jugée AVANT toute écriture : refusée, rien ne change — pas même la limite.
|
||||
if (params.contains("phaseSafetyMarginPercent")
|
||||
&& params.value("phaseSafetyMarginPercent").toUInt() > SmartChargingManager::PhaseSafetyMarginMaxPercent) {
|
||||
QVariantMap returns;
|
||||
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter));
|
||||
return createReply(returns);
|
||||
}
|
||||
if (params.contains("phaseSafetyMarginPercent"))
|
||||
m_smartChargingManager->setPhaseSafetyMarginPercent(params.value("phaseSafetyMarginPercent").toUInt());
|
||||
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
|
||||
QVariantMap returns;
|
||||
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
|
||||
@ -1216,45 +1139,30 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms)
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms)
|
||||
{
|
||||
// Même forme que SetTariff : le code d'erreur amont, inchangé, et à côté le MOTIF publiable —
|
||||
// une cause et un endroit, jamais une phrase (règle 12). Le journal ne porte que la phrase
|
||||
// française rendue depuis ce motif, comme pour SetTariff : la phrase détaillée du refus
|
||||
// recopie ce que le client a saisi (mode, domaine, relais, libellé…), et un saut de ligne y
|
||||
// fabriquerait une fausse ligne de journal (relecture q7-suite, I-1).
|
||||
auto refuser = [this](const QVariantMap &faultParams) {
|
||||
// 7-d — le refus au moins aussi haut que l'enregistrement.
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)")
|
||||
.arg(renderFr({DecisionCode::LoadConfigRefused, faultParams}),
|
||||
QLatin1String(DecisionCode::LoadConfigRefused));
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||
{"fault", QLatin1String(DecisionCode::LoadConfigRefused)},
|
||||
{"faultParams", faultParams}});
|
||||
};
|
||||
|
||||
if (!m_loadConfigStore)
|
||||
return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}});
|
||||
if (!m_loadConfigStore) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré.";
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
|
||||
LoadConfigs configs;
|
||||
foreach (const QVariant &item, params.value("loadConfigs").toList())
|
||||
configs.append(LoadConfig::fromMap(item.toMap()));
|
||||
|
||||
QVariantMap faultParams;
|
||||
// Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : le motif ne cite
|
||||
// un identifiant que bien formé.
|
||||
if (!LoadConfigStore::validateBounds(configs, nullptr, &faultParams))
|
||||
return refuser(faultParams);
|
||||
|
||||
QString why;
|
||||
// LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part
|
||||
// ailleurs : c'est la seule couche qui sache qu'une écriture vient du dehors. Le moteur,
|
||||
// lui, pose légitimement "auto" en créant une entrée d'office.
|
||||
LoadConfigs reconciliees;
|
||||
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, nullptr, &faultParams))
|
||||
return refuser(faultParams);
|
||||
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
configs = reconciliees;
|
||||
|
||||
if (!m_loadConfigStore->setConfigs(configs, nullptr, &faultParams))
|
||||
return refuser(faultParams);
|
||||
if (!m_loadConfigStore->setConfigs(configs, &why)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||
}
|
||||
@ -1493,35 +1401,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;
|
||||
}
|
||||
@ -1550,17 +1429,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()},
|
||||
|
||||
@ -89,9 +89,6 @@ public:
|
||||
* \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc).
|
||||
*/
|
||||
bool progressMesurable(const LoadConfig &c, QString &cause) const;
|
||||
//! \brief Remplace l'ensemble des configurations de charge. \param params \c loadConfigs.
|
||||
//! \return \c energyError ; sur refus \c fault \c LOAD_CONFIG_REFUSED et \c faultParams
|
||||
//! (\c DecisionCode::LoadConfigRefused) — rien n'est modifié.
|
||||
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms);
|
||||
|
||||
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
||||
@ -119,17 +116,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.
|
||||
@ -239,14 +225,6 @@ private:
|
||||
* celui qu'on ne teste pas tous les jours.
|
||||
*/
|
||||
static QVariantMap loadTelemetrySchema();
|
||||
/*!
|
||||
* \brief [ETM] Charge utile commune de \c GetPhasePowerLimit et \c PhasePowerLimitChanged :
|
||||
* la limite, la marge de sécurité et le seuil effectif qui en résulte.
|
||||
* \return Le schéma (\c phasePowerLimitSchema) ou les valeurs courantes (\c phasePowerLimitPayload).
|
||||
*/
|
||||
static QVariantMap phasePowerLimitSchema();
|
||||
//! \brief [ETM] Valeurs courantes, dans la forme de \c phasePowerLimitSchema(). \return La charge utile.
|
||||
QVariantMap phasePowerLimitPayload() const;
|
||||
|
||||
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
|
||||
//! et \c OptimizerStatusChanged. \return Le schéma des champs.
|
||||
|
||||
@ -23,7 +23,6 @@
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#include "smartchargingmanager.h"
|
||||
#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal()
|
||||
#include "evcharger.h"
|
||||
#include "rootmeter.h"
|
||||
#include "energysettings.h"
|
||||
@ -44,11 +43,6 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa
|
||||
{
|
||||
EnergySettings settings;
|
||||
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
|
||||
// [ETM] Marge de sécurité (2026-10-27) : absente du réglage, elle vaut 10 % ; hors bornes (un
|
||||
// fichier édité à la main), elle reprend 10 % plutôt qu'une valeur qu'on refuserait par RPC.
|
||||
m_phaseSafetyMarginPercent = settings.value("phaseSafetyMarginPercent", 10).toUInt();
|
||||
if (m_phaseSafetyMarginPercent > PhaseSafetyMarginMaxPercent)
|
||||
m_phaseSafetyMarginPercent = 10;
|
||||
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
|
||||
// 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique.
|
||||
//
|
||||
@ -192,39 +186,6 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit)
|
||||
}
|
||||
}
|
||||
|
||||
bool SmartChargingManager::setPhaseSafetyMarginPercent(uint percent)
|
||||
{
|
||||
// [ETM] Hors bornes : refus, et l'état ne bouge pas.
|
||||
if (percent > PhaseSafetyMarginMaxPercent)
|
||||
return false;
|
||||
if (m_phaseSafetyMarginPercent != percent) {
|
||||
m_phaseSafetyMarginPercent = percent;
|
||||
emit phaseSafetyMarginPercentChanged(m_phaseSafetyMarginPercent);
|
||||
|
||||
EnergySettings settings;
|
||||
settings.setValue("phaseSafetyMarginPercent", m_phaseSafetyMarginPercent);
|
||||
|
||||
#ifndef ENERGY_SIMULATION
|
||||
// Même raison que setPhasePowerLimit() : le seuil change, le cycle le prend tout de suite.
|
||||
update(QDateTime::currentDateTime());
|
||||
#endif
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QHash<QString, double> SmartChargingManager::phaseConsumptionW() const
|
||||
{
|
||||
if (!m_rootMeter)
|
||||
return {};
|
||||
// [ETM] Les phases relevées au niveau du total si elles sont en retard sur lui — voir
|
||||
// tiragesCoherentsAvecLeTotal() : la phase la plus chargée porte au moins le tiers du total.
|
||||
const QList<double> w = tiragesCoherentsAvecLeTotal({m_rootMeter->currentPowerPhaseA(),
|
||||
m_rootMeter->currentPowerPhaseB(),
|
||||
m_rootMeter->currentPowerPhaseC()},
|
||||
m_rootMeter->currentPower());
|
||||
return {{"A", w.at(0)}, {"B", w.at(1)}, {"C", w.at(2)}};
|
||||
}
|
||||
|
||||
double SmartChargingManager::acquisitionTolerance() const
|
||||
{
|
||||
return m_acquisitionTolerance;
|
||||
@ -407,13 +368,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
||||
return;
|
||||
}
|
||||
|
||||
// [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la
|
||||
// marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27.
|
||||
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
||||
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
||||
{"B", m_rootMeter->currentPowerPhaseB()},
|
||||
{"C", m_rootMeter->currentPowerPhaseC()}};
|
||||
|
||||
bool limitExceeded = false;
|
||||
|
||||
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
||||
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
||||
|
||||
// Note: for now we assume the phases are always connected this way:
|
||||
// EV phase A -> house phase A
|
||||
@ -433,7 +394,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate
|
||||
|
||||
foreach (const QString &phase, currentPhaseConsumption.keys()) {
|
||||
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
|
||||
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << effectivePhaseLimit() << "A)";
|
||||
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)";
|
||||
limitExceeded = true;
|
||||
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
|
||||
requiredThrottlePower[phase] = overshot;
|
||||
@ -515,10 +476,11 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
||||
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
|
||||
|
||||
|
||||
// [ETM] Mêmes lectures et même seuil que verifyOverloadProtection().
|
||||
QHash<QString, double> currentPhaseConsumption = phaseConsumptionW();
|
||||
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
|
||||
{"B", m_rootMeter->currentPowerPhaseB()},
|
||||
{"C", m_rootMeter->currentPowerPhaseC()}};
|
||||
|
||||
double consumptionLimitInWatt = 230 * effectivePhaseLimit();
|
||||
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
|
||||
|
||||
QHash<QString, double> availablePhasePower = {{"A", 0},
|
||||
{"B", 0},
|
||||
@ -621,7 +583,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur
|
||||
void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
||||
{
|
||||
// We don't assume we have the entire phase power for charging...
|
||||
double housePhaseLimitPower = effectivePhaseLimit() * 230; // [ETM] marge de sécurité
|
||||
double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230;
|
||||
|
||||
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
|
||||
|
||||
@ -675,12 +637,6 @@ void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime)
|
||||
evChargers.append(evCharger);
|
||||
}
|
||||
|
||||
// [ETM] Les bornes préparées CE cycle — exactement celles que l'étape 2 va traiter. La table
|
||||
// m_processInfos ne le dit pas : elle n'est jamais vidée, et operator[] y crée une entrée par
|
||||
// défaut (1 phase, pas de bascule) dès qu'on la lit ainsi, dans verifyOverloadProtection() par
|
||||
// exemple. Lue par internalHasProcessInfo(), voir sa documentation.
|
||||
m_preparedChargers = QSet<EvCharger *>(evChargers.constBegin(), evChargers.constEnd());
|
||||
|
||||
// 2. Prepare all information avaialble for each charger
|
||||
foreach (EvCharger *evCharger, evChargers) {
|
||||
|
||||
@ -1358,9 +1314,7 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime)
|
||||
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
|
||||
|
||||
// Adjust down to not overload any phases.
|
||||
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
||||
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit)
|
||||
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
|
||||
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit);
|
||||
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
|
||||
|
||||
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
|
||||
@ -1442,9 +1396,7 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime)
|
||||
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
|
||||
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
|
||||
|
||||
// [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite.
|
||||
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit)
|
||||
- (m_phasePowerConsumptionLimit - effectivePhaseLimit());
|
||||
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit);
|
||||
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
|
||||
|
||||
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection
|
||||
|
||||
@ -27,7 +27,6 @@
|
||||
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <QSet>
|
||||
|
||||
#include "energymanagerconfiguration.h"
|
||||
#include "types/charginginfo.h"
|
||||
@ -64,30 +63,6 @@ public:
|
||||
uint phasePowerLimit() const;
|
||||
void setPhasePowerLimit(uint phasePowerLimit);
|
||||
|
||||
/*!
|
||||
* \brief [ETM] Marge de sécurité sous la limite de phase, en pourcents (décision du 2026-10-27).
|
||||
* \return 10 par défaut ; persistée avec la limite (clé \c phaseSafetyMarginPercent).
|
||||
* \note Le moteur vise \c effectivePhaseLimit(), pas \c phasePowerLimit() : pour le plafond
|
||||
* qu'il déclare et alloue, et pour le seuil où la protection de surcharge agit.
|
||||
*/
|
||||
uint phaseSafetyMarginPercent() const { return m_phaseSafetyMarginPercent; }
|
||||
/*!
|
||||
* \brief [ETM] Règle la marge de sécurité.
|
||||
* \param percent Entre 0 et \c PhaseSafetyMarginMaxPercent inclus.
|
||||
* \return Faux, sans rien changer, hors de ces bornes.
|
||||
* \note 0 est admis : c'est le comportement d'avant la marge, et la bonne valeur pour une
|
||||
* installation dont la limite saisie est déjà une limite réduite.
|
||||
*/
|
||||
bool setPhaseSafetyMarginPercent(uint percent);
|
||||
//! [ETM] Borne haute de la marge : au-delà, la maison perd plus d'un quart de son branchement.
|
||||
static constexpr uint PhaseSafetyMarginMaxPercent = 25;
|
||||
/*!
|
||||
* \brief [ETM] Le seuil effectif par phase, en ampères : limite × (1 − marge).
|
||||
* \return 0 quand la limite n'est pas réglée (protection désactivée, contrat amont).
|
||||
*/
|
||||
double effectivePhaseLimit() const
|
||||
{ return m_phasePowerConsumptionLimit * (100.0 - m_phaseSafetyMarginPercent) / 100.0; }
|
||||
|
||||
double acquisitionTolerance() const;
|
||||
void setAcquisitionTolerance(double acquisitionTolerance);
|
||||
|
||||
@ -124,7 +99,6 @@ public:
|
||||
|
||||
signals:
|
||||
void phasePowerLimitChanged(int phasePowerLimit);
|
||||
void phaseSafetyMarginPercentChanged(uint percent); // [ETM]
|
||||
void acquisitionToleranceChanged(double acquisitionTolerance);
|
||||
void batteryLevelConsiderationChanged(double batteryLevelConsideration);
|
||||
void lockOnUnplugChanged(bool lockOnUnplug);
|
||||
@ -139,9 +113,7 @@ signals:
|
||||
|
||||
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
|
||||
// All changes below are visibility-only (private → protected / virtual added).
|
||||
// Zero logic change — EXCEPT items 9 to 11 of the inventory (lot délestage triphasé,
|
||||
// 2026-10-27), which change the overload protection on purpose and say so.
|
||||
// Revert by deleting this block and restoring private slots.
|
||||
// Zero logic change. Revert by deleting this block and restoring private slots.
|
||||
//
|
||||
// INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et
|
||||
// échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la
|
||||
@ -159,27 +131,10 @@ 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.
|
||||
// 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() :
|
||||
// les bornes préparées au dernier cycle. Seul
|
||||
// lecteur : internalHasProcessInfo().
|
||||
// 10. phaseSafetyMarginPercent(), setPhaseSafetyMarginPercent(), effectivePhaseLimit(),
|
||||
// PhaseSafetyMarginMaxPercent, phaseSafetyMarginPercentChanged()
|
||||
// — la marge de sécurité (décision du 2026-10-27),
|
||||
// documentée en tête de la classe. Elle CHANGE la
|
||||
// logique amont, en connaissance de cause : le seuil
|
||||
// de verifyOverloadProtection() et de sa reprise, la
|
||||
// limite de prepareInformation() et les deux
|
||||
// allowances du proxy (marques [ETM] dans le .cpp).
|
||||
// 11. phaseConsumptionW() — lecture des phases cohérente avec le total, seule
|
||||
// lecture de la protection et de sa reprise.
|
||||
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
|
||||
// — crochets ETM, virtuels, documentés ci-dessous.
|
||||
// Ce sont EUX qui garantissent « une charge, un
|
||||
@ -199,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é.
|
||||
@ -352,27 +286,8 @@ protected:
|
||||
* \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de
|
||||
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
|
||||
* budget du cycle est divisé par trois.
|
||||
* \note **Lu sur l'ensemble des bornes préparées ce cycle, pas sur la table.** La table
|
||||
* \c m_processInfos n'est jamais vidée, et l'amont la lit aussi par \c operator[], qui y
|
||||
* insère une entrée par défaut : \c verifyOverloadProtection() le fait pour toute borne qu'il
|
||||
* rabaisse. Tester la table rendait donc VRAI pour une borne sans voiture après sa première
|
||||
* surcharge, et la borne triphasée devenait monophasée dans les comptes (relevé du
|
||||
* 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée
|
||||
* laissait son entrée de la veille (\c testAnUnassignedCarTakesItsPhasesAway).
|
||||
*/
|
||||
/*!
|
||||
* \brief [ETM] Tirage de chaque phase au compteur principal, en watts, cohérent avec le total.
|
||||
* \return Clés \c "A", \c "B", \c "C" : \c currentPowerPhaseX relevés par
|
||||
* \c tiragesCoherentsAvecLeTotal() si leur somme est sous \c currentPower — jamais abaissés.
|
||||
* Vide sans compteur principal.
|
||||
* \note Seule lecture des phases par la protection de surcharge et sa reprise, et par la
|
||||
* publication de ses gestes (\c EnergyArbitrator::noterGesteBorne()) : le dépassement publié
|
||||
* est celui qui a décidé.
|
||||
*/
|
||||
QHash<QString, double> phaseConsumptionW() const;
|
||||
|
||||
bool internalHasProcessInfo(EvCharger *evCharger) const // [ETM] new
|
||||
{ return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); }
|
||||
bool internalHasProcessInfo(EvCharger *evCharger) const { return m_processInfos.contains(evCharger); } // [ETM] new
|
||||
// [ETM] END
|
||||
|
||||
private slots:
|
||||
@ -404,7 +319,6 @@ private:
|
||||
|
||||
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
|
||||
QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
|
||||
QSet<EvCharger *> m_preparedChargers; // [ETM] préparées au dernier prepareInformation()
|
||||
QHash<EvCharger *, ChargingActions> m_chargingActions;
|
||||
|
||||
QDateTime m_lastSpotMarketPlanning;
|
||||
@ -413,7 +327,6 @@ private:
|
||||
QHash<EvCharger *, bool> m_overloadProtectionActive;
|
||||
|
||||
uint m_phasePowerConsumptionLimit = 25;
|
||||
uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27
|
||||
|
||||
double m_acquisitionTolerance = 0.5;
|
||||
double m_batteryLevelConsideration = 0.9;
|
||||
|
||||
@ -332,8 +332,7 @@ void TestCharging::addChargingInfo()
|
||||
|
||||
// Set phase power limit
|
||||
uint phasePowerLimit = 25000;
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
verifyEnergyError(response);
|
||||
|
||||
removeDevices();
|
||||
@ -692,8 +691,7 @@ void TestCharging::testEcoMode()
|
||||
|
||||
// Set phase power limit
|
||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||
|
||||
// Add mock meter
|
||||
@ -1071,8 +1069,7 @@ void TestCharging::testEcoModePhaseSwitching()
|
||||
|
||||
// Set phase power limit
|
||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||
|
||||
// Add mock meter
|
||||
@ -1384,8 +1381,7 @@ void TestCharging::testBatteryLevelConsideration()
|
||||
|
||||
// Set phase power limit
|
||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||
|
||||
// Add mock meter
|
||||
@ -1606,8 +1602,7 @@ void TestCharging::batteryLevelConsiderationStopCharging()
|
||||
|
||||
// Set phase power limit
|
||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||
|
||||
// Add mock meter
|
||||
@ -1845,8 +1840,7 @@ void TestCharging::testOverloadProtectionManualMode()
|
||||
|
||||
// Set phase power limit
|
||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||
|
||||
// Add mock meter
|
||||
@ -2017,8 +2011,7 @@ void TestCharging::testOverloadProtectionEcoMode()
|
||||
|
||||
// Set phase power limit
|
||||
params.clear(); response.clear(); notification.clear(); notificationSpy.clear();
|
||||
// Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite).
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}}));
|
||||
response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}}));
|
||||
QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError");
|
||||
|
||||
// Add mock meter
|
||||
@ -2209,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},
|
||||
@ -2330,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}})));
|
||||
|
||||
@ -451,23 +451,6 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin
|
||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||
}
|
||||
|
||||
QUuid EnergyTestBase::addUnmeteredCharger(const QString &phases, double maxChargingCurrentUpperLimit, quint16 port)
|
||||
{
|
||||
const QVariantList thingParams = {
|
||||
QVariantMap{{"paramTypeId", "{4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5}"}, {"value", port}},
|
||||
QVariantMap{{"paramTypeId", "{ec3e73a0-cca9-415a-bc64-9e841d666853}"}, {"value", phases}},
|
||||
QVariantMap{{"paramTypeId", "{803ba501-ae0f-4d58-8097-6ebb0928b3d8}"}, {"value", maxChargingCurrentUpperLimit}},
|
||||
};
|
||||
QVariantMap params;
|
||||
params.insert("thingClassId", mockChargerUnmeteredThingClassId.toString());
|
||||
params.insert("name", "Unmetered charger");
|
||||
params.insert("thingParams", thingParams);
|
||||
|
||||
QVariant response = injectAndWait("Integrations.AddThing", params);
|
||||
verifyThingError(response);
|
||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||
}
|
||||
|
||||
QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port)
|
||||
{
|
||||
QVariantList thingParams;
|
||||
|
||||
@ -51,9 +51,6 @@ static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5
|
||||
static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7");
|
||||
static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c");
|
||||
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
|
||||
//! \brief Borne qui mesure sa PUISSANCE mais pas ses phases au sens du moteur — `currentL1..3`
|
||||
//! comme le plugin V2C Trydan, jamais `currentPhaseX`. `EvCharger::meteredPhases()` y est vide.
|
||||
static QUuid mockChargerUnmeteredThingClassId = QUuid("e297f8c4-934f-4af3-a655-29b5b799289b");
|
||||
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
|
||||
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
|
||||
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
|
||||
@ -113,9 +110,6 @@ public:
|
||||
QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657);
|
||||
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
|
||||
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
|
||||
//! \brief Ajoute une borne sans courants par phase lisibles par le moteur (façon V2C Trydan).
|
||||
//! \param phases Câblage, `A`, `B`, `C` ou `ABC` — fixe, sans bascule.
|
||||
QUuid addUnmeteredCharger(const QString &phases = "A", double maxChargingCurrentUpperLimit = 16, quint16 port = 26677);
|
||||
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
|
||||
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
|
||||
|
||||
|
||||
@ -15,13 +15,11 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
|
||||
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
|
||||
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
|
||||
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
||||
| `testAnInvalidContextIsRefusedWithItsProblems` | protocole 1.3, §7 : un `GetPlan` dont le contexte est invalide (`{"timestamp": "valeur-sonde:7Qz9"}`) → `-32602` à son `id`, connexion **gardée** ; `error.data` jugé par le validateur publié contre le contrat embarqué (`--responses-only`) : forme `{"problems":[{path, problem}]}` **exigée** d'un optimiseur qui s'annonce 1.3 ou plus, **avertie** (`AVERTISSEMENT`, test vert) d'un optimiseur 1.2 ; la valeur reçue recopiée dans `error.data` est **refusée** quelle que soit la version (règle de 1.2) |
|
||||
| `testTheTokenIsCheckedBeforeTheMajor` | protocole 1.3, §3 : token faux **et** autre majeure dans le même handshake → `-32001` (le token d'abord), puis l'optimiseur ferme. Exigé à partir de 1.3 ; `-32602` d'un optimiseur 1.2 est averti. **SKIP** sans `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` : sans token attendu, l'ordre ne s'éprouve pas |
|
||||
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
|
||||
|
||||
Les lignes `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop` valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||||
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||||
|
||||
Les fonctions 6 à 9, 11 et 12 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
|
||||
Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
|
||||
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
|
||||
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
|
||||
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
||||
@ -44,8 +42,7 @@ export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {end
|
||||
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
||||
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
||||
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \
|
||||
testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \
|
||||
testTheTokenIsCheckedBeforeTheMajor; do
|
||||
testTheSessionMatchesTheContractOffUtc; do
|
||||
systemd-run --user --scope -q -p MemoryMax=2G \
|
||||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
||||
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
||||
@ -64,19 +61,6 @@ commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp:/
|
||||
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
|
||||
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
|
||||
|
||||
**Un optimiseur 1.2 face aux règles 1.3.** Une mineure ajoute des règles (protocole §9) : la suite
|
||||
lit la version que l'optimiseur annonce au handshake et n'exige la forme d'`error.data` et l'ordre
|
||||
token puis majeure qu'à partir de 1.3. En deçà, l'écart est écrit en `AVERTISSEMENT` et la fonction
|
||||
reste verte — l'optimiseur fonctionne avec ce moteur, il s'aligne avant de s'annoncer 1.3. Pour
|
||||
éprouver l'ordre, donner un token (`ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE`, et le même fichier à
|
||||
l'optimiseur) :
|
||||
|
||||
```sh
|
||||
printf 'jeton-de-conformite-0123456789' > /chemin/token
|
||||
export ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE=/chemin/token
|
||||
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint} --token-file /chemin/token'
|
||||
```
|
||||
|
||||
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
|
||||
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
|
||||
cycles non appliqués avec leur `fallbackCode`, latences.
|
||||
@ -111,7 +95,7 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne
|
||||
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
|
||||
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
|
||||
forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
|
||||
le moteur n'envoie de token que si l'installateur en a posé un), l'exhaustivité de la validation du
|
||||
contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour
|
||||
juger la forme), le comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||||
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan`
|
||||
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut
|
||||
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||||
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).
|
||||
|
||||
@ -22,11 +22,7 @@
|
||||
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
|
||||
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ;
|
||||
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
|
||||
// locale comme en production, à travers le passage à l'heure d'hiver ;
|
||||
// 11 et 12 (protocole 1.3), par la sonde : un contexte GetPlan invalide → -32602 dont `error.data`
|
||||
// a la forme normée, jugée par le validateur publié — exigée d'un optimiseur qui s'annonce 1.3
|
||||
// ou plus, avertie pour un 1.2 ; la valeur reçue n'y est jamais recopiée. Token faux ET autre
|
||||
// majeure dans le même handshake → -32001 (le token d'abord), puis fermeture.
|
||||
// locale comme en production, à travers le passage à l'heure d'hiver.
|
||||
//
|
||||
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
|
||||
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
|
||||
@ -424,8 +420,6 @@ private slots:
|
||||
void testABatchIsRefused();
|
||||
void testALineOverTheLimitIsRefused();
|
||||
void testTheSessionMatchesTheContractOffUtc();
|
||||
void testAnInvalidContextIsRefusedWithItsProblems();
|
||||
void testTheTokenIsCheckedBeforeTheMajor();
|
||||
|
||||
private:
|
||||
void monter();
|
||||
@ -437,8 +431,8 @@ private:
|
||||
void sonder(std::unique_ptr<Sonde> &sonde);
|
||||
//! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide.
|
||||
QByteArray handshake(int id, const QString &version, const QString &token) const;
|
||||
//! Handshake accepté sur \p sonde, ou échec du test ; \p reponse : le `result` reçu, si demandé.
|
||||
void ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse = nullptr);
|
||||
//! Handshake accepté sur \p sonde, ou échec du test.
|
||||
void ouvrirSession(Sonde &sonde, int id);
|
||||
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
|
||||
void brancher(bool parLeRelais);
|
||||
void cycle(const QDateTime &t);
|
||||
@ -852,7 +846,7 @@ QByteArray TestOptimizerConformance::handshake(int id, const QString &version, c
|
||||
.toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse)
|
||||
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
|
||||
{
|
||||
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
|
||||
QByteArray ligne;
|
||||
@ -860,33 +854,8 @@ void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id, QJsonObject *
|
||||
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
|
||||
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
|
||||
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
|
||||
if (reponse)
|
||||
*reponse = m;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
//! « 1.3 » → 103 ; illisible → -1. Seules majeure et mineure comptent (§9).
|
||||
int versionComparable(const QString &v)
|
||||
{
|
||||
bool okMajeure = false, okMineure = true;
|
||||
const int majeure = v.section('.', 0, 0).toInt(&okMajeure);
|
||||
const int mineure = v.contains('.') ? v.section('.', 1, 1).toInt(&okMineure) : 0;
|
||||
return okMajeure && okMineure ? majeure * 100 + mineure : -1;
|
||||
}
|
||||
|
||||
//! Version à partir de laquelle les règles 1.3 sont EXIGÉES ; en deçà, un écart est averti.
|
||||
constexpr int kExigeDepuis = 103;
|
||||
|
||||
//! Vrai si \p annoncee est LISIBLE et antérieure à 1.3 — une version illisible est jugée 1.3.
|
||||
bool avantRegles13(const QString &annoncee)
|
||||
{
|
||||
const int v = versionComparable(annoncee);
|
||||
return v >= 0 && v < kExigeDepuis;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
|
||||
{
|
||||
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
|
||||
@ -1014,111 +983,5 @@ void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc()
|
||||
jugerLaSession();
|
||||
}
|
||||
|
||||
// --- 11 et 12 : protocole 1.3, par une connexion de sonde --------------------------------------------
|
||||
|
||||
void TestOptimizerConformance::testAnInvalidContextIsRefusedWithItsProblems()
|
||||
{
|
||||
// §7 (1.2) : un contexte GetPlan invalide → -32602, connexion gardée. §7 (1.3) : `error.data`
|
||||
// = {"problems":[{path, problem}]}, jamais la valeur reçue. La forme est jugée par le VALIDATEUR
|
||||
// PUBLIÉ, sur la session de sonde (--responses-only : la requête est fautive à dessein) et
|
||||
// contre le contrat EMBARQUÉ ; c'est lui qui exige ou avertit selon la version que
|
||||
// l'optimiseur a annoncée. Le contexte porte une valeur qu'aucun chemin conforme ne peut
|
||||
// contenir : si elle revient dans `error.data`, c'est une recopie.
|
||||
static const QString marqueur = QStringLiteral("valeur-sonde:7Qz9");
|
||||
std::unique_ptr<Sonde> sonde;
|
||||
sonder(sonde);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
QJsonObject hsReponse;
|
||||
ouvrirSession(*sonde, 1, &hsReponse);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
|
||||
const QJsonObject getPlan{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "GetPlan"},
|
||||
{"params", QJsonObject{{"timestamp", marqueur}}}};
|
||||
sonde->envoyer(QJsonDocument(getPlan).toJson(QJsonDocument::Compact));
|
||||
QByteArray ligne;
|
||||
QVERIFY2(sonde->recevoir(&ligne), "contexte invalide : aucune réponse (ou connexion fermée)");
|
||||
const QString e = verifierErreur(ligne, 2, -32602);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
const QJsonObject reponse = QJsonDocument::fromJson(ligne).object();
|
||||
// Garde indépendante du validateur : la valeur reçue n'est nulle part dans `error.data`.
|
||||
const QJsonValue data = reponse.value("error").toObject().value("data");
|
||||
const QByteArray dataTexte = QJsonDocument(QJsonArray{data}).toJson(QJsonDocument::Compact);
|
||||
QVERIFY2(!dataTexte.contains(marqueur.toUtf8()),
|
||||
"error.data recopie la valeur reçue (params.timestamp) — jamais la valeur reçue (§7)");
|
||||
|
||||
// La session de sonde, jugée par le validateur publié. Le token y devient des astérisques.
|
||||
QJsonObject hsParams = QJsonDocument::fromJson(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion),
|
||||
m_token)).object();
|
||||
if (!m_token.isEmpty()) {
|
||||
QJsonObject p = hsParams.value("params").toObject();
|
||||
p.insert("token", QString(m_token.size(), QLatin1Char('*')));
|
||||
hsParams.insert("params", p);
|
||||
}
|
||||
QTemporaryDir dir;
|
||||
QVERIFY(dir.isValid());
|
||||
const Contrat::Verdict v = Contrat::juger({hsParams, hsReponse, getPlan, reponse},
|
||||
Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()),
|
||||
{QStringLiteral("--responses-only")});
|
||||
QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("optimiseur en %1 — error.data NON CONFORME au contrat :\n")
|
||||
.arg(annoncee.left(16)) + v.sortie.right(4000)));
|
||||
// Le contrôle a bien eu lieu : un validateur qui ne trouverait plus la forme dans le contrat
|
||||
// passerait au vert sans rien juger.
|
||||
QVERIFY2(v.compte("GetPlan.error.data") == 1, qUtf8Printable(v.sortie.right(1000)));
|
||||
const int avertissements = v.resume.value("avertissements").toInt();
|
||||
for (const QString &l : v.sortie.split('\n'))
|
||||
if (l.startsWith(QStringLiteral("AVERTISSEMENT")))
|
||||
qWarning().noquote() << "[Conformité]" << l;
|
||||
// Un avertissement n'est permis qu'à un optimiseur d'avant 1.3 : au-delà, c'est un écart.
|
||||
QVERIFY2(avertissements == 0 || avantRegles13(annoncee), qUtf8Printable(v.sortie.right(1000)));
|
||||
qInfo().noquote() << "[Conformité] contexte invalide : -32602, optimiseur en" << annoncee
|
||||
<< (avertissements ? QStringLiteral("— forme d'error.data AVERTIE (%1), à aligner avant 1.3").arg(avertissements)
|
||||
: QStringLiteral("— forme d'error.data conforme"));
|
||||
// Connexion gardée : la sonde peut encore parler.
|
||||
sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})");
|
||||
QVERIFY2(sonde->recevoir(&ligne), "après le contexte invalide : aucune réponse (ou connexion fermée)");
|
||||
const QString e2 = verifierErreur(ligne, 3, -32601);
|
||||
QVERIFY2(e2.isEmpty(), qUtf8Printable(e2));
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::testTheTokenIsCheckedBeforeTheMajor()
|
||||
{
|
||||
// §3 (1.3) : token faux ET autre majeure dans le même handshake → -32001, le token d'abord,
|
||||
// puis l'optimiseur ferme. Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2
|
||||
// qui juge la majeure d'abord (-32602) est averti. Sans token attendu, rien à éprouver.
|
||||
std::unique_ptr<Sonde> sonde;
|
||||
sonder(sonde);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
if (m_token.isEmpty())
|
||||
QSKIP("aucun token attendu (ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE) : ordre token puis majeure non éprouvé");
|
||||
QJsonObject hsReponse;
|
||||
ouvrirSession(*sonde, 1, &hsReponse); // la version annoncée, sur une session acceptée
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
|
||||
const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0";
|
||||
Sonde faux(endpoint());
|
||||
QVERIFY(faux.ouverte());
|
||||
faux.envoyer(handshake(1, autreMajeure, m_token + QStringLiteral("-faux")));
|
||||
QByteArray ligne;
|
||||
QVERIFY2(faux.recevoir(&ligne), "token faux et autre majeure : aucune réponse avant la fermeture");
|
||||
const QString e = verifierErreur(ligne, 1, -32001);
|
||||
if (!e.isEmpty()) {
|
||||
// La majeure jugée d'abord : -32602 (règle 1.2), un écart d'ordre seulement.
|
||||
const bool majeureDabord = verifierErreur(ligne, 1, -32602).isEmpty();
|
||||
QVERIFY2(majeureDabord && avantRegles13(annoncee),
|
||||
qUtf8Printable(QStringLiteral("optimiseur en %1 : ").arg(annoncee.left(16)) + e));
|
||||
qWarning().noquote() << "[Conformité] AVERTISSEMENT optimiseur en" << annoncee.left(16)
|
||||
<< ": token faux et autre majeure → -32602, la majeure jugée avant le token —"
|
||||
" -32001 exigé à partir de 1.3, à aligner avant de s'annoncer 1.3";
|
||||
} else {
|
||||
qInfo().noquote() << "[Conformité] token faux et autre majeure : -32001, le token d'abord (optimiseur en"
|
||||
<< annoncee.left(16) + ")";
|
||||
}
|
||||
QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé : l'optimiseur n'a pas fermé la connexion en 5 s");
|
||||
}
|
||||
|
||||
QTEST_MAIN(TestOptimizerConformance)
|
||||
#include "testconformance.moc"
|
||||
|
||||
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)
|
||||
@ -64,7 +63,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
||||
DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle,
|
||||
DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused,
|
||||
DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused,
|
||||
DecisionCode::LoadConfigRefused, DecisionCode::SpotMarketNotInBuild}) {
|
||||
DecisionCode::SpotMarketNotInBuild}) {
|
||||
QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c);
|
||||
}
|
||||
QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS")));
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
#include "etm/types/drawcap.h"
|
||||
#include "etm/types/ecotarget.h"
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
#include "etm/types/decisionreason.h"
|
||||
#include "etm/ratios/energyratioscalculator.h"
|
||||
#include "etm/tariff/tariffconfig.h"
|
||||
#include "etm/forecast/baseloadlog.h"
|
||||
@ -29,8 +28,6 @@
|
||||
#include <QJsonObject>
|
||||
#include <QTimeZone>
|
||||
#include <QJsonArray>
|
||||
#include <QRegularExpression>
|
||||
#include <QScopeGuard>
|
||||
|
||||
namespace {
|
||||
|
||||
@ -474,527 +471,6 @@ void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards()
|
||||
QCOMPARE(relu.configs().count(), 2);
|
||||
}
|
||||
|
||||
void TestLoadModel::testLabelIsBoundedOnWriteNeverOnLoad()
|
||||
{
|
||||
// Protocole optimiseur 1.2, Q7 : le libellé part tel quel dans chaque GetPlan. Borné à
|
||||
// l'ÉCRITURE (256 caractères), jamais au chargement — même partage que les rangs (LM-1209-a) :
|
||||
// une règle resserrée après coup refuse une écriture, elle ne fait pas disparaître une charge.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-labels.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
QString err;
|
||||
|
||||
// La borne compte des CARACTÈRES : 256 « é » (deux octets UTF-8 chacun) passent, et un
|
||||
// caractère hors plan de base (deux unités UTF-16) compte pour un.
|
||||
auto avecLibelle = [](const QString &label) {
|
||||
QVariantMap m = borneMap(BORNE, 1);
|
||||
m.insert("label", label);
|
||||
return LoadConfigs(QList<LoadConfig>{LoadConfig::fromMap(m)});
|
||||
};
|
||||
const QString limite = QString(LoadConfigStore::MaxLabelChars - 1, QChar(0x00e9)) + QString::fromUcs4(U"\U0001F50C");
|
||||
QCOMPARE(limite.toUcs4().size(), qsizetype(256));
|
||||
QVERIFY(limite.size() > 256); // en unités UTF-16, il dépasserait : c'est bien le caractère qui compte
|
||||
|
||||
LoadConfigStore store;
|
||||
QVERIFY2(store.setConfigs(avecLibelle(limite), &err), qUtf8Printable(err));
|
||||
QCOMPARE(store.configs().at(0).label(), limite);
|
||||
QFile f(cfgPath);
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
const QByteArray persiste = f.readAll();
|
||||
f.close();
|
||||
|
||||
// 257 : refusé, RIEN n'est modifié — ni en mémoire, ni sur disque. Le motif nomme la charge
|
||||
// par son identifiant et donne la longueur, jamais le libellé.
|
||||
const QString tropLong = QString(LoadConfigStore::MaxLabelChars + 1, QLatin1Char('L'));
|
||||
QVERIFY(!store.setConfigs(avecLibelle(tropLong), &err));
|
||||
QVERIFY2(err.contains(QStringLiteral("257 caractères")) && err.contains(BORNE), qUtf8Printable(err));
|
||||
QVERIFY(!err.contains(tropLong.left(32)));
|
||||
QCOMPARE(store.configs().count(), 1);
|
||||
QCOMPARE(store.configs().at(0).label(), limite);
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(f.readAll(), persiste);
|
||||
f.close();
|
||||
|
||||
// ---- Au CHARGEMENT : une configuration héritée garde son libellé, entier ----------------
|
||||
QVariantMap herite = borneMap(BORNE, 1);
|
||||
herite.insert("label", tropLong);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray{QJsonObject::fromVariantMap(herite)}}}).toJson());
|
||||
f.close();
|
||||
LoadConfigStore relu;
|
||||
QCOMPARE(relu.configs().count(), 1);
|
||||
QCOMPARE(relu.configs().at(0).label(), tropLong); // ni écarté, ni tronqué
|
||||
|
||||
// L'aller-retour verbatim est refusé tant que le libellé n'est pas raccourci ; raccourci, il passe.
|
||||
QVERIFY(!relu.setConfigs(relu.configs(), &err));
|
||||
QCOMPARE(relu.configs().at(0).label(), tropLong);
|
||||
LoadConfigs repare = relu.configs();
|
||||
repare[0].setLabel(QStringLiteral("Borne garage"));
|
||||
QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err));
|
||||
QFile::remove(cfgPath);
|
||||
}
|
||||
|
||||
// ── Q7, suite — le refus de SetLoadConfig publie sa cause ──────────────────────────────────
|
||||
//
|
||||
// Les identifiants ci-dessous sont INVENTÉS, de même forme que ceux d'une installation : un ThingId
|
||||
// entre accolades, et des noms courts à tirets.
|
||||
namespace {
|
||||
const QString RELAIS_A = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000a1}");
|
||||
const QString RELAIS_C = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000c3}");
|
||||
const QString ROUTEUR_B = QStringLiteral("{bbbbbbbb-0000-4000-8000-0000000000b2}");
|
||||
|
||||
QVariantMap ballonMap()
|
||||
{
|
||||
return {{"id", "ballon-a"}, {"label", "Ballon"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||||
{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 1000}}}}};
|
||||
}
|
||||
|
||||
QVariantMap routeurMap()
|
||||
{
|
||||
return {{"id", ROUTEUR_B}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
|
||||
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}};
|
||||
}
|
||||
|
||||
QVariantList baseCharges() { return {ballonMap(), routeurMap()}; }
|
||||
|
||||
//! La base, l'entrée \p index modifiée par \p modifs.
|
||||
QVariantList chargesAvec(int index, const QVariantMap &modifs)
|
||||
{
|
||||
QVariantList l = baseCharges();
|
||||
QVariantMap m = l.at(index).toMap();
|
||||
for (auto it = modifs.constBegin(); it != modifs.constEnd(); ++it)
|
||||
m.insert(it.key(), it.value());
|
||||
l[index] = m;
|
||||
return l;
|
||||
}
|
||||
|
||||
LoadConfigs versConfigs(const QVariantList &l)
|
||||
{
|
||||
LoadConfigs c;
|
||||
for (const QVariant &v : l)
|
||||
c.append(LoadConfig::fromMap(v.toMap()));
|
||||
return c;
|
||||
}
|
||||
|
||||
QVariantList versCartes(const LoadConfigs &c)
|
||||
{
|
||||
QVariantList l;
|
||||
for (const LoadConfig &x : c)
|
||||
l.append(x.toMap());
|
||||
return l;
|
||||
}
|
||||
|
||||
QByteArray lireFichier(const QString &chemin)
|
||||
{
|
||||
QFile f(chemin);
|
||||
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
|
||||
}
|
||||
|
||||
QByteArray json(const QVariant &v) { return QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact); }
|
||||
|
||||
//! Le chemin d'écriture de SetLoadConfig, dans son ordre : bornes, réconciliation, écriture.
|
||||
bool ecrireCommeLeClient(LoadConfigStore &store, const LoadConfigs &c, QString *why, QVariantMap *motif)
|
||||
{
|
||||
if (!LoadConfigStore::validateBounds(c, why, motif))
|
||||
return false;
|
||||
LoadConfigs r;
|
||||
if (!store.reconcileRankOrigin(c, &r, why, motif))
|
||||
return false;
|
||||
return store.setConfigs(r, why, motif);
|
||||
}
|
||||
|
||||
void ecrireFichier(const QString &chemin, const QVariantList &charges)
|
||||
{
|
||||
QFile f(chemin);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray::fromVariantList(charges)}}).toJson());
|
||||
}
|
||||
}
|
||||
|
||||
void TestLoadModel::testLoadConfigRefusalNamesItsCause_data()
|
||||
{
|
||||
QTest::addColumn<QVariantList>("charges");
|
||||
QTest::addColumn<QVariantMap>("motif");
|
||||
QTest::addColumn<bool>("disqueInaccessible");
|
||||
|
||||
const QVariantMap paire{{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 1000}},
|
||||
QVariantMap{{"thingId", RELAIS_A}, {"powerW", 500}}}}};
|
||||
auto pac = [](const QVariantList &etats) {
|
||||
return QVariantMap{{"id", "pac-c"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
|
||||
{"priority", 3}, {"enabled", true},
|
||||
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
|
||||
};
|
||||
auto etat = [](int n) {
|
||||
return QVariantMap{{"state", n}, {"relays", QVariantList{RELAIS_C}}, {"estimatedPowerW", 1500}};
|
||||
};
|
||||
QVariantMap ballonC = ballonMap();
|
||||
ballonC.insert("id", "ballon-c");
|
||||
ballonC.insert("priority", 3);
|
||||
ballonC.insert("relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 500}}});
|
||||
|
||||
auto ligne = [](const char *nom, const QVariantList &charges, const QVariantMap &motif, bool disque = false) {
|
||||
QTest::newRow(nom) << charges << motif << disque;
|
||||
};
|
||||
// Par entrée : la position, et l'identifiant quand il est bien formé — jamais celui qu'on refuse.
|
||||
ligne("ID trop long", chargesAvec(0, {{"id", QString(129, QLatin1Char('x'))}}),
|
||||
{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}});
|
||||
ligne("ID hors jeu", chargesAvec(0, {{"id", "ballon a"}}),
|
||||
{{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}});
|
||||
ligne("ID vide", chargesAvec(1, {{"id", ""}}), {{"cause", "ID"}, {"index", 1}, {"length", 0}, {"max", 128}});
|
||||
ligne("LABEL", chargesAvec(0, {{"label", QString(257, QLatin1Char('L'))}}),
|
||||
{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"}, {"length", 257}, {"max", 256}});
|
||||
ligne("DEADLINE", chargesAvec(0, {{"needs", QVariantMap{{"dailyDeadline", "7:00"}}}}),
|
||||
{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}});
|
||||
ligne("MODE", chargesAvec(0, {{"mode", "variable"}}), {{"cause", "MODE"}, {"index", 0}, {"id", "ballon-a"}});
|
||||
ligne("DOMAIN", chargesAvec(0, {{"domain", "garage"}}), {{"cause", "DOMAIN"}, {"index", 0}, {"id", "ballon-a"}});
|
||||
ligne("RANK_ORIGIN", chargesAvec(0, {{"rankOrigin", "robot"}}),
|
||||
{{"cause", "RANK_ORIGIN"}, {"index", 0}, {"id", "ballon-a"}});
|
||||
ligne("RANK_ORIGIN_AUTO", chargesAvec(1, {{"rankOrigin", "auto"}}),
|
||||
{{"cause", "RANK_ORIGIN_AUTO"}, {"index", 1}, {"id", ROUTEUR_B}});
|
||||
ligne("ADAPTER", chargesAvec(0, {{"adapter", "pompe"}}), {{"cause", "ADAPTER"}, {"index", 0}, {"id", "ballon-a"}});
|
||||
ligne("FIELD_FORBIDDEN", chargesAvec(0, {{"minPowerW", 100}}),
|
||||
{{"cause", "FIELD_FORBIDDEN"}, {"index", 0}, {"id", "ballon-a"}, {"field", "minPowerW"}});
|
||||
ligne("FIELD_REQUIRED", chargesAvec(1, {{"maxPowerW", 0}}),
|
||||
{{"cause", "FIELD_REQUIRED"}, {"index", 1}, {"id", ROUTEUR_B}, {"field", "maxPowerW"}});
|
||||
ligne("FIELD_RANGE", chargesAvec(0, {{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 0}}}}}),
|
||||
{{"cause", "FIELD_RANGE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.powerW"}});
|
||||
ligne("FIELD_DUPLICATE", chargesAvec(0, paire),
|
||||
{{"cause", "FIELD_DUPLICATE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.thingId"}});
|
||||
ligne("FIELD_DUPLICATE état", baseCharges() << pac({etat(2), etat(3), etat(3)}),
|
||||
{{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"}, {"field", "sgReady.states.state"},
|
||||
{"state", 3}});
|
||||
ligne("SG_READY_STATE_2", baseCharges() << pac({etat(3)}),
|
||||
{{"cause", "SG_READY_STATE_2"}, {"index", 2}, {"id", "pac-c"}});
|
||||
// Entre entrées : la paire.
|
||||
ligne("ID_DUPLICATE", chargesAvec(1, {{"id", "ballon-a"}}),
|
||||
{{"cause", "ID_DUPLICATE"}, {"index", 1}, {"id", "ballon-a"}});
|
||||
ligne("THING_SHARED", baseCharges() << ballonC,
|
||||
{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 2}}, {"ids", QVariantList{"ballon-a", "ballon-c"}}});
|
||||
ligne("RANK_DUPLICATE", chargesAvec(1, {{"priority", 1}}),
|
||||
{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, {"ids", QVariantList{"ballon-a", ROUTEUR_B}},
|
||||
{"rank", 1}});
|
||||
// Sans entrée : l'écriture elle-même a échoué. La charge utile est VALIDE (un libellé changé).
|
||||
ligne("WRITE_FAILED", chargesAvec(0, {{"label", "Ballon du bas"}}), {{"cause", "WRITE_FAILED"}}, true);
|
||||
}
|
||||
|
||||
void TestLoadModel::testLoadConfigRefusalNamesItsCause()
|
||||
{
|
||||
QFETCH(QVariantList, charges);
|
||||
QFETCH(QVariantMap, motif);
|
||||
QFETCH(bool, disqueInaccessible);
|
||||
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-causes.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
|
||||
|
||||
LoadConfigStore store;
|
||||
QString err;
|
||||
QVariantMap recu;
|
||||
QVERIFY2(ecrireCommeLeClient(store, versConfigs(baseCharges()), &err, &recu), qUtf8Printable(err));
|
||||
QVERIFY(recu.isEmpty()); // un succès ne publie aucun motif
|
||||
const QVariantList enMemoire = versCartes(store.configs());
|
||||
const QByteArray surDisque = lireFichier(cfgPath);
|
||||
QVERIFY(!surDisque.isEmpty());
|
||||
|
||||
if (disqueInaccessible)
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", "/q7-repertoire-absent/energy-load-configuration.json");
|
||||
const bool accepte = ecrireCommeLeClient(store, versConfigs(charges), &err, &recu);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
QVERIFY(!accepte);
|
||||
QVERIFY(!err.isEmpty()); // la phrase du journal reste là
|
||||
QVERIFY2(json(recu) == json(motif), json(recu).constData());
|
||||
// RIEN n'est modifié : ni en mémoire, ni sur disque (octet pour octet).
|
||||
QCOMPARE(json(versCartes(store.configs())), json(enMemoire));
|
||||
QCOMPARE(lireFichier(cfgPath), surDisque);
|
||||
}
|
||||
|
||||
void TestLoadModel::testLoadConfigRefusedIsRendered()
|
||||
{
|
||||
auto rendu = [](const QVariantMap &p) { return renderFr(DecisionReason(DecisionCode::LoadConfigRefused, p)); };
|
||||
QCOMPARE(rendu({{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}}),
|
||||
QStringLiteral("Configuration des charges refusée (ID) : charge 1, 8 caractères sur 128 au plus, "
|
||||
"caractère 7 hors [A-Za-z0-9._{}-] — rien n'est modifié"));
|
||||
QCOMPARE(rendu({{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"},
|
||||
{"field", "sgReady.states.state"}, {"state", 3}}),
|
||||
QStringLiteral("Configuration des charges refusée (FIELD_DUPLICATE) : charge 3 « pac-c », "
|
||||
"champ sgReady.states.state, état 3 — rien n'est modifié"));
|
||||
QCOMPARE(rendu({{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}},
|
||||
{"ids", QVariantList{"ballon-a", "b"}}, {"rank", 1}}),
|
||||
QStringLiteral("Configuration des charges refusée (RANK_DUPLICATE) : charges 1 et 2, rang 1 — "
|
||||
"rien n'est modifié"));
|
||||
QCOMPARE(rendu({{"cause", "WRITE_FAILED"}}),
|
||||
QStringLiteral("Configuration des charges refusée (WRITE_FAILED) — rien n'est modifié"));
|
||||
}
|
||||
|
||||
void TestLoadModel::testIdIsBoundedOnWriteNeverOnLoad()
|
||||
{
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ids.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
|
||||
QString err;
|
||||
QVariantMap motif;
|
||||
LoadConfigStore store;
|
||||
auto avecId = [](const QString &id) { return versConfigs(chargesAvec(0, {{"id", id}})); };
|
||||
|
||||
// Acceptés : la borne exacte, et les deux formes réelles (inventées ici) — un ThingId entre
|
||||
// accolades (38 caractères) et un nom court à tirets ; le point et le souligné aussi.
|
||||
const QString limite(LoadConfigStore::MaxIdChars, QLatin1Char('x'));
|
||||
for (const QString &id : {limite, QStringLiteral("{0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0}"),
|
||||
QStringLiteral("ballon-test"), QStringLiteral("pac.jardin_2")}) {
|
||||
QVERIFY2(ecrireCommeLeClient(store, avecId(id), &err, &motif), qUtf8Printable(id + " : " + err));
|
||||
QCOMPARE(store.configs().at(0).id(), id);
|
||||
}
|
||||
const QByteArray surDisque = lireFichier(cfgPath);
|
||||
|
||||
// Refusés : 129, un espace, une barre, un accent, un saut de ligne final — rien de modifié.
|
||||
for (const QString &id : {limite + QLatin1Char('x'), QStringLiteral("ballon test"),
|
||||
QStringLiteral("ballon/test"), QStringLiteral("bâton"),
|
||||
QStringLiteral("ballon-test\n")}) {
|
||||
QVERIFY2(!ecrireCommeLeClient(store, avecId(id), &err, &motif), qUtf8Printable(id));
|
||||
QCOMPARE(motif.value("cause").toString(), QStringLiteral("ID"));
|
||||
QVERIFY(!motif.contains("id")); // l'identifiant refusé n'est jamais recopié
|
||||
QVERIFY2(!err.contains(id), qUtf8Printable(err));
|
||||
QCOMPARE(store.configs().at(0).id(), QStringLiteral("pac.jardin_2"));
|
||||
QCOMPARE(lireFichier(cfgPath), surDisque);
|
||||
}
|
||||
|
||||
// ---- Au CHARGEMENT : hors règle, l'entrée est conservée telle quelle, et signalée ---------
|
||||
QVariantMap long_ = ballonMap();
|
||||
long_.insert("id", QString(129, QLatin1Char('x')));
|
||||
QVariantMap espace = routeurMap();
|
||||
espace.insert("id", "routeur b");
|
||||
ecrireFichier(cfgPath, {long_, espace});
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
|
||||
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x{64}…\" \\(129 caractères")));
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
|
||||
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"routeur b\" \\(9 caractères")));
|
||||
LoadConfigStore relu;
|
||||
QCOMPARE(relu.configs().count(), 2);
|
||||
QCOMPARE(relu.configs().at(0).id(), QString(129, QLatin1Char('x')));
|
||||
QCOMPARE(relu.configs().at(1).id(), QStringLiteral("routeur b"));
|
||||
// L'écho verbatim est refusé ; l'identifiant remplacé, il passe.
|
||||
QVERIFY(!ecrireCommeLeClient(relu, relu.configs(), &err, &motif));
|
||||
QCOMPARE(json(motif), json(QVariantMap{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}}));
|
||||
LoadConfigs repare = relu.configs();
|
||||
repare[0].setId(QStringLiteral("ballon-a"));
|
||||
repare[1].setId(ROUTEUR_B);
|
||||
QVERIFY2(ecrireCommeLeClient(relu, repare, &err, &motif), qUtf8Printable(err));
|
||||
}
|
||||
|
||||
void TestLoadModel::testDailyDeadlineIsCheckedOnWriteAndDroppedOnLoad()
|
||||
{
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-deadline.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
|
||||
QString err;
|
||||
QVariantMap motif;
|
||||
LoadConfigStore store;
|
||||
auto avecEcheance = [](const QString &h) {
|
||||
return versConfigs(chargesAvec(0, {{"needs", QVariantMap{{"dailyDeadline", h}, {"minEnergyWhPerDay", 2000}}}}));
|
||||
};
|
||||
|
||||
for (const QString &h : {QString(), QStringLiteral("00:00"), QStringLiteral("23:59"), QStringLiteral("12:00")}) {
|
||||
QVERIFY2(ecrireCommeLeClient(store, avecEcheance(h), &err, &motif), qUtf8Printable(h + " : " + err));
|
||||
QCOMPARE(store.configs().at(0).needs().dailyDeadline(), h);
|
||||
}
|
||||
const QByteArray surDisque = lireFichier(cfgPath);
|
||||
for (const QString &h : {QStringLiteral("24:00"), QStringLiteral("7:00"), QStringLiteral("12:60"),
|
||||
QStringLiteral("12h00"), QStringLiteral("12:00\n"), QStringLiteral(" 12:00")}) {
|
||||
QVERIFY2(!ecrireCommeLeClient(store, avecEcheance(h), &err, &motif), qUtf8Printable(h));
|
||||
QCOMPARE(json(motif), json(QVariantMap{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}}));
|
||||
QCOMPARE(store.configs().at(0).needs().dailyDeadline(), QStringLiteral("12:00"));
|
||||
QCOMPARE(lireFichier(cfgPath), surDisque);
|
||||
}
|
||||
|
||||
// ---- Au CHARGEMENT : l'entrée est gardée, l'échéance illisible est RETIRÉE de la mémoire ---
|
||||
QVariantMap illisible = ballonMap();
|
||||
illisible.insert("needs", QVariantMap{{"dailyDeadline", "7:00"}, {"minEnergyWhPerDay", 2000}});
|
||||
QVariantMap lisible = routeurMap();
|
||||
lisible.insert("needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 1000}});
|
||||
ecrireFichier(cfgPath, {illisible, lisible});
|
||||
const QByteArray herite = lireFichier(cfgPath);
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
|
||||
QStringLiteral("ÉCHÉANCE ILLISIBLE : charge \"ballon-a\", needs.dailyDeadline \"7:00\"")));
|
||||
LoadConfigStore relu;
|
||||
QCOMPARE(relu.configs().count(), 2);
|
||||
QCOMPARE(relu.configs().at(0).needs().dailyDeadline(), QString()); // retirée
|
||||
QCOMPARE(relu.configs().at(0).needs().minEnergyWhPerDay(), 2000); // le reste demeure
|
||||
QCOMPARE(relu.configs().at(1).needs().dailyDeadline(), QStringLiteral("07:00")); // contre-épreuve
|
||||
QCOMPARE(lireFichier(cfgPath), herite); // le fichier n'est pas réécrit par une lecture
|
||||
}
|
||||
|
||||
void TestLoadModel::testLeftCharsNeverSplitsAPair()
|
||||
{
|
||||
const QString prise = QString::fromUcs4(U"\U0001F50C");
|
||||
// L'emoji est le 256e caractère : gardé ENTIER — QString::left(256) l'aurait coupé en deux.
|
||||
const QString nom = QString(255, QLatin1Char('a')) + prise + QString(44, QLatin1Char('b'));
|
||||
QCOMPARE(nom.toUcs4().size(), qsizetype(300));
|
||||
QVERIFY(nom.left(256).back().isHighSurrogate()); // le piège que leftChars() évite
|
||||
const QString coupe = LoadConfigStore::leftChars(nom, 256);
|
||||
QCOMPARE(coupe, QString(255, QLatin1Char('a')) + prise);
|
||||
QCOMPARE(coupe.toUcs4().size(), qsizetype(256));
|
||||
QCOMPARE(coupe.size(), qsizetype(257));
|
||||
// L'emoji est le 257e : il part ENTIER, rien n'en reste.
|
||||
QCOMPARE(LoadConfigStore::leftChars(QString(256, QLatin1Char('a')) + prise, 256), QString(256, QLatin1Char('a')));
|
||||
// Plus court que la borne : rendu tel quel.
|
||||
QCOMPARE(LoadConfigStore::leftChars(prise, 256), prise);
|
||||
}
|
||||
|
||||
void TestLoadModel::testAppendConfigBoundsOnlyTheAddedEntry()
|
||||
{
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-append.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
|
||||
|
||||
// Une configuration HÉRITÉE dont une entrée porte un libellé de 300 caractères.
|
||||
QVariantMap herite = ballonMap();
|
||||
const QString tropLong(300, QLatin1Char('L'));
|
||||
herite.insert("label", tropLong);
|
||||
ecrireFichier(cfgPath, {herite});
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("LIBELLÉ TROP LONG")));
|
||||
LoadConfigStore store;
|
||||
QCOMPARE(store.configs().count(), 1);
|
||||
|
||||
QString err;
|
||||
QVariantMap motif;
|
||||
// Le moteur ajoute une borne : le libellé hérité ne la bloque pas.
|
||||
LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE, 2));
|
||||
borne.setLabel(QString(LoadConfigStore::MaxLabelChars, QLatin1Char('B')));
|
||||
QVERIFY2(store.appendConfig(borne, &err, &motif), qUtf8Printable(err));
|
||||
QCOMPARE(store.configs().count(), 2);
|
||||
QCOMPARE(store.configs().at(0).label(), tropLong); // l'hérité, intact
|
||||
LoadConfigStore relu; // et persisté avec lui
|
||||
QCOMPARE(relu.configs().count(), 2);
|
||||
|
||||
// Contre-épreuves : l'entrée AJOUTÉE reste bornée…
|
||||
LoadConfig autre = LoadConfig::fromMap(borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000f1}"), 3));
|
||||
autre.setLabel(QString(LoadConfigStore::MaxLabelChars + 1, QLatin1Char('B')));
|
||||
QVERIFY(!store.appendConfig(autre, &err, &motif));
|
||||
QCOMPARE(motif.value("cause").toString(), QStringLiteral("LABEL"));
|
||||
QCOMPARE(motif.value("index").toInt(), 2);
|
||||
QCOMPARE(store.configs().count(), 2);
|
||||
// …et l'écriture CLIENTE de l'ensemble reste refusée tant que l'hérité n'est pas raccourci.
|
||||
QVERIFY(!store.setConfigs(store.configs(), &err, &motif));
|
||||
QCOMPARE(json(motif), json(QVariantMap{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"},
|
||||
{"length", 300}, {"max", 256}}));
|
||||
}
|
||||
|
||||
// ── relecture q7-suite — le journal ne recopie jamais une saisie telle quelle ──────────────────
|
||||
namespace {
|
||||
//! Ce qu'un client glisse dans un champ pour fabriquer une ligne de journal.
|
||||
const QString FORGE = QStringLiteral("x\n[NymeaEnergy] FAUX");
|
||||
|
||||
//! Capture les messages Qt UN PAR UN : une fausse ligne ne se voit qu'à l'intérieur d'un message.
|
||||
class Messages
|
||||
{
|
||||
public:
|
||||
Messages() { s_courant = this; s_origine = qInstallMessageHandler(&Messages::collecte); }
|
||||
~Messages() { qInstallMessageHandler(s_origine); s_courant = nullptr; }
|
||||
|
||||
bool contient(const QString &fragment) const
|
||||
{
|
||||
for (const QString &m : m_liste)
|
||||
if (m.contains(fragment))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
//! Les lignes qu'un message AJOUTE au journal en se faisant passer pour un message du moteur.
|
||||
QStringList faussesLignes() const
|
||||
{
|
||||
QStringList f;
|
||||
for (const QString &m : m_liste) {
|
||||
const QStringList lignes = m.split(QLatin1Char('\n'));
|
||||
for (int i = 1; i < lignes.count(); ++i)
|
||||
if (lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive))
|
||||
f << lignes.at(i);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
private:
|
||||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||||
{
|
||||
if (s_courant)
|
||||
s_courant->m_liste << msg;
|
||||
if (s_origine)
|
||||
s_origine(type, ctx, msg);
|
||||
}
|
||||
QStringList m_liste;
|
||||
static inline Messages *s_courant = nullptr;
|
||||
static inline QtMessageHandler s_origine = nullptr;
|
||||
};
|
||||
}
|
||||
|
||||
void TestLoadModel::testInheritedValuesAreEscapedInTheJournal()
|
||||
{
|
||||
// Avant Q7, un client pouvait persister un identifiant ou une échéance portant un saut de
|
||||
// ligne. load() les cite : échappés, ils ne fabriquent aucune fausse ligne de journal.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
|
||||
|
||||
QVariantMap idForge = ballonMap();
|
||||
idForge.insert("id", FORGE);
|
||||
idForge.insert("label", QString(300, QLatin1Char('L'))); // LIBELLÉ TROP LONG cite l'identifiant
|
||||
QVariantMap echeanceForgee = routeurMap();
|
||||
echeanceForgee.insert("needs", QVariantMap{{"dailyDeadline", FORGE}, {"minEnergyWhPerDay", 1000}});
|
||||
ecrireFichier(cfgPath, {idForge, echeanceForgee});
|
||||
|
||||
Messages journal;
|
||||
LoadConfigStore relu;
|
||||
QCOMPARE(relu.configs().count(), 2);
|
||||
// Non vide : les trois avertissements sont partis, et ils citent la valeur ÉCHAPPÉE.
|
||||
QVERIFY(journal.contient(QStringLiteral("LIBELLÉ TROP LONG")));
|
||||
QVERIFY(journal.contient(QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x\\n[NymeaEnergy] FAUX\"")));
|
||||
QVERIFY(journal.contient(QStringLiteral("needs.dailyDeadline \"x\\n[NymeaEnergy] …\"")));
|
||||
QVERIFY2(journal.faussesLignes().isEmpty(), qUtf8Printable(journal.faussesLignes().join(QStringLiteral(" | "))));
|
||||
}
|
||||
|
||||
void TestLoadModel::testInheritedLabelIsCutInSetRefusals()
|
||||
{
|
||||
// [m-1] L'ajout d'une borne ne borne que l'entrée ajoutée ; ses refus d'ENSEMBLE citaient
|
||||
// pourtant le libellé hérité en entier, et provisionEvChargerConfig() le journalise.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-libelle-motif.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
|
||||
|
||||
// Héritée : rang 2, libellé de 300 caractères, et elle commande le Thing de la borne.
|
||||
const QString tropLong = QString(299, QLatin1Char('L')) + QLatin1Char('Z');
|
||||
QVariantMap herite = ballonMap();
|
||||
herite.insert("label", tropLong);
|
||||
herite.insert("priority", 2);
|
||||
herite.insert("relays", QVariantList{QVariantMap{{"thingId", BORNE}, {"powerW", 1000}}});
|
||||
ecrireFichier(cfgPath, {herite});
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("LIBELLÉ TROP LONG")));
|
||||
LoadConfigStore store;
|
||||
QCOMPARE(store.configs().count(), 1);
|
||||
const QString cite = LoadConfigStore::leftChars(tropLong, 64) + QStringLiteral("…");
|
||||
QString err;
|
||||
QVariantMap motif;
|
||||
|
||||
// THING_SHARED : le motif publié est inchangé, la phrase ne cite que 64 caractères.
|
||||
QVERIFY(!store.appendConfig(LoadConfig::fromMap(borneMap(BORNE, 3)), &err, &motif));
|
||||
QCOMPARE(json(motif), json(QVariantMap{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 1}},
|
||||
{"ids", QVariantList{"ballon-a", BORNE}}}));
|
||||
QVERIFY2(err.contains(cite) && !err.contains(tropLong), qUtf8Printable(err));
|
||||
|
||||
// RANK_DUPLICATE : idem.
|
||||
const QString autreBorne = QStringLiteral("{dddddddd-0000-0000-0000-0000000000f2}");
|
||||
QVERIFY(!store.appendConfig(LoadConfig::fromMap(borneMap(autreBorne, 2)), &err, &motif));
|
||||
QCOMPARE(json(motif), json(QVariantMap{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}},
|
||||
{"ids", QVariantList{"ballon-a", autreBorne}}, {"rank", 2}}));
|
||||
QVERIFY2(err.contains(cite) && !err.contains(tropLong), qUtf8Printable(err));
|
||||
QCOMPARE(store.configs().count(), 1);
|
||||
}
|
||||
|
||||
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
|
||||
{
|
||||
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
|
||||
@ -2656,91 +2132,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;
|
||||
@ -3070,32 +2461,3 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
|
||||
horsService.observerMinute(huitJours.first());
|
||||
QVERIFY(!horsService.profil().calcule);
|
||||
}
|
||||
|
||||
void TestLoadModel::testPhaseDrawsAreNeverBelowTheTotal()
|
||||
{
|
||||
// Le cas relevé le 2026-10-27 : 29,1 A lus sur chaque phase pour 22 277 W au total. Le manque
|
||||
// est réparti au prorata : trois phases égales, trois tiers du total.
|
||||
QList<double> w = tiragesCoherentsAvecLeTotal({6700, 6700, 6700}, 22277);
|
||||
for (double x : w)
|
||||
QVERIFY(qAbs(x - 22277.0 / 3) < 0.01);
|
||||
|
||||
// Cohérentes, ou AU-DESSUS du total (lecture plus fraîche que le total) : rien ne bouge.
|
||||
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 6000), (QList<double>{1000, 2000, 3000}));
|
||||
QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 5000), (QList<double>{1000, 2000, 3000}));
|
||||
|
||||
// Une phase qui EXPORTE n'est jamais relevée vers l'import, et ne se voit jamais abaissée :
|
||||
// le manque va aux phases qui importent, au prorata de ce qu'elles lisent.
|
||||
w = tiragesCoherentsAvecLeTotal({-1000, 1000, 3000}, 4000);
|
||||
QCOMPARE(w.at(0), -1000.0);
|
||||
QCOMPARE(w.at(1), 1250.0);
|
||||
QCOMPARE(w.at(2), 3750.0);
|
||||
|
||||
// Aucune phase n'importe (compteur sans courants par phase : tout à 0) : le total se répartit à
|
||||
// parts égales — la phase la plus chargée en porte au moins le tiers, c'est la seule garantie.
|
||||
w = tiragesCoherentsAvecLeTotal({0, 0, 0}, 9000);
|
||||
QCOMPARE(w, (QList<double>{3000, 3000, 3000}));
|
||||
|
||||
// En export, rien à relever.
|
||||
QCOMPARE(tiragesCoherentsAvecLeTotal({-500, -500, -500}, -1500), (QList<double>{-500, -500, -500}));
|
||||
QVERIFY(tiragesCoherentsAvecLeTotal({}, 1000).isEmpty());
|
||||
}
|
||||
|
||||
@ -75,29 +75,6 @@ private slots:
|
||||
//! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un
|
||||
//! client ne peut pas fabriquer un "auto" (LM-1209-c).
|
||||
void testRankOriginFollowsTheRankNotTheWrite();
|
||||
// Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement
|
||||
void testLabelIsBoundedOnWriteNeverOnLoad();
|
||||
// Q7, suite — le refus de SetLoadConfig publie sa cause ; id et échéance bornés à l'écriture
|
||||
//! Chaque cause de refus : son motif (cause et endroit), et rien de modifié, ni en mémoire
|
||||
//! ni sur disque.
|
||||
void testLoadConfigRefusalNamesItsCause_data();
|
||||
void testLoadConfigRefusalNamesItsCause();
|
||||
//! Le motif se rend en français, avec l'endroit et sans le libellé.
|
||||
void testLoadConfigRefusedIsRendered();
|
||||
//! id : 128 caractères de [A-Za-z0-9._{}-] à l'écriture ; une config héritée hors règle est
|
||||
//! chargée telle quelle et signalée.
|
||||
void testIdIsBoundedOnWriteNeverOnLoad();
|
||||
//! dailyDeadline : vide ou HH:MM à l'écriture ; héritée illisible, l'entrée est chargée et
|
||||
//! l'échéance retirée de la mémoire.
|
||||
void testDailyDeadlineIsCheckedOnWriteAndDroppedOnLoad();
|
||||
//! Couper en caractères ne coupe jamais une paire de substitution.
|
||||
void testLeftCharsNeverSplitsAPair();
|
||||
//! L'ajout par le moteur ne borne que l'entrée ajoutée — un hérité hors règle ne le bloque pas.
|
||||
void testAppendConfigBoundsOnlyTheAddedEntry();
|
||||
//! Relecture q7-suite : une valeur héritée citée par load() est échappée — aucune fausse ligne.
|
||||
void testInheritedValuesAreEscapedInTheJournal();
|
||||
//! Relecture q7-suite (m-1) : un refus d'ensemble ne cite que 64 caractères d'un libellé hérité.
|
||||
void testInheritedLabelIsCutInSetRefusals();
|
||||
|
||||
// ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
|
||||
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait
|
||||
@ -108,8 +85,6 @@ private slots:
|
||||
void testStoredInstantsSurviveATimeZoneChange();
|
||||
void testReportingDayBoundaryIsPublishedNotDeducible();
|
||||
void testDrawMarginComparesHeadroomsNotCaps();
|
||||
//! Lot délestage triphasé — des phases en retard sur le total sont RELEVÉES, jamais abaissées.
|
||||
void testPhaseDrawsAreNeverBelowTheTotal();
|
||||
|
||||
//! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide.
|
||||
void testTheContentIsTheRevisionIdentity();
|
||||
@ -144,8 +119,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();
|
||||
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
#include <QLocalSocket>
|
||||
#include <QLoggingCategory>
|
||||
#include <QPointer>
|
||||
#include <QRegularExpression>
|
||||
#include <QSignalSpy>
|
||||
#include <QTcpServer>
|
||||
#include <QTcpSocket>
|
||||
@ -149,8 +148,7 @@ private slots:
|
||||
void testARefusedHandshakeIsClosedByTheEngine();
|
||||
void testAReceivedBatchIsNeverUnpacked();
|
||||
void testEachLineSentIsOneObject();
|
||||
void testARequestOverTheLineLimitIsNeverSent();
|
||||
void testARequestTooLongIsLoggedOncePerConnection();
|
||||
void testAContextOverTheLineLimitIsStillSent();
|
||||
|
||||
private:
|
||||
// Démarre le client et mène le handshake jusqu'à Ready.
|
||||
@ -288,9 +286,9 @@ void TestOptimizerClient::testHandshakeAndPlan()
|
||||
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
|
||||
QCOMPARE(hs.value("id").toInteger(), 1);
|
||||
const QJsonObject params = hs.value("params").toObject();
|
||||
// Protocole 1.3 : la version ANNONCÉE au handshake est celle que le moteur sert
|
||||
// Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert
|
||||
// (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur.
|
||||
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.3"));
|
||||
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2"));
|
||||
QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion));
|
||||
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
|
||||
QVERIFY(!params.contains("token"));
|
||||
@ -354,7 +352,7 @@ void TestOptimizerClient::testWrongMajorIsRefused()
|
||||
QCOMPARE(readySpy.count(), 0);
|
||||
QCOMPARE(client.requestPlan({}), quint64(0));
|
||||
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
|
||||
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.3 — majeures différentes, refusé"), 1);
|
||||
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1);
|
||||
|
||||
// Nouvelle tentative au backoff (§3), pas avant.
|
||||
clock.advance(999);
|
||||
@ -989,123 +987,32 @@ void TestOptimizerClient::testEachLineSentIsOneObject()
|
||||
}
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testARequestOverTheLineLimitIsNeverSent()
|
||||
void TestOptimizerClient::testAContextOverTheLineLimitIsStillSent()
|
||||
{
|
||||
// Protocole 1.2 (Q7, maxLineBytes dans les deux sens), sens moteur → optimiseur. Une requête
|
||||
// dont la ligne dépasserait 1 Mio n'est PAS écrite : un optimiseur conforme la jetterait
|
||||
// (-32600 à id null) et la requête finirait en délai dépassé, la cause perdue. Elle échoue
|
||||
// tout de suite, sous son propre code, et la connexion reste utilisable. La limite est
|
||||
// éprouvée des deux côtés : exactement kMaxLineBytes part, un octet de plus ne part pas.
|
||||
// ÉCART au protocole 1.2 (Q7, maxLineBytes dans les deux sens), constaté et NON CORRIGÉ dans le
|
||||
// lot qui norme la règle : requestPlan() n'applique pas kMaxLineBytes à ce qu'il ÉMET. Rien ne
|
||||
// borne non plus la taille d'un contexte en amont — un `label` de charge saisi par SetLoadConfig
|
||||
// suffit. Un optimiseur conforme jette la ligne (-32600) et la requête finit en délai dépassé.
|
||||
// Le jour où le moteur borne l'émission, ce test passe (XPASS) et doit être réécrit.
|
||||
RawOptimizer raw;
|
||||
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
|
||||
QVERIFY(!endpoint.isEmpty());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
|
||||
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
|
||||
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
|
||||
|
||||
client.start();
|
||||
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
|
||||
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
|
||||
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
|
||||
const int recuApresHandshake = raw.received.size();
|
||||
|
||||
// Un contexte dont la ligne GetPlan fait exactement `octets` octets, '\n' non compté. Le
|
||||
// libellé est en ASCII sans caractère à échapper : un caractère, un octet. Le gabarit est
|
||||
// mesuré sur la forme compacte ; une erreur de calcul ferait échouer la comparaison de taille
|
||||
// sur le fil, plus bas — elle ne peut pas faire passer le test à tort.
|
||||
auto contexte = [](quint64 id, int octets) {
|
||||
auto ligne = [id](const QString &label) {
|
||||
return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)}, {"method", "GetPlan"},
|
||||
{"params", QJsonObject{{"loads", QJsonArray{QJsonObject{
|
||||
{"id", "ecs"}, {"label", label}}}}}}})
|
||||
.toJson(QJsonDocument::Compact).size();
|
||||
};
|
||||
const int gabarit = int(ligne(QString()));
|
||||
return QVariantMap{{"loads", QVariantList{QVariantMap{
|
||||
{"id", "ecs"}, {"label", QString(octets - gabarit, QLatin1Char('x'))}}}}};
|
||||
};
|
||||
|
||||
// Un octet de trop : l'identifiant est rendu, mais l'échec n'est PAS émis avant le retour —
|
||||
// l'appelant ne pourrait pas l'attribuer.
|
||||
const quint64 tropLong = client.requestPlan(contexte(2, OptimizerClient::kMaxLineBytes + 1));
|
||||
QCOMPARE(tropLong, quint64(2));
|
||||
QCOMPARE(failSpy.count(), 0);
|
||||
QVERIFY(waitFor([&] { return failSpy.count() == 1; }));
|
||||
QCOMPARE(failSpy.at(0).at(0).value<quint64>(), tropLong);
|
||||
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("request-too-long"));
|
||||
QCOMPARE(client.lastErrorCode(), QStringLiteral("request-too-long"));
|
||||
QCOMPARE(client.pendingRequestId(), quint64(0));
|
||||
QVERIFY(!clock.armed()); // aucun délai de 10 s ne court pour une requête jamais partie
|
||||
QCOMPARE(client.state(), OptimizerClient::Ready);
|
||||
QCOMPARE(client.protocolErrors(), 0); // ce n'est pas une ligne REÇUE écartée
|
||||
|
||||
// AUCUN octet n'est parti : le serveur n'a rien reçu de plus que le handshake.
|
||||
settle();
|
||||
raw.lines();
|
||||
QCOMPARE(raw.received.size(), recuApresHandshake);
|
||||
|
||||
// La connexion sert la requête suivante, exactement à la limite : elle part, entière.
|
||||
const quint64 limite = client.requestPlan(contexte(3, OptimizerClient::kMaxLineBytes));
|
||||
QCOMPARE(limite, quint64(3));
|
||||
const QVariantMap context{{"loads", QVariantList{QVariantMap{
|
||||
{"id", "ecs"}, {"label", QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}};
|
||||
QCOMPARE(client.requestPlan(context), quint64(2));
|
||||
QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000));
|
||||
const QByteArray ligne = raw.lines().at(1);
|
||||
QCOMPARE(ligne.size(), OptimizerClient::kMaxLineBytes);
|
||||
QCOMPARE(QJsonDocument::fromJson(ligne).object().value("id").toInteger(), qint64(limite));
|
||||
raw.send(R"({"jsonrpc":"2.0","id":3,"result":{"planId":"servi"}})");
|
||||
QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
|
||||
QCOMPARE(planSpy.at(0).at(0).value<quint64>(), limite);
|
||||
QCOMPARE(failSpy.count(), 1);
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testARequestTooLongIsLoggedOncePerConnection()
|
||||
{
|
||||
// Q7 : une requête trop longue se journalise UNE fois par connexion, avec sa taille et jamais
|
||||
// son contenu. Une charge au libellé démesuré la reproduirait à chaque cycle : une ligne par
|
||||
// minute noierait le journal. Chaque échec reste émis, donc compté par l'appelant.
|
||||
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
|
||||
QVERIFY(server.listen());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
|
||||
bringUp(client, server);
|
||||
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
|
||||
|
||||
const QString marque = QStringLiteral("contenu-jamais-journalise-");
|
||||
const QVariantMap contexte{{"loads", QVariantList{QVariantMap{
|
||||
{"id", "ecs"}, {"label", marque + QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}};
|
||||
const QString ligneJournal = QStringLiteral("NON ENVOYÉ");
|
||||
|
||||
for (int i = 1; i <= 3; ++i) {
|
||||
client.requestPlan(contexte);
|
||||
QVERIFY(waitFor([&] { return failSpy.count() == i; }));
|
||||
}
|
||||
QCOMPARE(countLog(ligneJournal), 1);
|
||||
QCOMPARE(countLog(QStringLiteral("request-too-long")), 1); // aucune ligne de rappel non plus
|
||||
QCOMPARE(countLog(marque), 0);
|
||||
// La taille y est, et c'est celle de la requête, pas la limite.
|
||||
const QRegularExpression taille(QStringLiteral("NON ENVOYÉ : (\\d+) octets"));
|
||||
QRegularExpressionMatch m;
|
||||
for (const QString &l : std::as_const(s_log))
|
||||
if ((m = taille.match(l)).hasMatch())
|
||||
break;
|
||||
QVERIFY(m.hasMatch());
|
||||
QVERIFY(m.captured(1).toInt() > OptimizerClient::kMaxLineBytes + marque.size());
|
||||
|
||||
// Nouvelle connexion : la première se redit.
|
||||
server.abortClient();
|
||||
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
|
||||
clock.advance(client.nextBackoffMs());
|
||||
QJsonObject hs;
|
||||
QVERIFY(server.waitMessage(&hs));
|
||||
server.replyResult(hs.value("id").toInteger(), handshakeResult());
|
||||
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
|
||||
client.requestPlan(contexte);
|
||||
client.requestPlan(contexte); // remplace la précédente avant son échec différé
|
||||
QVERIFY(waitFor([&] { return failSpy.count() == 4; }));
|
||||
settle();
|
||||
QCOMPARE(failSpy.count(), 4); // la requête remplacée n'émet rien (contrat de requestPlan)
|
||||
QCOMPARE(countLog(ligneJournal), 2);
|
||||
QCOMPARE(countLog(marque), 0);
|
||||
const QByteArray line = raw.lines().at(1);
|
||||
QVERIFY(line.contains("\"GetPlan\""));
|
||||
QEXPECT_FAIL("", "écart Q7 : le moteur émet une ligne de plus de maxLineBytes (rapport protocole 1.2)", Continue);
|
||||
QVERIFY2(line.size() <= OptimizerClient::kMaxLineBytes,
|
||||
qPrintable(QStringLiteral("GetPlan émis sur %1 octets").arg(line.size())));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestOptimizerClient)
|
||||
|
||||
@ -223,7 +223,6 @@ private slots:
|
||||
void testAProtocol10OptimizerIsAccepted();
|
||||
void testTracesReplayWithZeroDivergence();
|
||||
void testSafetyWinsOverTheOptimizer();
|
||||
void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum();
|
||||
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
||||
void cleanupTestCase();
|
||||
|
||||
@ -937,116 +936,6 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
|
||||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testTheDrawCapHoldsAgainstAnOptimizerAtMaximum()
|
||||
{
|
||||
// DÉFAUTS 4 et 5 — relevés le 2026-10-27 sur une installation d'essai, sous un optimiseur qui
|
||||
// demande le MAXIMUM à tout. La protection déleste ; au cycle suivant, le compteur est revenu
|
||||
// juste sous le seuil, le délestage se relâche, l'optimiseur redemande tout, et la surcharge
|
||||
// reprend une minute. Aucune mémoire entre la protection et le plan suivant : la marge que le
|
||||
// compteur laisse n'était opposée à AUCUNE consigne d'optimiseur — seulement à la cascade du
|
||||
// rule-based. Ici la borne (triphasée, sans voiture) et la charge variable 2 jouent le rôle de
|
||||
// la borne et du chauffe-eau de l'essai.
|
||||
monter(Banc::Restreint);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
const QUuid borneId = addCharger("ABC", 16, 26818);
|
||||
Thing *borne = tm->findConfiguredThing(borneId);
|
||||
QVERIFY(borne);
|
||||
for (const char *e : {"connected", "pluggedIn"})
|
||||
borne->setStateValue(e, true);
|
||||
borne->setStateValue("power", false);
|
||||
borne->setStateValue("maxChargingCurrent", 6);
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo",
|
||||
QVariantMap{{"chargingInfo", QVariantMap{{"evChargerId", borneId},
|
||||
{"chargingMode", "ChargingModeEco"}}}})
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
m_arb->setPhasePowerLimit(25);
|
||||
RefOptimizer ref;
|
||||
brancher(ref, Transport::Unix, {"--mode", "over-limit"});
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
|
||||
// Un pas : le compteur est POSÉ (total et phases), la réponse de l'optimiseur attendue, puis
|
||||
// les charges variables consomment leur consigne. La borne, elle, mesure ce qu'elle tient.
|
||||
QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||||
const auto pas = [&](double totalW, double phaseA, int dtS = 60) {
|
||||
t = t.addSecs(dtS);
|
||||
m_compteur->setStateValue("currentPower", totalW);
|
||||
for (const char *ph : {"A", "B", "C"}) {
|
||||
m_compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), phaseA);
|
||||
m_compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), phaseA * 230);
|
||||
}
|
||||
const int avant = m_jugees;
|
||||
m_arb->recordMeterUpdate(t);
|
||||
m_arb->simulationCallUpdate(t);
|
||||
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur jamais jugée");
|
||||
settle(30);
|
||||
for (const QString &v : {m_var1, m_var2}) {
|
||||
Thing *th = tm->findConfiguredThing(ThingId(v));
|
||||
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
|
||||
}
|
||||
};
|
||||
const auto var2W = [&]() {
|
||||
return tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble();
|
||||
};
|
||||
const auto borneA = [&]() {
|
||||
return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0;
|
||||
};
|
||||
const auto decision = [&](const QString &id) {
|
||||
for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList())
|
||||
if (v.toMap().value("loadId").toString() == id)
|
||||
return v.toMap().value("decision").toMap();
|
||||
return QVariantMap();
|
||||
};
|
||||
|
||||
// 1. Export : l'optimiseur demande 50 kW à tout, les adaptateurs écrêtent au matériel.
|
||||
for (int k = 0; k < 3; ++k)
|
||||
pas(-20000, -20);
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
QCOMPARE(borneA(), 16);
|
||||
QCOMPARE(var2W(), 3000.0);
|
||||
|
||||
// 2. LA CHARGE EN WATTS (le chauffe-eau de l'essai). 40 A par phase : la protection déleste
|
||||
// la cascade en ordre inverse du rang, la PAC puis la charge 2.
|
||||
pas(40 * 230 * 3, 40);
|
||||
const double var2Apres = var2W();
|
||||
QVERIFY2(var2Apres < 3000, qUtf8Printable(QString::number(var2Apres)));
|
||||
|
||||
// 3. Le compteur revient JUSTE sous le seuil : 22 A par phase (seuil 22,5 A, 25 A − 10 %).
|
||||
// La marge ne laisse que 0,5 A par phase : ce qui a été retiré ne peut pas revenir.
|
||||
pas(22 * 230 * 3, 22);
|
||||
QVERIFY2(var2W() <= var2Apres + 0.5 * 230 + 1,
|
||||
qUtf8Printable(QStringLiteral("la charge 2 est remontée de %1 à %2 W").arg(var2Apres).arg(var2W())));
|
||||
// DÉFAUT 5 — la consigne écrêtée par la sécurité porte le motif de la sécurité, pas celui que
|
||||
// l'optimiseur avait donné à sa demande.
|
||||
QCOMPARE(decision(m_var2).value("code").toString(), QString(DecisionCode::DrawCap));
|
||||
|
||||
// 4. La place revient : tout ce que l'optimiseur demande tient sous le seuil, il est servi.
|
||||
// Six minutes : le verrou de réenclenchement de la borne, coupée en 2, est expiré.
|
||||
pas(-20000, -20, 360);
|
||||
pas(-20000, -20, 360);
|
||||
QCOMPARE(borneA(), 16);
|
||||
QCOMPARE(var2W(), 3000.0);
|
||||
|
||||
// 5. LA BORNE. 26 A par phase : la protection la rabaisse sans la couper. (Le harnais rappelle
|
||||
// la protection après le cycle sur le MÊME compteur : au-delà, deux gestes la coupent.)
|
||||
pas(26 * 230 * 3, 26);
|
||||
const int borneApres = borneA();
|
||||
QVERIFY2(borneApres > 0 && borneApres < 16, qUtf8Printable(QString::number(borneApres)));
|
||||
|
||||
// 6. Juste sous le seuil : la borne ne remonte pas au-delà de ce que la marge laisse.
|
||||
pas(22 * 230 * 3, 22);
|
||||
QVERIFY2(borneA() <= borneApres,
|
||||
qUtf8Printable(QStringLiteral("la borne est remontée de %1 à %2 A").arg(borneApres).arg(borneA())));
|
||||
|
||||
// 7. La place revient : la borne retrouve ses 16 A.
|
||||
pas(-20000, -20);
|
||||
pas(-20000, -20);
|
||||
QCOMPARE(borneA(), 16);
|
||||
}
|
||||
|
||||
void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
|
||||
{
|
||||
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -27,12 +27,6 @@
|
||||
|
||||
#include "simulationbase.h"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class EnergyArbitrator;
|
||||
struct EcritureBatterie;
|
||||
|
||||
using namespace nymeaserver;
|
||||
|
||||
class ChargerPlugEvent
|
||||
@ -58,69 +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.
|
||||
//! Lot délestage V2C : les mêmes, monophasée sur A et triphasée, SANS courants par phase
|
||||
//! lisibles par le moteur (`currentL1..3`, façon V2C Trydan).
|
||||
enum class BorneHc { Monophasee, Triphasee, Bascule, MonophaseeNonMesuree, TriphaseeNonMesuree };
|
||||
//! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
|
||||
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
|
||||
BorneHc borne = BorneHc::Monophasee);
|
||||
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
|
||||
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
|
||||
|
||||
// ── Lot délestage triphasé — une borne SANS voiture assignée, limite de phase 30 A ────────
|
||||
struct BancTriphase {
|
||||
EnergyArbitrator *arb = nullptr;
|
||||
Thing *compteur = nullptr;
|
||||
Thing *borne = nullptr;
|
||||
QString borneId;
|
||||
};
|
||||
//! Serveur neuf, compteur, borne 16 A « Éco » branchée et sans voiture (le cas de l'invité,
|
||||
//! LM-1208-b). \p phases : câblage du mock ; \p bascule : la borne porte `desiredPhaseCount`
|
||||
//! (mise en triphasé si elle est câblée sur trois).
|
||||
BancTriphase preparerBancTriphase(const QString &nom, const QString &phases, bool bascule);
|
||||
|
||||
// ── Recrédit — une borne mesurée par un compteur dédié, cycle par cycle ──────────────────
|
||||
//! Ce que la télémétrie dit de la borne à un cycle, et ce qu'elle a reçu.
|
||||
struct CycleBorne {
|
||||
int mesureW = 0; //!< ce que la borne tire au début du cycle
|
||||
QString motif;
|
||||
int refusDepuisS = -1;
|
||||
int retenuW = 0;
|
||||
int surplusW = 0;
|
||||
int recrediteW = 0;
|
||||
int consigneA = 0; //!< ce que la borne a reçu ; 0 = coupée
|
||||
int consigneW = 0;
|
||||
int attenduW = 0;
|
||||
bool divergent = false;
|
||||
};
|
||||
//! Tirage de la borne au cycle \p k, depuis la commande reçue au cycle précédent et le
|
||||
//! tirage précédent (W). C'est le modèle de la borne : rampe, refus, figeage.
|
||||
using TirageBorne = std::function<double(int k, double commandeW, double tireW)>;
|
||||
/*!
|
||||
* \brief Rejoue \p cycles cycles de 60 s d'une borne monophasée 6-16 A, maison à 300 W.
|
||||
* \param nom Suffixe du fichier de configuration. \param tirage Modèle de la borne.
|
||||
* \param productionW Production au cycle k (W). \param cycles Nombre de cycles.
|
||||
* \return La trace, cycle par cycle ; vide si le scénario n'a pas pu être posé.
|
||||
*/
|
||||
QList<CycleBorne> rejouerBorne(const QString &nom, const TirageBorne &tirage,
|
||||
const std::function<double(int)> &productionW, int cycles);
|
||||
|
||||
private slots:
|
||||
void run_data();
|
||||
void run();
|
||||
@ -281,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();
|
||||
@ -313,60 +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();
|
||||
//! Lot délestage V2C — borne SANS courants par phase lisibles (façon V2C Trydan), limite 25 A,
|
||||
//! marge 10 % : la consigne se pose au plafond qui tient compte de la borne, sans alterner
|
||||
//! (monophasée sur A, triphasée ; maison seule, puis lave-vaisselle sur A).
|
||||
void testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit();
|
||||
//! Lot délestage V2C — quand l'attribution n'est pas sûre, rien n'est rendu : triphasée avec
|
||||
//! une voiture monophasée (phases visées ≠ phases annoncées), borne qui publie des courants
|
||||
//! par phase nuls alors qu'elle tire.
|
||||
void testUnmeteredChargerGivesNothingBackWhenUnsure();
|
||||
//! Lot délestage V2C (question 5) — hors heures creuses, sous le rule-based, une borne limitée
|
||||
//! par sa phase se pose sous la limite sans alterner, maison légère ou chargée.
|
||||
void testWaterfallChargerSettlesUnderThePhaseLimit();
|
||||
//! Lot délestage V2C (question 5) — EN DÉPASSEMENT, la règle de jour ne rend rien : phases du
|
||||
//! compteur aveugles à la borne, la stratégie la tient à son plancher.
|
||||
void testWaterfallChargerGetsNothingBackInOverload();
|
||||
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||
void testEveryGetterReturnsWhatItDeclares();
|
||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
||||
@ -385,51 +250,12 @@ private slots:
|
||||
// réglable — le trou de 3g-2 rouvert par le côté client.
|
||||
void testDeletedEvChargerConfigComesBack();
|
||||
|
||||
// [Q7, suite] Le refus de SetLoadConfig PUBLIE sa cause (fault LOAD_CONFIG_REFUSED et
|
||||
// faultParams), déclarée dans le schéma, sans rien modifier — mémoire, GetLoadConfig, disque.
|
||||
void testSetLoadConfigRefusalIsPublished();
|
||||
|
||||
// [Q7, suite — relecture etm72] Une borne au nom de 300 caractères est AJOUTÉE, son libellé
|
||||
// coupé à 256 caractères sans couper de paire de substitution ; un libellé hérité trop long
|
||||
// sur une autre entrée ne bloque pas l'ajout.
|
||||
void testAutoProvisionedLabelIsCutNeverRefused();
|
||||
|
||||
// [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de
|
||||
// l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la
|
||||
// puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du
|
||||
// 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();
|
||||
|
||||
// ── Lot délestage triphasé (2026-10-27) ─────────────────────────────────
|
||||
//! Après une surcharge, la borne garde ses phases dans les comptes : allocation, commande et
|
||||
//! matériel disent le même nombre de phases — triphasée sans bascule, à bascule, monophasée.
|
||||
void testOverloadLeavesTheChargerItsPhases_data();
|
||||
void testOverloadLeavesTheChargerItsPhases();
|
||||
//! Une voiture monophasée désassignée ne laisse pas « 1 phase » à une borne triphasée.
|
||||
void testAnUnassignedCarTakesItsPhasesAway();
|
||||
//! Une borne qui tire sur trois phases ce que le moteur croit commandé sur une : divergente.
|
||||
void testCommandDivergenceSeesAPhaseCountError();
|
||||
//! Des courants par phase en retard sur le total ne retardent pas la protection.
|
||||
void testOverloadReactsToTheTotalWhenPhasesLag();
|
||||
//! Délesté sous son minOn, le chauffe-eau franchit le verrou ET ses relais s'ouvrent.
|
||||
void testSheddingOpensTheRelaysUnderMinOn();
|
||||
//! La marge de sécurité : 10 % par défaut, réglable et bornée, persistée, et c'est à 90 %
|
||||
//! de la limite que la protection agit.
|
||||
void testPhaseSafetyMargin();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
@ -13,8 +13,6 @@
|
||||
|
||||
#include "energysettings.h"
|
||||
#include "etm/energyarbitrator.h"
|
||||
#include "etm/config/loadconfigstore.h"
|
||||
#include "etm/scheduler/optimizerclient.h"
|
||||
#include "etm/scheduler/socketscheduler.h"
|
||||
#include "etm/scheduler/optimizerstatus.h"
|
||||
#include "etm/scheduler/publishedplan.h"
|
||||
@ -26,7 +24,6 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QLoggingCategory>
|
||||
#include <QRegularExpression>
|
||||
#include <QSignalSpy>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
@ -128,10 +125,6 @@ private slots:
|
||||
void testGetPlanPublishesThePlanInForce();
|
||||
void testGetPlanShowsTheAppliedPowerUnderALock();
|
||||
void testUpdateLearnedIsLoggedOnceAndIgnored();
|
||||
// Protocole 1.2, Q7 — une requête trop longue n'est pas envoyée, et l'échec compte
|
||||
void testARequestTooLongCountsAsAFailure();
|
||||
// Q7, suite — une configuration héritée hors règle, et ce qui part réellement dans GetPlan
|
||||
void testInheritedOutOfRuleConfigWhatGetPlanSends();
|
||||
// Brief 4 T6 — traces de décision (annexe D)
|
||||
void testTraceIsOffByDefault();
|
||||
void testTraceSettingByRpc();
|
||||
@ -144,8 +137,6 @@ private slots:
|
||||
void testTokenIsInNoTrace();
|
||||
void testTraceLineSize();
|
||||
void testTracerPairsTheResponseByRequestId();
|
||||
//! Q7, suite : une requête trop longue pour partir n'est tracée que par sa TAILLE.
|
||||
void testARequestNotSentIsTracedBySizeOnly();
|
||||
// Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part
|
||||
void testAnEchoedTokenLeaksNowhere();
|
||||
// Lot contrat OpenRPC — le moteur parle le contrat qu'il sert
|
||||
@ -189,9 +180,6 @@ private:
|
||||
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
|
||||
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
|
||||
static QStringList triplets(const QSignalSpy &spy);
|
||||
//! Q7, suite : remplace la configuration par \p charges ÉCRITES SUR DISQUE et relues par un
|
||||
//! store neuf — le chemin d'une configuration héritée, que SetLoadConfig refuserait.
|
||||
void chargerConfigHeritee(const QVariantList &charges);
|
||||
//! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin.
|
||||
QString activerTraces();
|
||||
//! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir
|
||||
@ -1575,169 +1563,6 @@ QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
|
||||
return lignes;
|
||||
}
|
||||
|
||||
void TestSocketScheduler::chargerConfigHeritee(const QVariantList &charges)
|
||||
{
|
||||
QFile f(qEnvironmentVariable("NYMEA_ENERGY_LOAD_CONFIG"));
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray::fromVariantList(charges)}}).toJson());
|
||||
f.close();
|
||||
m_arb->setLoadConfigStore(new LoadConfigStore(m_arb));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testInheritedOutOfRuleConfigWhatGetPlanSends()
|
||||
{
|
||||
// Q7, suite : une configuration ENREGISTRÉE avant la règle est chargée, signalée — et voici ce
|
||||
// qui en part réellement vers l'optimiseur, lu sur le faux optimiseur. Un identifiant hors
|
||||
// règle part tel quel (c'est une clé : le changer serait retirer une charge) ; une échéance
|
||||
// illisible NE PART PAS — le contrat (LoadNeeds.dailyDeadline, motif ^[0-9]{2}:[0-9]{2}$)
|
||||
// l'aurait de toute façon déclarée non conforme.
|
||||
monter();
|
||||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; };
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
|
||||
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"ballon test\"")));
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
|
||||
QStringLiteral("ÉCHÉANCE ILLISIBLE : charge \"%1\", needs.dailyDeadline \"7:00\"")
|
||||
.arg(QRegularExpression::escape(m_var))));
|
||||
chargerConfigHeritee({
|
||||
QVariantMap{{"id", "ballon test"}, {"label", "Ballon"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||||
{"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}},
|
||||
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 1000}}}},
|
||||
QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
|
||||
{"needs", QVariantMap{{"dailyDeadline", "7:00"}, {"minEnergyWhPerDay", 2000}}}}});
|
||||
|
||||
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000);
|
||||
QVERIFY(!m_requetes.isEmpty());
|
||||
QHash<QString, QVariantMap> charges;
|
||||
for (const QVariant &v : m_requetes.last().value("loads").toList())
|
||||
charges.insert(v.toMap().value("id").toString(), v.toMap());
|
||||
|
||||
// L'identifiant hors règle part TEL QUEL, et l'échéance lisible avec lui (contre-épreuve) :
|
||||
// « 07:00 » est envoyée, et l'obligation a son échéance absolue.
|
||||
QVERIFY2(charges.contains("ballon test"), qUtf8Printable(charges.keys().join(", ")));
|
||||
const QVariantMap besoinsLisibles = charges.value("ballon test").value("needs").toMap();
|
||||
QCOMPARE(besoinsLisibles.value("dailyDeadline").toString(), QStringLiteral("07:00"));
|
||||
QVERIFY(!besoinsLisibles.value("energyTargets").toList().isEmpty());
|
||||
|
||||
// L'échéance illisible est ABSENTE : ni brute, ni résolue. L'obligation en énergie part
|
||||
// encore — sans échéance, elle n'a pas de cadence, et rien n'est acheté pour elle.
|
||||
QVERIFY(charges.contains(m_var));
|
||||
const QVariantMap besoins = charges.value(m_var).value("needs").toMap();
|
||||
QVERIFY2(!besoins.contains("dailyDeadline"), qUtf8Printable(QString::fromUtf8(QJsonDocument::fromVariant(besoins).toJson())));
|
||||
QVERIFY(!besoins.contains("energyTargets"));
|
||||
QCOMPARE(besoins.value("minEnergyWhPerDay").toInt(), 2000);
|
||||
|
||||
// Et ce qui est parti est CONFORME au contrat publié.
|
||||
jugerLesRequetesDuMoteur();
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testARequestNotSentIsTracedBySizeOnly()
|
||||
{
|
||||
// Q7, suite : la trace recopiait la requête du cycle, même quand le client ne l'avait PAS
|
||||
// écrite (ligne de plus de 1 Mio, request-too-long) — environ 1 Mio par cycle jusqu'au plafond
|
||||
// journalier, pour une requête que personne n'a reçue. Seule sa TAILLE y figure désormais.
|
||||
monter();
|
||||
const QString d = activerTraces();
|
||||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; };
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
cycle(t0, -3000); // CONTRE-ÉPREUVE : une requête envoyée est recopiée
|
||||
const int recues = m_requetes.count();
|
||||
|
||||
// Une configuration héritée au libellé démesuré : SetLoadConfig la refuserait, le chargement
|
||||
// la garde, et le contexte dépasse la ligne du protocole.
|
||||
const QString demesure(OptimizerClient::kMaxLineBytes + 1000, QLatin1Char('L'));
|
||||
const QVariantList charges{
|
||||
QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||||
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
|
||||
{"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}}},
|
||||
QVariantMap{{"id", m_var}, {"label", demesure}, {"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}};
|
||||
chargerConfigHeritee(charges);
|
||||
m_compteur->setStateValue("currentPower", -3000);
|
||||
m_arb->recordMeterUpdate(t0.addSecs(60));
|
||||
m_arb->simulationCallUpdate(t0.addSecs(60));
|
||||
QVERIFY(waitFor([&] { return m_codesEchec.contains(QStringLiteral("request-too-long")); }));
|
||||
settle();
|
||||
QCOMPARE(m_requetes.count(), recues); // rien n'est parti
|
||||
|
||||
QList<QVariantMap> l = traces(d, 2);
|
||||
QCOMPARE(l.count(), 2);
|
||||
QVERIFY(l.at(0).contains("request"));
|
||||
QVERIFY(!l.at(0).contains("requestBytes"));
|
||||
QVERIFY(!l.at(1).contains("request"));
|
||||
const qint64 octets = l.at(1).value("requestBytes").toLongLong();
|
||||
QVERIFY2(octets > OptimizerClient::kMaxLineBytes && octets < 2 * OptimizerClient::kMaxLineBytes,
|
||||
qPrintable(QString::number(octets)));
|
||||
QCOMPARE(l.at(1).value("response").toMap(),
|
||||
(QVariantMap{{"error", QVariantMap{{"code", "request-too-long"}}}}));
|
||||
// Le libellé n'est NULLE PART dans la trace : le fichier entier est loin du mégaoctet.
|
||||
qint64 taille = 0;
|
||||
for (const QString &f : fichiersTrace(d))
|
||||
taille += QFileInfo(QDir(d).filePath(f)).size();
|
||||
QVERIFY2(taille < 200 * 1024, qPrintable(QString::number(taille)));
|
||||
|
||||
// Sans optimiseur : la requête qui SERAIT partie suit la même règle.
|
||||
monter(false);
|
||||
const QString d2 = activerTraces();
|
||||
chargerConfigHeritee(charges);
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
l = traces(d2, 1);
|
||||
QCOMPARE(l.count(), 1);
|
||||
QVERIFY(!l.at(0).contains("request"));
|
||||
QVERIFY(l.at(0).value("requestBytes").toLongLong() > OptimizerClient::kMaxLineBytes);
|
||||
QCOMPARE(l.at(0).value("strategy").toString(), QStringLiteral("rules"));
|
||||
QVERIFY(l.at(0).value("response").toMap().contains("result"));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testARequestTooLongCountsAsAFailure()
|
||||
{
|
||||
// Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un
|
||||
// ÉCHEC de la requête, compté par la règle des deux échecs consécutifs (§7) — même logique
|
||||
// qu'une erreur renvoyée par l'optimiseur (Q2) : un contexte hors protocole est un défaut du
|
||||
// moteur, le taire serait pire. Banc autonome : le vrai SocketScheduler et le vrai client sur
|
||||
// un faux optimiseur, sans arbitre ; le contexte est fabriqué, c'est son libellé qui déborde.
|
||||
struct Ombre : IScheduler {
|
||||
Plan getPlan(const SurplusContext &) override { return Plan(); }
|
||||
} ombre;
|
||||
QTemporaryDir dir;
|
||||
QVERIFY(dir.isValid());
|
||||
FakeOptimizer serveur(FakeOptimizer::Unix, dir.path());
|
||||
QVERIFY(serveur.listen());
|
||||
QStringList methodes;
|
||||
serveur.responder = [&](const QJsonObject &m) {
|
||||
methodes << m.value("method").toString();
|
||||
if (m.value("method").toString() == QLatin1String("Handshake"))
|
||||
serveur.replyResult(m.value("id").toInteger(), handshakeResult());
|
||||
return true;
|
||||
};
|
||||
auto *client = new OptimizerClient(OptimizerEndpoint::parse(serveur.endpoint()), QString(), "9.9");
|
||||
SocketScheduler s(&ombre, client, nullptr);
|
||||
QVERIFY(waitFor([&] { return client->state() == OptimizerClient::Ready; }));
|
||||
QSignalSpy echecs(&s, &SocketScheduler::echecRequete);
|
||||
|
||||
SurplusContext ctx;
|
||||
ctx.timestamp = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
LoadContext charge;
|
||||
charge.id = QStringLiteral("ecs");
|
||||
charge.adapter = QStringLiteral("relay-router");
|
||||
charge.label = QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'));
|
||||
ctx.loads << charge;
|
||||
|
||||
s.getPlan(ctx);
|
||||
QVERIFY(waitFor([&] { return echecs.count() == 1; }));
|
||||
QCOMPARE(echecs.at(0).at(0).toString(), QStringLiteral("request-too-long"));
|
||||
QCOMPARE(s.vivacite().echecsConsecutifs(), 1);
|
||||
s.getPlan(ctx);
|
||||
QVERIFY(waitFor([&] { return echecs.count() == 2; }));
|
||||
QCOMPARE(s.vivacite().echecsConsecutifs(), 2);
|
||||
QVERIFY(!s.vivacite().vivant());
|
||||
QCOMPARE(s.vivacite().dernierCodeErreur(), QStringLiteral("request-too-long"));
|
||||
QCOMPARE(client->state(), OptimizerClient::Ready);
|
||||
settle();
|
||||
QCOMPARE(methodes, QStringList{QStringLiteral("Handshake")}); // aucun GetPlan n'est parti
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTraceIsOffByDefault()
|
||||
{
|
||||
// Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal.
|
||||
@ -2341,7 +2166,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes()
|
||||
cycle(t0.addSecs(60 * i), -3000);
|
||||
|
||||
const QVariantMap r = rpc("GetOptimizerProtocol");
|
||||
QCOMPARE(r.value("protocolVersion").toString(), QString("1.3"));
|
||||
QCOMPARE(r.value("protocolVersion").toString(), QString("1.2"));
|
||||
const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap());
|
||||
const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object();
|
||||
QVERIFY(!embarque.isEmpty());
|
||||
|
||||
@ -187,43 +187,6 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
||||
|
||||
return;
|
||||
|
||||
} else if (thing->thingClassId() == chargerUnmeteredThingClassId) {
|
||||
// Lot délestage V2C — une borne qui mesure sa puissance mais PAS ses phases au sens du
|
||||
// moteur : `currentL1..3` (noms du plugin V2C Trydan), jamais `currentPhaseX`. Son nombre
|
||||
// de phases est celui de son câblage, fixe : aucune bascule.
|
||||
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||
quint16 port = thing->paramValue(paramType.id()).toUInt();
|
||||
if (!controller->listen(QHostAddress::Any, port)) {
|
||||
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
|
||||
delete controller;
|
||||
info->finish(Thing::ThingErrorThingInUse);
|
||||
return;
|
||||
}
|
||||
|
||||
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
|
||||
if (query.hasQueryItem("connected"))
|
||||
thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool());
|
||||
|
||||
if (query.hasQueryItem("power"))
|
||||
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
|
||||
|
||||
if (query.hasQueryItem("pluggedIn"))
|
||||
thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool());
|
||||
|
||||
if (query.hasQueryItem("maxChargingCurrent"))
|
||||
thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt());
|
||||
|
||||
updateUnmeteredChargerMeter(thing);
|
||||
});
|
||||
|
||||
m_controllers.insert(thing, controller);
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
|
||||
thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt());
|
||||
updateUnmeteredChargerMeter(thing);
|
||||
return;
|
||||
|
||||
} else if (thing->thingClassId() == simpleChargerThingClassId) {
|
||||
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||
@ -467,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
|
||||
@ -640,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
|
||||
@ -736,15 +673,6 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
||||
}
|
||||
|
||||
|
||||
if (thing->thingClassId() == chargerUnmeteredThingClassId) {
|
||||
if (actionType.name() == "maxChargingCurrent")
|
||||
thing->setStateValue("maxChargingCurrent",
|
||||
action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt());
|
||||
if (actionType.name() == "power")
|
||||
thing->setStateValue("power", action.paramValue(actionType.paramTypes().findByName("power").id()).toBool());
|
||||
updateUnmeteredChargerMeter(thing);
|
||||
}
|
||||
|
||||
if (thing->thingClassId() == simpleChargerThingClassId) {
|
||||
if (actionType.name() == "maxChargingCurrent") {
|
||||
uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt();
|
||||
@ -797,24 +725,6 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
}
|
||||
|
||||
void IntegrationPluginEnergyMocks::updateUnmeteredChargerMeter(Thing *thing)
|
||||
{
|
||||
// Le courant commandé, tiré sur les phases câblées — comme updateChargerMeter(), mais publié
|
||||
// sous `currentL1..3`, que le moteur ne lit pas : EvCharger::meteredPhases() reste vide.
|
||||
const Electricity::Phases cablees = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
|
||||
const uint phaseCount = Electricity::getPhaseCount(cablees);
|
||||
thing->setStateValue("phaseCount", phaseCount);
|
||||
|
||||
const bool tire = thing->stateValue("power").toBool() && thing->stateValue("connected").toBool()
|
||||
&& thing->stateValue("pluggedIn").toBool();
|
||||
const double courantA = tire ? thing->stateValue("maxChargingCurrent").toDouble() : 0.0;
|
||||
thing->setStateValue("currentL1", cablees.testFlag(Electricity::PhaseA) ? courantA : 0.0);
|
||||
thing->setStateValue("currentL2", cablees.testFlag(Electricity::PhaseB) ? courantA : 0.0);
|
||||
thing->setStateValue("currentL3", cablees.testFlag(Electricity::PhaseC) ? courantA : 0.0);
|
||||
thing->setStateValue("currentPower", courantA * 230 * phaseCount);
|
||||
thing->setStateValue("charging", tire);
|
||||
}
|
||||
|
||||
void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing)
|
||||
{
|
||||
Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString());
|
||||
|
||||
@ -78,7 +78,6 @@ private:
|
||||
|
||||
// Update the meter data of the charger based on the current max charging current, connected, power and plugged in state
|
||||
void updateChargerMeter(Thing *thing);
|
||||
void updateUnmeteredChargerMeter(Thing *thing);
|
||||
|
||||
};
|
||||
|
||||
|
||||
@ -717,146 +717,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "chargerUnmetered",
|
||||
"displayName": "Mocked charger without per-phase metering",
|
||||
"id": "e297f8c4-934f-4af3-a655-29b5b799289b",
|
||||
"createMethods": [
|
||||
"user"
|
||||
],
|
||||
"interfaces": [
|
||||
"evcharger",
|
||||
"smartmeterconsumer",
|
||||
"connectable"
|
||||
],
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5",
|
||||
"name": "port",
|
||||
"displayName": "Port",
|
||||
"type": "uint",
|
||||
"defaultValue": 6658
|
||||
},
|
||||
{
|
||||
"id": "ec3e73a0-cca9-415a-bc64-9e841d666853",
|
||||
"name": "phases",
|
||||
"displayName": "Phases connected",
|
||||
"type": "QString",
|
||||
"allowedValues": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"ABC"
|
||||
],
|
||||
"defaultValue": "A"
|
||||
},
|
||||
{
|
||||
"id": "803ba501-ae0f-4d58-8097-6ebb0928b3d8",
|
||||
"name": "maxChargingCurrentUpperLimit",
|
||||
"displayName": "Maximum charging current",
|
||||
"type": "double",
|
||||
"unit": "Ampere",
|
||||
"defaultValue": 32
|
||||
}
|
||||
],
|
||||
"stateTypes": [
|
||||
{
|
||||
"id": "b35e0da3-7fc1-4c42-b6b4-2c43f9cb937a",
|
||||
"name": "connected",
|
||||
"displayName": "Connected",
|
||||
"type": "bool",
|
||||
"defaultValue": false,
|
||||
"cached": false
|
||||
},
|
||||
{
|
||||
"id": "6ce62b4f-39e1-4693-b204-e399ea4e6637",
|
||||
"name": "power",
|
||||
"displayName": "Charging enabled",
|
||||
"displayNameAction": "Enable/disable charging",
|
||||
"type": "bool",
|
||||
"defaultValue": true,
|
||||
"writable": true
|
||||
},
|
||||
{
|
||||
"id": "1b1c75b1-362e-4341-960d-4024b2f28341",
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "double",
|
||||
"defaultValue": 6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
"unit": "Ampere",
|
||||
"writable": true
|
||||
},
|
||||
{
|
||||
"id": "4406b3d3-0102-4932-94ee-e678cc850106",
|
||||
"name": "totalEnergyConsumed",
|
||||
"displayName": "Total consumed energy",
|
||||
"type": "double",
|
||||
"unit": "KiloWattHour",
|
||||
"defaultValue": 0,
|
||||
"cached": true
|
||||
},
|
||||
{
|
||||
"id": "06b4ceea-5893-4776-b854-4e6c67469b47",
|
||||
"name": "pluggedIn",
|
||||
"displayName": "Car is plugged in",
|
||||
"type": "bool",
|
||||
"defaultValue": false,
|
||||
"cached": false
|
||||
},
|
||||
{
|
||||
"id": "cca231ab-9fb5-4565-90b4-56ac33ccbcd4",
|
||||
"name": "charging",
|
||||
"displayName": "Charging",
|
||||
"type": "bool",
|
||||
"defaultValue": false,
|
||||
"cached": false
|
||||
},
|
||||
{
|
||||
"id": "79601cfe-433b-4fcb-b61f-5b6a0b8b0292",
|
||||
"name": "phaseCount",
|
||||
"displayName": "Active phases",
|
||||
"type": "uint",
|
||||
"minValue": 1,
|
||||
"maxValue": 3,
|
||||
"defaultValue": 1
|
||||
},
|
||||
{
|
||||
"id": "923e31e9-04be-43fc-a79d-10098f7aadc1",
|
||||
"name": "currentPower",
|
||||
"displayName": "Current power consumption",
|
||||
"type": "double",
|
||||
"unit": "Watt",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "edfb0a5d-6897-4b27-9ae4-2f636ab700a0",
|
||||
"name": "currentL1",
|
||||
"displayName": "Current L1",
|
||||
"type": "double",
|
||||
"unit": "Ampere",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "8bf7ff7b-78a3-4917-a4bd-1e9b7d95ca53",
|
||||
"name": "currentL2",
|
||||
"displayName": "Current L2",
|
||||
"type": "double",
|
||||
"unit": "Ampere",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "20292cd6-dd59-4608-9d38-18096f484cbf",
|
||||
"name": "currentL3",
|
||||
"displayName": "Current L3",
|
||||
"type": "double",
|
||||
"unit": "Ampere",
|
||||
"defaultValue": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "simpleCharger",
|
||||
"displayName": "Mocked charger simple",
|
||||
@ -1153,124 +1013,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "controllableStorage",
|
||||
"displayName": "Mocked energy storage with a discharge limit",
|
||||
"id": "648e1a54-96c9-4f0f-9fc8-4326ab7c643e",
|
||||
"createMethods": [
|
||||
"user"
|
||||
],
|
||||
"interfaces": [
|
||||
"energystorage"
|
||||
],
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "6250973b-c0f5-4f2d-9bc9-b077898025de",
|
||||
"name": "port",
|
||||
"displayName": "Port",
|
||||
"type": "uint",
|
||||
"defaultValue": 26690
|
||||
},
|
||||
{
|
||||
"id": "13b73424-5076-46b9-8eb8-38ee0078236e",
|
||||
"name": "revertTime",
|
||||
"displayName": "Revert time",
|
||||
"type": "uint",
|
||||
"unit": "Seconds",
|
||||
"minValue": 0,
|
||||
"maxValue": 28800,
|
||||
"defaultValue": 300
|
||||
}
|
||||
],
|
||||
"stateTypes": [
|
||||
{
|
||||
"id": "f288ff92-0404-496b-a26b-5590b6896d76",
|
||||
"name": "capacity",
|
||||
"displayName": "Capacity",
|
||||
"type": "double",
|
||||
"unit": "KiloWattHour",
|
||||
"defaultValue": 7.68
|
||||
},
|
||||
{
|
||||
"id": "ca7fcade-1599-4e7d-a0d7-a70461c16493",
|
||||
"name": "batteryLevel",
|
||||
"displayName": "Battery level",
|
||||
"displayNameAction": "Set battery level",
|
||||
"type": "int",
|
||||
"unit": "Percentage",
|
||||
"minValue": 0,
|
||||
"maxValue": 100,
|
||||
"writable": true,
|
||||
"defaultValue": 60
|
||||
},
|
||||
{
|
||||
"id": "1f74f29f-55fa-420d-a2b3-4d2a53018dad",
|
||||
"name": "currentPower",
|
||||
"displayName": "Current power consumption",
|
||||
"type": "double",
|
||||
"unit": "Watt",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "e1381991-e137-43c7-a75a-62ea97271dca",
|
||||
"name": "batteryCritical",
|
||||
"displayName": "Battery level critical",
|
||||
"type": "bool",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"id": "ad1597dd-b1ff-454e-99e7-293425c64c6b",
|
||||
"name": "enableCharging",
|
||||
"displayName": "Charge limit active",
|
||||
"displayNameAction": "Set charge limit active",
|
||||
"type": "bool",
|
||||
"writable": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"id": "11ba1548-03be-4232-a615-b8edbc564437",
|
||||
"name": "enableDischarging",
|
||||
"displayName": "Discharge limit active",
|
||||
"displayNameAction": "Set discharge limit active",
|
||||
"type": "bool",
|
||||
"writable": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"id": "0c2d52b4-c117-477f-a13c-173bce8816a6",
|
||||
"name": "chargingRate",
|
||||
"displayName": "Charging rate",
|
||||
"displayNameAction": "Set charging rate",
|
||||
"type": "int",
|
||||
"unit": "Percentage",
|
||||
"minValue": -100,
|
||||
"maxValue": 100,
|
||||
"writable": true,
|
||||
"defaultValue": 100
|
||||
},
|
||||
{
|
||||
"id": "55ac7803-9fb4-4f4e-b38a-4af2b45eeb2e",
|
||||
"name": "dischargingRate",
|
||||
"displayName": "Discharging rate",
|
||||
"displayNameAction": "Set discharging rate",
|
||||
"type": "int",
|
||||
"unit": "Percentage",
|
||||
"minValue": -100,
|
||||
"maxValue": 100,
|
||||
"writable": true,
|
||||
"defaultValue": 100
|
||||
},
|
||||
{
|
||||
"id": "26c0e94e-fdb0-485d-afa3-ab157052d365",
|
||||
"name": "gridCharging",
|
||||
"displayName": "Grid charging",
|
||||
"displayNameAction": "Set grid charging",
|
||||
"type": "bool",
|
||||
"writable": true,
|
||||
"defaultValue": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "powerSwitch",
|
||||
"displayName": "Mocked Power Switch (relais ECS)",
|
||||
|
||||
@ -122,19 +122,6 @@ class TestGenProtocolDoc(unittest.TestCase):
|
||||
self.assertEqual(lancer(*self.args())[0], 0)
|
||||
self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose))
|
||||
|
||||
def test_la_forme_d_error_data_se_lit_dans_sa_table(self):
|
||||
# Protocole 1.3 : le motif du chemin porte une alternative — un `|` nu fermerait la colonne,
|
||||
# et la ligne aurait six cellules au lieu de cinq. Une forme de l'OPTIMISEUR fermée à dessein
|
||||
# ne se dit pas « émise par le moteur ».
|
||||
p = self.lire(self.prose)
|
||||
ligne = next(l for l in p.splitlines() if l.startswith('| `path` | string |'))
|
||||
self.assertEqual(len(ligne.replace('\\|', '').split('|')), 7, ligne)
|
||||
self.assertIn('motif `^params(\\.[A-Za-z0-9_]+\\|\\[[0-9]+\\])*$`', ligne)
|
||||
debut = p.index('<!-- BEGIN GENERATED table:InvalidContextData -->')
|
||||
bloc = p[debut:p.index('<!-- END GENERATED -->', debut)]
|
||||
self.assertIn('**refusés** (forme de l\'optimiseur fermée à dessein', bloc)
|
||||
self.assertNotIn('émise par le moteur', bloc)
|
||||
|
||||
def test_des_balises_cassees_sont_une_erreur(self):
|
||||
p = self.lire(self.prose)
|
||||
i = p.index('<!-- END GENERATED -->')
|
||||
|
||||
@ -13,9 +13,7 @@ Trois choses :
|
||||
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
|
||||
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
|
||||
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
|
||||
champ ajouté, un mauvais type le font échouer ;
|
||||
4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie
|
||||
d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux.
|
||||
champ ajouté, un mauvais type le font échouer.
|
||||
"""
|
||||
|
||||
import copy
|
||||
@ -39,9 +37,6 @@ GOLDEN = sorted(glob.glob(os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'da
|
||||
TRACES = sorted(glob.glob(os.path.join(ICI, 'data', '*.ndjson')))
|
||||
REQUETE = os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'data', 'optimizer', 'site-pv-ecs-borne-sgready.json')
|
||||
TOKEN = 'jeton-de-test-0123456789'
|
||||
with open(SCHEMA, encoding='utf-8') as _f:
|
||||
# La version du MOTEUR, celle que le contrat impose à ses Handshake (const) — jamais recopiée.
|
||||
VERSION_MOTEUR = json.load(_f)['components']['schemas']['ProtocolVersion']['const']
|
||||
|
||||
# mode → (qui rejette, verdict sémantique attendu). « schéma:<mot-clé> » : écart de forme ;
|
||||
# « règle » : la forme passe, une règle x-etm-semantic-rules refuse ; « — » : rien ne rejette.
|
||||
@ -61,18 +56,8 @@ TABLE_MODES = {
|
||||
'unknown-load': ('règle', 'unknown-load'),
|
||||
'forbidden-kind': ('règle', 'kind-mismatch'),
|
||||
'missing-load': ('règle', 'incomplete-slot'),
|
||||
# Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`.
|
||||
'old-data': ('—', 'valide'),
|
||||
'echo-value': ('—', 'valide'),
|
||||
'major-first': ('—', 'valide'),
|
||||
# Une consigne hors des bornes est un plan VALIDE : c'est au moteur de l'écrêter, pas au contrat.
|
||||
'over-limit': ('—', 'valide'),
|
||||
}
|
||||
|
||||
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
|
||||
# error.data, c'est une recopie.
|
||||
MARQUEUR = 'valeur-sonde:7Qz9'
|
||||
|
||||
|
||||
def verifier(*args):
|
||||
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
|
||||
@ -115,7 +100,7 @@ def session(mode, dossier):
|
||||
with open(REQUETE, encoding='utf-8') as f:
|
||||
params = json.load(f)
|
||||
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
|
||||
'params': {'protocolVersion': VERSION_MOTEUR, 'engine': 'etm-powersync-energy-plugin-etm/test',
|
||||
'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test',
|
||||
'token': TOKEN}}).encode() + b'\n')
|
||||
hs = lire_ligne(s, tampon)
|
||||
if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):
|
||||
@ -129,33 +114,6 @@ def session(mode, dossier):
|
||||
return capture
|
||||
|
||||
|
||||
def session_contexte_invalide(dossier, mode, annonce, params):
|
||||
"""Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé."""
|
||||
sock_path = os.path.join(dossier, 'opt.sock')
|
||||
capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson')
|
||||
proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode,
|
||||
'--announce', annonce, '--capture', capture],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
||||
try:
|
||||
assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet'
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(sock_path)
|
||||
tampon = [b'']
|
||||
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
|
||||
'params': {'protocolVersion': VERSION_MOTEUR,
|
||||
'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n')
|
||||
assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce
|
||||
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n')
|
||||
reponse = lire_ligne(s, tampon)
|
||||
assert reponse['error']['code'] == -32602, reponse
|
||||
s.close()
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(5)
|
||||
proc.stdout.close()
|
||||
return capture
|
||||
|
||||
|
||||
class TestCeQueLeMoteurEmet(unittest.TestCase):
|
||||
def test_les_references_du_serialiseur_sont_conformes(self):
|
||||
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
|
||||
@ -278,135 +236,5 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase):
|
||||
self.assertNotIn('uniqueItems', sortie)
|
||||
|
||||
|
||||
class TestDonneesDErreur(unittest.TestCase):
|
||||
"""error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de
|
||||
référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS
|
||||
--semantic (sinon la réponse error est un refus, quel que soit son data)."""
|
||||
|
||||
# (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0)
|
||||
ATTENDU = {
|
||||
('normal', '1.3'): (0, False, False),
|
||||
('normal', '1.2'): (0, False, False),
|
||||
('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée
|
||||
('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie
|
||||
('echo-value', '1.3'): (1, True, False),
|
||||
('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2)
|
||||
}
|
||||
|
||||
def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self):
|
||||
params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
|
||||
params['timestamp'] = MARQUEUR # sans décalage, illisible : `format`
|
||||
obtenu = {}
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for (mode, annonce) in self.ATTENDU:
|
||||
capture = session_contexte_invalide(d, mode, annonce, params)
|
||||
code, r, sortie = verifier('--ndjson', capture, '--responses-only')
|
||||
self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie)
|
||||
self.assertNotIn('GetPlan.params', r['comptes'])
|
||||
self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie)
|
||||
obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0)
|
||||
print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements '
|
||||
f'{r["avertissements"]}', file=sys.stderr)
|
||||
if mode == 'echo-value':
|
||||
self.assertIn('recopie la valeur reçue en params.timestamp', sortie)
|
||||
self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur')
|
||||
if (mode, annonce) == ('old-data', '1.2'):
|
||||
self.assertIn('AVERTISSEMENT', sortie)
|
||||
self.assertIn('à aligner avant de s\'annoncer 1.3', sortie)
|
||||
self.assertEqual(obtenu, self.ATTENDU)
|
||||
|
||||
def test_la_forme_vient_du_contrat(self):
|
||||
# Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est
|
||||
# bien le contrat qui juge, pas une règle cachée dans l'outil.
|
||||
with open(SCHEMA, encoding='utf-8') as f:
|
||||
s = json.load(f)
|
||||
for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']:
|
||||
e.pop('x-etm-data-schema', None)
|
||||
e.pop('x-etm-data-required-from', None)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
schema = os.path.join(d, 's.json')
|
||||
with open(schema, 'w', encoding='utf-8') as f:
|
||||
json.dump(s, f, ensure_ascii=False)
|
||||
capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR})
|
||||
self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1)
|
||||
code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only')
|
||||
self.assertEqual(code, 0, sortie)
|
||||
self.assertNotIn('GetPlan.error.data', r['comptes'])
|
||||
|
||||
def ecrire(self, d, nom, contenu):
|
||||
chemin = os.path.join(d, nom)
|
||||
with open(chemin, 'w', encoding='utf-8') as f:
|
||||
json.dump(contenu, f, ensure_ascii=False)
|
||||
return chemin
|
||||
|
||||
def juger(self, d, data, requete=None, version='1.3'):
|
||||
args = ['--method', 'GetPlan', '--error',
|
||||
self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}),
|
||||
'--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})]
|
||||
if requete is not None:
|
||||
args += ['--request', self.ecrire(d, 'q.json', requete)]
|
||||
return verifier(*args)
|
||||
|
||||
def test_la_forme_ligne_a_ligne(self):
|
||||
p = {'path': 'params.loads[0].id', 'problem': 'missing'}
|
||||
cas = [
|
||||
({'problems': [p]}, 0, None),
|
||||
({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None),
|
||||
({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None),
|
||||
({'problems': [p] * 50, 'truncated': True}, 0, None),
|
||||
({'problems': []}, 1, '[minItems]'),
|
||||
({'problems': [p] * 51}, 1, '[maxItems]'),
|
||||
({'problems': [p] * 50, 'truncated': False}, 1, '[const]'),
|
||||
({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'),
|
||||
({'problems': [dict(p, problem='absent')]}, 1, '[enum]'),
|
||||
({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'),
|
||||
({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'),
|
||||
({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'),
|
||||
({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'),
|
||||
({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'),
|
||||
({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'),
|
||||
({'problemes': ['params.timestamp : format']}, 1, '[required]'),
|
||||
('contexte invalide', 1, '[type]'),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for data, code_attendu, mot in cas:
|
||||
code, r, sortie = self.juger(d, data)
|
||||
self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}')
|
||||
if mot:
|
||||
self.assertIn(mot, sortie, data)
|
||||
# data absent : écart en 1.3
|
||||
chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'})
|
||||
code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin)
|
||||
self.assertEqual(code, 1, sortie)
|
||||
self.assertIn('« data » manquant', sortie)
|
||||
# Un autre code d'erreur n'a pas de forme normée : rien à juger.
|
||||
chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']})
|
||||
code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin)
|
||||
self.assertEqual((code, r['ecarts']), (0, 0), sortie)
|
||||
|
||||
def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self):
|
||||
# Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger
|
||||
# « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une
|
||||
# recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est.
|
||||
requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
|
||||
requete['trigger'] = 'forecast'
|
||||
requete['loads'][0]['domain'] = 'battery'
|
||||
data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'},
|
||||
{'path': 'params.forecast.pv[3].from', 'problem': 'format'},
|
||||
{'path': 'params.meter.source', 'problem': 'enum'}]}
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
code, r, sortie = self.juger(d, data, requete)
|
||||
self.assertEqual(code, 0, sortie)
|
||||
ident = requete['loads'][0]['id']
|
||||
self.assertRegex(ident, '[^A-Za-z0-9_]')
|
||||
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2')
|
||||
self.assertEqual(code, 1, sortie)
|
||||
self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie)
|
||||
self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0])
|
||||
# Avant 1.2, la règle n'existait pas : avertie seulement.
|
||||
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1')
|
||||
self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@ -60,9 +60,6 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat :
|
||||
./tools/etm-powersync-schema-check --method GetPlan --params requete.json
|
||||
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
|
||||
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
|
||||
./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein
|
||||
./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \
|
||||
--handshake handshake.json # error.data d'un -32602 (protocole 1.3)
|
||||
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
|
||||
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
|
||||
|
||||
@ -80,10 +77,6 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <build>/tests/auto/protocolschema/etm-
|
||||
mot-clé est une erreur de schéma (code 2), jamais ignoré.
|
||||
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
|
||||
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
|
||||
- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme
|
||||
(`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce
|
||||
`x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet
|
||||
sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2.
|
||||
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
|
||||
schéma.
|
||||
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite
|
||||
|
||||
@ -145,15 +145,14 @@ class Rendu:
|
||||
chemin = f'{prefixe}{nom}'
|
||||
r = self.resoudre(s)
|
||||
sens = self.sens(s) or (self.sens(r) if self.nom_ref(s) and r.get('type') not in ('object',) else '')
|
||||
# Un `|` dans une cellule (alternative d'un motif) fermerait la colonne : échappé.
|
||||
out.append(f'| `{chemin}` | {self.type_de(s)} | {"oui" if nom in requis else "non"} | '
|
||||
f'{self.valeurs(s).replace("|", chr(92) + "|")} | {sens} |')
|
||||
f'{self.valeurs(s)} | {sens} |')
|
||||
# Un objet EN LIGNE se déroule ; un composant nommé a sa propre table.
|
||||
if self.nom_ref(s) is None and isinstance(s, dict) and isinstance(s.get('properties'), dict):
|
||||
out += self.lignes(s['properties'], s.get('required', []), chemin + '.')
|
||||
return out
|
||||
|
||||
def table(self, props, requis, titre=None, description=None, ouvert=None, ferme_pourquoi=None):
|
||||
def table(self, props, requis, titre=None, description=None, ouvert=None):
|
||||
out = []
|
||||
if titre:
|
||||
out.append(f'**{titre}**' + (f' — {description}' if description else ''))
|
||||
@ -163,13 +162,8 @@ class Rendu:
|
||||
out += self.lignes(props, requis)
|
||||
if ouvert is not None:
|
||||
out.append('')
|
||||
if ouvert is not False:
|
||||
out.append('Champs non déclarés : ignorés (§9).')
|
||||
else:
|
||||
# Une forme fermée est d'ordinaire celle qu'émet le moteur ; une forme de l'optimiseur
|
||||
# fermée À DESSEIN dit pourquoi (`x-etm-closed-why`, 1.3 : error.data).
|
||||
out.append('Champs non déclarés : **refusés** ('
|
||||
+ (ferme_pourquoi or 'forme émise par le moteur, fermée') + ').')
|
||||
out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False
|
||||
else 'ignorés (§9).'))
|
||||
return out
|
||||
|
||||
def composant(self, nom):
|
||||
@ -190,8 +184,7 @@ class Rendu:
|
||||
if 'properties' not in s:
|
||||
return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()]
|
||||
out = self.table(s['properties'], s.get('required', []), titre=f'`{nom}`',
|
||||
description=self.sens(s) or None, ouvert=s.get('additionalProperties', True),
|
||||
ferme_pourquoi=s.get('x-etm-closed-why'))
|
||||
description=self.sens(s) or None, ouvert=s.get('additionalProperties', True))
|
||||
if 'anyOf' in s and all(set(b) == {'required'} for b in s['anyOf']):
|
||||
out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.')
|
||||
return out
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# `etm-powersync-ref-optimizer` — optimiseur de référence
|
||||
|
||||
Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md`
|
||||
1.3 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
|
||||
1.2 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
|
||||
confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun
|
||||
accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de
|
||||
comparaison d'un banc, exemple pour qui écrit son propre optimiseur.
|
||||
@ -29,16 +29,16 @@ Il écrit `ÉCOUTE <endpoint>` sur la sortie standard quand il écoute (le port
|
||||
et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état
|
||||
partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement.
|
||||
|
||||
## Erreurs (protocole 1.2 et 1.3)
|
||||
## Erreurs (protocole 1.2)
|
||||
|
||||
Codes JSON-RPC 2.0, plus `-32001` (protocole §7) :
|
||||
|
||||
| Reçu | Réponse | Connexion |
|
||||
|---|---|---|
|
||||
| `Handshake` sans le token attendu, ou avec un autre — **même d'une autre majeure** (1.3 : le token d'abord) | `-32001` | fermée |
|
||||
| `Handshake` d'une autre majeure, token accepté ou non exigé | `-32602` | fermée |
|
||||
| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée |
|
||||
| `Handshake` d'une autre majeure | `-32602` | fermée |
|
||||
| `GetPlan` avant un handshake réussi | `-32600` | gardée |
|
||||
| `GetPlan` dont le `timestamp` est absent, n'est pas une chaîne, ou est illisible ou sans décalage | `-32602`, `error.data` = `{"problems":[{"path":"params.timestamp","problem":"missing"\|"type"\|"format"}]}` (forme 1.3, protocole §7), jamais la valeur | gardée |
|
||||
| `GetPlan` dont le `timestamp` est absent, illisible ou sans décalage | `-32602`, `error.data.problems` = `[{path, problem}]`, jamais la valeur | gardée |
|
||||
| ligne non JSON | `-32700`, `id: null` | gardée |
|
||||
| lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée |
|
||||
| ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée |
|
||||
@ -80,15 +80,7 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg
|
||||
`constraint`) · `missing-load` (une charge disponible absente du créneau courant) · `echo-token`
|
||||
(recopie le token dans `reason` et `planId`) · `protocol-1.0` (optimiseur 1.0 : sans champs 1.1) ·
|
||||
`deviate` (cascade en ordre inverse : doit diverger) · `wrong-type` (`planId` numérique : le moteur
|
||||
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data`
|
||||
(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) ·
|
||||
`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure
|
||||
jugée avant le token) · `over-limit` (toute consigne en watts portée à 50 kW, au-delà des bornes
|
||||
de la charge et du branchement : le moteur doit l'écrêter).
|
||||
|
||||
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
|
||||
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité
|
||||
n'exige plus de lui la forme 1.3 d'`error.data` ni l'ordre token puis majeure, elle avertit.
|
||||
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité).
|
||||
|
||||
`--mode-file FICHIER` : le mode est relu dans ce fichier à chaque message — un test change de mode
|
||||
sans reconnexion. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.3, annexe E).
|
||||
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E).
|
||||
|
||||
etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port]
|
||||
[--token-file FICHIER] [--mode MODE | --mode-file FICHIER]
|
||||
[--slow-s N] [--horizon-s S] [--capture FICHIER] [--announce VERSION]
|
||||
[--slow-s N] [--horizon-s S] [--capture FICHIER]
|
||||
etm-powersync-ref-optimizer --list-codes
|
||||
|
||||
Un serveur JSON-RPC 2.0 sur NDJSON qui répond au `Handshake` et à `GetPlan` par un plan à UN
|
||||
@ -37,16 +37,6 @@ Modes (`--mode`, pour les tests ; un seul à la fois) :
|
||||
deviate cascade servie en ordre de rang INVERSE (contre-épreuve : doit diverger)
|
||||
wrong-type `planId` numérique au lieu d'une chaîne : le moteur l'ACCEPTE (il le lit comme
|
||||
texte), le contrat OpenRPC le REFUSE — contre-épreuve de la suite de conformité
|
||||
old-data contexte invalide : `error.data` dans la forme d'avant 1.3 (`{"problemes": [chaînes]}`,
|
||||
celle du premier lot de l'optimiseur ETM) — refusée d'un optimiseur 1.3, avertie
|
||||
d'un optimiseur 1.2 (`--announce 1.2`)
|
||||
echo-value contexte invalide : `error.data` recopie la valeur reçue — refusé quelle que soit
|
||||
la version annoncée (règle de 1.2)
|
||||
major-first handshake : la majeure jugée AVANT le token (token faux et autre majeure →
|
||||
-32602 au lieu de -32001) — refusé d'un optimiseur 1.3
|
||||
|
||||
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
|
||||
(défaut : celle que parle cet optimiseur, 1.3). Pour les contre-épreuves : un optimiseur 1.2.
|
||||
|
||||
`--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par message, dans l'ordre
|
||||
(la session que `tools/etm-powersync-schema-check --ndjson` juge). Le token d'un handshake y est
|
||||
@ -65,14 +55,11 @@ se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans
|
||||
autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un
|
||||
token reçu est accepté sans être lu.
|
||||
|
||||
Erreurs (protocole 1.2 et 1.3, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
|
||||
token absent ou différent -32001, puis fermeture — jugé AVANT la majeure (1.3)
|
||||
Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
|
||||
Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment)
|
||||
GetPlan avant un handshake réussi -32600, connexion gardée
|
||||
contexte GetPlan invalide -32602, `error.data` = {"problems": [{path, problem}]} (1.3 :
|
||||
chemin depuis `params`, code d'une liste fermée, 50 au plus et
|
||||
`truncated` au-delà), jamais la valeur reçue — une erreur, pas
|
||||
une abstention
|
||||
contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la
|
||||
valeur reçue — une erreur, pas une abstention
|
||||
ligne non JSON -32700, id null
|
||||
lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité
|
||||
ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire
|
||||
@ -101,7 +88,8 @@ import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
|
||||
VERSION = '1.3'
|
||||
VERSION = '1.2'
|
||||
MAJEURE = VERSION.split('.')[0]
|
||||
NOM = 'etm-powersync-ref-optimizer'
|
||||
# Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer`
|
||||
# est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt.
|
||||
@ -113,11 +101,9 @@ CODE_REQUETE_INVALIDE = -32600
|
||||
CODE_METHODE_INCONNUE = -32601
|
||||
CODE_PARAMS_INVALIDES = -32602
|
||||
TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée
|
||||
PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `truncated`
|
||||
|
||||
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
|
||||
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type',
|
||||
'old-data', 'echo-value', 'major-first', 'over-limit']
|
||||
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type']
|
||||
|
||||
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
|
||||
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
|
||||
@ -342,32 +328,13 @@ def iso(t):
|
||||
|
||||
|
||||
def problemes_contexte(ctx):
|
||||
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}] (§7, 1.3),
|
||||
chemin depuis `params`, nature dans la liste fermée — jamais la valeur reçue. Seul `timestamp`
|
||||
est exigé : tout le reste se lit avec un défaut."""
|
||||
if 'timestamp' not in ctx:
|
||||
return [{'path': 'params.timestamp', 'problem': 'missing'}]
|
||||
if not isinstance(ctx['timestamp'], str):
|
||||
return [{'path': 'params.timestamp', 'problem': 'type'}]
|
||||
if instant(ctx['timestamp']) is None:
|
||||
return [{'path': 'params.timestamp', 'problem': 'format'}] # illisible, ou sans décalage
|
||||
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais
|
||||
la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut."""
|
||||
if instant(ctx.get('timestamp')) is None:
|
||||
return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}]
|
||||
return []
|
||||
|
||||
|
||||
def donnees_contexte_invalide(problemes, ctx, mode):
|
||||
"""`error.data` du -32602 de GetPlan. Forme 1.3 : les 50 premiers problèmes, `truncated` au-delà.
|
||||
Les modes old-data et echo-value fabriquent ce qu'un optimiseur NON conforme répondrait."""
|
||||
if mode == 'old-data':
|
||||
# Forme du premier lot de l'optimiseur ETM (avant 1.3) : des phrases, sans liste fermée.
|
||||
return {'problemes': [f'{p["path"]} : {p["problem"]}' for p in problemes]}
|
||||
data = {'problems': problemes[:PROBLEMES_MAX]}
|
||||
if len(problemes) > PROBLEMES_MAX:
|
||||
data['truncated'] = True
|
||||
if mode == 'echo-value':
|
||||
data['problems'][0] = dict(data['problems'][0], received=ctx.get('timestamp'))
|
||||
return data
|
||||
|
||||
|
||||
def plan(ctx, mode, horizon_s, token):
|
||||
"""La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
|
||||
t0 = time.monotonic()
|
||||
@ -389,13 +356,6 @@ def plan(ctx, mode, horizon_s, token):
|
||||
if a['loadId'] in disponibles:
|
||||
del actions[i]
|
||||
break
|
||||
elif mode == 'over-limit':
|
||||
# Un optimiseur qui demande le MAXIMUM à tout ce qui se règle en watts, au-delà des bornes
|
||||
# de la charge et du branchement. Le moteur doit l'écrêter (invariant 2), jamais le poser.
|
||||
for a in actions:
|
||||
if a.get('kind') == 'setpoint':
|
||||
a['powerW'] = 50000.0
|
||||
a['reason'] = 'mode over-limit (test) : 50 kW demandés'
|
||||
elif mode == 'echo-token' and token:
|
||||
for a in actions:
|
||||
a['reason'] = f'{a["reason"]} — écho {token}'
|
||||
@ -538,27 +498,19 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
mode = mode_courant(cfg)
|
||||
if methode == 'Handshake':
|
||||
attendu = lire_token(cfg.token_file)
|
||||
version = params.get('protocolVersion')
|
||||
token_refuse = attendu is not None and params.get('token') != attendu
|
||||
majeure_refusee = not isinstance(version, str) or version.split('.')[0] != cfg.announce.split('.')[0]
|
||||
# §3, 1.3 : le token d'abord — un pair sans le secret n'apprend pas quelle majeure on
|
||||
# parle. Le mode major-first inverse l'ordre (contre-épreuve de la conformité).
|
||||
if mode == 'major-first' and majeure_refusee:
|
||||
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]}), mode major-first')
|
||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
|
||||
return
|
||||
if token_refuse:
|
||||
if attendu is not None and params.get('token') != attendu:
|
||||
# Le token reçu n'est jamais recopié ni journalisé.
|
||||
journal('handshake refusé : token absent ou différent')
|
||||
self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
|
||||
return
|
||||
if majeure_refusee:
|
||||
version = params.get('protocolVersion')
|
||||
if not isinstance(version, str) or version.split('.')[0] != MAJEURE:
|
||||
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
|
||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
|
||||
return
|
||||
token = attendu
|
||||
pret = True
|
||||
reponse = {'protocolVersion': cfg.announce, 'name': NOM_ANNONCE, 'planning': False,
|
||||
reponse = {'protocolVersion': VERSION, 'name': NOM_ANNONCE, 'planning': False,
|
||||
'capabilities': {'resolutionS': cfg.horizon_s, 'kinds': ['setpoint', 'state'],
|
||||
'usesForecast': [], 'reasonCodes': True}}
|
||||
if mode == 'wrong-major':
|
||||
@ -574,9 +526,8 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
continue
|
||||
problemes = problemes_contexte(params)
|
||||
if problemes:
|
||||
journal(f'contexte invalide : {", ".join(p["path"] + " " + p["problem"] for p in problemes)}')
|
||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide',
|
||||
donnees_contexte_invalide(problemes, params, mode))
|
||||
journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}')
|
||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes})
|
||||
continue
|
||||
if mode == 'drop':
|
||||
journal('mode drop : connexion fermée sans réponse')
|
||||
@ -631,7 +582,7 @@ def ecouter(endpoint):
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.3, annexe E).')
|
||||
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).')
|
||||
p.add_argument('--listen', default=ENDPOINT_DEFAUT,
|
||||
help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})')
|
||||
p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)')
|
||||
@ -641,16 +592,12 @@ def main(argv=None):
|
||||
p.add_argument('--horizon-s', type=int, default=300,
|
||||
help='durée du créneau et de validUntil, en s (défaut 300)')
|
||||
p.add_argument('--capture', help='écrit chaque message reçu et envoyé dans ce fichier (NDJSON)')
|
||||
p.add_argument('--announce', default=VERSION,
|
||||
help=f'version annoncée au handshake, sa majeure exigée du moteur (défaut : {VERSION})')
|
||||
p.add_argument('--list-codes', action='store_true',
|
||||
help='écrit le catalogue des codes émissibles (JSON) et sort')
|
||||
args = p.parse_args(argv)
|
||||
if args.list_codes:
|
||||
print(json.dumps(CODES_EMISSIBLES, sort_keys=True))
|
||||
return 0
|
||||
if not args.announce.split('.')[0].isdigit():
|
||||
p.error(f'--announce : version semver attendue, pas « {args.announce} »')
|
||||
def arreter(*_):
|
||||
raise KeyboardInterrupt
|
||||
signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré
|
||||
|
||||
@ -6,10 +6,7 @@
|
||||
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
|
||||
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
|
||||
[--request PARAMS.json] [--handshake RESULT.json] [--semantic]
|
||||
etm-powersync-schema-check [--schema FICHIER] --method M --error FICHIER.json
|
||||
[--request PARAMS.json] [--handshake RESULT.json]
|
||||
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic]
|
||||
[--requests-only | --responses-only]
|
||||
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] [--requests-only]
|
||||
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
|
||||
etm-powersync-schema-check --subset
|
||||
|
||||
@ -31,35 +28,18 @@ Ce que l'outil lit en plus du JSON Schema :
|
||||
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
|
||||
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
|
||||
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
|
||||
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ;
|
||||
- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse
|
||||
error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui
|
||||
s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne,
|
||||
un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas
|
||||
de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles :
|
||||
`truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans
|
||||
`error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier
|
||||
contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas
|
||||
contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret,
|
||||
libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui
|
||||
échappe ; la forme 1.3, fermée, la rend inexprimable.
|
||||
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence.
|
||||
|
||||
Modes :
|
||||
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
|
||||
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
|
||||
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ;
|
||||
`--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les
|
||||
requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond).
|
||||
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes).
|
||||
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
|
||||
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
|
||||
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une
|
||||
erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`.
|
||||
- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session,
|
||||
la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`.
|
||||
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur.
|
||||
|
||||
Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la
|
||||
version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
|
||||
avertissements, verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
|
||||
Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
|
||||
verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
|
||||
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
|
||||
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
|
||||
"""
|
||||
@ -145,10 +125,6 @@ class Validateur:
|
||||
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
|
||||
if 'result' in m:
|
||||
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
|
||||
for e in m.get('errors', []):
|
||||
if 'x-etm-data-schema' in e:
|
||||
racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data',
|
||||
e['x-etm-data-schema']))
|
||||
for ou, s in racines:
|
||||
self.auditer_schema(s, ou)
|
||||
|
||||
@ -308,16 +284,6 @@ class Contrat:
|
||||
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
|
||||
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
|
||||
self.cles_motif = doc.get('x-etm-reason-params') or {}
|
||||
# (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé)
|
||||
self.donnees_erreur = {}
|
||||
for m in doc.get('methods', []):
|
||||
for e in m.get('errors', []):
|
||||
if 'x-etm-data-schema' in e:
|
||||
depuis = version_lue(e.get('x-etm-data-required-from'))
|
||||
if depuis is None:
|
||||
raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from '
|
||||
f'absent ou illisible')
|
||||
self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis)
|
||||
|
||||
def methode(self, nom):
|
||||
if nom not in self.methodes:
|
||||
@ -364,85 +330,6 @@ class Contrat:
|
||||
return e
|
||||
|
||||
|
||||
# --- error.data (protocole 1.3) ----------------------------------------------------------------------
|
||||
|
||||
def version_lue(v):
|
||||
"""« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None."""
|
||||
if not isinstance(v, str):
|
||||
return None
|
||||
morceaux = v.split('.')
|
||||
try:
|
||||
return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]')
|
||||
RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7)
|
||||
MAX_PROBLEMES = 50
|
||||
|
||||
|
||||
def valeurs_reconnaissables(params, chemin='params'):
|
||||
"""Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur)."""
|
||||
if isinstance(params, dict):
|
||||
for k, v in params.items():
|
||||
yield from valeurs_reconnaissables(v, f'{chemin}.{k}')
|
||||
elif isinstance(params, list):
|
||||
for i, v in enumerate(params):
|
||||
yield from valeurs_reconnaissables(v, f'{chemin}[{i}]')
|
||||
elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params):
|
||||
yield chemin, params
|
||||
|
||||
|
||||
def textes(v):
|
||||
"""Toutes les chaînes d'un JSON, clés comprises."""
|
||||
if isinstance(v, str):
|
||||
yield v
|
||||
elif isinstance(v, dict):
|
||||
for k, x in v.items():
|
||||
yield k
|
||||
yield from textes(x)
|
||||
elif isinstance(v, list):
|
||||
for x in v:
|
||||
yield from textes(x)
|
||||
|
||||
|
||||
def juger_donnees_erreur(contrat, methode, erreur, requete, version):
|
||||
"""error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message).
|
||||
|
||||
`version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme
|
||||
celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie
|
||||
d'une valeur reçue est un écart dès 1.2."""
|
||||
if not isinstance(erreur, dict):
|
||||
return [], []
|
||||
forme = contrat.donnees_erreur.get((methode, erreur.get('code')))
|
||||
if forme is None:
|
||||
return [], []
|
||||
schema, depuis = forme
|
||||
v = version_lue(version) or version_lue(contrat.version)
|
||||
if 'data' not in erreur:
|
||||
ecarts_forme = [('/error', 'required', '« data » manquant')]
|
||||
else:
|
||||
data = erreur['data']
|
||||
ecarts_forme = contrat.v.valider(data, schema, '/error/data')
|
||||
if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \
|
||||
and len(data['problems']) != MAX_PROBLEMES:
|
||||
ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated',
|
||||
f'truncated avec {len(data["problems"])} problème(s) : seulement quand '
|
||||
f'{MAX_PROBLEMES} sont donnés et d\'autres omis'))
|
||||
recopies = []
|
||||
if 'data' in erreur and isinstance(requete, dict):
|
||||
vus = list(textes(erreur['data']))
|
||||
for chemin, valeur in valeurs_reconnaissables(requete):
|
||||
if any(valeur in t for t in vus):
|
||||
recopies.append(('/error/data', 'x-etm-no-echo',
|
||||
f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)'))
|
||||
ecarts, avertissements = [], []
|
||||
(ecarts if v >= depuis else avertissements).extend(ecarts_forme)
|
||||
(ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies)
|
||||
return ecarts, avertissements
|
||||
|
||||
|
||||
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
|
||||
|
||||
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
|
||||
@ -613,7 +500,6 @@ class Bilan:
|
||||
self.semantique = semantique
|
||||
self.comptes = {}
|
||||
self.ecarts = []
|
||||
self.avertissements = []
|
||||
self.verdicts = {}
|
||||
self.refus = 0
|
||||
|
||||
@ -626,22 +512,6 @@ class Bilan:
|
||||
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
||||
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
|
||||
|
||||
def avertir(self, ou, partie, ecarts, pourquoi):
|
||||
for chemin, mot, msg in ecarts:
|
||||
self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
||||
print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}')
|
||||
|
||||
def juger_erreur(self, ou, methode, erreur, requete, version):
|
||||
ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version)
|
||||
if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur:
|
||||
return
|
||||
partie = f'{methode}.error.data'
|
||||
self.noter(ou, partie, ecarts)
|
||||
forme = self.c.donnees_erreur[(methode, erreur['code'])]
|
||||
self.avertir(ou, partie, avertissements,
|
||||
f'optimiseur en {str(version)[:16]}, forme exigée à partir de '
|
||||
f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}')
|
||||
|
||||
def verdict(self, ou, code):
|
||||
if code is None:
|
||||
code = 'valide'
|
||||
@ -659,7 +529,6 @@ class Bilan:
|
||||
|
||||
def resume(self):
|
||||
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
|
||||
'avertissements': len(self.avertissements),
|
||||
'verdicts': self.verdicts, 'refus': self.refus},
|
||||
ensure_ascii=False, sort_keys=True))
|
||||
|
||||
@ -684,11 +553,10 @@ class ErreurLigne:
|
||||
self.msg = msg
|
||||
|
||||
|
||||
def session(b, chemin, requetes_seules, reponses_seules=False):
|
||||
def session(b, chemin, requetes_seules):
|
||||
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
|
||||
attente = {} # id → (méthode, params)
|
||||
handshake = None # dernier result de Handshake accepté
|
||||
version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3)
|
||||
for n, m in lignes(chemin):
|
||||
ou = f'ligne {n}'
|
||||
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
|
||||
@ -701,8 +569,7 @@ def session(b, chemin, requetes_seules, reponses_seules=False):
|
||||
if nom not in b.c.methodes:
|
||||
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
|
||||
continue
|
||||
if not reponses_seules:
|
||||
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
|
||||
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
|
||||
if m.get('id') is not None:
|
||||
attente[m['id']] = (nom, m.get('params'))
|
||||
continue
|
||||
@ -720,11 +587,9 @@ def session(b, chemin, requetes_seules, reponses_seules=False):
|
||||
b.verdict(ou, code)
|
||||
if code is None:
|
||||
handshake = m['result']
|
||||
version = handshake.get('protocolVersion')
|
||||
elif 'error' in m:
|
||||
b.compter(f'{nom}.error')
|
||||
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
|
||||
b.juger_erreur(ou, nom, m['error'], params, version)
|
||||
if b.semantique:
|
||||
b.refus += 1
|
||||
else:
|
||||
@ -758,15 +623,12 @@ def main(argv=None):
|
||||
p.add_argument('--method', help='méthode du message (--params / --result)')
|
||||
p.add_argument('--params', help='fichier JSON : les params d\'une requête')
|
||||
p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
|
||||
p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)')
|
||||
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
|
||||
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
|
||||
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
|
||||
p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
|
||||
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
|
||||
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
|
||||
p.add_argument('--responses-only', action='store_true',
|
||||
help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)')
|
||||
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
|
||||
a = p.parse_args(argv)
|
||||
if a.subset:
|
||||
@ -777,12 +639,10 @@ def main(argv=None):
|
||||
contrat = Contrat(lire_json(a.schema))
|
||||
b = Bilan(contrat, a.semantic)
|
||||
if a.ndjson:
|
||||
if a.requests_only and a.responses_only:
|
||||
p.error('--requests-only et --responses-only s\'excluent')
|
||||
session(b, a.ndjson, a.requests_only, a.responses_only)
|
||||
session(b, a.ndjson, a.requests_only)
|
||||
elif a.trace:
|
||||
trace(b, a.trace)
|
||||
elif a.method and (a.params or a.result or a.error):
|
||||
elif a.method and (a.params or a.result):
|
||||
if a.params:
|
||||
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
|
||||
if a.result:
|
||||
@ -796,14 +656,8 @@ def main(argv=None):
|
||||
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
|
||||
elif a.semantic and a.method == 'Handshake':
|
||||
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
|
||||
if a.error:
|
||||
contrat.methode(a.method)
|
||||
hs = lire_json(a.handshake) if a.handshake else None
|
||||
b.compter(f'{a.method}.error')
|
||||
b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None,
|
||||
hs.get('protocolVersion') if isinstance(hs, dict) else None)
|
||||
else:
|
||||
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error')
|
||||
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result')
|
||||
except ErreurSchema as e:
|
||||
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user