Fix G: coupure EDF générique quand l'override EVCS disparaît mi-session

Incident nuit 21-22/08 : la fenêtre HC nuit se ferme pendant que le véhicule
est encore en session active (pas encore flag "charge terminée"). L'EVCS
relâche correctement son override, mais émet un état transitoire générique
(stop_source_lost/idle_wait_source) qu'aucune règle de coupure immédiate du
workflow Grid Hysteresis (Fix C/E2) ne reconnaît. EDF restait alors maintenu
des heures par la boucle générique jusqu'au seuil statique gridSocOff,
plafonnant le MPPT Victron à ~100W et privant la maison de solaire.

Nouvelle règle générique (Compute Grid Hysteresis) basée sur evcsGridOverride
plutôt que sur une liste blanche de noms de surplus_state — couvre donc aussi
tout futur état non énuméré. Garde anti-glitch sur lecture HA invalide.
Nouvelle suite tests/golden-hysteresis/ (7 scénarios, première pour ce
workflow), câblée bloquante dans tools/n8n-push.mjs. Déployé et validé
(golden-hysteresis 7/7, golden EVCS 39/39 inchangé).

Écarte le diagnostic incomplet laissé par un autre agent dans HANDOFF.md
(edf_general_ok_off côté EVCS aurait créé un deuxième écrivain non coordonné
sur le même switch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tallagrand Thierry
2026-08-22 19:56:06 +02:00
parent 5bc9784583
commit e68fa160b6
8 changed files with 577 additions and 5 deletions

View File

@@ -1109,5 +1109,136 @@ p11-changement-vehicule-nuit (prev=night_recovery_done_idle + charging_time 15s
2. CHANGEMENT DE VÉHICULE / nouvelle connexion (charging_time ≤ 90 s, P11) ; 2. CHANGEMENT DE VÉHICULE / nouvelle connexion (charging_time ≤ 90 s, P11) ;
3. force_charge_now / force_grid_now (échappatoires manuelles). 3. force_charge_now / force_grid_now (échappatoires manuelles).
## Diagnostic en attente de vérification (Qwen, 22/08)
Voici mon analyse et plan de correction :
Diagnostic
Le bug est dans HA_SERVICE_CALLS_OUTPUT.js:987-996 :
edf_general_ok_on: {
execute: ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && !parsed.gridAvailable,
...
}
Il n'existe pas de edf_general_ok_off. Le workflow ne coupe JAMAIS activement EDF. Il ne fait que le rallumer quand sourceMode est grid ET que le réseau n'est pas encore disponible. Quand sourceMode revient à solar_only/blocked, le switch reste ON dans l'état qu'il avait.
Conséquence : après la fenêtre HC nocturne (~06:00), EDF reste allumé. La présence du réseau bloque la production PV (Victron plafonne à ~100 W). Le débranchement du véhicule résout parce que c'est le seul mécanisme qui coupe physiquement le switch.
Plan de correction
Fichier : src/nodes/HA_SERVICE_CALLS_OUTPUT.js
Ajouter un call edf_general_ok_off juste après edf_general_ok_on (ligne 996) :
edf_general_ok_off: {
execute:
!['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&
parsed.gridAvailable &parsed.evcsVehicle
**Verdict (Claude, 22/08) : diagnostic ÉCARTÉ, voir §24 ci-dessous.** Le
symptôme est correctement identifié (EDF ne se coupe jamais activement après
la fenêtre HC) mais la cause et le correctif proposés sont faux : le bug n'est
pas dans `HA_SERVICE_CALLS_OUTPUT.js` (qui n'a jamais eu vocation à couper EDF
— la coupure est le rôle exclusif du workflow séparé "Victron - Battery / Grid
Hysteresis Fix V1"), et ajouter un `edf_general_ok_off` côté EVCS créerait un
deuxième écrivain non coordonné sur `switch.edf_general_ok_switch` (courses
30s/1min entre les deux workflows, le hysteresis reprenant la main au cycle
suivant via son propre `latchIsOn` et rallumant probablement EDF quand même).
Le vrai bug et son correctif (Fix G, côté hystérésis) sont documentés en §24.
--- ---
*Document mis à jour le : 12/08/2026 (~05:05) — P11 déployé et validé live, golden 39/39* *Document mis à jour le : 22/08/2026 (~18:00) — Fix G déployé (hystérésis), diagnostic Qwen écarté, golden-hysteresis 7/7*
## 24. Session du 22/08/2026 — Fix G : coupure EDF quand l'override EVCS disparaît mi-session (hystérésis)
### Incident (nuit 21-22/08)
Le takeover nuit (P7) a correctement rechargé la batterie à ~35 % via EDF.
Quand la fenêtre HC s'est fermée (~06:00-07:24) avec le véhicule **encore
branché en session active** (pas encore flag "charge terminée"), EDF est resté
allumé — le réseau a plafonné le MPPT Victron à ~100 W, privant la maison de
600-4000 W de solaire disponible (elle tirait ~1000 W d'EDF). Seul le
débranchement du véhicule a libéré EDF (2179 W de PV immédiatement après).
### Root cause
Côté EVCS (`WINDOWS_SOURCEV2.js`/`DECISION_ENGINEV2.js`) : **pas le bug**.
Chaque cycle, `nightSessionActive` et tout le moteur nuit sont activement
recalculés à `false`/`'blocked'` dès la fermeture de fenêtre — `helper
night_grid_takeover_needed` passe à `off` et `source_mode` à `'Bloqué'`
correctement et immédiatement, même mi-session. MAIS les deux branches
"collantes" de fin de session (Fix F, exige `profile === 'night_hc'` ; P10
`day_vehicle_full_idle`, exige que le `surplus_state` précédent soit déjà un
pattern "terminé") ne se déclenchent PAS sur le cycle exact où la fenêtre se
ferme mi-session non-terminée. Le système retombe sur des états génériques
transitoires (`stop_source_lost` puis `idle_wait_source`).
Côté **Grid Hysteresis** (`workflows/victron-grid-hysteresis-v1.json`, node
"Compute Grid Hysteresis") : **c'est là le bug**. Les règles de coupure
immédiate existantes (Fix C `turn_off_vehicle_unplugged`, Fix E2
`turn_off_recovery_done`) ne matchent que des noms exacts de `surplus_state`
(`vehicle_not_connected`, `night_recovery_done_idle`, `departure_done_idle`) —
aucune ne matche `stop_source_lost`/`idle_wait_source` (ni `day_vehicle_full_idle`
lui-même). Sans matche, le code retombe sur la boucle générique qui maintient
EDF jusqu'au seuil statique `grid_soc_off` (35 %, `input_number.victron_grid_soc_off`),
totalement découplé de la cible dynamique de l'EVCS — potentiellement des
heures, d'autant qu'EDF-ON lui-même bride la recharge solaire organique.
### Fix G (déployé 22/08 ~18:00, golden-hysteresis 7/7)
Au lieu d'ajouter encore un nom d'état à une liste blanche fragile, nouvelle
règle **générique** dans "Compute Grid Hysteresis", basée sur `evcsGridOverride`
(déjà fiable) plutôt que sur le nom de `surplus_state` — couvre donc aussi
automatiquement tout futur nom d'état non énuméré :
```javascript
if (
!forcedCut && latchIsOn && gridIsOn &&
!evcsGridOverride &&
soc > gridSocOn && // 22%, PAS gridSocOff 35% — protège le cas
// indépendant "SOC bas" qui doit tenir
// jusqu'à gridSocOff
evcsSourceModeLooksValid // garde anti-glitch (lecture HA vide/
// unavailable/unknown → pas d'action)
) {
desiredGrid = 'off'; desiredLatch = 'off';
state = 'turn_off_evcs_override_released';
forcedCut = true;
}
```
Coupure immédiate, bypass min_on (25 min), comme Fix C/E2. Décision validée
avec l'utilisateur : vitesse de libération prioritaire sur le risque résiduel
(un glitch de lecture HA sur un seul cycle pourrait causer une coupure/
rallumage isolé, auto-diagnosticable dans le logbook — pas de chatter soutenu
car il faudrait une lecture fausse à *chaque* cycle).
### Nouvelle suite de tests : `tests/golden-hysteresis/` (7 scénarios, première
suite pour ce workflow — 0 → 7)
`g-incident-gap-mid-session`/`g-incident-gap-idle-wait-source` (les deux noms
d'état zombies de l'incident, min_on non écoulé) · `g-incident-replay-hc-boundary`
(rejeu complet, min_on déjà écoulé aussi — prouve que ce n'est pas seulement un
bypass min_on) · `g-baseline-low-soc-no-override` (garde non-régression : SOC
bas indépendant doit continuer à tenir) · `g-regression-fixc-unplugged` /
`g-regression-fixe2-recovery-done` (Fix C/E2 toujours prioritaires, pas
absorbés par Fix G) · `g-glitch-guard-unavailable` (garde anti-glitch).
Lancée AVANT patch : 3 échecs (les scénarios d'incident, stuck sur
`hold_on_min_runtime`/`hold_on_until_soc_off`) + 4 succès (gardes déjà bonnes)
— confirme que le harnais détecte vraiment le bug. Après patch : 7/7.
Câblée dans `tools/n8n-push.mjs` (nouveau `isHysteresisWorkflow`), bloquante
au push comme les 2 autres workflows.
### Outillage
`tools/patch-20260822-g-override-released-cut.mjs` (rejouable, même patron que
`patch-20260809-e2-recovery-done-cut.mjs`) · `tests/golden-hysteresis/{fixture-snapshot.json,run.mjs,scenarios.mjs}`.
### Déploiement
`node tools/n8n-push.mjs workflows/victron-grid-hysteresis-v1.json` → backup
serveur auto (`modified/prepush-7iIlu7LLTW7TZA8o-*.json`) → syntaxe OK →
golden-hysteresis 7/7 → PUT OK. `grid_min_on_minutes` réel vérifié via MCP
`ha-get-state` avant de figer le fixture : **25** (pas le fallback 45 du code).
### Hors périmètre (volontairement)
Aucun changement côté `src/nodes/*` (EVCS V2.6) — le fix est entièrement côté
hystérésis. Amélioration cosmétique future possible, non incluse ici :
remplacer les noms zombies `stop_source_lost`/`idle_wait_source` par un état de
transition dédié pour la lisibilité dashboard/logbook.
### À surveiller (prochaine fermeture de fenêtre HC avec véhicule encore
branché mi-session)
1. Logbook doit montrer `turn_off_evcs_override_released` peu après la
fermeture de fenêtre si le véhicule n'est pas encore flag "terminé" ;
2. La PV doit remonter (MPPT non-plafonné) immédiatement après ;
3. Aucune coupure/rallumage répété (`turn_off_evcs_override_released` suivi de
près par `turn_on_evcs_override` répétitivement) — signe d'un glitch de
lecture HA à investiguer si observé.
---
*Document mis à jour le : 22/08/2026 (~18:05) — Fix G déployé et validé (syntaxe + golden-hysteresis 7/7 + PUT OK), à confirmer en conditions réelles à la prochaine fermeture de fenêtre HC mi-session

View File

@@ -1,7 +1,7 @@
# Manuel Utilisateur - Système EVCS Solaire # Manuel Utilisateur - Système EVCS Solaire
> Document généré le 17/07/2026 — dernière mise à jour le 09/08/2026 > Document généré le 17/07/2026 — dernière mise à jour le 22/08/2026
> (fixes anti-rebond A→F, câblage advisor cible SOC, P9 départ, véhicules VW/JEEP) > (fixes anti-rebond A→G, câblage advisor cible SOC, P9 départ, véhicules VW/JEEP)
> Projet: n8n / Home Assistant / EVCS Solaire sans injection réseau > Projet: n8n / Home Assistant / EVCS Solaire sans injection réseau
--- ---
@@ -445,6 +445,13 @@ Y1 EDF ON + latch
│ (turn_off_vehicle_unplugged, bypass min_on — Fix C) │ (turn_off_vehicle_unplugged, bypass min_on — Fix C)
│ v4: es ∈ {night_recovery_done_idle, departure_done_idle} + SOC ≥ seuil OFF │ v4: es ∈ {night_recovery_done_idle, departure_done_idle} + SOC ≥ seuil OFF
│ → coupure immédiate (turn_off_recovery_done, bypass min_on — E2+P9) │ → coupure immédiate (turn_off_recovery_done, bypass min_on — E2+P9)
│ v5: override EVCS disparu (evcsGridOverride → false) ET SOC > seuil ON
│ (PAS seuil OFF : ne touche pas au cas indépendant "SOC bas")
│ → coupure immédiate (turn_off_evcs_override_released, bypass
│ min_on — Fix G, 22/08/2026). Contrairement à v3/v4, ne dépend PAS
│ d'un nom exact de `surplus_state` — couvre tout état transitoire
│ (y compris ceux non encore nommés). Garde anti-glitch : ignoré si
│ la lecture `evcs_source_mode` est vide/unavailable/unknown.
└─▶ retour Y0 (cooldown 15 min) └─▶ retour Y0 (cooldown 15 min)
``` ```
@@ -477,6 +484,12 @@ retesté. Remises à zéro : débranchement, session fraîche (P11), ou force_*.
- **Matin 09/08 (VW pleine rebranchée)** : micro-charges du véhicule sur - **Matin 09/08 (VW pleine rebranchée)** : micro-charges du véhicule sur
batterie → hard stop ÉCRASAIT l'état collant → re-P7. → Fix F (mémoire batterie → hard stop ÉCRASAIT l'état collant → re-P7. → Fix F (mémoire
prioritaire sur les gardes, réécrite chaque cycle). prioritaire sur les gardes, réécrite chaque cycle).
- **Nuit 21-22/08** : fenêtre HC fermée mi-session (véhicule pas encore flag
terminé) → `surplus_state` retombe sur un état générique transitoire
(`stop_source_lost`/`idle_wait_source`) qu'aucune règle v3/v4 ne reconnaît →
EDF maintenu des heures par la boucle générique jusqu'à `gridSocOff`, PV
plafonné ~100 W par le MPPT. → Fix G (v5 : coupure générique dès que
l'override EVCS disparaît, sans dépendre du nom d'état `surplus_state`).
### 11.7 Conseiller IA — mode ombre et câblages réels ### 11.7 Conseiller IA — mode ombre et câblages réels
@@ -586,4 +599,4 @@ git commit && git push
--- ---
*Document généré le 17/07/2026 — mis à jour le 09/08/2026 (HANDOFF §17-21 : fixes A→F, advisor, P9, véhicules)* *Document généré le 17/07/2026 — mis à jour le 22/08/2026 (HANDOFF §17-24 : fixes A→G, advisor, P9, véhicules)*

View File

@@ -0,0 +1,32 @@
{
"soc_percent": "50",
"grid_state": "off",
"pv_power_w": "2000",
"house_ac_power_w": "700",
"grid_power_w": "0",
"evcs_ac_power_w": "0",
"evcs_charging": "off",
"evcs_status_raw": "6",
"hysteresis_enabled": "on",
"notifications_enabled": "on",
"grid_latch": "off",
"grid_soc_on": "22",
"grid_soc_off": "35",
"grid_min_on_minutes": "25",
"grid_cooldown_minutes": "15",
"ac_loads_max_safe_w": "7800",
"last_on_at": "",
"last_off_at": "",
"last_signature": "",
"evcs_priority_active": "off",
"night_grid_takeover_needed": "off",
"evcs_source_mode": "Bloqué",
"user_strategy": "auto_solar_plus",
"evcs_surplus_state": "idle_wait_conditions"
}

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
// Suite de tests GOLDEN du workflow "Victron - Battery / Grid Hysteresis Fix V1".
// Exécute le VRAI code du node "Compute Grid Hysteresis" sur un snapshot HA réel
// modifié par scénario, avec horloge simulée.
//
// Usage:
// node tests/golden-hysteresis/run.mjs # assertions (exit 1 si échec)
// node tests/golden-hysteresis/run.mjs --print # affiche les résumés (pour figer les attendus)
// node tests/golden-hysteresis/run.mjs --only=incident # filtre par id
//
// RÈGLE : cette suite DOIT passer avant tout push du workflow hystérésis
// (intégrée à tools/n8n-push.mjs).
import fs from 'fs';
import vm from 'vm';
import path from 'path';
import { fileURLToPath } from 'url';
import { scenarios } from './scenarios.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
const WORKFLOW_FILE = path.join(ROOT, 'workflows/victron-grid-hysteresis-v1.json');
const FIXTURE_FILE = path.join(__dirname, 'fixture-snapshot.json');
const PRINT = process.argv.includes('--print');
const JSON_OUT = process.argv.includes('--json');
const only = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7);
const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8'));
const fixture = JSON.parse(fs.readFileSync(FIXTURE_FILE, 'utf8'));
const nodeCode = wf.nodes.find(n => n.name === 'Compute Grid Hysteresis').parameters.jsCode;
// Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox.
// Le node utilise new Date() (zéro argument) pour "maintenant" et
// new Date(y, mo-1, d, h, mi, se) (multi-arguments) pour reparser last_on_at
// /last_off_at — seuls les appels zéro-argument doivent être épinglés.
function makeFakeDate(fixedMs) {
return class FakeDate extends Date {
constructor(...args) {
if (args.length === 0) super(fixedMs);
else super(...args);
}
static now() { return fixedMs; }
};
}
function runNode(jsCode, inputJson, fixedMs) {
const ctx = {
$input: { first: () => ({ json: inputJson }) },
console, Intl, Math, Number, Object, String, JSON, Array, Boolean,
Date: makeFakeDate(fixedMs),
};
vm.createContext(ctx);
const out = vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx);
return out[0].json;
}
function summarize(out) {
const c = out.computed || {};
const d = out.decision || {};
return {
state: d.state,
gridAction: d.grid_action,
latchAction: d.latch_action,
shouldNotify: !!d.should_notify,
evcsGridOverride: !!c.evcs_grid_override,
};
}
const jsonResults = {};
let failures = 0, ran = 0;
for (const sc of scenarios) {
if (only && !sc.id.includes(only)) continue;
ran++;
const fixedMs = new Date(sc.time).getTime();
if (!Number.isFinite(fixedMs)) throw new Error(`${sc.id}: time invalide`);
const raw = { ...fixture, ...sc.overrides };
const payload = { data: JSON.stringify(raw) };
let out;
try {
out = runNode(nodeCode, payload, fixedMs);
} catch (e) {
failures++;
console.log(`💥 ${sc.id} : CRASH — ${e.message}`);
continue;
}
const got = summarize(out);
if (JSON_OUT) {
jsonResults[sc.id] = got;
continue;
}
if (PRINT) {
console.log(`\n─── ${sc.id} | ${sc.label}`);
console.log(' expect:', JSON.stringify(got));
if (out.decision?.short_reason) console.log(' raison:', out.decision.short_reason);
continue;
}
const diffs = [];
for (const [k, v] of Object.entries(sc.expect || {})) {
if (JSON.stringify(got[k]) !== JSON.stringify(v)) diffs.push(`${k}: attendu ${JSON.stringify(v)}, obtenu ${JSON.stringify(got[k])}`);
}
if (diffs.length) {
failures++;
console.log(`❌ ${sc.id} | ${sc.label}`);
for (const d of diffs) console.log(` ${d}`);
if (out.decision?.short_reason) console.log(` raison: ${out.decision.short_reason}`);
} else {
console.log(`✅ ${sc.id}`);
}
}
if (JSON_OUT) {
console.log(JSON.stringify(jsonResults, null, 2));
process.exit(0);
}
if (!PRINT) {
console.log(failures
? `\n${failures}/${ran} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION`
: `\nGOLDEN HYSTERESIS OK — ${ran}/${ran} scénarios conformes`);
process.exit(failures ? 1 : 0);
}

View File

@@ -0,0 +1,168 @@
// Scénarios golden pour le node "Compute Grid Hysteresis"
// (workflow "Victron - Battery / Grid Hysteresis Fix V1").
//
// Contexte (Fix G, 22/08/2026) : incident nuit 21-22/08 — la fenêtre HC nuit
// se ferme pendant que le véhicule est encore en session active (pas encore
// flag "charge terminée"). Le contrôleur EVCS relâche correctement son
// override (helper night_grid_takeover_needed -> off, source_mode -> Bloqué)
// mais émet un état transitoire générique (stop_source_lost / idle_wait_source)
// qu'aucune règle de coupure immédiate existante (Fix C/E2) ne reconnaît.
// EDF restait alors maintenu par la boucle générique jusqu'au seuil
// grid_soc_off (35%), potentiellement des heures.
function stamp(ms) {
const p = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).formatToParts(new Date(ms));
const g = t => p.find(x => x.type === t).value;
return `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}`;
}
function minutesAgo(fixedMs, minutes) {
return stamp(fixedMs - minutes * 60000);
}
const T = '2026-08-22T06:15:00+02:00'; // fenêtre HC (01:24-07:24) fermée mi-session
const T_MS = new Date(T).getTime();
export const scenarios = [
{
id: 'g-incident-gap-mid-session',
label: 'Fenêtre fermée mi-session (stop_source_lost) — min_on NON écoulé',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '30',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'Bloqué',
evcs_surplus_state: 'stop_source_lost',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 5), // << 25 min de min_on
},
expect: {
state: 'turn_off_evcs_override_released',
gridAction: 'turn_off',
latchAction: 'turn_off',
evcsGridOverride: false,
},
},
{
id: 'g-incident-gap-idle-wait-source',
label: 'Fenêtre fermée mi-session (idle_wait_source) — même bypass générique',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '30',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'Bloqué',
evcs_surplus_state: 'idle_wait_source',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
state: 'turn_off_evcs_override_released',
gridAction: 'turn_off',
latchAction: 'turn_off',
},
},
{
id: 'g-baseline-low-soc-no-override',
label: 'SOC bas indépendant (18% <= seuil ON 22%) — Fix G ne doit PAS agir',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '18',
night_grid_takeover_needed: 'off',
evcs_source_mode: '',
evcs_surplus_state: '',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
state: 'hold_on_low_soc',
gridAction: 'none',
latchAction: 'none',
},
},
{
id: 'g-regression-fixc-unplugged',
label: 'Véhicule débranché — Fix C doit toujours gagner (pas absorbé par Fix G)',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '40',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'Bloqué',
evcs_surplus_state: 'vehicle_not_connected',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
state: 'turn_off_vehicle_unplugged',
gridAction: 'turn_off',
},
},
{
id: 'g-regression-fixe2-recovery-done',
label: 'Recovery done — Fix E2 doit toujours gagner (pas absorbé par Fix G)',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '40',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'Bloqué',
evcs_surplus_state: 'night_recovery_done_idle',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
state: 'turn_off_recovery_done',
gridAction: 'turn_off',
},
},
{
id: 'g-incident-replay-hc-boundary',
label: 'Rejeu incident complet : min_on déjà écoulé aussi — pas seulement un bypass min_on',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '30',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'Bloqué',
evcs_surplus_state: 'stop_source_lost',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 120), // min_on largement écoulé
},
expect: {
state: 'turn_off_evcs_override_released',
gridAction: 'turn_off',
latchAction: 'turn_off',
},
},
{
id: 'g-glitch-guard-unavailable',
label: 'Lecture HA source_mode invalide — garde anti-glitch, Fix G ne doit PAS agir',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '30',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'unavailable',
evcs_surplus_state: 'stop_source_lost',
user_strategy: 'auto_solar_plus',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
state: 'hold_on_min_runtime',
gridAction: 'none',
},
},
];

