Fix E: latch 'charge terminée' en session active + coupure recovery_done (incident rebond 08-09/08)

- DECISION: branche completed prioritaire dans la session active (avant VDF/source-lost)
  → état collant écrit au 1er cycle status=3, relais encore fermé
- WINDOWS: garde puissance <=300W sur Fix D (anti-glitch)
- Hystérésis: turn_off_recovery_done (bypass min_on, dry-run 5 cas)
- golden 23→25 (session armée status 3, flap statut post-latch)
- HANDOFF §18
This commit is contained in:
Tallagrand Thierry
2026-08-09 06:31:33 +02:00
parent 9bc4af0ea1
commit b287e5ba73
7 changed files with 225 additions and 5 deletions

View File

@@ -801,3 +801,57 @@ P7 a chargé la VW dès 01:24 ✓ (session 1h32m). Ensuite :
--- ---
*Document mis à jour le : 08/08/2026 (~14:15) — fixes A/B/C/D déployés, golden 23/23* *Document mis à jour le : 08/08/2026 (~14:15) — fixes A/B/C/D déployés, golden 23/23*
## 18. Session du 09/08/2026 — Incident nuit 08-09/08 : le rebond est revenu par un AUTRE chemin (Fix E)
### Incident (captures HA, VW branchée, HC, VW pleine vers ~03:00)
Fix A/D du 08/08 ont bien fonctionné CÔTÉ WINDOWS (status 3 → completed →
takeover Désactivé, source blocked). MAIS l'état EVCS observé était
`hold_min_run_source_lost` / `stop_source_lost` — jamais `night_recovery_done_idle`.
Cycles ~30 min de 03:00 à 05:40 : EDF ON (2531 W réseau, SOC 51 % > cible 35)
→ arrêt urgence utilisateur 05:43 (EDF URGENCE OFF + stratégie stop) → débranchement.
### Root cause (ordre des branches DECISION)
1. Status 3 arrive PENDANT la session armée (relais fermé, chg=1/perm=1/l=1).
2. WINDOWS → sourceMode 'blocked' (Fix D) ; mais dans DECISION la branche
session ACTIVE traite 'blocked' comme « source perdue » :
`hold_min_run_source_lost` (25 min de session zombie !) puis `stop_source_lost`.
La branche P8 (état collant) est dans la chaîne NON-active → jamais atteinte.
3. Au stop, le relais s'ouvre → le statut EVCS RETOMBE (3 → 1/6, même piège que
le VDF documenté en P8) → completed=false → re-P7 → takeover ON 1 cycle →
`edf_general_ok_on` rallume EDF + restart → status 3 → goto 2. Rebond ~30 min.
4. L'hystérésis adoptait l'EDF ON du cycle de flap puis `hold_on_min_runtime`
25 min (ov=0/eg=0 ensuite, mais latch pris) → EDF ON quasi permanent.
### Fix E (déployé 09/08 ~06:25, golden 25/25)
- **DECISION** : nouvelle PREMIÈRE branche du bloc session-active (avant VDF) :
`night_hc && vehicleChargeCompleted && !force*` → setOff immédiat avec l'état
collant (`night_recovery_done_idle` si SOC ≥ cible, sinon
`night_session_done_idle`), decision `stop_vehicle_full_or_paused`.
→ l'état collant est écrit AU PREMIER cycle où status=3, relais encore fermé ;
les retombées de statut ensuite sont sans effet (prev state dans la liste).
- **WINDOWS** : garde puissance sur Fix D (`status==3 && evcsAcPowerW <= 300 W`)
pour ne jamais latcher sur un glitch en pleine charge.
- **Hystérésis** (patch tools/patch-20260809-e2-recovery-done-cut.mjs) :
`turn_off_recovery_done` = es=night_recovery_done_idle + pas d'override +
SOC ≥ seuil OFF → coupure EDF immédiate (bypass min_on 25). Sûr car l'état
est collant (le takeover ne peut pas revenir, sauf SOC ≤ cible−2 → override).
Dry-run 5 cas validé avant push.
### Golden : 23 → 25
- `night-charged-en-session-active` : status 3 en session ARMÉE → arrêt propre
+ latch immédiat (LE chemin de l'incident) ;
- `night-flap-statut-apres-latch` : relais ouvert, statut retombé à 1,
prev=night_recovery_done_idle → collant, aucun re-P7/EDF.
### Comportement attendu au REBRANCHEMENT d'une VW pleine en HC
1. Session nuit → P7 → EDF ON → tentative de charge (inévitable : le système
ne peut pas savoir que la VW est pleine sans fermer le relais) ;
2. VW refuse (0 W, status 3) → ≤ 1 min : `night_recovery_done_idle` collant,
takeover OFF, session stoppée proprement ;
3. Hystérésis : `turn_off_recovery_done` → EDF OFF ≤ 1 min (plus de min_on 25) ;
4. Plus RIEN jusqu'au débranchement / fin de fenêtre (flaps de statut ignorés).
Total attendu : EDF ON ~2-3 min UNE seule fois, puis silence.
---
*Document mis à jour le : 09/08/2026 (~06:35) — Fix E déployé (EVCS + hystérésis), golden 25/25*

View File

@@ -629,6 +629,35 @@ else if (cloud.sessionObservedActive) {
const effectiveHoldConditionsOk = const effectiveHoldConditionsOk =
holdConditionsOk || manualLocalHoldOverride; holdConditionsOk || manualLocalHoldOverride;
// =================================================
// Fix E (09/08/2026) : CHARGE TERMINÉE PENDANT LA SESSION ACTIVE.
// Nuit 08-09/08 : status EVCS 3 "charged" → WINDOWS passe la source à
// 'blocked' (Fix D), mais ICI les branches source-lost prenaient la main
// (hold_min_run_source_lost 25 min puis stop_source_lost), l'état collant
// n'était JAMAIS écrit ; à l'ouverture du relais le statut retombe (3→1),
// completed redevenait faux → re-P7 → EDF rallumé → rebond ~30 min.
// On latche l'état collant AU PREMIER cycle où la fin de charge est vue,
// relais encore fermé, et on termine la session proprement (pas de min ON).
// =================================================
if (
context.profile === 'night_hc' &&
nightEvcs?.vehicleChargeCompleted &&
!manualLocalChargeAllowed &&
!strategy.forceGridRequested
) {
setOff({
stateName: nightEvcs?.nightRecoveryDone
? 'night_recovery_done_idle'
: 'night_session_done_idle',
decisionName: 'stop_vehicle_full_or_paused',
reasonText: 'Charge véhicule terminée en session active : arrêt propre, veille nuit collante',
dynamic: false,
mustStopFlag: false,
blockReason: 'none',
blockedUntilStamp: now.stamp,
});
}
// ================================================= // =================================================
// VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION
// IMPORTANT: // IMPORTANT:
@@ -636,7 +665,7 @@ else if (cloud.sessionObservedActive) {
// - uniquement si la source est réellement livrable // - uniquement si la source est réellement livrable
// - sans utiliser le PV comme critère // - sans utiliser le PV comme critère
// ================================================= // =================================================
if ( else if (
vehicleDemandFilter.evcsVehicleNoDemandSemantic && vehicleDemandFilter.evcsVehicleNoDemandSemantic &&
vehicleDemandFilter.sourceDeliverableForNoDemand vehicleDemandFilter.sourceDeliverableForNoDemand
) { ) {

View File

@@ -322,7 +322,13 @@ const prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLo
// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été // charge, sans attendre le VehicleDemandFilter (désarmé si la session a été
// stoppée par une garde, ex battery_current_hard_stop → détection tardive // stoppée par une garde, ex battery_current_hard_stop → détection tardive
// observée la nuit du 07-08/08). // observée la nuit du 07-08/08).
const evcsStatusCharged = Number.parseFloat(parsed.evcsStatusRaw) === 3; // Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher "fini"
// sur un glitch de statut pendant une vraie charge. NB : le statut retombe
// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état
// collant écrit par DECISION pendant la session (voir Fix E DECISION).
const evcsStatusCharged =
Number.parseFloat(parsed.evcsStatusRaw) === 3 &&
Math.max(0, parsed.evcsAcPowerW || 0) <= 300;
const vehicleChargeCompleted = const vehicleChargeCompleted =
nightSessionActive && nightSessionActive &&

View File

@@ -297,6 +297,60 @@ export const scenarios = [
}, },
}, },
// ===== FIX E 09/08/2026 : latch "charge terminée" PENDANT la session active =====
// Incident nuit 08-09/08 : status 3 en session armée → source-lost zombie 25 min,
// état collant jamais écrit, statut retombe à l'ouverture du relais → rebond EDF.
{
id: 'night-charged-en-session-active',
label: 'Nuit HC 04:30, session ARMÉE (relais fermé, 0 W), statut 3 → arrêt propre + latch collant immédiat',
time: '2026-08-09T04:30:00+02:00',
overrides: {
...VEHICLE_NIGHT, soc_percent: '51', allow_grid_hc_night: 'on',
grid_state: 'on', victron_acin1_available: '1',
battery_current_a: '2', night_takeover_prev: 'on',
evcs_charging_switch: 'on',
charge_permission_now: 'on',
control_latch: 'on',
evcs_current_a: '0',
evcs_ac_power_w: '0',
evcs_status_raw: '3',
prev_surplus_state: 'charging_hold',
},
expect: {
profile: 'night_hc',
sourceMode: 'blocked',
state: 'night_recovery_done_idle',
decision: 'stop_vehicle_full_or_paused',
desiredCharging: false,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: 'turn_off',
},
},
{
id: 'night-flap-statut-apres-latch',
label: 'Nuit HC 05:00, relais ouvert, statut retombé à 1, prev=night_recovery_done_idle → collant, AUCUN re-P7/EDF',
time: '2026-08-09T05:00:00+02:00',
overrides: {
...VEHICLE_NIGHT, soc_percent: '51', allow_grid_hc_night: 'on',
evcs_charging_switch: 'off',
evcs_status_raw: '1',
prev_surplus_state: 'night_recovery_done_idle',
},
expect: {
profile: 'night_hc',
sourceMode: 'blocked',
state: 'night_recovery_done_idle',
decision: 'hold',
desiredCharging: false,
currentA: 6,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: 'turn_off',
},
},
// ================= JOUR SOLAIRE ================= // ================= JOUR SOLAIRE =================
{ {
id: 'morning-solaire-fort', id: 'morning-solaire-fort',

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
// Patch 09/08/2026 — FIX E2 (relais) : coupure EDF immédiate quand l'état
// surplus est 'night_recovery_done_idle' (collant : le takeover ne peut plus
// revenir) et qu'aucun override n'est actif. Évite les 25 min de
// hold_on_min_runtime après la tentative de charge d'un véhicule déjà plein.
// 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_recovery_done')) {
console.log('Déjà appliqué — rien à faire.');
process.exit(0);
}
const anchor = ` state = 'turn_off_vehicle_unplugged';
reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)');
forcedCut = true;
}
`;
const insertion = ` state = 'turn_off_vehicle_unplugged';
reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)');
forcedCut = true;
}
// ======================================================
// FIX E2 (09/08/2026): RECOVERY DONE -> COUPURE IMMEDIATE
// 'night_recovery_done_idle' est un etat COLLANT du controleur EVCS :
// charge terminee ET batterie a la cible, le takeover ne reviendra pas
// (sauf SOC <= cible-2, qui repasserait par l'override). Attendre le
// min_on 25 min ne protege ici de rien : coupure immediate.
// ======================================================
if (
!forcedCut &&
latchIsOn &&
gridIsOn &&
!evcsGridOverride &&
evcsSurplusState === 'night_recovery_done_idle' &&
soc >= gridSocOff
) {
desiredGrid = 'off';
desiredLatch = 'off';
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 turn_off_vehicle_unplugged introuvable — appliquer d abord patch-20260808-c-unplug-cut.mjs');
code = code.replace(anchor, insertion);
const notifAnchor = `} else if (state === 'turn_off_recovered') {`;
const notifInsertion = `} else if (state === 'turn_off_recovery_done') {
notifyTitle = '✅ EDF coupé (charge terminée)';
notifyMessage = \`Charge VE terminée et batterie à la cible (SOC \${round1(soc)}%). Coupure immédiate du réseau.\`;
notifyWanted = true;
} else if (state === 'turn_off_recovered') {`;
if (!code.includes(notifAnchor)) throw new Error('Ancre notification turn_off_recovered introuvable');
code = code.replace(notifAnchor, notifInsertion);
node.parameters.jsCode = code;
fs.writeFileSync(FILE, JSON.stringify(wf, null, 2) + '\n');
console.log('Patch appliqué →', FILE, `(jsCode ${code.length} chars)`);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long