Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. 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. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
19 KiB
TEST_TERRAIN.md — Procédure de test Palier 1 (nymea-dev arm64)
Test terrain du moteur etm-powersync-energy-plugin-etm sur banc : compteur mock forçable (puissance injectée par HTTP) + relais GPIO réels (état vérifié via JSON-RPC nymead ou multimètre NO-COM). 14 tests (T1–T14). Chaque test : inject → log attendu → vérif relais → case ✓/✗.
Convention puissance compteur :
currentPower < 0= export (surplus PV) ;> 0= import. Le moteur calcule un surplus net SIGNÉ(exportW − importW)qui cascade par priorité.
§0 — Pré-vol (à remplir SUR LA BOX)
| Élément | Valeur | Source |
|---|---|---|
| IP / hostname | 192.168.1.75 — hostname hems, user etm (sudo) |
briefing terrain |
| Port JSON-RPC | nymeas://hems:2222 (TCP+TLS) · wss://hems:4444 |
connu |
| Auth requise ? | __________ |
[À LIRE SUR LA BOX] JSONRPC.Hello → champ authenticationRequired |
| Token (si auth) | __________ |
Users.Authenticate {username,password,deviceName} → token |
| ThingClassId compteur mock | 2721a051-6e12-471a-baba-21d87c4cebc9 |
connu (energymocks) |
| ThingId compteur mock | __________ |
[À LIRE] après Integrations.AddThing, ou Integrations.GetThings |
| Port HTTP compteur mock | 26655 |
connu |
| ThingId R500 (Relay1 C1 BCM5, pin 29) | 8538782f-2c8c-4a30-bfce-c8140f791c9b |
briefing terrain |
| ThingId R1000 (Relay2 C2 BCM6, pin 31) | 2ebe6bef-829b-4695-9d11-ddb4c16c5448 |
briefing terrain |
| ThingId R2000 (Relay3 C3 BCM13, pin 33) | b033b212-1adb-4df0-ba2b-8fa477de52a2 |
briefing terrain |
| ThingId K1 (Relay4 C4 BCM16, pin 36) | beaf92e1-aedc-4b84-9ce4-e423648638cc |
briefing terrain |
| ThingId K2 (Relay5 C5 BCM19, pin 35) | bf236e64-5ae7-4bf1-82a6-4ede03de75a6 |
briefing terrain |
| gpiochip + BCM offsets | gpiochip0 ; BCM R500=5, R1000=6, R2000=13, K1=16, K2=19 |
vérifiés — gpioget valide uniquement nymead ARRÊTÉ |
| Conteneur build cross-arm64 | __________ |
[À LIRE DANS etm-powersync-deploy/DEPLOY.md] |
[À LIRE SUR LA BOX]= dépend du déploiement, pas inventé ici. Tout le reste est figé.
§1 — Déploiement (option a : build cross-arm64 → scp → dpkg)
a. Build cross-arm64 (depuis le poste dev)
# Dans le conteneur de build cross-arm64 (cf. DEPLOY.md du repo etm-powersync-deploy).
# Produit le .deb arm64 : nymea-energy-plugin-nymea_<ver>_arm64.deb
# (TARGET inchangé = libnymea_energypluginnymea.so, drop-in remplaçant l'amont).
Le nom de paquet/TARGET est inchangé (décision Phase 1) → le
.debETM remplace le plugin énergie amont. Un seul plugin énergie chargé.
b. Déploiement sur la box
~/projects/etm-powersync/etm-powersync-deploy/deploy.sh \
powersync-energy-plugin-nymea_<ver>_arm64.deb --host 192.168.1.75
ssh etm@hems 'journalctl -u nymead -f' # suivre les logs
Ne PAS installer à la main, et surtout pas par glob. La recette qui figurait ici —
scp … /tmp/puissudo dpkg -i /tmp/…_*_arm64.deb— porte les trois défauts qui ont rétrogradé.75de+etm12à+etm9le 2026-08-25 : un glob sur un répertoire qui accumule, undpkg -iqui autorise la rétrogradation par défaut (dpkg --force-help:[*] downgrade), et aucune relecture de la version après coup.+etm12a été perdu.deploy.sharchive avant de déposer, installe par nom, refuse une rétrogradation non demandée et échoue bruyamment si la version installée n'est pas celle attendue. Détail et rollback :DEPLOY.md§7.6 du dépôtetm-powersync-deploy..soinstallé dans/usr/lib/<multiarch>/nymea/energy/libnymea_energypluginnymea.so.
c. Activer le logging [Arbitre] (SINON aucune trace de décision)
La catégorie est exactement NymeaEnergy (NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")).
Méthode robuste — drop-in systemd (Qt logging rules) :
ssh etm@$BOX 'sudo mkdir -p /etc/systemd/system/nymead.service.d && \
printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" | \
sudo tee /etc/systemd/system/nymead.service.d/etm-logging.conf >/dev/null && \
sudo systemctl daemon-reload && sudo systemctl restart nymead'
Alternative selon la version :
nymead --logging <règles>ou la section logging de/etc/nymea/nymead.conf. [VÉRIFIERnymead --helpSUR LA BOX] si le drop-in ne suffit pas. Les lignes attendues commencent par[Arbitre]et[EcsRelayAdapter]/[SgReadyAdapter].
d. Déclarer les adaptateurs de test — LA VRAIE MÉTHODE
⚠️ Il n'existe pas encore de config runtime des adaptateurs (déféré : couche « config
priorités »). En production, energypluginnymea.cpp::init() crée l'EnergyArbitrator mais
n'enregistre aucun adaptateur ECS/SG-Ready. La déclaration se fait donc par un bloc de
code ajouté à energypluginnymea.cpp (juste après la création de chargingManager,
ligne ~54), recompilé dans le .deb de test.
Workflow : déployer une 1re fois → Integrations.AddThing le compteur mock + les relais GPIO
→ relever leurs ThingId (GetThings) → coller le bloc ci-dessous avec ces ThingId → rebuild → redéployer.
// energypluginnymea.cpp, init(), APRÈS la ligne :
// EnergyArbitrator *chargingManager = new EnergyArbitrator(...);
#ifdef ETM_ARBITRATOR
{
ThingManager *tm = thingManager();
// --- ECS 3 relais (8 niveaux binaires) ---
const QString R500 = "{8538782f-2c8c-4a30-bfce-c8140f791c9b}"; // Relay1 C1 BCM5
const QString R1000 = "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}"; // Relay2 C2 BCM6
const QString R2000 = "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"; // Relay3 C3 BCM13
auto *ecs = new EcsRelayAdapter(
tm, "ecs-terrain", "ECS banc",
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({ {}, {R500}, {R1000}, {R500,R1000},
{R2000}, {R500,R2000}, {R1000,R2000}, {R500,R1000,R2000} }),
/*minOnS*/ 60, /*minOffS*/ 60, /*priority*/ 1, chargingManager);
chargingManager->registerEcsAdapter(ecs);
// --- SG-Ready (2 bits K1/K2) ---
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}"; // Relay4 C4 BCM16
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"; // Relay5 C5 BCM19
auto *pac = new SgReadyAdapter(
tm, "pac-terrain", "PAC banc",
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
/*minStateHoldS*/ 300, /*priority*/ 2, chargingManager);
chargingManager->registerSgReadyAdapter(pac);
}
#endif
Inclure
#include "etm/adapters/ecsrelayadapter.h"etetm/adapters/sgreadyadapter.h. Le root meter = le compteur mock : le déclarer via l'expérience énergie nymea (Energy.SetRootMeter/ config) avec le ThingId du compteur mock. Pour l'ECS simple (T1/T2), utiliser un seul relais :stages {0,2000},mapping {{},{R2000}}.
Helpers bash (poste dev)
BOX=hems; MPORT=26655
# ThingIds relais (banc hems — pré-vérifiés, cf. §0)
TID_R500="8538782f-2c8c-4a30-bfce-c8140f791c9b"
TID_R1000="2ebe6bef-829b-4695-9d11-ddb4c16c5448"
TID_R2000="b033b212-1adb-4df0-ba2b-8fa477de52a2"
TID_K1="beaf92e1-aedc-4b84-9ce4-e423648638cc"
TID_K2="bf236e64-5ae7-4bf1-82a6-4ede03de75a6"
# Injecter une puissance compteur (W). Négatif = export (surplus PV), positif = import.
inject(){ curl -s "http://$BOX:$MPORT/setstates?connected=true¤tPowerPhaseA=$(($1/3))¤tPowerPhaseB=$(($1/3))¤tPowerPhaseC=$(($1/3))" >/dev/null; echo "compteur = $1 W"; }
# Compteur MUET : on cesse d'injecter (>90 s) → watchdog L2 bascule (QTimer 30 s, seuil 90 s).
mute(){ echo "NE PLUS injecter pendant >90 s…"; sleep 95; }
# Suivre les décisions de l'arbitre.
logs(){ ssh etm@$BOX "sudo journalctl -u nymead -f | grep -E 'Arbitre|EcsRelay|SgReady'"; }
# relay <ThingId> → 1 (ON) | 0 (OFF) via JSON-RPC local nymead.
# Protocole complet : Hello → auth si requise → GetThingClasses (résolution stateTypeId
# 'power') → GetThings. Framing : boucle par id — ignore les notifications push nymea.
# gpioget inutilisable nymead actif (libgpiod v2 busy). Filet : multimètre NO-COM nymead arrêté.
# Variables optionnelles : NYMEA_USER (défaut admin) NYMEA_PASS (défaut vide).
# ACCEPTANCE avant T1 : forcer un relais ON via l'app nymea, vérifier relay $TID_Rxx → 1 ;
# forcer OFF → 0. Tant que ce test isolé ne passe pas, la colonne "vérif" de T1–T14 est aveugle.
relay(){
local tid="$1"
local nu="${NYMEA_USER:-admin}" np="${NYMEA_PASS:-}"
ssh etm@$BOX "python3 - '$nu' '$np'" << GETSTATE
import ssl, socket, json, sys
user, pw = sys.argv[1], sys.argv[2]
ctx = ssl.create_default_context()
ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
buf = b''
def call(s, rid, method, params=None, token=None):
global buf
req = {'id': rid, 'method': method}
if params: req['params'] = params
if token: req['token'] = token
s.sendall((json.dumps(req) + '\n').encode())
while True:
buf += s.recv(4096)
lines = buf.split(b'\n'); buf = lines[-1]
for ln in lines[:-1]:
ln = ln.strip()
if not ln: continue
try:
m = json.loads(ln)
if m.get('id') == rid: return m
except: pass
with socket.create_connection(('localhost', 2222), timeout=10) as raw, \
ctx.wrap_socket(raw) as s:
hello = call(s, 1, 'JSONRPC.Hello')
token = None
if hello.get('params', {}).get('authenticationRequired', False):
r = call(s, 2, 'Users.Authenticate',
{'username': user, 'password': pw, 'deviceName': 'relay-chk'})
p = r.get('params', {})
if not p.get('success', True) or 'token' not in p:
sys.exit('AUTH_FAILED — définir NYMEA_USER / NYMEA_PASS')
token = p['token']
# Résoudre stateTypeId de 'power' pour chaque ThingClass (les states dans GetThings
# ne portent pas le nom, seulement le stateTypeId — cf. nymea JSON-RPC spec).
tc = call(s, 3, 'Integrations.GetThingClasses', token=token)
power_stid = {}
for c in tc.get('params', {}).get('thingClasses', []):
for st in c.get('stateTypes', []):
if st.get('name') == 'power':
power_stid[c['id']] = st['id']
resp = call(s, 4, 'Integrations.GetThings', token=token)
things = resp.get('params', {}).get('things', [])
t = next((x for x in things if x['id'] == '${tid}'), None)
if not t: sys.exit('THING_NOT_FOUND: ${tid}')
stid = power_stid.get(t.get('thingClassId', ''), '')
state = next((st for st in t.get('states', []) if st.get('stateTypeId') == stid), None)
print(1 if state and state.get('value') else 0)
GETSTATE
}
Le compteur mock pose
currentPower = somme des 3 phases.RootMeter::currentPower()le relit.
§2 — ECS simple (1 relais, paliers {0, 2000})
T1 — Montée sur surplus
- inject :
inject -2500 - log attendu :
[Arbitre] … Surplus PV … ECS palier 1puis[EcsRelayAdapter] … → stage 1 - vérif :
relay $TID_R2000→ 1 (ON) - ✓ / ✗ :
____
T2 — Délestage (import)
- inject :
inject 1000(import → surplus net négatif) - log attendu :
Surplus insuffisant … ECS éteint;→ stage 0 - vérif :
relay $TID_R2000→ 0 (OFF) - ✓ / ✗ :
____
§3 — ECS 3 relais (8 niveaux binaires R500/R1000/R2000)
T3 — Cascade montante
- inject :
inject -1700(budget 1700 → palier 1500) - log :
ECS palier 3 (1500 W) - vérif :
relay $TID_R500→1,relay $TID_R1000→1,relay $TID_R2000→0 - ✓ / ✗ :
____
T4 — Transition NON-CASCADÉE 1500 → 2000 (le test clé)
- contexte : on part de T3 (palier 1500, l'ECS mesure 1500 W).
- inject :
inject -700(budget = 700 + 1500 recrédit = 2200 → palier 2000) - log :
ECS palier 4 (2000 W); commutation off-before-on (R500/R1000 coupés avant R2000) - vérif :
relay $TID_R500→0,relay $TID_R1000→0,relay $TID_R2000→1 ← set final R2000 SEUL, pas un état parasite durable - ✓ / ✗ :
____
T5 — Protection minOn (anti court-cycling)
- contexte : ECS vient de commuter (< minOn 60 s).
- inject :
inject 1200(import → le budget voudrait éteindre) - log attendu :
Verrou minOn — … maintenu palier … - vérif : relais inchangés (maintien). Attendre > 60 s puis re-
inject 1200→ délestage. - ✓ / ✗ :
____
T6 — Délestage complet
- inject :
inject 2000(import franc, > minOn écoulé) - vérif :
relay $TID_R500→0,relay $TID_R1000→0,relay $TID_R2000→0 - ✓ / ✗ :
____
§3.5 — Pré-vol SG-Ready (à cocher avant T7, nymead ARRÊTÉ)
⚠️ La polarité K1/K2 est le seul point non vérifié proprement de toute la chaîne. Le test initial a échoué sur
Device or resource busy(nymead tient les lignes GPIO via libgpiod v2). Cette vérification doit se faire nymead arrêté, au multimètre, avant toute mise sous tension côté PAC.
ssh etm@$BOX 'sudo systemctl stop nymead'
ssh etm@$BOX 'gpioget -c gpiochip0 16 19' # attendu : 0 0 (repos LOW BCM16/BCM19)
gpiogetrépond 0 0 — K1 (BCM16) et K2 (BCM19) au repos LOW. ✓- K1 repos = circuit ouvert : multimètre en continuité NO-COM sur bornes K1 → circuit ouvert = PAC interprète 0:0 = état 2 « normal » (jamais 1:0 = blocage au boot). ✓
- K2 repos = circuit ouvert (idem). ✓
- Fail-safe boot confirmé : nymead démarre K1=K2=0 (repos LOW, NO/NC → circuit ouvert → PAC = normal). ✓
sudo systemctl start nymeadavant T7.
§4 — SG-Ready (PAC, 2 bits K1/K2 ; P3=1500, P4=3000)
Seuils code (
rulebasedscheduler.cpp,kForceMargin=1.2, P3=1500, P4=3000) :
- État 3 si budgetW ≥ 1500 ; état 4 si budgetW ≥ P4×1,2 = 3600 ; zone morte état 4 si budgetW ≥ 3000.
budgetW = exportW_brut + allocatedNow; allocatedNow = 0 (état 2), 1500 (état 3), 3000 (état 4).- Depuis état 2 (sans recrédit) : min. inject ≤ −3600 pour atteindre état 4 directement.
- Depuis état 3 (recrédit 1500) : min. inject ≤ −2100 pour atteindre état 4.
T7 — Montée d'états 2 → 3 → 4
inject -1000→ état 2 (budget 1000 < 1500, K1=0,K2=0).inject -2000→ état 3 (budget 2000 ≥ 1500, K2=1). Attendre > minStateHold (300 s).inject -2500→ état 4 (budget = 2500 + 1500 recrédit = 4000 ≥ 3600, K1=1,K2=1).- log :
… recommandée (état 3 …)puis… forcée (état 4 …) - vérif : état 3 →
relay $TID_K1→0,relay $TID_K2→1 ; état 4 →relay $TID_K1→1,relay $TID_K2→1 - ✓ / ✗ :
____
T8 — Atomicité (transitoire bénin)
- contexte : transition 2→4 (00→11) commute K1 ET K2.
- vérif : set final
relay $TID_K1→1,relay $TID_K2→1 (état 4). Le transitoire passe par K2 d'abord (01=reco), jamais 10=blocage — sub-ms, non observable, mais garanti code (transientHarm). Confirmer simplement le set final correct. - ✓ / ✗ :
____
T9 — Zone morte hystérésis (pas de bascule 4→3 sur fluctuation surplus)
- contexte : PAC en état 4 (K1=K2=1) depuis > minStateHold (300 s).
L'opérateur injecte le surplus brut au compteur — le moteur recrédite la puissance
allouée (3000 W déclarés) en interne ; ne pas l'ajouter à
inject. - Faire fluctuer le surplus :
inject -300→ reste 4 ;inject -500→ reste 4 ;inject -100→ reste 4 ;inject 0(équilibré) → reste 4 (tout inject ≤ 0 tient l'état). - Sortie :
inject 300(import net) → état 3. - vérif :
relay $TID_K1→1,relay $TID_K2→1 stable pendant la fluctuation ;relay $TID_K1→0 uniquement aprèsinject 300. - ✓ / ✗ :
____
§5 — Watchdog L2 (compteur muet)
T10 — Compteur muet → mode dégradé
- inject
-2500(ECS/PAC servis), puismute(>90 s sans injection). - log attendu :
[Arbitre] Compteur muet depuis … mode dégradé L2; ECS palier 0 ; PAC état 2. - vérif :
relay $TID_R500→0,relay $TID_R1000→0,relay $TID_R2000→0 ;relay $TID_K1→0,relay $TID_K2→0 (état 2, jamais blocage). - ✓ / ✗ :
____
T11 — Stabilité (faux surplus piège)
- contexte : toujours muet ; la dernière valeur injectée (-2500) reste « collée » au compteur.
- vérif : sur plusieurs minutes muettes, ECS reste 0 malgré ce surplus stale (planification suspendue — pas de replanif sur cache mort).
- ✓ / ✗ :
____
T12 — Reprise
- inject
-2500(le compteur re-parle). - log attendu :
Compteur de nouveau actif — sortie du mode dégradé; recalcul normal. - vérif : ECS resuit le surplus (palier > 0). Pas de restauration d'ancienne consigne.
- ✓ / ✗ :
____
§6 — Interaction budget partagé
T13 — Ordre de priorité ECS↔PAC inversable
- contexte : surplus moyen
inject -3000, ECS palier 2400 vs PAC P3 1500. - ECS prio 1 / PAC prio 2 : ECS se sert (palier), PAC voit le reliquat (600) → état 2.
- Inverser les priorités (échanger
prioritydans le bloc §1.d, rebuild/redeploy) → PAC se sert (état 3), ECS voit le reliquat (1500 < 2400) → palier 0. - vérif : le service s'inverse selon la priorité (preuve du waterfall unifié).
- ✓ / ✗ :
____
§7 — OPTIONNEL EV / V2C (si plugin prêt)
T14 — Ordre étagé EV → ECS (beta : EV servi avant le waterfall)
- contexte : borne EV branchée + surplus moyen.
- attendu : l'EV est servi en premier (proxy amont, décision B), l'ECS voit le reliquat.
- vérif : courant EV puis palier ECS sur le reliquat. (V2C = session dédiée, hors Palier 1.)
- ✓ / ✗ :
____
Checklist sécurité (AVANT mise sous tension)
- Disjoncteur L0 du banc accessible et identifié (coupure physique immédiate).
- Banc câblé hors tension ; sections/calibres relais conformes aux puissances (R2000 = 2000 W).
- Polarité SG-Ready vérifiée (§3.5 fait et coché) — seul point non vérifié avant cette session.
- Relais résistifs ECS : pas de charge inductive sur ces voies.
- Mapping BCM↔relais consigné (
gpioinfo gpiochip0, nymead arrêté) avant tout test. - L1 (failsafe bornes/relais) configuré si applicable ; L0 reste le filet ultime.
- Un opérateur à la main sur le disjoncteur pendant T1–T6 (montées de puissance).