View File

@@ -108,10 +108,12 @@ console.log('Syntaxe OK sur tous les nodes jsCode.');
// 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden // 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
const skipGolden = process.argv.includes('--skip-golden'); const skipGolden = process.argv.includes('--skip-golden');
const testSuite = const testSuite =
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' :
isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' :
null; null;
if (testSuite) { if (testSuite) {
if (skipGolden) { if (skipGolden) {

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
// Patch 22/08/2026 — FIX G (relais) : coupure EDF immédiate, générique, dès
// que l'override EVCS disparaît (evcsGridOverride === false) et que le SOC
// est au-dessus du seuil bas ON (gridSocOn, PAS gridSocOff) — au lieu de
// dépendre d'une liste blanche de noms exacts de surplus_state (Fix C/E2),
// qui ratait les états transitoires génériques (stop_source_lost,
// idle_wait_source) émis quand la fenêtre HC nuit se ferme mi-session.
// Incident : nuit 21-22/08/2026, EDF resté ON des heures après la fermeture
// de la fenêtre HC, PV plafonné ~100W par le MPPT.
// Cible : workflows/victron-grid-hysteresis-v1.json (node "Compute Grid Hysteresis").
// Rejouable : détecte si déjà appliqué.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const FILE = path.join(ROOT, 'workflows/victron-grid-hysteresis-v1.json');
const wf = JSON.parse(fs.readFileSync(FILE, 'utf8'));
const node = wf.nodes.find(n => n.name === 'Compute Grid Hysteresis');
if (!node) throw new Error('Node "Compute Grid Hysteresis" introuvable');
let code = node.parameters.jsCode;
if (code.includes('turn_off_evcs_override_released')) {
console.log('Déjà appliqué — rien à faire.');
process.exit(0);
}
const anchor = ` state = 'turn_off_recovery_done';
reason.push('Charge terminee + batterie a la cible : coupure immediate du reseau (bypass min_on)');
forcedCut = true;
}
`;
if (!code.includes(anchor)) {
throw new Error('Ancre Fix E2 introuvable — le node a peut-être changé, vérifier manuellement.');
}
const insertion = `${anchor}
// ======================================================
// FIX G (22/08/2026): OVERRIDE EVCS DISPARU -> COUPURE IMMEDIATE
// Incident nuit 21-22/08 : a la fermeture de la fenetre HC (~06-07h), le
// controleur EVCS remet correctement son override a false MAIS le vehicule
// n'est pas encore flag "charge terminee" -> DECISION_ENGINEV2 retombe sur
// des etats generiques transitoires (stop_source_lost, idle_wait_source,
// et potentiellement d'autres non enumeres ici). Fix C/E2 ne matchent
// aucun de ces noms -> EDF restait maintenu ON par la boucle generique
// jusqu'a gridSocOff (35%), potentiellement des heures.
//
// Regle GENERIQUE (pas un whitelist de plus) : des que evcsGridOverride
// redevient false ET SOC > gridSocOn (22%, PAS gridSocOff 35% : on ne
// touche pas au cas independant "SOC bas" qui doit tenir jusqu'a
// gridSocOff), on coupe immediatement.
//
// Garde anti-glitch : n'agit pas si la lecture evcs_source_mode est
// vide/unavailable/unknown (panne HA transitoire) - dans ce cas la boucle
// generique gere normalement plutot que de couper sur une lecture non fiable.
// ======================================================
const evcsSourceModeLooksValid =
evcsSourceModeRaw !== '' &&
!['unavailable', 'unknown'].includes(low(evcsSourceModeRaw));
if (
!forcedCut &&
latchIsOn &&
gridIsOn &&
!evcsGridOverride &&
soc > gridSocOn &&
evcsSourceModeLooksValid
) {
desiredGrid = 'off';
desiredLatch = 'off';
state = 'turn_off_evcs_override_released';
reason.push(\`Override EVCS disparu (source=\${evcsSourceModeRaw || 'n/a'}, surplus_state=\${evcsSurplusStateRaw || 'n/a'}) + SOC \${round1(soc)}% > seuil ON \${gridSocOn}% : coupure immediate du reseau (bypass min_on, libere le solaire)\`);
forcedCut = true;
}
`;
code = code.replace(anchor, insertion);
const notifAnchor = `} else if (state === 'turn_off_recovered') {`;
if (!code.includes(notifAnchor)) {
throw new Error('Ancre notification turn_off_recovered introuvable.');
}
const notifInsertion = `} else if (state === 'turn_off_evcs_override_released') {
notifyTitle = '✅ EDF coupé (override EVCS disparu)';
notifyMessage = \`L'override EVCS a disparu (source=\${evcsSourceModeRaw || 'n/a'}) et SOC \${round1(soc)}% > seuil ON \${gridSocOn}%. Coupure immédiate du réseau pour libérer le solaire.\`;
notifyWanted = true;
${notifAnchor}`;
code = code.replace(notifAnchor, notifInsertion);
node.parameters.jsCode = code;
fs.writeFileSync(FILE, JSON.stringify(wf, null, 2) + '\n', 'utf8');
console.log('Fix G appliqué à', FILE);

File diff suppressed because one or more lines are too long