Patrick Schurig be401dec40 fix(dérogation): une borne n'est pas écrêtée deux fois, et un mode écrit la termine
Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16),
et le premier est celui que Patrick a vu en regardant la voiture.

LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS.

La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or
`margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée
est une enveloppe sur TROIS : les comparer divise son allocation par trois.
Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à
13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des
11 que la voiture pouvait prendre.

Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage :
evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases
occupées. La passe confort ne fait pas cette faute, et son commentaire dit
pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la
plus sévère ». Je l'ai réécrite quinze lignes plus haut sans la relire : une
règle notée à un seul endroit du code n'est pas une règle.

L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle
ne se voyait qu'en regardant la voiture.

LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE.

Choisir un mode est durable ; une dérogation répond à un besoin ponctuel. Le
durable prime, et il prime tout de suite. Et SANS restituer le mode mémorisé,
qui écraserait celui que l'utilisateur vient de choisir.

Le cas vient du banc : le bouton « boost » de l'app écrit encore
ChargingModeNormal. Reçu pendant une dérogation, il fait restituer les valeurs
manuelles par l'amont — chargingEnabled à FAUX — et arme un verrou de 300 s,
pendant que la dérogation continue de commander 9 A. Deux commandeurs pour un
organe, et six minutes de dérogation servie sur le papier dans une borne
éteinte. Seul le journal de divergence le voyait, sans pouvoir en dire la cause.

Deux autres corrections du même essai :

- DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ;
  ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et
  ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur
  était illisible.
- poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale
  quand le cycle lit l'horloge injectée : sans effet en production, où les deux
  sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le
  test, pas par la lecture.

Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une
PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et
ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas
sous test.

simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 11:43:39 +02:00

1889 lines
99 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# INTERFACE.md — API JSON-RPC du plugin `nymea-energy-plugin-nymea`
> Ce fichier fait autorité sur l'interface exposée par le plugin.
> Mettre à jour ce fichier à chaque modification de l'API.
>
> ⚠️ **UN REFUS ARRIVE EN `status: "success"`.** Le transport a réussi ; le verdict métier est
> dans **`params.energyError`**. Une sonde qui teste `status` annonce donc « accepté » sur une
> écriture **rejetée** — constaté le 2026-08-29 sur une sonde jetable, qui a conclu à un refus
> de `SetLoadConfig` là où il n'y en avait pas, et inversement.
>
> ```python
> rep = call('NymeaEnergy.SetLoadConfig', {...})
> ok = rep['params']['energyError'] == 'EnergyErrorNoError' # ← le verdict est ICI
> # rep['status'] == 'success' ne dit QUE « la requête a été traitée »
> ```
>
> C'est la convention JSON-RPC de nymea, pas une particularité de ce plugin — mais toute sonde
> écrite à la va-vite retombe dedans, et une sonde qui se trompe de champ produit un relevé qui
> a l'air d'un résultat. Même famille que le journal lu par un `sudo` qui échoue en silence
> (`AGENTS.md`, « Lire le journal de la box »).
> ## ⚑ UN CLIENT TESTE LA PRÉSENCE D'UN CHAMP, JAMAIS UNE VERSION
>
> *(Règle posée par l'agent app le 2026-08-30, en déplaçant son exception `EV_GRID_START` :
> il ne l'a pas retirée, il l'a mise sur la bonne frontière — elle survit là où la charge utile
> n'a pas de `counts`, c'est-à-dire sur les installations encore en `+etm2x`.)*
>
> **Son argument, et il vaut pour tout ce qui sera ajouté ici** : ce qui a rendu l'exception
> caduque n'est pas une **version**, c'est une **charge utile**. Un champ présent décrit ce que
> la box sait faire ; un numéro de version décrit ce qu'on croit qu'elle sait faire. La première
> frontière est stable, la seconde ne l'est pas — une box peut être rétrogradée, un paquet
> reconstruit, une branche déployée hors séquence.
>
> **Conséquence pour ce contrat** : chaque ajout se conçoit pour être détectable **par sa
> présence**, et chaque absence porte un sens écrit. C'est déjà le cas de `levels[]` (absent =
> box antérieure **ou** mode dégradé), `measurement.source` (`"none"` publié plutôt que la clé
> retirée), `draw` (absent = pas de plan), `rankOrigin` (absent = rien d'affirmé) et
> `decision.level` (présent exactement quand `levels[]` l'est).
>
> Les numéros de version cités dans ce document datent les changements — **ils ne sont pas des
> conditions à tester.**
> **Namespace :** `NymeaEnergy`
> **Versions enregistrées :** 0–8 (`registerExperienceHandler(..., 0, 8)`)
> **Transport :** WebSocket JSON-RPC 2.0, port 4444 (nymea standard)
---
## Enums
### `ChargingMode`
| Valeur | Description |
|---|---|
| `ChargingModeNormal` | **MANUEL** — la borne sort de l'arbitrage : le moteur ne commande **rien**. Seule la protection de surcharge la touche encore, et elle peut donc la couper. Ce n'est **pas** un « boost » : un boost commanderait le maximum |
| `ChargingModeEco` | Recharge sur **surplus** solaire uniquement |
| `ChargingModeEcoWithTargetTime` | Eco + deadline de fin (`endDateTime` + `targetPercentage`) |
| `ChargingModeEcoWithMinCurrent` | **Surplus + minimum réseau** — le surplus d'abord, et le complément **acheté au réseau** jusqu'au plancher de la borne (`maxChargingCurrent.minValue × phaseCount × 230 V`). Ne promet **pas** que le minimum sera servi : le rang, le plafond de soutirage et la réserve batterie peuvent l'empêcher, et c'est le **motif** qui le rapporte |
| `ChargingModeEcoMinWithTargetTime` | Surplus + minimum réseau + deadline |
> **`MinCurrent` nomme l'exigence de la BORNE, pas une promesse du mode.** Les 6 A sous lesquels
> elle refuse de charger sont une donnée de matériel. Le nom a longtemps laissé lire « le mode
> maintient un courant minimum » — ce qu'aucun chemin de code ne faisait : le mode était réécrit
> en `Eco` à chaque cycle chez l'amont, puis la branche qui l'appliquait est devenue
> inatteignable quand le waterfall a pris la borne. **Trois modes annoncés, deux implémentés, et
> celui qui manquait était celui que l'utilisateur croyait utiliser.** Voir
> `docs/DESIGN_MIN_RESEAU.md`.
>
> **Les mots « garanti » et « prioritaire » sont bannis** à propos de ce mode, dans le contrat
> comme à l'écran. Le premier promet une certitude que rien ne tient ; le second serait vrai face
> à tout le confort et faux face à un autre besoin éco mieux rangé — donc faux exactement dans le
> cas d'échec, celui qu'un libellé doit couvrir.
### `ChargingState` (lecture seule — calculé par le manager)
| Valeur | Description |
|---|---|
| `ChargingStateIdle` | Pas de recharge active |
| `ChargingStateSurplusCharging` | Recharge sur surplus solaire en cours |
| `ChargingStateSpotMarketCharging` | Recharge sur créneau spot market en cours |
| `ChargingStateTimeRequirement` | Recharge forcée pour atteindre la deadline |
### `ChargingActionIssuer`
| Valeur | Description |
|---|---|
| `ChargingActionIssuerUnknown` | Émetteur non identifié |
| `ChargingActionIssuerSurplusCharging` | Action émise par le surplus manager |
| `ChargingActionIssuerSpotMarketCharging` | Action émise par le spot market manager |
| `ChargingActionIssuerTimeRequirement` | Action émise pour honorer la deadline |
| `ChargingActionIssuerOverloadProtection` | Action émise par la protection surcharge |
### `EnergyError`
| Valeur | Description |
|---|---|
| `EnergyErrorNoError` | Succès |
| `EnergyErrorMissingParameter` | Paramètre obligatoire absent |
| `EnergyErrorInvalidParameter` | Valeur de paramètre invalide |
---
## Types
### `ChargingInfo`
Configuration d'une borne EV. Seuls les champs marqués `(writable)` peuvent être modifiés via `SetChargingInfo` — les autres sont ignorés ou read-only.
> ⚠️ **`SetChargingInfo` REMPLACE l'objet entier.** Un champ writable absent de l'appel n'est pas
> préservé : il retombe sur son **défaut** (colonne ci-dessous). Voir `NymeaEnergy.SetChargingInfo`.
| Champ | Type | Writable | Description |
|---|---|---|---|
| `evChargerId` | `Uuid` | — | Identifiant de la borne (obligatoire dans SetChargingInfo) |
| `assignedCarId` | `Uuid` | ✓ | Véhicule associé (optionnel) |
| `chargingMode` | `ChargingMode` | ✓ | Mode de recharge |
| `endDateTime` | `DateTime` | ✓ | Deadline cible (EcoWithTargetTime / EcoMinWithTargetTime) |
| `repeatDays` | `[Int]` | ✓ | Jours de répétition (**1=Lun … 7=Dim** ; hors [1, 7] → `EnergyErrorInvalidParameter`). Défaut : liste vide (pas de répétition) |
| `targetPercentage` | `Uint` | ✓ | Niveau batterie cible en %. Défaut : **80** ; > 100 → `EnergyErrorInvalidParameter` |
| `chargingState` | `ChargingState` | — | État calculé (lecture seule) |
| `spotMarketChargingEnabled` | `Bool` | ✓ | Activer la recharge sur spot market |
| `dailySpotMarketPercentage` | `Uint` | ✓ | % de la charge quotidienne à faire en heures spot bon marché |
### `ChargingAction`
Action envoyée au charger physique (présente dans les schedules).
| Champ | Type | Description |
|---|---|---|
| `chargingEnabled` | `Bool` | Activer/désactiver la recharge |
| `maxChargingCurrent` | `Double` | Courant max en Ampères |
| `desiredPhaseCount` | `Uint` | Nombre de phases souhaitées (1 ou 3) |
| `issuer` | `ChargingActionIssuer` | Origine de la décision |
### `ChargingSchedule`
Créneau de recharge planifié (résultat du calcul du manager).
| Champ | Type | Description |
|---|---|---|
| `evChargerId` | `Uuid` | Borne concernée |
| `startDateTime` | `DateTime` | Début du créneau |
| `endDateTime` | `DateTime` | Fin du créneau |
| `action` | `ChargingAction` | Action prévue sur ce créneau |
### `ScoreEntry`
Entrée de cotation horaire du spot market, avec pondération relative.
| Champ | Type | Description |
|---|---|---|
| `startDateTime` | `DateTime` | Début du créneau tarifaire |
| `endDateTime` | `DateTime` | Fin du créneau tarifaire |
| `value` | `Float` | Prix brut (€/MWh, selon provider) |
| `weighting` | `Float` | Pondération relative : 0.0 = le plus cher, 1.0 = le moins cher |
### `SpotMarketProviderInfo`
Fournisseur de données spot market disponible.
| Champ | Type | Description |
|---|---|---|
| `providerId` | `Uuid` | Identifiant du provider |
| `name` | `String` | Nom affiché |
| `country` | `Uint` | Pays (`QLocale::Country`) |
| `website` | `String` | URL du site du provider |
**Providers enregistrés :**
| Nom | `providerId` |
|---|---|
| aWATTar AT | `5196b3cc-b2ee-46d6-b63a-7af2cf70ba67` |
| aWATTar DE | `0ca6ad88-e243-438d-a0f8-986cecf61834` |
---
## Méthodes
> Notation : `o:` = paramètre optionnel. Tous les appels requièrent un token d'auth nymea.
---
### Overload protection
#### `NymeaEnergy.GetPhasePowerLimit`
Retourne la limite de courant par phase configurée.
**Params :** aucun
**Returns :**
```json
{ "phasePowerLimit": 25 }
```
> Valeur en **AMPÈRES par phase**, pas en watts — c'est la limite du branchement.
>
> **`0` = non configuré.** Ce que cela désactive, précisément, et la phrase précédente
> (« tout le smart charging est désactivé ») était **fausse depuis la transplantation du
> waterfall** :
>
> | | à `0` |
> |---|---|
> | cascade de surplus, paliers, planchers éco | **tournent normalement** — mesuré des semaines durant sur `.75` |
> | `draw.*` (autorisation de soutirage) | **inerte** : `drawCaps()` ne publie aucun plafond, `houseDrawMargin()` rend « sans objet » |
> | motif `DRAW_CAP` | **jamais émis** — un plafond publié serait une limite inventée |
> | délestage de surcharge | **ne fait rien** : `shedWaterfallForOverload()` sort à la première ligne, faute de seuil à faire respecter |
>
> **Conséquence à connaître** : à `0`, le moteur peut engager du soutirage sans borne — et il le
> fait depuis que les planchers éco achètent. C'est une valeur d'installation, pas un défaut à
> `0` près : sans elle, la couche de protection existe et n'a rien à protéger.
---
#### `NymeaEnergy.SetPhasePowerLimit`
Définit la limite de courant par phase (en Ampères). `0` désactive le smart charging.
**Params :**
```json
{ "phasePowerLimit": 25 }
```
**Returns :**
```json
{ "energyError": "EnergyErrorNoError" }
```
---
### Paramètres de recharge
#### `NymeaEnergy.GetAcquisitionTolerance`
Retourne le seuil de surplus nécessaire pour démarrer une recharge Eco.
**Params :** aucun
**Returns :**
```json
{ "acquisitionTolerance": 0.5 }
```
> Valeur entre 0.0 et 1.0. Plus la valeur est haute, plus il faut de surplus.
---
#### `NymeaEnergy.SetAcquisitionTolerance`
**Params :**
```json
{ "acquisitionTolerance": 0.5 }
```
**Returns :**
```json
{ "energyError": "EnergyErrorNoError" }
```
> Retourne `EnergyErrorInvalidParameter` si hors de `[0.0, 1.0]`.
---
#### `NymeaEnergy.GetBatteryLevelConsideration`
Retourne le facteur de prise en compte du stockage batterie dans le calcul du surplus.
**Params :** aucun
**Returns :**
```json
{ "batteryLevelConsideration": 0.9 }
```
> 0.0 = stockage ignoré, 1.0 = surplus réduit intégralement de la puissance de charge batterie.
---
#### `NymeaEnergy.SetBatteryLevelConsideration`
**Params :**
```json
{ "batteryLevelConsideration": 0.9 }
```
**Returns :**
```json
{ "energyError": "EnergyErrorNoError" }
```
> Retourne `EnergyErrorInvalidParameter` si hors de `[0.0, 1.0]`.
---
#### `NymeaEnergy.GetLockOnUnplug`
Retourne si la borne doit être verrouillée à la déconnexion du véhicule.
**Params :** aucun
**Returns :**
```json
{ "lockOnUnplug": false }
```
---
#### `NymeaEnergy.SetLockOnUnplug`
**Params :**
```json
{ "lockOnUnplug": true }
```
**Returns :**
```json
{ "energyError": "EnergyErrorNoError" }
```
---
### Configuration EV
#### `NymeaEnergy.GetChargingInfos`
Retourne la configuration de recharge de toutes les bornes, ou d'une seule.
**Params :**
```json
{ "o:evChargerId": "uuid-de-la-borne" }
```
**Returns :**
```json
{
"chargingInfos": [
{
"evChargerId": "uuid-de-la-borne",
"assignedCarId": "uuid-du-car",
"chargingMode": "ChargingModeEco",
"chargingState": "ChargingStateSurplusCharging",
"endDateTime": "2025-06-15T08:00:00+02:00",
"repeatDays": [1, 2, 3, 4, 5],
"targetPercentage": 80,
"spotMarketChargingEnabled": false,
"dailySpotMarketPercentage": 0
}
]
}
```
---
#### `NymeaEnergy.SetChargingInfo`
Met à jour la configuration d'une borne. Seuls les champs `USER true` sont modifiables (voir type `ChargingInfo`) ; les autres (`chargingState`) sont ignorés.
⚠️ **L'objet donné REMPLACE le stocké — l'appel n'est pas partiel.** Un champ writable omis
**retombe sur son défaut**, il n'est pas préservé :
| Champ omis | Valeur obtenue |
|---|---|
| `assignedCarId` | aucune (null) |
| `chargingMode` | `ChargingModeNormal` |
| `endDateTime` | aucune |
| `repeatDays` | liste vide |
| `targetPercentage` | **80** |
| `spotMarketChargingEnabled` | `false` |
| `dailySpotMarketPercentage` | `0` |
Et l'appel réussit : `EnergyErrorNoError`. Rien ne signale la perte.
**Constaté sur le banc `.75` le 2026-08-26** : un appel ne portant que `chargingMode` a porté
`targetPercentage` de 0 à 80.
**Le geste correct est lire → modifier → renvoyer complet** : `GetChargingInfos`, changer le
champ voulu, réémettre l'objet entier. Le renvoyer tel quel est sûr — `chargingState`, en lecture
seule, est ignoré.
**Params :**
```json
{
"chargingInfo": {
"evChargerId": "uuid-de-la-borne",
"chargingMode": "ChargingModeEcoWithTargetTime",
"endDateTime": "2025-06-15T08:00:00+02:00",
"targetPercentage": 80
}
}
```
**Returns :**
```json
{ "energyError": "EnergyErrorNoError" }
```
---
#### `NymeaEnergy.GetChargingSchedules`
Retourne le planning de recharge calculé (toutes les bornes, horizon ~24h), **et** l'état du
mode dégradé L2.
**Params :** aucun
**Returns :**
```json
{
"chargingSchedules": [
{
"evChargerId": "uuid-de-la-borne",
"startDateTime": "2025-06-15T02:00:00+02:00",
"endDateTime": "2025-06-15T04:30:00+02:00",
"action": {
"chargingEnabled": true,
"maxChargingCurrent": 16.0,
"desiredPhaseCount": 1,
"issuer": "ChargingActionIssuerSpotMarketCharging"
}
}
],
"degradedMode": false
}
```
> `degradedMode` *(bool, [ETM])* : `true` quand le watchdog L2 a basculé en repli (compteur muet
> > 90 s, planification suspendue). Il était auparavant **poussé en notification seule** par
> `ChargingSchedulesChanged` : un client connecté après la bascule ne pouvait pas savoir dans
> quel état il se trouvait. Règle de ce dépôt : **tout élément runtime a un `Get*` ET un
> `*Changed`**, jamais une notification seule.
---
#### `NymeaEnergy.GetLoadConfig` / `SetLoadConfig` — champ `domain`
`LoadConfig` porte un champ optionnel `domain` : catégorie d'**intention** déclarée par
l'installateur, énumération **fermée** — `ecs` · `ev` · `heating` · `hvac` · `battery`.
- **Pure métadonnée.** L'arbitre ne la lit pas ; aucun comportement moteur n'en dépend.
- **Un code, jamais un libellé** — même raison que pour `decision.code` ci-dessous.
- **Absente ou vide = charge « non classée ».** Le plugin ne la range pas d'office : un
classement inventé serait indistinguable d'un classement choisi.
`GetLoadConfig` sérialise via le **méta-objet**, donc la propriété sort **toujours**, à `""`
pour une charge non classée — comme `"sgReady": {}` ou `"powerLevels": []`. C'est ce qui rend
l'aller-retour `Get` → `Set` possible (corollaire LM-302). La forme **persistée** omet la clé.
Un client traite `""` **et** l'absence comme « non classée ».
- `SetLoadConfig` **rejette en bloc** (`EnergyErrorInvalidParameter`) une valeur hors
énumération.
Détail complet : `docs/INTERFACE_etmvariableload.md` §4.
---
#### `LoadConfig` — champ `o:minPowerW` (plancher de modulation)
Le domaine d'une consigne n'est pas `[0, maxPowerW]` : c'est **`{0} ∪ [minPowerW, maxPowerW]`**,
un ensemble **discontinu**. C'est ce que fait le matériel réel — une borne ne charge pas sous
6 A, une PAC n'a pas d'« état 3 à moitié ».
- **Déclaré pour un seul mécanisme.** `etmvariableload` en mode `dynamic`, où rien ne permet de
deviner le plancher. Sur `relay-router` et en mode `fixed`, il **se dérive** — plus petit
palier non nul — et `SetLoadConfig` **refuse** de le déclarer en double : deux sources d'une
même vérité divergent, et rien ne dirait laquelle fait foi.
- **Refusé aussi s'il dépasse `maxPowerW`** : le domaine serait réduit à `{0}` et la charge ne
démarrerait jamais, sans que rien ne dise pourquoi.
**Conséquence sur l'allocation** — et c'est elle qui compte : quand le budget disponible est
**sous le plancher**, la charge reçoit **0 W et le budget PASSE à la suivante**. Sans cette
règle, une charge de rang 1 au plancher élevé retiendrait en otage un budget qu'elle ne peut
pas utiliser, et tout le waterfall en aval resterait à sec pendant qu'elle-même ne démarre pas.
> **« Il n'y a pas assez » contre « il y en a, mais pas au bon format ».** C'est toute la
> distinction que le motif `BELOW_MIN_POWER` porte, et elle décide du geste :
> `SURPLUS_INSUFFICIENT` et `SG_NORMAL` disent qu'il n'y a **rien** (budget ≤ 0) — on attend le
> soleil. `BELOW_MIN_POWER` dit qu'il y a du budget mais qu'**aucun palier ne sait le prendre**
> — on relit une fiche technique.
> **Le cas qui rend la règle nécessaire** : 2 kW de surplus, deux bornes à 1,38 kW de plancher
> (6 A monophasé). Réparties également, 1 kW chacune, **aucune ne démarre et les 2 kW partent au
> réseau**. En cascade, la première prend 1,38 kW et charge ; la seconde reçoit le reliquat,
> n'atteint pas son plancher, et reçoit 0.
---
#### `LoadConfig` — champ `o:rankOrigin`
D'où vient le **rang** d'une charge : d'un choix, ou d'un défaut d'insertion. *(§12 / LM-1209-c.)*
```jsonc
{ "id": "…", "priority": 3,
"rankOrigin": "auto" } // o: — "auto" | "user" | absent
```
| Valeur | Ce qu'elle affirme | Qui l'écrit |
|---|---|---|
| `"auto"` | l'entrée a été **créée d'office** par le moteur, et **personne n'a jamais classé cette charge** — son rang est un défaut d'insertion (queue de liste) | **le moteur seul** |
| `"user"` | le rang est **assumé** — la voie pour lever la marque **sans déplacer** la charge | un client |
| *absent* | **rien n'est affirmé** — ni « choisi », ni « par défaut » | — |
- **Un client ne peut pas écrire `"auto"`** là où le moteur ne l'a pas posé : ce serait fabriquer
une affirmation sur un geste du moteur, et `SetLoadConfig` refuse. L'aller-retour **verbatim**
d'une entrée qui la porte déjà reste licite — sans quoi la neutralité `Get` → `Set` serait
rompue.
- **La marque tombe quand le RANG change, jamais parce qu'une écriture arrive.** `SetLoadConfig`
remplace en bloc : réordonner une **autre** charge renvoie forcément celle-ci, et l'omission de
la clé **ne lève rien**. Pour lever sans déplacer, envoyer `"user"` explicitement.
- Quand elle tombe, elle tombe vers l'**absence** — le moteur constate un déplacement, il ne sait
pas si un humain ou un script l'a voulu.
> **Pourquoi une énumération et pas un booléen.** `rankIsDefault: false` *affirmerait* que le rang
> a été choisi, alors que c'est précisément ce qu'on ignore d'une configuration héritée.
> **L'absence doit pouvoir porter le « on ne sait pas »**, et un booléen ne sait pas se taire.
> Même motif de conception que `measurement.source` : publier le régime, et ne jamais laisser un
> silence passer pour une affirmation.
> **Ce qu'un client en fait** : afficher « rang par défaut » **uniquement** sur un `"auto"`
> explicite — une entrée créée d'office prend une place dans l'ordre de service, et cela ne doit
> pas se produire en silence. Sur tout le reste, ne rien affirmer.
---
#### `LoadConfig` — champs `o:meterThingId` et `o:sensorThingId`
Deux Things de **mesure**, optionnels, de même forme. Ils décrivent ce qu'on **observe** d'une
charge — jamais ce qu'on lui **commande**.
```jsonc
{ "id": "chauffe-eau", …,
"meterThingId": "{uuid}", // o: — compteur dédié à CETTE charge
"sensorThingId": "{uuid}" } // o: — sonde du service rendu (température)
```
- **L'arbitre ne s'en sert pas pour décider.** Les adaptateurs ne voient même pas ces Things :
ils sont résolus au moment de **publier** la télémétrie, et nulle part ailleurs. « Aucun effet
sur les décisions » est donc vrai par construction, pas par discipline.
- **Choix par INTERFACE nymea, jamais par plugin.** Le compteur se prend parmi les Things
portant `energymeter` / `smartmeter`, la sonde parmi celles portant la température. Nommer un
plugin rendrait le modèle faux au prochain qui rend le même service.
- **Aucun contrôle d'existence à l'écriture.** Un `ThingId` encore inconnu est accepté : refuser
imposerait un ordre aux opérations — déclarer le compteur avant la charge — que rien ne
justifie. Un id qui ne résout pas fait **retomber sur la mesure de l'appareil** si elle
existe (`measurement.source: "device"`), sinon produit `"none"` — jamais une configuration
invalide.
- **`meterThingId` n'est plus REQUIS pour obtenir une mesure** *(LM-1105, 2026-08-28)*. Quand le
Thing piloté porte lui-même l'état de puissance — borne, `etmvariableload`, relais mesurant —
le moteur le lit sans configuration, et publie `measurement.source: "device"`. Le champ reste
**autorisé partout** et **prioritaire** quand il est renseigné : un compteur posé exprès est
le seul à pouvoir contredire un appareil qui se déclare. Ce qui change est qu'il cesse d'être
une corvée là où le moteur peut déduire — un client avisé le **masque par défaut** sur les
charges dont `source` vaut déjà `device`, sans l'interdire. **Cette règle d'affichage se lit
sur `measurement.source`, jamais sur `adapter`** : voir la note sous `measurement`.
- **`priority` est UNIQUE, et le contrat le garantit** *(LM-1209-a)*. `SetLoadConfig` **refuse**
en bloc un ensemble où deux charges portent le même rang. Ce que l'égalité coûte n'est PAS le
déterminisme — le waterfall trie sur `(rang, identifiant)`, donc l'ordre est stable — c'est
qu'il devient **non réglable** : il se décide sur des identifiants, c'est-à-dire sur personne.
> **Une configuration HÉRITÉE à rangs doublés est conservée, jamais amputée.** Le contrôle
> d'unicité ne s'applique qu'à l'écriture ; au chargement, le doublon est signalé au journal et
> les deux charges restent. La première écriture le répare. L'inverse ferait disparaître des
> charges au redémarrage — de la configuration *et* de l'arbitrage, sans état sûr.
- **Ni l'un ni l'autre n'est exclusif**, contrairement à `relays[]`. Ils mesurent, ils ne
commandent pas : `validateSet()` les ignore, et deux charges peuvent partager une sonde de
pièce ou un compteur de tableau divisionnaire.
- **Publiés toujours, persistés seulement s'ils valent quelque chose** — exactement comme
`domain`, et pour la même raison (corollaire LM-302). `GetLoadConfig` sérialise via le
méta-objet : les deux clés **sortent toujours**, à `""` quand rien n'est rattaché, comme
`sgReady: {}` ou `powerLevels: []`. La forme **persistée**, elle, omet la clé. C'est ce qui
rend l'aller-retour `Get` → `Set` neutre : un client qui renvoie verbatim ce qu'il a lu ne
déclare rien par accident. **Un client traite `""` et l'absence comme « aucun Thing
rattaché ».**
> **Le compteur VÉRIFIE, il ne budgète pas.** Commandé 3 000 W, mesuré 0 W sur plusieurs
> cycles : la charge ne fait pas ce qu'on lui demande. La mesure n'est **pas** injectée dans le
> budget, pour deux raisons dont chacune suffit — elle est **en retard sur la commande**
> (scrutation Modbus, moyennage du compteur), et l'y mettre brute réintroduirait l'oscillation
> que le recrédit anti-clignotement a supprimée ; et la charge est **déjà comptée** dans le
> compteur racine, dont ce compteur-ci n'est qu'une **décomposition**, pas une source à
> additionner.
---
### Télémétrie d'arbitrage
#### `NymeaEnergy.GetLoadTelemetry`
Instantané de l'état runtime de l'arbitrage : budget de surplus, allocation par charge,
disponibilité, code de défaut, verrou actif en **secondes restantes**, charge utile du
mécanisme, et **motif de décision sous forme de code**.
**Params :** aucun
**Returns :** voir la charge utile ci-dessous — **identique** à celle de la notification
`LoadTelemetryChanged`. Le `Get` ne recalcule rien : il rend l'objet qui a été publié.
```jsonc
{
"timestamp": "2026-08-25T14:32:07Z", // fin du DERNIER cycle d'arbitrage — o:, voir plus bas
"degradedMode": false,
"budget": { // o: — absent en mode dégradé, voir plus bas
"surplusW": 8348, // budget entrant du waterfall non-EV (net signé − réservation EV)
"evReservedW": 0, // puissance EV commandée ce cycle, pas encore vue au compteur
"recreditedW": 1500, // recrédits anti-clignotement rendus aux charges (correction B)
"allocatedW": 3500, // somme des allocations du cycle
"remainingW": 6348 // résidu de fin de cascade
},
"loads": [
{
"loadId": "ecs-chauffe-eau",
"allocatedW": 3500, // ce que le waterfall a ALLOUÉ (pas la mesure)
"levels": [ // une entrée par passe RÉELLEMENT parcourue
{ "level": "comfort", "targetW": 3500, "funding": "surplus" }
],
"available": true,
"faultCode": "WRITE_FAILED", // o: — absent = aucun défaut
"lock": { "kind": "minOff", "remainingS": 42 }, // o: — absent = aucun verrou actif
"mechanism": { "kind": "relay", "stageW": 3500, "maxStageW": 4500 }, // union discriminée
"decision": { "code": "LOCK_MIN_OFF",
"params": { "budgetW": 8348, "requestedW": 3500 } }
}
]
}
```
**Identité vérifiable :** `surplusW + recreditedW − allocatedW == remainingW`.
`recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre consommation de
début de cycle avant d'arrondir (anti-clignotement, `AGENTS.md` correction B). Sans ce terme,
`surplus − alloué == restant` serait faux **dès qu'une charge consomme déjà**, c'est-à-dire dans
le cas normal.
**Champs omis, jamais nuls** (règle maison, cf. `GetEnergyRatios`) :
- `timestamp` **absent** = aucun cycle d'arbitrage depuis le démarrage du service. En forger un
se lirait « cycle exécuté, rien à allouer » — exactement le mensonge que ce champ existe pour
empêcher. Présent, c'est la **fin du dernier cycle** : c'est la preuve de fraîcheur.
- `budget` **absent** = mode dégradé L2 : la planification est **suspendue**, il n'existe aucun
budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer ».
**Charges présentes.** Toutes les charges **arbitrées par le waterfall** : `relay-router`,
`etmvariableload`, `sg-ready` — et, **depuis 3g-1, les bornes de recharge**.
> **Changement de contrat en 3g-1.** Le VE ne figurait pas dans `loads[]` : il était décidé en
> amont, et sa puissance seulement *réservée* dans `budget.evReservedW`. Il y figure désormais,
> son allocation décrémente la cascade comme celle de n'importe quelle charge, et la somme des
> `allocatedW` **des charges financées au surplus** retombe sur `budget.allocatedW` — ce qui
> n'était pas vrai avant.
>
**`levels[]`** — **une entrée par passe RÉELLEMENT parcourue**, et c'est là que vit le
**financement**. *(R2 de la maquette des deux passes, 2026-08-29.)*
| Clé | Sens |
|---|---|
| `level` | `"eco"` ou `"comfort"` — quel BESOIN cette entrée sert |
| `targetW` | ce que le niveau a **DÉCIDÉ** (R4) — à ne pas confondre avec `mechanism.*`, qui dit ce qui a été **appliqué** |
| `funding` | `"surplus"` ou `"grid"` — l'ORIGINE des watts de ce niveau. `"grid"` **dès qu'un seul watt est acheté** (LM-1013) |
| `counts` | `{ destination → watts }` — le REGISTRE où ils sont comptés. **`Σ counts == targetW`, toujours** |
**R1 — `levels[]` ne contient que les passes RÉELLEMENT parcourues, et l'absence n'est pas le
zéro.** Les deux moitiés se répondent, et publier l'une sans l'autre les rendrait
indistinguables :
| | |
|---|---|
| **niveau absent** | la passe **n'a pas été parcourue** — une charge sans obligation éco n'aura jamais d'entrée `"eco"` |
| **niveau à `targetW: 0`** | la passe **a été parcourue**, elle n'avait rien à prendre — la charge a été examinée, et son motif le dit |
> Un zéro forgé à la place d'une absence détruirait la première information ; une absence à la
> place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours parcourue dès
> qu'une charge est arbitrée : c'est le **mode dégradé** qui porte le cas « aucune passe », la
> planification y étant suspendue — `levels[]` y est **absent**, et `decision.level` avec lui.
>
> Épinglé par `testLevelAbsenceAndZeroDifferInMeaning`, **vérifié échouant** dans les deux sens.
**R7 — `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents.**
`decision` reste au niveau de la charge — un client qui l'a toujours lu continue de le lire — mais
il reflète le motif d'**un** niveau. Sans le dire, chaque client réinventerait sa règle de fusion
(« le motif du dernier niveau servi ? du premier refusé ? ») et deux écrans afficheraient deux
motifs pour le même cycle. Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas —
**c'est précisément pourquoi le champ arrive maintenant** : quand elle se posera, les clients le
liront déjà.
> **Aujourd'hui il y a exactement UN niveau, et c'est `"comfort"`.** La passe unique
> d'aujourd'hui est celle qui, au §10, servira le confort depuis le surplus ; la passe éco est
> la nouveauté du lot, pas la passe existante rebaptisée. Une charge **sans obligation éco**
> n'aura jamais d'entrée `"eco"` à zéro : **l'absence est l'information**, et un zéro forgé la
> détruirait.
>
> **`levels[]` entièrement absent** = box antérieure à `+etm30`, ou **mode dégradé** — sans
> plan, il n'y a pas de financement.
> ⚠️ **`funding` N'EXISTE PLUS au niveau de la charge**, et il ne revient pas en résumé. La
> raison n'est pas la propreté : au §10 la même charge sera financée **au réseau pour son éco et
> au surplus pour son confort dans le même cycle**, et un résumé n'aurait alors **aucune valeur
> vraie** à porter — « mixte » serait un troisième code que personne n'a demandé, « grid si l'un
> l'est » une convention que chaque client redériverait autrement. Deux endroits pour un fait
> divergent.
>
> **Pourquoi `decision` reste, lui, au niveau de la charge** : parce qu'on sait dire de quel
> niveau il parle (`decision.level`, R7). On conserve un champ hérité **quand on peut en dire
> quelque chose de vrai**, jamais par symétrie.
### `draw` — le registre des watts ACHETÉS
Publié **dans la même trame que `budget`** : les deux décrivent le même cycle, et les lire à deux
instants ferait comparer deux mondes.
```jsonc
// SANS plafond de soutirage réglé — quatre champs ABSENTS, jamais à 0 :
"draw": { "committedW": 1400 }
// AVEC un plafond en vigueur :
"draw": { "committedW": 1400, "authorisedW": 3450, "remainingW": 2050,
"binding": "connection", "perPhaseBound": true }
```
**`committedW` est publié TOUJOURS** : le registre des watts achetés existe à chaque cycle, et
zéro y est une valeur. **Les quatre autres ne sont publiés QUE si un plafond est en vigueur.**
C'est la même règle des deux côtés — *le zéro est une valeur quand la grandeur existe, l'absence
en est une quand elle n'existe pas*. Un `authorisedW: 0` assorti d'un `binding` par défaut
afficherait un plafond de soutirage **qui n'existe pas, avec sa source**.
| Champ | Ce qu'il dit |
|---|---|
| `authorisedW` | Autorisation du cycle, **SIGNÉE**. Négative ⇒ la maison est **déjà au-delà** du plafond, et l'ampleur du dépassement se lit directement. L'écrêter à zéro confondrait « pile à la limite » et « on dépasse de 1200 W ». |
| `remainingW` | `authorisedW − committedW`. **L'identité tient toujours** — c'est ce qui rend la charge utile vérifiable. Peut être négative : les bornes VE engagent du soutirage borné par LEUR phase, pas par la marge de la maison. |
| `binding` | Code opaque de la source qui borne : `connection` \| `gridOperator` \| `selfImposed`. Ce qu'on a le droit de faire d'un dépassement en dépend. |
| `perPhaseBound` | Le plafond qui borne est-il **par phase** ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. |
| `bindingPhase` | **Laquelle** — `A`, `B` ou `C`. Absent si le plafond est un total. Sans lui, l'écran ne peut dire que « bridé » **sans pouvoir dire par quoi**. |
> ### ⚠️ Une installation qui EXPORTE peut être bridée — et l'écran ne doit jamais l'agréger
>
> Cas mesuré (`LM-1210-a`) : phase B saturée à 30 A sous un plafond de 25, phases A et C en
> export, **compteur global en export**. La marge retenue est celle de B — **négative** — pendant
> que le total afficherait un confort trompeur.
>
> C'est contre-intuitif au point qu'un installateur **cherchera l'erreur ailleurs**. D'où deux
> interdits pour l'écran :
>
> 1. **ne jamais présenter une marge globale** — elle rassurerait à tort ;
> 2. **ne jamais présenter la meilleure phase** — c'est la pire qui décide.
>
> `bindingPhase` est ce qui rend la phrase juste possible : « bridé sur la phase B », et non
> « bridé » tout court. La phase figure aussi dans le motif `DRAW_CAP`, mais celui-ci n'est émis
> **que si une charge est refusée** : un cycle qui borde sans rien refuser laisserait l'écran
> muet. **Les deux endroits sont nécessaires et ne se recouvrent pas.**
### `progress.purchasedWh` et `progress.measuredSince`
```jsonc
"progress": { "regime": "measured", "targetWh": 4000, "deliveredWh": 500,
"purchasedWh": 3200, "measuredSince": "2026-09-01T09:14:00Z" }
```
**`purchasedWh` est publié dans TOUS les régimes, `unmeasurable` compris.** Ce n'est pas une
entorse à la règle qui interdit `deliveredWh` là-bas — c'en est le complément exact :
| | ce que c'est | connu sans mesure ? |
|---|---|---|
| `deliveredWh` | ce qui a été **livré à la charge** | **non** — c'est une observation qu'on ne peut pas faire |
| `purchasedWh` | ce qui a été **acheté au réseau** | **oui** — c'est notre propre commande |
Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriquée à partir du taux.
C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule de période
(`LM-1014-b`).
> ### Un SECOND usage, sous `measured` cette fois — et il n'est pas redondant
>
> Le champ a été demandé pour `unmeasurable`. Mesuré sur le banc, il gagne un usage que personne
> n'avait prévu : sous `measured`, `purchasedWh` **accumule** — 0 → 9,15 → 18,33 → 27,52 Wh —
> pendant que `deliveredWh` peut rester **plat**.
>
> **Cette divergence est un contrôle que l'écran n'aurait pas su fabriquer.** De l'acheté qui
> monte avec du livré plat signifie l'une de deux choses, et les deux méritent d'être vues :
> soit **la charge ne consomme pas ce qu'on paie**, soit **le compteur ne compte pas**.
>
> C'est écrit ici parce que le jour où les deux valeurs coïncideront, quelqu'un jugera le champ
> redondant et proposera de le retirer. Il ne l'est pas : **c'est précisément quand ils
> divergent que la paire devient informative**, et un champ qui ne sert que dans l'anomalie n'a
> pas l'air utile le reste du temps.
> ### `measuredSince` ferme un piège d'affichage, et il faut savoir lequel
>
> Quand une obligation bascule de `unmeasurable` à `measured` **en cours de période**, la base
> d'avancement est capturée à cet instant : `deliveredWh` repart de 0 pendant que `targetWh`
> reste la cible **entière**. Une barre afficherait alors **« 0,5 sur 4,0 »** après une bascule
> où 3 kWh ont pu être achetés — crédible, et faux.
>
> **Rebaser `targetWh` serait pire** : on perdrait l'obligation réelle — l'utilisateur a demandé
> 4 kWh, pas 1 — et l'écran ne pourrait plus dire ce qui reste dû.
>
> `measuredSince` prend la seconde sortie : l'écran dit **« mesuré depuis 09:14 »**, et
> `purchasedWh` couvre la période d'avant. **Les deux se répondent**, et ensemble ils disent la
> vérité complète. Le champ est **absent** tant que rien n'a jamais été mesuré dans la période.
>
> **`measuredGaps` — présent seulement s'il y a eu des trous.** `measuredSince` date le PREMIER
> instant mesuré de la période ; sur une borne dont le lien bat, la continuité qu'il suggère est
> fausse. Ce compteur autorise la phrase honnête : **« mesuré par intermittence depuis 09:14 »**.
>
> **`deliveredWh` survit aux trous** : les tronçons mesurés d'une même période s'**additionnent**
> (`LM-1014-c`). Sans cela, chaque retour de lien effaçait ce qui précède — l'écran
> sous-estimait, et la passe éco **rachetait le déjà livré**.
### `progressMeasurable` / `progressUnmeasurableCause` — dans `GetLoadConfig`, pas dans la télémétrie
```jsonc
// GetLoadConfig → loadConfigs[]
{ "id": "…", "adapter": "relay-router", …,
"progressMeasurable": false, "progressUnmeasurableCause": "meterWithoutEnergy" }
```
**Pourquoi ici et pas dans `loads[]`.** `measurement` n'existe que pour les charges arbitrées.
Or une charge qu'on **déclare** est précisément celle qui n'y est pas encore — pas arbitrée, ou
créée à l'instant. L'écran serait muet **au moment exact où il doit parler** : celui où
l'installateur saisit une obligation qui, sans mesure, achètera à l'aveugle tous les jours.
| Cause | Ce qu'elle veut dire | Le geste |
|---|---|---|
| `noMeter` | aucun compteur rattaché | **réparable en une manipulation** |
| `meterWithoutEnergy` | le compteur ne cumule pas l'énergie | **à remplacer** |
| `noSessionEnergy` | la borne ne compte pas sa session | **rien à faire — c'est le matériel** |
Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Même structure que les
trois sources de `DRAW_CAP` : trois causes, trois gestes.
> **`meterThingId` ne permet PAS de le déduire.** Un compteur peut publier `currentPower` **sans**
> `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. Le champ est renseigné, le
> compteur existe, et pourtant rien ne cumule : **la puissance ne suffit pas, il faut une
> énergie**. Un client qui déduirait la mesurabilité de la présence du champ se tromperait
> exactement sur ce cas.
>
> `progressMeasurable` est en `r:` — **lecture seule**. C'est un constat sur le matériel, pas un
> réglage : un client qui l'écrirait affirmerait quelque chose sur une ThingClass.
### `command` — l'écart entre le commandé et le mesuré
Publié **seulement** quand une source mesure (`measurement.source` ≠ `none`) **et** qu'une
commande est partie. Absent sinon — pas `divergent: false`, qui affirmerait une concordance
qu'on ne peut pas constater. Une charge jamais commandée n'est pas une charge commandée à zéro.
```jsonc
"command": { "expectedW": 3680, "observedW": 800, "divergent": true, "since": "2026-09-01T…" }
```
**Ce que l'écart vaut dépend de la source, dans les DEUX sens :**
| | concordance | divergence |
|---|---|---|
| `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même |
| `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage |
> **Un écart n'est PAS un défaut, et ce champ ne le qualifie pas.** Une borne qui n'obéit pas
> parce que le véhicule limite, parce que le câble est en 16 A, ou parce qu'elle est en mode
> manuel, produit **le même nombre** qu'une commande perdue. L'écart se publie, il ne se
> diagnostique pas : **la cause n'est pas dans les watts.** Un écran qui en ferait une alerte
> accuserait des installations saines.
**`since` est ce qui rend le champ utile.** Sans lui, une divergence permanente ne se distingue
pas d'un déphasage d'un cycle entre l'écriture et sa mesure — et c'est toute la différence entre
un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait indéfiniment parce
que plus personne ne commandait.
**`divergent` est publié plutôt que laissé au client** : la tolérance (bruit de mesure, arrondi
à l'ampère, déphasage d'un cycle) est une connaissance du moteur. La laisser au client ferait
réinventer un seuil par écran, et deux écrans ne diraient pas la même chose du même cycle.
> ### Le point de rupture se lit sur `DRAW_CAP`, jamais sur un solde tombé à zéro
>
> `draw.remainingW` à 0 dit qu'il ne reste rien **à cet instant** — ce qui arrive aussi quand
> tout a été alloué normalement, sans qu'aucun plafond n'ait rien refusé. Un écran qui guetterait
> ce solde signalerait une rupture là où il n'y a qu'un budget consommé, et manquerait la vraie
> rupture le cycle où le plafond mord sans que le solde soit exactement nul.
>
> **`DRAW_CAP` est le signal**, et lui seul : il n'est émis que lorsqu'une décision a réellement
> été refusée par un plafond, et il porte **laquelle** — `binding` / `source` distingue le
> gestionnaire de réseau, la protection du branchement et la limite auto-imposée. Trois causes,
> trois gestes, et aucun ne se déduit d'un nombre à zéro.
> ### ⚠️ `budget.remainingW == 0` et `draw.remainingW != 0` dans la MÊME trame : c'est NORMAL
>
> Sous la **réserve batterie**, le budget de surplus est **annulé** — pas réduit (`LM-1203-b` :
> servir « un peu » retarderait la recharge sans probablement démarrer personne). Mais la réserve
> n'annule **que le surplus**. L'**autorisation de soutirage** est une contrainte physique du
> branchement : aucune règle de stockage ne la modifie.
>
> Un client verra donc, légitimement, un budget à zéro à côté d'une autorisation intacte. **Ce
> n'est pas une incohérence**, et il aurait raison de la signaler si personne ne la lui avait
> dite — c'est pourquoi elle est écrite ici, et non en note de bas de page. Les deux grandeurs
> répondent à deux questions différentes : « puis-je dépenser sans acheter ? » et « le
> branchement supporte-t-il que j'achète ? »
>
> Épinglé par `testDrawAuthorisationSurvivesTheBatteryReserve`, qui dit aussi à quelle condition
> il devient faux : le jour où l'on déciderait qu'une batterie basse interdit d'acheter au
> réseau — une décision de modèle, pas une correction.
> **Ce qu'il compte** : ce que le moteur a **décidé d'acheter** selon son modèle de financement,
> pas ce que le compteur verra passer. Pour une action du proxy — échéance, tarif dynamique —
> **toute** l'allocation est comptée comme achetée : l'échéance décide de charger qu'il y ait du
> soleil ou non, et aucun partage n'est calculé. Là où le moteur **sait** partager, il partage :
> `EV_GRID_START` ne verse ici que sa part réseau, sa part surplus allant dans
> `budget.allocatedW`.
>
> **Omis en mode dégradé**, comme `budget` : sans plan, il n'y a pas de soutirage décidé.
### ⚠️ `budget.evReservedW` n'est PAS un registre — et ne se réconcilie jamais
*(Définition demandée par l'app le 2026-08-30, qui l'affichait sous « Réservé au véhicule » — un
libellé qui affirme une réservation là où il y a une correction.)*
**`evReservedW` est une CORRECTION du budget de surplus.** Il vaut la somme des puissances
**commandées mais pas encore visibles au compteur** pour les charges servies au réseau. Il **tend
vers 0** à mesure que la mesure rattrape la commande, **sans qu'aucune décision n'ait changé** :
c'est un rattrapage de **latence**, pas un montant mis de côté.
| | `budget.allocatedW` | `budget.evReservedW` | `draw.committedW` |
|---|---|---|---|
| Nature | registre d'allocation (surplus) | **correction** de budget | registre des watts achetés |
| Réconciliable | oui | **non, et jamais** | oui |
| Varie sans décision | non | **oui** — décroît quand la mesure rattrape | non |
> **Ne l'additionnez à rien.** Le sommer avec des allocations compare deux natures, et le total
> obtenu serait **vrai au premier cycle après un changement, faux après convergence** — donc
> juste dans un test portant sur un cycle isolé, et faux chez le client. Une identité dépendante
> du temps est **pire que pas d'identité** : elle porte le sceau d'une identité publiée.
>
> **Depuis `1.15.2+etm31` il n'a plus qu'UNE nature.** Il recevait aussi la part réseau
> d'`EV_GRID_START` — une vraie tranche d'allocation — ce qui rendait toute définition du champ
> fausse pour l'un des deux termes. Cette part est passée dans `draw.committedW`.
### Les TROIS nombres d'une charge — décidé, appliqué, mesuré
*(Rendu explicite le 2026-08-30, après qu'une prémisse de la maquette app eut confondu les deux
premiers. Trois nombres coexistent pour une même charge dans un même cycle, ils disent trois
choses différentes, et **aucun ne se déduit d'un autre**.)*
| | Où | Ce qu'il dit | Ce qu'un écart avec le suivant signifie |
|---|---|---|---|
| **DÉCIDÉ** | `allocatedW`, et `levels[].targetW` | ce que la cascade a **alloué** | → l'**arrondi** du mécanisme : 1 400 W décidés tombent sur un palier de 1 500 W |
| **APPLIQUÉ** | `mechanism.stageW` · `setpointW` · `state` · `currentA` | ce qui a été **commandé au matériel** | → la charge **n'obéit pas** : véhicule qui refuse, thermostat ouvert, verrou qui a mordu |
| **MESURÉ** | `measurement.powerW` (avec sa `source`) | ce qu'un appareil ou un compteur a **vu** | — |
> ⚠️ **`allocatedW` est du DÉCIDÉ, pas du commandé.** `buildTelemetry()` publie le plan, et
> `applyActionsToAdapters()` reçoit le slot **`const`** : rien n'est réécrit après le dispatch.
> Mesurable — `testUnpluggedChargerTakesNoBudget` attend `allocatedW == 3000` sur une borne dont
> le courant commandé donne 2 990 W.
>
> **Conséquence** : `Σ levels[].targetW == allocatedW` tient **exactement**, les deux étant du
> décidé. L'écart « décidé → appliqué » ne se lit donc **pas** entre eux, mais entre
> `allocatedW` et la charge utile de **mécanisme**.
>
> **Et l'arrondi ne se répartit pas entre les niveaux.** Toute clé de répartition serait une
> fiction, dont un client hériterait comme d'un fait : l'écart appartient à la **charge**, pas à
> l'un de ses niveaux.
> **Pourquoi trois et pas deux.** Chaque frontière répond à une question différente, et les
> confondre envoie diagnostiquer le mauvais problème (règle 7-b). Décidé ≠ appliqué est une
> affaire de **format** — le mécanisme ne sait pas prendre n'importe quelle valeur. Appliqué ≠
> mesuré est une affaire d'**obéissance** — et ce que cet écart autorise à conclure dépend
> encore de `measurement.source` (§11) : sous `device` il accuse la charge, sous `meter` il
> ouvre trois hypothèses, sous `none` il n'est pas calculable.
#### `counts` — la ventilation, et pourquoi elle ne se déduit pas de `funding`
```jsonc
"levels": [
{ "level": "comfort", "targetW": 1380, "funding": "grid",
"counts": { "budget.allocatedW": 900, "draw.committedW": 480 } }
]
```
**`funding` dit d'où les watts VIENNENT, `counts` dit dans quel registre ils TOMBENT.** Ce sont
deux questions, et aucune ne se dérive de l'autre : ci-dessus, `funding` vaut `"grid"` — un watt
est acheté, la règle suffit — pendant que `counts` porte **deux** destinations.
| Clé | Ce qu'elle compte |
|---|---|
| `budget.allocatedW` | watts de **surplus** alloués par la cascade |
| `draw.committedW` | watts **achetés** au réseau |
> **Clés OPAQUES, jamais recyclées.** Une clé inconnue de votre version s'affiche « destination
> inconnue » et laisse `Σ counts == targetW` vérifiable ; elle ne vaut **jamais** zéro. Le moteur
> s'interdit de réattribuer un identifiant à un sens nouveau — même règle que les numéros de
> règle retirés : recycler ferait ranger des watts dans le mauvais compteur **sans qu'aucune
> erreur ne le signale**.
>
> **Toujours publié : à une seule destination, et à zéro.** `counts: {}` satisferait l'identité
> en **perdant la destination**, sur le cas le plus fréquent. Un zéro **avec** sa destination dit
> « cette grandeur existe et vaut 0 » ; l'absence dirait « elle n'existe pas ». C'est la même
> distinction que `draw: {"committedW": 0}` — zéro est une valeur quand la grandeur existe,
> l'absence en est une quand elle n'existe pas.
>
> **Qui fait foi** : `counts` pour la **comptabilité**, `decision.params` pour la **phrase** du
> motif. `params.budgetW`/`gridW` d'un `EV_GRID_START` expliquent *pourquoi* la charge démarre
> malgré un budget insuffisant ; `counts` dit *où* les watts sont comptés. Si les deux
> divergeaient, c'est `counts` qui décrit le bilan.
### L'identité de réconciliation — écrite ici, jamais reconstruite
```
Σ charges Σ niveaux counts["budget.allocatedW"] == budget.allocatedW
Σ charges Σ niveaux counts["draw.committedW"] == draw.committedW
Σ counts d'un niveau == niveau.targetW
```
**Elle ne porte plus sur `funding`.** Sommer les `targetW` des niveaux `funding == "surplus"`
était juste tant qu'un niveau n'avait qu'une destination ; un `EV_GRID_START` la rendait fausse.
La ventilation supprime le détour : on somme des **compteurs**, pas des origines.
Elle a changé de forme avec R2 : elle portait sur `allocatedW` des charges dont le `funding`
valait `"surplus"`, elle porte maintenant sur les **niveaux**. Un client qui la reconstruirait
depuis l'ancienne forme divergerait **au premier cas mixte** — c'est-à-dire au premier cycle du
§10.
> **`EV_GRID_START` n'est plus une exception depuis `+etm31`.** Ce motif partage **une**
> allocation entre deux registres, et les deux sont désormais des registres : `params.budgetW`
> va dans `budget.allocatedW`, `params.gridW` dans **`draw.committedW`** — et
> `budgetW + gridW == allocatedW` toujours. Sa contribution au surplus reste
> `decision.params.budgetW`, pas `targetW`.
>
> Le partage lui-même ne disparaîtra pas — c'est un fait, une part est achetée et l'autre non.
> Ce qui disparaît est l'**exception** : les deux moitiés tombent maintenant dans des compteurs
> de même nature. `counts` (**R3**) publiera ce partage par niveau plutôt que de le laisser
> déduire du motif.
>
> `budget.evReservedW` **subsiste**, mais restreint : il ne compte plus que les bornes servies
> par un financement **réseau** (échéance de départ, spot market), qui restent décidées en
> amont. Une borne servie par la cascade y serait comptée deux fois.
>
> **Deux bornes ne se partageaient pas le budget avant 3g-1** : l'allocation était calculée une
> fois et offerte entière à chacune. Ce n'est plus le cas — la première est servie, la seconde
> reçoit le reliquat, et 0 si ce reliquat est sous son plancher.
>
> ✅ **Le rang d'une borne est configurable depuis 3g-2**, et il vit là où vivent tous les rangs :
> `LoadConfig.priority` (LM-1206/LM-1207). Il n'existe **pas** de second système de priorité —
> un classement propre aux bornes ne saurait pas exprimer « VE1 > ECS > VE2 », et deux
> classements qui ne peuvent pas s'interclasser sont un défaut, pas deux fonctionnalités.
> `ChargingInfo` ne porte donc **aucun** champ de rang, et n'en portera pas.
>
> Le tri est **total** : à rang égal, l'identifiant départage. Laquelle est servie est donc
> reproductible d'un cycle à l'autre, même avant tout réglage.
>
> *(Jusqu'à 3g-1, `EvAdapter::descriptor()` fixait `priority = 100` pour toutes : même clé de
> tri, `std::sort` non stable, et laquelle démarrait n'était ni réglable ni reproductible.)*
**Bornes exclues de `loads[]`** : celles sans voiture assignée, celles en mode manuel
(`ChargingModeNormal`), et celles **sans véhicule branché**. Elles sont **hors arbitrage**, pas
servies en dernier — leur consommation est déjà au compteur racine, donc déjà dans le budget.
> `pluggedIn` a rejoint ce filtre après le banc du 2026-08-27 : une borne débranchée ne peut
> rien tirer de ce qu'on lui alloue, et en cascade cette allocation est prise aux charges
> suivantes. 4 853 W mesurés donnés à personne (`docs/RELEVE_3g2.md` §3.1).
#### `loads[]` ⊆ `GetLoadConfig` — toute charge publiée est configurable
**Tout `loadId` de `loads[]` a une entrée dans `GetLoadConfig`.** C'est un invariant de
**construction**, pas une discipline : depuis le lot B-bis, toute charge arbitrée est bâtie
depuis la configuration persistée (`LoadConfigStore`), et il n'existe plus aucun chemin
d'enregistrement en dur — le moteur n'a qu'une table de charges, alimentée par un seul endroit.
Le client peut donc résoudre le libellé, le `domain` et le rang de **chaque** charge publiée, et
lui proposer son écran de réglage.
**L'inclusion ne vaut que dans ce sens.** `GetLoadConfig` peut contenir des charges absentes de
`loads[]` : celles déclarées `enabled: false`, dont le rôle est décrit mais qui sont **exclues de
l'arbitrage** (contrat §9). Aucun adaptateur n'est construit pour elles, elles ne consomment
aucun budget, elles n'ont donc aucune télémétrie à publier. Une charge configurée absente de
`loads[]` se lit **« désactivée »**, jamais « perdue ».
> ⚠️ **3g-1 avait ROMPU cet invariant, et 3g-2 le rétablit.** En faisant entrer les bornes dans
> `loads[]`, 3g-1 publiait des charges qui ne figuraient dans aucune `GetLoadConfig` — c'est-à-dire
> exactement la charge fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout.
>
> **Depuis 3g-2, toute borne détectée reçoit d'office une entrée `LoadConfig`** de mécanisme
> `evcharger` (voir ci-dessous). L'inclusion est de nouveau vraie par construction.
##### Le mécanisme `evcharger` dans `GetLoadConfig` / `SetLoadConfig` *(3g-2)*
Une entrée `evcharger` porte **le rang, et rien d'autre** :
| Champ | Valeur |
|---|---|
| `id` | ThingId de la borne |
| `adapter` | `"evcharger"` |
| `mode` | `"dynamic"` (une borne module ; `"fixed"` est refusé) |
| `domain` | `"ev"` |
| `label`, `priority`, `enabled` | comme toute autre charge |
**Toute charge utile est refusée** : `relays[]`, `sgReady`, `powerLevels`, `maxPowerW`,
`minPowerW`, `minOnS`, `minOffS`. Les limites d'une borne — courant minimal et maximal, nombre de
phases, disponibilité — viennent du **Thing** et changent avec le véhicule branché ; les déclarer
ici en ferait une seconde source de vérité, périmée dès le premier débranchement.
- **Création automatique.** Une borne détectée entre en configuration sans intervention, à un rang
de tête (le plus petit rang existant moins un, borné à 1) qui ne réécrit le rang de personne.
C'est un point de départ de mise en service, pas une politique : **l'app pose les vrais rangs**.
La création est journalisée, une ligne par borne, à la création seulement.
- **Aucun adaptateur n'en est construit** : celui de la borne vient du Thing. Un aller-retour
`GetLoadConfig` → `SetLoadConfig` **verbatim** ne change donc rien et ne reconstruit rien —
aucun verrou n'est réarmé.
- **L'entrée survit à son Thing.** Borne remplacée, ThingId changé, appareil déposé : l'entrée
**n'est pas supprimée** — c'est une configuration, et l'installateur a pu la classer. Elle
reste publiée dans `loads[]`, `available: false`, `faultCode: "THING_MISSING"`, motif
`LOAD_UNAVAILABLE`, et ne retient aucun budget. La supprimer d'office rendrait « borne
remplacée » indistinguable de « borne jamais configurée ».
- **Exclusivité.** L'entrée revendique le Thing de la borne : la déclarer en plus comme charge
pilotée est refusé par `SetLoadConfig` (deux commandeurs sur un même organe, LM-201).
> **La règle de détection de `+etm14` est RETIRÉE.** Cette section documentait jusqu'ici
> l'inverse : une charge pouvait être arbitrée et publiée **sans** exister dans `GetLoadConfig`,
> et le client était invité à la détecter par différence d'ensembles — un `loadId` sans
> équivalent valant « charge non configurable » : afficher le `loadId` faute de libellé, la
> traiter comme non classée, ne proposer aucun écran de réglage. Cette règle n'avait qu'un
> porteur, `pac-terrain`, la PAC SG-Ready **codée en dur** du banc `.75`. Le lot B-bis en fait
> une `LoadConfig` ordinaire et supprime le bloc d'enregistrement : la règle est sans objet.
> **Aucun champ `configurable: false` n'a été ajouté** — il n'aurait plus rien à signaler ; ne
> pas l'attendre dans la charge utile. Un client qui a déjà implémenté la règle de détection
> peut la conserver sans dommage : elle ne se déclenchera plus.
---
#### Codes — aucune phrase composée par la box
> La box transporte un **code** et des **paramètres**. La phrase est fabriquée par le client,
> dans la langue de son utilisateur.
Ce n'est pas une préférence de style. La locale nymea est **par connexion** (`JSONRPC.Hello`
accepte `o:locale`) : deux utilisateurs d'une même box peuvent avoir deux langues, ce qu'une
phrase composée en C++ ne peut pas servir. Par ailleurs `NymeaEnergyJsonHandler` est un
`JsonHandler` **sans `pluginId`** : il est structurellement hors du catalogue de traduction
nymea, et rien ne rattraperait une phrase française émise ici.
**`decision.code`** — catalogue fermé. `params` ne contient que des nombres et des booléens ;
le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoadConfig`).
| Code | `params` | Sens |
|---|---|---|
| `EV_SURPLUS` | — | VE rechargé sur surplus PV |
| `EV_SPOT_MARKET` | — | VE rechargé sur créneau tarifaire favorable |
| `EV_DEADLINE` | — | VE prioritaire, échéance approchante |
| `EV_ECO_MIN` | — | Pas de surplus, courant minimum maintenu (mode EcoMin) |
| ~~`EV_IDLE`~~ | — | **Retiré en 3g-1** — remplacé par `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT`, qui disent *pourquoi* la borne ne charge pas |
| ~~`EV_SURPLUS`~~ | — | **Retiré en 3g-1** — la recharge sur surplus produit désormais `SURPLUS_SETPOINT`, comme toute autre charge |
| ~~`EV_ECO_MIN`~~ | — | **Retiré en 3g-1** — le courant minimum est devenu le **plancher** du §12, exprimé une seule fois pour tous les mécanismes |
| `LOAD_UNAVAILABLE` | `frozenW` | Charge en défaut : aucune commande émise, `frozenW` reste compté au budget. Levée par `ClearLoadFault` |
| `LOCK_MIN_ON` | `appliedW`, `budgetW` | Le verrou `minOn` a **relevé** la consigne : puissance engagée non coupable |
| `LOCK_MIN_OFF` | `budgetW`, `requestedW` | Le verrou `minOff` interdit tout enclenchement — le budget, lui, payait `requestedW` |
| `LOCK_MIN_OFF_CAPPED` | `appliedW`, `budgetW`, `requestedW` | Le verrou `minOff` plafonne sans interdire |
| `SURPLUS_SETPOINT` | `budgetW`, `setpointW`, `stepped` | Consigne servie par le surplus ; `stepped` = arrondie sur un palier déclaré |
| `SURPLUS_INSUFFICIENT` | `budgetW` | Le budget ne payait aucun palier — **aucun verrou n'a mordu** |
| `BATTERY_RESERVE` | `socPercent`, `reservePercent`, `withheldW` | Le surplus **existe** mais la réserve de stockage l'interdit : **aucune** charge n'est servie. Depuis 3g-2 `withheldW` compte les **deux** termes retenus à cette charge — le surplus annulé **et** sa propre consommation, que le recrédit lui aurait rendue |
| `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `o:state` | Budget **sous le premier palier atteignable** : 0 W, et le budget passe à la suivante. `state` nommé pour les mécanismes à états |
| `EV_GRID_START` *(3g-2)* | `budgetW`, `floorW`, `gridW` | La borne **démarre en soutirant** : le surplus ne paie pas son plancher, mais `acquisitionTolerance` autorise l'appoint. `gridW = floorW − budgetW` est ce qui vient du réseau. Voir la note de comptabilité ci-dessous |
| `PHASE_LIMIT` *(3g-2)* | `limitW`, `requiredW` | C'est la **limite de phase** qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil |
| `DRAW_CAP` *(délestage §3)* | `marginW`, `overshootW`, `source`, `o:phase` | L'**autorisation de soutirage** est épuisée — une seconde ressource, distincte du surplus. `source` est un code opaque : `connection` (le branchement), `gridOperator` (imposé), `selfImposed` (auto-imposé) ; ce qu'on a le droit d'en faire en dépend. `marginW` est **signé** et `overshootW` dit de combien on dépasse. `phase` (`A`/`B`/`C`) n'est présent que si le plafond est **par phase** : son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A ». **Précédence (LM-1011)** : tant qu'il reste de l'autorisation, même insuffisante, le motif est `BELOW_MIN_POWER` — `DRAW_CAP` est réservé à l'autorisation **nulle** |
| `ECO_FLOOR_GRID` *(§10)* | `gridW`, `atMaximum`, `o:remainingWh`, `o:targetWh`, `o:remainingS`, `o:regime`, `o:minAlwaysW`, `o:boundBy` | L'obligation éco est servie **en achetant**. Les six champs donnent la **trajectoire**, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et **le plafond dès seize minutes**. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec `remainingWh` et `remainingS`, si. `atMaximum` dit que l'étalement a cessé (point de non-retour), `regime` sur quoi on achète. **La LOI DE COMMANDE dépend du régime** — voir l'encadré ci-dessous |
| `ECO_FLOOR_MET` *(§10)* | `deliveredWh`, `targetWh` | L'obligation est **tenue**. **INTERDIT sous `regime: unmeasurable`** (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure |
| `ECO_FLOOR_MISSED` *(§10)* | `targetWh`, `cause`, `o:missingWh` | Une période d'obligation s'est close **sans avoir été tenue**. `cause` ∈ `drawCap` \| `loadCapacity` \| `unmeasurable` \| `unavailable` — **trois causes, trois gestes** : revoir l'abonnement, lire la fiche technique de la charge, ou regarder ce que la borne publie. Sans elle, l'écran n'affiche qu'un reproche sans recours. `missingWh` est **absent** sous `unmeasurable` : on ne sait pas ce qui a été livré, donc pas ce qui manquait — un zéro se lirait « il ne manquait rien ». Motif d'ÉVÉNEMENT : publié le seul cycle de la bascule |
| `RECREDIT_SUSPENDED` | `refusedSinceS`, `withheldW` | Le recrédit anti-clignotement est **suspendu** : la charge ne suit pas la commande depuis plus longtemps que la latence de son mécanisme. Les watts ne sont **pas rendus au budget**, donc **toutes les charges suivantes reçoivent moins** — et sans ce motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Un écart RÉCENT est une latence, pas un refus : le seuil vient du mécanisme (V2C mesurée : montée ~30 s, descente ~7 s), jamais d'une constante |
| `DEROGATION_ACTIVE` *(§15)* | `maxW`, `gridW`, `endsAt`, `o:targetWh`, `o:deliveredWh`, `o:boundBy` | Cette charge est en **dérogation** : servie à son maximum, financée au réseau, en tête de passe éco. Ce n'est **pas un mode** — elle se superpose et s'efface, et `endsAt` (ISO 8601 UTC) est le **filet** qui permet à l'écran de montrer un compte à rebours plutôt qu'un état sans horizon. `targetWh` et `deliveredWh` sont **absents** quand aucune cible n'a été donnée : un `targetWh: 0` se lirait « cible atteinte ». `boundBy` dit **qui** a bridé — `drawCap` (le plafond de soutirage de la maison) ou `loadCeiling` (la charge est à son plafond de cycle ; pour une borne ce plafond porte déjà son autorisation de phase, et `maxW` est alors la vraie réponse à « pourquoi pas plus »). Sans lui, une dérogation servie à moitié se lit comme une charge qui ne suit pas |
> ### La FIN d'une dérogation est un événement de TRAME, pas un motif
>
> Elle est publiée dans `derogationEnded[]` **à côté** de `loads[]`, et non dans la charge :
> au débranchement la charge n'y est **déjà plus**, et l'événement disparaîtrait exactement quand
> il explique le plus de choses. `decision` porte ce que le cycle a **décidé** ; une fin est un
> **événement**, et l'y loger obligerait à choisir entre les deux.
>
> ```json
> "derogationEnded": [ { "loadId": "{...}", "cause": "durationElapsed", "restoredMode": 3 } ]
> ```
>
> `cause` ∈ `targetReached` | `unplugged` | `measurementLost` | `durationElapsed` |
> `modeChanged` | `cancelled`. **Six causes, six gestes** — cible atteinte : rien à faire ;
> mesure perdue : regarder le lien ou le compteur ; débranchement ; durée écoulée ;
> **mode changé : quelqu'un a repris le volant** ; annulée à la main. Une fin qui ne dirait pas
> laquelle a mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera encore.
>
> Sous `modeChanged`, `restoredMode` est **absent** : le mode mémorisé n'est PAS restitué, il
> écraserait celui que l'utilisateur vient de choisir. Écrire un mode pendant une dérogation la
> termine — le durable prime sur le ponctuel.
>
> `restoredMode` est le `ChargingMode` **mémorisé** auquel on revient : le client ne peut pas le
> déduire, il n'a jamais vu celui d'avant la pose. Présent seulement si un mode avait été écrit.
>
> Le champ est **absent** hors du cycle de la fin — jamais une liste vide qui se lirait « rien ne
> s'est terminé, on regarde ».
> ### `ECO_FLOOR_GRID` sert DEUX besoins, et dit lequel a décidé
>
> Le plancher éco peut venir d'une **obligation en énergie** (une cadence) ou d'un **minimum
> réseau** (une puissance demandée tant que la charge est là). Les deux produisent un `gridW`, et
> rien dans le nombre ne dit lequel a fixé la consigne.
>
> | champ | présent quand |
> |---|---|
> | `remainingWh`, `targetWh`, `remainingS`, `regime` | il y a une **obligation en énergie** |
> | `minAlwaysW` | un **minimum** est demandé |
> | `boundBy` ∈ `obligation` \| `minimum` | un minimum est demandé — dit **lequel des deux mord** |
>
> **Les grandeurs d'obligation sont ABSENTES sur une charge à minimum seul**, jamais à zéro : un
> `remainingS: 0` inventerait une échéance imminente et un `remainingWh: 0` une obligation
> tenue — deux affirmations fausses au lieu d'un silence. `progress` est absent pour la même
> raison : il ne décrit que l'obligation.
> ### Sous `unmeasurable`, la cadence est CONSTANTE — la loi de commande change avec le régime
>
> Un écran qui suppose la même loi dans les deux régimes se trompera sur la trajectoire, et c'est
> la trajectoire qu'il vend.
>
> | régime | loi | `atMaximum` |
> |---|---|---|
> | `measured` / `estimated` | `reste ÷ temps restant`, **recalculée** — elle se durcit à l'approche | peut passer à `true` |
> | `unmeasurable` | `cible ÷ jour`, **constante** — elle ne bouge pas de la fenêtre | **jamais `true`** |
>
> **Pourquoi.** Sous `unmeasurable`, `remainingWh` est figé à la cible (encadré suivant). Un
> rapport dont seul le dénominateur décroît ne suit plus un retard : **il suit l'horloge**. On
> achèterait davantage parce que le temps passe, pas parce que quelque chose est en retard.
> Chiffré : obligation 4 kWh, plafond 7 kW, fenêtre d'un jour — l'ancienne loi achetait
> **19 010 Wh**, soit ×4,75 l'obligation, et le facteur grandissait avec la fenêtre.
>
> Sous la loi constante, **le total acheté sur la fenêtre vaut exactement la cible**.
>
> **Ce que l'écran peut donc affirmer, et ce qu'il ne peut pas.** Sous `unmeasurable`, `gridW` ne
> montera pas : une phrase du type « l'achat va s'intensifier à l'approche de l'échéance » y
> serait fausse. Et `atMaximum: false` n'y signifie pas « tout va bien » — il signifie qu'il n'y
> a **pas d'étalement du tout**, donc rien qui puisse cesser.
> ### `remainingWh` sous `unmeasurable` : **la cible INTACTE**, et il faut le lire ainsi
>
> Sous `regime: unmeasurable`, **`remainingWh == targetWh`**. Rien n'est soustrait, parce qu'on
> ne sait rien de ce qui a été livré.
>
> **2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS « rien
> livré ».** Les deux lectures commandent des phrases d'écran opposées — l'une dit qu'il n'y a
> aucune information, l'autre accuse une charge de n'avoir rien reçu — et l'écran ne peut
> trancher que si le contrat le dit. Il le dit ici.
>
> **Pourquoi rien n'est soustrait.** Le moteur dispose bien d'une intégration interne. La
> soustraire ferait franchir la frontière à une **estimation sous l'allure d'un fait** : le
> chemin exact de `carBatteryLevel`, refusé en `LM-1009` D3. Et que `regime` voyage dans les
> mêmes params ne suffirait pas — un client qui lit `remainingWh` sans regarder `regime`
> obtiendrait un nombre calculé, sans rien pour le lui dire.
>
> **Identité vérifiable sous `measured`** — troisième contrôle du même genre que
> `Σ counts == targetW` et `authorisedW − committedW == remainingW` :
>
> ```
> progress.deliveredWh + params.remainingWh == params.targetWh
> ```
>
> Elle ne tient QUE sous `measured` : sous `unmeasurable`, `deliveredWh` est absent, et c'est
> l'égalité `remainingWh == targetWh` qui prend le relais. Un client peut donc vérifier la
> cohérence dans les deux régimes — mais pas avec la même formule, et c'est le régime qui dit
> laquelle appliquer.
| `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) |
| `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) |
| `SG_RECOMMENDED` | `budgetW`, `estimatedW` | PAC en état 3 (recommandation) |
| `SG_NORMAL` | `budgetW` | PAC en état 2 : **aucun surplus à allouer** (budget ≤ 0). Un budget positif mais sous l'état 3 donne `BELOW_MIN_POWER`, pas celui-ci |
| `DEGRADED_L2` | — | Consigne de repli du watchdog L2 (compteur muet) |
| `SAFE_STATE_RELAY` | — | État sûr avant retrait : tous relais ouverts (ECS-413) |
| `SAFE_STATE_SETPOINT` | — | État sûr avant retrait : consigne 0 W (ECS-413) |
| `SAFE_STATE_SG_READY` | — | État sûr avant retrait : état 2, **jamais** le blocage (ECS-413) |
> **Comptabilité d'un `EV_GRID_START`** — c'est le seul cas où l'`allocatedW` d'une charge se
> **partage** entre les deux compteurs du budget, et les deux termes sont dans le motif :
> `budgetW` vient du surplus et entre dans `budget.allocatedW` ; `gridW` vient du réseau et entre
> dans `budget.evReservedW`. `budgetW + gridW == allocatedW`, toujours. L'entrée porte
> `funding: "grid"` : une part de cette puissance est achetée.
>
> **Borné par construction** : un démarrage sous tolérance exige du surplus réel (`budgetW > 0`)
> et consomme **tout** le budget restant — donc **au plus une borne par cycle** peut soutirer.
> Le plafond de la protection de surcharge s'applique avant, et la couche L4 garde le dernier mot.
>
> `LOCK_MIN_ON` / `LOCK_MIN_OFF` / `SURPLUS_INSUFFICIENT` se lisent sur le **même axe** : le
> verrou a relevé la consigne, il l'a rabaissée, ou aucun verrou n'a mordu et c'est le budget.
> La distinction est le fruit d'ECS-309 et ECS-309-b (corrections de terrain) — un motif faux
> envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication.
**`measurement`** *(o:)* — ce qui est **observé** de la charge, avec sa **source**.
*(Remplace `measuredW` — voir la note de migration ci-dessous. Décidé le 2026-08-28,
`specs/spec_loadmodel.md` **LM-1105/LM-1106**.)*
```jsonc
"measurement": {
"source": "meter" | "device" | "none",
"powerW": 3980.0 // o: — absent si source == "none"
}
```
| `source` | D'où vient `powerW` |
|---|---|
| `meter` | Un Thing tiers, désigné par `meterThingId`. **Prioritaire** quand il résout et porte l'état |
| `device` | L'appareil piloté publie lui-même sa puissance — borne, `etmvariableload`, relais mesurant |
| `none` | **Rien ne mesure cette charge.** `powerW` est alors ABSENT, jamais nul |
**Quand vaut-il `none` ?** Trois cas, et le premier surprend :
- **Toujours pour `sg-ready`**, même montée sur des relais qui mesurent. Un contact SG-Ready est
un contact de **signal** : la PAC est alimentée ailleurs, son courant ne traverse jamais K1/K2,
et un contact qui mesurerait quelque chose mesurerait **sa propre bobine**. C'est le seul
mécanisme pour lequel `meterThingId` reste la seule voie vers une mesure.
- **Topologie de relais MIXTE** — un relais mesurant, deux nus. Une somme partielle présentée
comme la mesure de la charge annoncerait 500 W là où 1 500 W coulent, et l'écart enverrait
chercher une panne inexistante. Une mesure partielle n'est pas une mesure.
- **Thing sans l'état de puissance**, tout simplement.
> **`source` ne dépend ni de la valeur ni de l'instant.** Un `relay-router` au palier 0 n'a aucun
> relais fermé et publie `{source: "device", powerW: 0}` : **0 W est une mesure**, pas une
> absence. La capacité se juge sur la ThingClass de **tous** les Things déclarés — sinon le bloc
> de mesure apparaîtrait et disparaîtrait au rythme du thermostat.
Publiée **à côté** de `allocatedW`, jamais fondue dedans : `allocatedW` est ce que le waterfall
a **alloué**, `measurement.powerW` ce qui a été **vu**. Les confondre rendrait la somme des
allocations irréconciliable avec `budget.allocatedW`.
> **Pourquoi la source est dans la charge utile et pas déduite.** Un écart commandé↔mesuré ne
> conclut pas la même chose selon elle : sous `device` il dit « la charge n'obéit pas » (commande
> et mesure sortent de la même boîte) ; sous `meter` il ouvre trois hypothèses — la charge, le
> compteur, ou le câblage ; sous `none` il n'est **pas calculable**, et le fabriquer depuis la
> valeur nominale produirait un écart identiquement nul à tous les cycles, c'est-à-dire un
> « tout concorde » permanent.
>
> `none` est un état **positif**, pas une absence : il dit « cette charge n'est pas mesurable »
> — un `sg-ready` sur contacts secs ne le sera jamais — et permet à un client de **masquer** le
> bloc de mesure au lieu d'afficher un vide. Sans lui, « pas mesurable » et « compteur en
> panne » se ressemblent.
> **L'asymétrie `device` / `meter`, et pourquoi elle vaut d'être dite à l'écran.** Sous
> `device`, une **concordance** ne prouve pas grand-chose : plusieurs wallbox renvoient leur
> consigne en guise de puissance, et « commandé == mesuré » n'y est alors qu'une tautologie.
> Sous `meter`, une concordance est une **preuve** — commande et mesure viennent de deux boîtes
> différentes. Autrement dit : **`device` détecte la désobéissance, `meter` est le seul à
> pouvoir détecter un mensonge.** C'est ce qui justifie de laisser `meterThingId` **disponible**
> sur une charge qui publie déjà sa puissance : masqué par défaut, jamais interdit.
> **Une charge ABSENTE de `loads[]` n'est pas `none`.** *(Distinction remontée par l'agent app,
> 2026-08-28, et confirmée sur le banc le jour même.)* `none` est **publié** : il AFFIRME « cette
> charge n'est pas mesurable ». L'absence n'affirme rien — elle dit seulement que la charge n'a
> **pas participé à l'arbitrage de ce cycle**, ce qui est une question sans rapport avec la
> mesure. Une borne en mode manuel ou sans véhicule branché sort de `loads[]` (voir
> `GetLoadTelemetry`, exclusions du waterfall) alors qu'elle publiera sa propre puissance dès le
> cycle où elle y rentrera.
>
> Sans cette distinction, un client afficherait « pas mesurable — le compteur dédié est la seule
> voie » sur une borne débranchée qui, une fois branchée, mesure très bien toute seule.
> **Constaté tel quel sur `.75` en `+etm26`** : deux bornes `pluggedIn: false`, absentes de
> `loads[]`, dont l'une porte `currentPower` et publiera `device`.
>
> La règle est simple parce que `measurement` n'est jamais omis d'une entrée présente : **la clé
> existe pour toute charge de `loads[]`, et pour elle seule.** Donc — charge dans `loads[]` :
> lire `measurement.source`. Charge absente : ne rien conclure sur sa mesurabilité, et ne rien
> afficher qui l'affirme.
> **Décidez sur `source`, jamais sur `adapter`.** « Masquer le compteur dédié sauf pour
> `sg-ready` » paraît équivalent et ne l'est pas : un `relay-router` à topologie **mixte**
> publie lui aussi `none`, et un client qui déciderait au mécanisme masquerait le champ sur un
> ECS qui ne peut être mesuré **que** par un compteur dédié. La règle sûre tient en une ligne —
> masquer par défaut quand `source == "device"`, proposer sinon — et elle couvre l'exception
> SG-Ready sans qu'aucun client ait à la connaître.
>
> C'est la raison d'être de `source` : le régime est publié **pour** qu'il ne soit pas déduit.
> Le déduire du mécanisme côté client remettrait dans l'app la déduction qu'on vient de retirer
> du moteur.
> **Migration depuis `measuredW`.** L'ancienne clé disparaît ; elle ne pouvait pas exprimer
> trois états, et la doubler d'un second champ aurait obligé le client à joindre deux clés pour
> lire une seule chose. `measuredW: X` devient `measurement: {source: …, powerW: X}` ; son
> **absence** devient `measurement.source: "none"` — ce qui est précisément l'information qui
> manquait. Un seul écran est concerné.
**`temperatureC`** *(o:)* — température lue sur la sonde déclarée. **Lecture seule, aucun effet
sur les décisions.** Son usage immédiat est le diagnostic de mise en service ; son usage durable
est de **constituer l'historique** (température, puissance, heure) sans lequel les seuils
éco/confort à venir ne pourront pas être réglés.
**`faultCode`** — absent quand la charge est saine.
| Code | Sens |
|---|---|
| `WRITE_FAILED` | Échelle d'écriture épuisée (ECS-410/414) : plus aucune commande émise jusqu'à `ClearLoadFault` |
| `THING_MISSING` | Le Thing piloté est absent de la configuration nymea |
| `UNUSABLE_ENCODING` | PAC SG-Ready dont l'encodage ne permet pas d'exprimer l'état 2 : jamais pilotable (ECS-110) |
**`lock.kind`** — absent quand aucun verrou ne mord. `minOn` · `minOff` · `minStateHold`.
`remainingS` = secondes restantes.
> **Pourquoi des secondes et pas des watts.** `lockMinPowerW`/`lockMaxPowerW` sont un artefact
> du mécanisme d'allocation, illisible pour un utilisateur ; le temps restant, lui, se lit.
> Ceci **n'enfreint pas ECS-306** : cette exigence interdit qu'un index de **palier** franchisse
> la frontière d'**allocation**. Une valeur d'**affichage** sortante est un autre chemin — et
> `stageW`/`state` sont des watts et des états normés, pas des index.
**`mechanism`** — union discriminée par `kind`, comme `LoadConfig`. Chaque variante porte sa
charge utile, et **seulement la sienne**. Toutes les clés internes sont optionnelles au schéma
(règle LM-302) ; tout mécanisme ajouté embarque son test.
| `kind` | Clés |
|---|---|
| `relay` | `stageW` (palier appliqué, W), `maxStageW` |
| `variable` | `setpointW`, `percent` (0–100, dérivé), `maxPowerW` |
| `sgReady` | `state` (1–4), `estimatedPowerW` (**estimation** déclarée, jamais un engagement) |
| `evcharger` | `chargingEnabled`, `pluggedIn`, `currentA`, `phaseCount` — **réservé 3g**, non émis en beta |
---
### Taux d'énergie — `GetEnergyRatios` / `EnergyRatiosChanged`
| champ | présence | sens |
|---|---|---|
| `selfConsumptionRate` | **omis** si n/a | part de la production consommée sur place, % dans [0, 100] |
| `autonomyRate` | **omis** si n/a | part de la consommation couverte sans soutirage, % |
| `periodStartedAt` | **toujours** | epoch (s) du **minuit local** qui ouvre la période de reporting |
| `measuredSince` | **toujours** | epoch (s) où la **baseline courante a été posée** |
| `timeZone` | **toujours** | identifiant IANA du fuseau où la frontière a été construite |
Les trois derniers sont présents **même quand les deux taux sont omis** : « la période a commencé
à minuit et rien n'est encore mesurable » est une phrase vraie et utile, là où une réponse vide ne
se distingue pas d'une panne.
> #### Pourquoi la frontière est PUBLIÉE et non recalculée
>
> `System.GetTime` publie un **nom IANA**. Un décalage ne s'en déduit pas sans base de fuseaux, et
> embarquer une base pour recalculer une frontière que le moteur connaît déjà serait le mauvais
> geste — le recalcul resterait **faux les jours de changement d'heure**, où la journée fait 23 ou
> 25 heures. Trois clients la recalculeraient de trois façons ; la seule qui compte est celle qui
> a servi au calcul. **Même règle que `counts`.**
> #### Les deux champs ne bornent pas la même chose, et ils arrivent dans la même trame
>
> **C'est le piège de cette charge utile, et il a déjà été tendu une fois.** Les deux dates sont
> voisines dans le JSON ; leurs noms se ressemblent ; elles n'appartiennent pas au même écran.
>
> | champ | borne | sert à |
> |---|---|---|
> | `measuredSince` | **la borne basse des RATIOS** | l'écran des taux — c'est depuis là qu'on COMPTE |
> | `periodStartedAt` | **la frontière de la JOURNÉE** | le bilan journalier — c'est là que la journée commence |
>
> **`periodStartedAt` n'est PAS la borne basse des ratios.** Les présenter comme journaliers alors
> qu'ils ne comptent que depuis le dernier redémarrage serait exactement le silence que ce champ
> prétend fermer.
>
> L'analogie avec `progress.measuredSince` de l'avancement éco s'arrête là, et c'est par là que
> l'erreur passe : sur une charge, `measuredSince` répond « depuis quand on MESURE » et borne une
> fenêtre dont `targetWh` donne l'autre bout. Ici il répond « depuis quand on COMPTE », et la
> paire ne borne aucune fenêtre — les deux dates parlent de deux objets différents.
> #### Pourquoi les deux ne coïncident pas, et ce que l'écart révèle
>
> La baseline se repose à **chaque redémarrage de nymead** et à chaque recul de compteur — donc en
> plein jour. Un jour où nymead a redémarré à 14 h, ces ratios ne couvrent pas la journée : ils
> couvrent **depuis 14 h**.
>
> Avec la seule frontière, l'écran écrirait « aujourd'hui » sur des chiffres qui ne parlent que de
> l'après-midi. Avec la paire, il peut écrire la phrase vraie : **« depuis 14:00 »**. Quand les
> deux coïncident — le cas normal — il n'y a rien à dire de plus, et c'est ce qui rend l'écart
> lisible le jour où il apparaît.
>
> Même paire, même raison que `progress.measuredSince` / `targetWh` de l'avancement éco.
> #### ⚠️ DÉFAUT CONNU — `measuredSince` DÉRIVE au changement de fuseau (2026-09-06)
>
> **Mesuré, non corrigé à ce jour.** Bascule `Europe/Paris` → `Europe/London` à `13:37:26Z`,
> sans redémarrage de `nymead` :
>
> ```
> periodStartedAt : 1788645600 → 1788649200 (+3600 s) ← CORRECT, la journée suit le fuseau
> measuredSince : 1788645607 → 1788649207 (+3600 s) ← FAUX, un instant absolu ne bouge pas
> ```
>
> **Cause** : `m_baselinePosee = now` conserve le `QDateTime` tel quel, en spécification
> `Qt::LocalTime`. Un tel objet garde des composantes d'horloge murale et résout son époque
> **au moment de la conversion**, avec le fuseau courant. Après une bascule, la même lecture
> « 00:00:07 » rend une époque différente d'une heure.
>
> **Conséquence** : le champ affirme que la baseline a été posée une heure plus tard qu'en
> réalité. Le champ posé pour rendre une couture lisible **est lui-même déplacé par la couture**.
>
> **Correctif** : `m_baselinePosee = now.toUTC()` — une ligne. Un instant absolu se stocke en
> UTC ; `periodStartedAt`, lui, doit rester construit dans le fuseau courant, puisque c'est
> précisément une frontière locale. Les deux champs n'ont pas la même nature, et le stockage doit
> le refléter.
> #### Ce que `timeZone` rend possible, et qui n'existait pas
>
> La période de reporting est la **troisième nature d'heure murale** du système, après l'habitude
> du client et le calendrier réglementé (`LM-1005-b`). Elle suit l'installation, ce qui est
> correct — **mais seulement si le fuseau de la box est celui de l'installation.** Relevé le
> 2026-09-04 : `.75` tourne en `Europe/London`, donc son bilan journalier se remet à zéro à
> **01:00 heure française**, et rien nulle part ne le disait.
>
> Publier le fuseau est ce qui rend cette vérification **possible** — c'est le seul des trois cas
> où personne ne contrôlait quoi que ce soit.
> #### `timeZone` qualifie une HORLOGE, il n'offre pas un choix — et ça change la phrase
>
> Le champ dit **dans quelle horloge la box interprétera les heures murales**. Ce n'est pas une
> préférence attachée à l'échéance, et l'écran ne doit pas le laisser croire :
>
> | | |
> |---|---|
> | **dit la chose** | « 12:00 **heure de la box** (Europe/Paris) » |
> | **laisse croire à un choix** | « Fuseau : Europe/Paris » |
>
> La nuance n'est pas cosmétique. Le fuseau **est** modifiable — mais c'est un réglage
> **système**, qui déplace d'un coup **toutes** les heures murales de l'installation : les
> échéances, la frontière de la journée de reporting, et demain les plages tarifaires. Un écran
> qui le présente sous un champ d'échéance suggère qu'on choisit le fuseau **de cette
> échéance-là**. Il n'y en a qu'un, et il vaut pour tout.
>
> #### L'avertissement de divergence est CONDITIONNEL, jamais permanent
>
> Il ne s'affiche que si le fuseau de la box **diffère** de celui de l'appareil. Un avertissement
> qui apparaît toujours cesse d'être lu — et il cesse d'être lu **précisément le jour où il
> devient vrai**.
>
> C'est la règle de l'absence qui informe, déjà appliquée deux fois dans ce contrat :
> `progressUnmeasurableCause` est **absent** quand la charge est mesurable, et `phase` est
> **absent** dans `DRAW_CAP` quand aucune phase n'est coupable. Un champ toujours présent ne
> porte aucune information ; un avertissement toujours affiché non plus.
---
### Spot market
#### `NymeaEnergy.GetAvailableSpotMarketProviders`
Retourne la liste des providers spot market disponibles.
**Params :** aucun
**Returns :**
```json
{
"providers": [
{
"providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67",
"name": "aWATTar AT",
"country": 14,
"website": "https://www.awattar.at"
},
{
"providerId": "0ca6ad88-e243-438d-a0f8-986cecf61834",
"name": "aWATTar DE",
"country": 82,
"website": "https://www.awattar.de"
}
]
}
```
---
#### `NymeaEnergy.GetSpotMarketConfiguration`
Retourne la configuration spot market courante.
**Params :** aucun
**Returns :**
```json
{
"enabled": true,
"available": true,
"o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67"
}
```
> `providerId` est absent si `enabled` est `false`.
---
#### `NymeaEnergy.SetSpotMarketConfiguration`
Active ou désactive le spot market et sélectionne le provider.
**Params :**
```json
{
"enabled": true,
"o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67"
}
```
> Si `enabled: true`, `providerId` est obligatoire et doit être un UUID valide.
**Returns :**
```json
{ "energyError": "EnergyErrorNoError" }
```
> Retourne `EnergyErrorInvalidParameter` si `enabled: true` sans `providerId`, ou `providerId` inconnu.
---
#### `NymeaEnergy.GetSpotMarketScoreEntries`
Retourne les cotations pondérées du provider actif. Liste vide si désactivé ou non disponible.
**Params :** aucun
**Returns :**
```json
{
"spotMarketScoreEntries": [
{
"startDateTime": "2025-06-15T00:00:00+02:00",
"endDateTime": "2025-06-15T01:00:00+02:00",
"value": 45.2,
"weighting": 0.85
}
]
}
```
---
### `NymeaEnergy.SetLoadDerogation`
**Une dérogation n'est pas un mode.** Elle se superpose au mode courant, sert la charge à son
**maximum**, financée au **réseau**, en **tête de passe éco** — devant toute autre charge, quel
que soit son rang — et **s'efface d'elle-même**. C'est le « boost » côté client.
Elle ne passe donc **pas** par `SetChargingInfo`, qui écrit un réglage durable.
**Params :**
| Champ | Type | Description |
|---|---|---|
| `loadId` | String | Charge visée (`ThingId` pour une borne, `id` de `LoadConfig` sinon) |
| `durationS` | Int | **Obligatoire**, borné `[60, 21600]`. C'est le **filet**, et il reste armé même quand `targetWh` est donné |
| `targetWh` | Double | *(optionnel)* Énergie livrée **depuis la pose**. `0` ou absent = pas de cible |
> **La durée est obligatoire, et c'est la règle du lot** : une dérogation qui ne sait pas dire
> quand elle finit ne devrait pas exister (`LM-1500`). Elle reste armée même avec une cible,
> parce qu'une cible repose sur un **instrument** et qu'un instrument peut se taire — à la perte
> du lien Modbus, la V2C **fige** `sessionEnergy` au lieu de l'effacer.
> **Une énergie, jamais un SoC.** Il n'y a pas de communication véhicule ; « 80 % » demanderait la
> capacité, l'état de santé et le rendement (`LM-1009`).
**Ce qui la borne** : l'autorisation de soutirage (`DRAW_CAP`) et le délestage — **elle prend la
tête de la passe éco, elle ne surplombe pas la sûreté**. Servie la première *dans ce qui reste*,
jamais au-delà. Le motif publie `boundBy: "drawCap"` quand le plafond a mordu.
**Le mode de charge précédent est mémorisé et restitué à la fin.** Si la borne était en
`ChargingModeNormal` (manuel, donc hors arbitrage), elle est basculée en éco pour la durée — seul
cas où le mode est écrit — puis **rendue à `Normal`**.
**Returns :** `{ "energyError": "EnergyErrorNoError" }` — `EnergyErrorInvalidParameter` si la
charge est inconnue ou la durée hors bornes.
---
### `NymeaEnergy.ClearLoadDerogation`
Lève une dérogation **avant son terme** et restitue le mode mémorisé. La fin est publiée avec la
cause `cancelled` — une fin **par décision**, distincte des quatre fins par mécanisme.
**Params :** `{ "loadId": "{...}" }`
**Returns :** `{ "energyError": "EnergyErrorNoError" }` — `EnergyErrorInvalidParameter` si aucune
dérogation n'était en cours sur cette charge.
---
## Notifications
S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`.
---
### `NymeaEnergy.PhasePowerLimitChanged`
```json
{ "phasePowerLimit": 25 }
```
---
### `NymeaEnergy.AcquisitionToleranceChanged`
```json
{ "acquisitionTolerance": 0.5 }
```
---
### `NymeaEnergy.BatteryLevelConsiderationChanged`
```json
{ "batteryLevelConsideration": 0.9 }
```
---
### `NymeaEnergy.LockOnUnplugChanged`
```json
{ "lockOnUnplug": true }
```
---
### `NymeaEnergy.ChargingInfoAdded`
Émis quand une nouvelle borne est détectée et sa `ChargingInfo` créée.
```json
{ "chargingInfo": { ... } }
```
---
### `NymeaEnergy.ChargingInfoRemoved`
Émis quand une borne est supprimée.
```json
{ "evChargerThingId": "uuid-de-la-borne" }
```
---
### `NymeaEnergy.ChargingInfoChanged`
Émis à chaque modification de la `ChargingInfo` (config ou état).
```json
{ "chargingInfo": { ... } }
```
---
### `NymeaEnergy.ChargingSchedulesChanged`
Émis à chaque recalcul du planning (cycle ~1 min), **et** à chaque transition du mode
dégradé L2 (watchdog fraîcheur compteur).
```json
{
"chargingSchedules": [ ... ],
"o:degradedMode": false
}
```
- `degradedMode` *(bool, optionnel — [ETM])* : `true` quand le compteur est muet depuis
> 90 s et que les consignes de repli L2 sont actives (planification suspendue, ECS coupé,
EV en charge clampé au minimum). Repasse à `false` au retour du compteur. Champ additif :
les clients antérieurs l'ignorent. Détail : `docs/SAFETY.md` §L2.
---
### `NymeaEnergy.LoadTelemetryChanged`
Émise quand l'état runtime d'arbitrage a **changé significativement**, **et** périodiquement
(battement de cœur, 60 s) même sans changement.
Charge utile **identique** à `GetLoadTelemetry` (schéma déclaré une seule fois côté plugin :
deux déclarations divergeraient, et c'est l'appel de reprise à froid qui casserait).
**« Changé significativement »** = mode dégradé, jeu de charges, `available`, `faultCode`,
`lock.kind`, `mechanism.kind`, `decision.code`, ou variation d'allocation **> 50 W** (par charge
ou sur le total). Sont volontairement **ignorés** : les secondes restantes d'un verrou (elles
décroissent à chaque seconde) et les paramètres numériques d'un motif (le budget bouge à chaque
cycle) — les comparer ferait notifier en permanence et le seuil ne servirait à rien.
**Le battement de cœur est ce qui rend un arbitre FIGÉ détectable** : les notifications
continuent d'arriver tandis que leur `timestamp` cesse d'avancer. Sans lui, « installation
stable » et « moteur arrêté » sont le même silence. Toute notification relance le décompte du
battement : la cadence totale reste d'environ **une notification par minute**.
---
### `NymeaEnergy.SpotMarketConfigurationChanged`
Émis quand le provider actif, l'état enabled ou l'état available change.
```json
{
"enabled": true,
"available": true,
"o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67"
}
```
---
### `NymeaEnergy.SpotMarketStatusChanged`
Émis quand `enabled` ou `available` change (sans les détails du provider).
```json
{ "enabled": true, "available": true }
```
---
### `NymeaEnergy.SpotMarketScoreEntriesChanged`
Émis quand les cotations sont mises à jour par le provider.
```json
{ "spotMarketScoreEntries": [ ... ] }
```
---
## Interfaces nymea consommées
Le plugin détecte les appareils **par interface**, jamais par ThingClassId.
| Interface | États lus | Actions envoyées |
|---|---|---|
| `evcharger` | `chargingEnabled`, `maxChargingCurrent`, `pluggedIn`, `charging`, `currentPhaseA/B/C`, `currentPowerPhaseA/B/C` | `setChargingEnabled`, `setMaxChargingCurrent` |
| `electricvehicle` | `batteryLevel`, `maxChargingCurrent`, `capacity`, `minChargingCurrent`, `phaseCount` | — |
| `rootmeter` / `energymeter` | `currentPowerPhaseA/B/C`, `currentPhaseA/B/C` | — |
| `energystorage` | `currentPower`, `batteryLevel` | — |
**Déclencheur du cycle :** signal `PowerBalanceEntryAdded` de `nymea-experience-plugin-energy` (~1 min).
**Déclencheur overload :** signal `EnergyManager::powerBalanceChanged` (temps réel, découplé du cycle).
---
## EnergyManagerConfiguration
Paramètres de tuning chargés **une seule fois au démarrage** depuis un fichier JSON.
Pas de setters — un changement nécessite un redémarrage du daemon nymea.
**Chemin (ordre de priorité) :**
1. `$NYMEA_ENERGY_MANAGER_CONFIG` (variable d'environnement)
2. `/var/lib/nymea/energy-manager-configuration.json`
3. Valeurs par défaut si aucun fichier trouvé
**Format JSON :**
```json
{
"chargingEnabledLockDuration": 300,
"chargingCurrentLockDuration": 10,
"minimumScheduleDuration": 15,
"spotMarketChargePredictableEnergyPercentage": 0.5
}
```
| Paramètre | Défaut | Unité | Rôle |
|---|---|---|---|
| `chargingEnabledLockDuration` | 300 | secondes | Anti-flapping : durée de verrouillage après changement ON/OFF |
| `chargingCurrentLockDuration` | 10 | secondes | Anti-flapping : durée de verrouillage après changement de courant |
| `minimumScheduleDuration` | 15 | minutes | Durée minimale d'un créneau spot market planifié |
| `spotMarketChargePredictableEnergyPercentage` | 0.5 | ratio [0–1] | Fraction de l'énergie spot considérée "prédictible" dans le planning |
---
## Flux interne — `SmartChargingManager`
### Entrées de données
```
EnergyManager::logs()::powerBalanceEntryAdded (SampleRate1Min)
└─→ update(now) ← cycle principal ~1/min
EnergyManager::powerBalanceChanged ← temps réel
└─→ verifyOverloadProtection(now) ← safety loop immédiate, découplée du cycle
RootMeter (wraps Thing interface=rootmeter/energymeter)
├── currentPower() ← total W (négatif = surplus / export)
├── currentPowerPhaseA/B/C() ← W par phase
└── currentPhaseA/B/C() ← A par phase
ThingManager → interface "energystorage"
├── batteryLevel ← % (moyenne de tous les stockages)
└── currentPower ← W total (+ = charge, − = décharge)
EvCharger → interface "evcharger" + "electricvehicle"
├── currentPower(), maxChargingCurrent(), phaseCount()
├── meteredPhases() ← phases réelles via currentPhaseA/B/C live
└── car: batteryLevel, capacity, minChargingCurrent, phaseCount
```
### Pipeline `update()` — exécuté à chaque cycle
```
update(currentDateTime)
│
├─ 1. updateManualSoCsWithoutMeter()
│ Estime le SoC à partir de l'énergie intégrée si pas de compteur sur le VE.
│
├─ 2. prepareInformation()
│ - Filtre les EV chargers actifs (plugged, car assignée, mode ≠ Normal)
│ - Calcule par charger : phases effectives, SoC, temps restant, phaseLimitPower
│ - Reset m_chargingActions à OFF/minCurrent pour les 3 issuers
│
├─ 3. verifyOverloadProtection()
│ - Lit rootMeter->currentPowerPhaseA/B/C()
│ - Si une phase dépasse phasePowerLimit × 230 W → throttle immédiat (issuer=OverloadProtection)
│
├─ 4. verifyOverloadProtectionRecovery()
│ - Ré-active les chargers throttlés si la marge est suffisante
│
├─ 5. planSpotMarketCharging()
│ - SpotMarketManager::scheduleChargingTime() → TimeFrames (créneaux bon marché)
│ - Remplit m_chargingSchedules + m_chargingActions[SpotMarket] = ON
│
├─ 6. planSurplusCharging()
│ - currentLoad = rootMeter->currentPower()
│ + correction batteries (fromBatteries)
│ + puissance ajoutée dans ce cycle (addedPower)
│ - allowanceInAmpere = −currentLoad / 230
│ - Si allowance ≥ minCurrent × acquisitionTolerance → chargingActions[Surplus] = ON
│
└─ 7. adjustEvChargers()
Applique la décision finale par priorité décroissante :
1. TimeRequirement → ON au max courant disponible (deadline imminente)
2. SurplusCharging → ON au courant surplus calculé
3. SpotMarketCharging → ON au courant max dans le créneau
4. EcoWithMinCurrent fallback → ON à 6 A (EcoMinChargingCurrent)
5. Idle → OFF
│
├── executeChargingAction()
│ └─→ Thing::executeAction(setChargingEnabled, setMaxChargingCurrent)
│
├── emit chargingInfoChanged() ← met à jour ChargingState (lu par JSON-RPC)
└── emit chargingSchedulesChanged() ← planning rafraîchi (lu par JSON-RPC)
```
### Persistance des settings utilisateur
| Données | Fichier |
|---|---|
| `phasePowerLimit`, `acquisitionTolerance`, `batteryLevelConsideration` | `EnergySettings` (QSettings INI, `energy.conf`) |
| `ChargingInfo` par charger (mode, endDateTime, repeatDays, etc.) | même `EnergySettings`, groupe `ChargingInfos/` |
| `lockOnUnplug` | `/var/lib/nymea/energy.conf` (QSettings séparé) |
| SpotMarket `enabled`, `providerId` | `EnergySettings` (géré par `SpotMarketManager`) |
### Point d'injection pour `powersync-optimizer`
Le point naturel est **entre `prepareInformation()` et `adjustEvChargers()`**.
L'optimizer reçoit les données de contexte (`SurplusData`) et retourne une `ChargingAction` qui remplace ou complète les actions calculées localement. Voir `PowerSyncClient::requestOptimization()` dans `etm/`.
---
## Notes d'intégration
- Les timestamps sont des `QDateTime` sérialisés ISO 8601 avec timezone.
- Les puissances sont en **Watts**, les courants en **Ampères**.
- `phasePowerLimit` est en **Ampères** (par phase), pas en Watts.
- `weighting` des `ScoreEntry` : 1.0 = créneau le moins cher, 0.0 = le plus cher.
- `SetChargingInfo` **n'est PAS partiel** : l'objet remplace le stocké, et tout champ writable omis retombe sur son défaut (`targetPercentage` → 80, `repeatDays` → vide, …), sans erreur. Lire avec `GetChargingInfos`, modifier, renvoyer complet. `evChargerId` est toujours requis.
- `currentPower` du root meter est **négatif** quand il y a surplus solaire (export réseau).
- Le plugin fonctionne sans `powersync-optimizer` (mode Community, dégradé proprement).