Compare commits

...

2 Commits

Author SHA1 Message Date
Tallagrand Thierry
057e0f7d8f Corrige l'inversion plancher de demarrage / plancher de maintien (EVCS)
Incident du 24/08 12:24-12:28 : bascule de profil vers midday_hc_or_solar
avec SOC 58 %. Plancher de demarrage = min(midday_soc_min 75, abondance P6
40) = 40 ; plancher de maintien = max(soc_stop_min 29, 75-15) = 60. Le
systeme avait donc le droit de DEMARRER ce qu'il n'avait pas le droit de
MAINTENIR : cycle limite a la periode de min_run (3 min) sur toute la bande
[40 %, 60 %], et ~2,6 kW de PV ecretes a chaque creux (PV 10,24 kW -> 7,64 kW
quand la borne s'arrete, ESS sans injection).

Deux volets indissociables dans BATTERY_CLOUD_LOCKS.js :
- effectiveSocHoldFloor = min(regle du profil, plancher de demarrage
  effectif). Sans abondance, comportement inchange.
- l'abondance P6 se prouve au demarrage (batterie >= +20 A) mais se maintient
  en charge (batterie >= 0) : sinon la charge du vehicule, qui detourne
  precisement le surplus, detruit sa propre condition d'autorisation.

Golden 39 -> 40. Preuve par mutation sur midday-inversion-start-hold : code
d'avant -> stop / "Conditions de maintien perdues", apres -> step_up 19 A.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-24 12:46:18 +02:00
Tallagrand Thierry
522f47e821 Retrait de Fix G + garde-fous anti-yoyo + diagnostic des deux oscillateurs
Fix G provoquait un yoyo EDF de 2-4 min (coupure a SOC 21 en bypass min_on
contre rallumage a SOC 20 en bypass cooldown) : son garde `soc > gridSocOn`
ne protegeait que l'instant de l'engagement sur SOC bas, pas la phase de
remontee 20 -> 30. jsCode restaure byte-identique a la version pre-Fix-G.

Le fixture de test portait les fallbacks du code (22/35) au lieu des valeurs
de production lues via MCP (20/30), et le scenario de non-regression testait
SOC 18 %, le seul point que Fix G epargnait. Suite reecrite avec les
garde-fous manquants h-recovery-band-no-cut-21/-25. Mutation : Fix G
reapplique -> 4/7 echecs, retire -> 7/7.

Documentation du diagnostic des deux oscillateurs (yoyo EDF et oscillation
de la charge EVCS en plein soleil) dans MANUEL_COMPLET 11.8 et HANDOFF 25.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-24 12:20:12 +02:00
9 changed files with 436 additions and 130 deletions

View File

@@ -1242,3 +1242,99 @@ branché mi-session)
---
*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
## 25. Session du 24/08/2026 — Fix G retiré + diagnostic des deux oscillateurs
### Fix G retiré de la production (déployé 24/08 12:18, golden-hysteresis 7/7)
Fix G (§24) a produit en production un yoyo EDF de 2-4 min la nuit du 23-24/08,
véhicule débranché : coupure à SOC 21 % (Fix G, bypass min_on) puis rallumage à
SOC 20 % (branche `hold_on_low_soc`, bypass cooldown), MPPT qui flappe à chaque
bascule. Logbook : `turn_off_evcs_override_released` 06:43:03 → `hold_on_low_soc`
06:45:03 → `turn_on_low_soc_cooldown_bypassed` 06:52:03.
**Erreur de conception** : le garde `soc > gridSocOn` ne protégeait que l'INSTANT
de l'engagement sur SOC bas (`soc <= 20`), pas la PHASE DE REMONTÉE 20 → 30 qui
est 100 % de la durée de vie de ce cas. Fix G a donc remplacé l'hystérésis 20/30
par une hystérésis 20/20,1.
**Erreur de test** : le fixture `tests/golden-hysteresis/` portait `grid_soc_on:
"22"` / `grid_soc_off: "35"` — les *valeurs de repli du code*, pas la production
(vérifiée via MCP : **20 / 30**, inchangées depuis le 07/08). Et le scénario de
non-régression `g-baseline-low-soc-no-override` utilisait SOC 18 %, c'est-à-dire
le seul point que Fix G épargnait. → **Règle : figer un fixture de test sur des
valeurs lues en live via MCP, jamais sur les fallbacks du code.**
Retrait : `tools/patch-20260824-revert-g.mjs` (rejouable) — le jsCode résultant
est byte-identique à la version pré-Fix-G (commit `0419c74`), vérifié.
Suite réécrite (7 scénarios, seuils réels 20/30) avec les garde-fous manquants
`h-recovery-band-no-cut-21` / `-25` : la remontée batterie sur réseau ne doit
JAMAIS être interrompue avant `grid_soc_off`. Test de mutation : Fix G réappliqué
→ 4/7 échecs, retiré → 7/7. Le filet attrape désormais la régression.
### Diagnostic : deux oscillateurs, même pathologie
Détail complet dans `docs/MANUEL_COMPLET.md` §11.8. Résumé :
**A — Yoyo EDF.** Le problème de fond n'est pas le réglage de `grid_soc_off`
mais le fait qu'il n'a aucun sens physique : le 24/08, la PV ne couvre la conso
maison qu'à partir de ~09:00 (0,07 → 0,63 → 2,74 kWh/h contre 0,75 kWh/h de
conso). Couper EDF à 06:43 à 21 % rend le yoyo inévitable quels que soient les
seuils. Le workflow hystérésis lit 25 champs et **aucune prévision solaire**.
Cible : session réseau unique dimensionnée jusqu'au croisement PV/conso,
arbitrée par le conseiller IA. NB : mesurer la vraie capacité PV impose de
couper EDF (sinon le MPPT est bridé et on ne voit rien).
**B — Oscillation charge EVCS en plein soleil** (24/08 12:25 → 12:28).
Ce n'est PAS une limite 5 kVA (aucun `ve_bus_overload` depuis le 20/08,
`ac_loads_max_safe_w` = 9000, AC-out L1 à 5335 W sans overload, plafond calculé
~20 A) et **pas non plus une bande morte batterie trop étroite** : le courant
batterie était à **+127 A** au moment de l'arrêt. Les deux atténuations que
j'avais d'abord proposées (`battery_discharge_soft_a` à −12 A,
`battery_neutral_band_a` à 3-5 A) n'avaient donc **aucun effet sur ce cas** —
l'utilisateur l'a signalé, à raison, avant que je les applique.
Cause réelle : **inversion du plancher de démarrage et du plancher de maintien**.
À 12:24:00 le profil bascule vers `midday_hc_or_solar` ; avec SOC 58 % le
plancher de démarrage vaut `min(midday_soc_min 75, abondance P6 40)` = **40**
et le plancher de maintien `max(soc_stop_min 29, 75-15)` = **60**. Le système a
le droit de DÉMARRER ce qu'il n'a pas le droit de MAINTENIR → cycle limite à la
période de `min_run_minutes` (3 min) sur toute la bande [40 %, 60 %], et ~2,6 kW
de PV écrêtés à chaque creux (PV 10,24 kW → 7,64 kW quand la borne s'arrête).
### Décisions de conception validées avec l'utilisateur
- La conso se **lit**, elle ne se devine pas ; filtrage numérique dynamique des
pointes (improbables la nuit hors charge EVCS ; pas de moyenne quand aucun
véhicule n'est branché).
- SOC : lissage pour les décisions de confort, **valeur instantanée conservée
pour la sécurité** (le risque prime).
- Les seuils fixes doivent devenir dynamiques (saison, prévision) et être
arbitrés par le conseiller IA : « un automatisme c'est trop rigide pour
traiter tous ces cas tordus ». Ne plus empiler de branches codées en dur.
- Exploiter les messages d'état réels du Victron et de la borne au lieu de
s'auto-limiter à l'aveugle sur un proxy.
---
### Correctif déployé 24/08 12:45 — invariant maintien <= démarrage
`src/nodes/BATTERY_CLOUD_LOCKS.js`, deux volets indissociables :
1. `effectiveSocHoldFloor = min(règle du profil, plancher de démarrage effectif)`
— on ne peut plus être autorisé à démarrer ce qu'on n'est pas autorisé à
maintenir. Sans abondance, comportement inchangé.
2. L'abondance P6 se PROUVE au démarrage (`solarAbundanceStart` : pvAvg >= 4000
ET batterie >= +20 A) mais se MAINTIENT en charge (`solarAbundanceHold` :
pvAvg >= 4000 ET batterie >= 0). Sans ce dédoublement, la charge du véhicule
détourne le surplus, fait tomber le courant batterie sous +20 A et détruit sa
propre condition d'autorisation — l'oscillation reviendrait dès que le PV
n'est que moyennement fort.
Golden 39 → 40 : `midday-inversion-start-hold` (relevé VRM du 24/08 12:25 :
PV 10,24 kW, charges 3815 W dont EVCS 3502 W, batterie +127 A, SOC 58 %).
Preuve par mutation : code d'avant → `stop` / « Conditions de maintien perdues » ;
après → `charging_step_up_battery` à 19 A. Les 39 scénarios existants inchangés.
**Leçon transverse** : trois oscillateurs identifiés le même jour (Fix G,
abondance P6 auto-destructrice, inversion démarrage/maintien) — même pathologie
à chaque fois, des seuils empilés par couches successives, chacun raisonnable
isolément, dont personne ne vérifie la cohérence d'ensemble. D'où le chantier
du bilan énergétique partagé plutôt qu'un garde de plus.
---
*Document mis à jour le : 24/08/2026 (~12:50) — Fix G retiré (golden-hysteresis 7/7, seuils réels 20/30), inversion démarrage/maintien corrigée (golden 40/40), diagnostic des deux oscillateurs*

View File

@@ -445,13 +445,7 @@ Y1 EDF ON + latch
│ (turn_off_vehicle_unplugged, bypass min_on — Fix C)
│ v4: es ∈ {night_recovery_done_idle, departure_done_idle} + SOC ≥ seuil OFF
│ → 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.
│ (v5 "Fix G" a existé du 22 au 24/08/2026 puis a été RETIRÉ : voir §11.8)
└─▶ retour Y0 (cooldown 15 min)
```
@@ -488,8 +482,12 @@ retesté. Remises à zéro : débranchement, session fraîche (P11), ou force_*.
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`).
plafonné ~100 W par le MPPT. → Fix G (v5), **retiré le 24/08** : mauvais
diagnostic, le sujet réel est le bilan énergétique (§11.8).
- **Nuit 23-24/08 (yoyo EDF de 2-4 min)** : Fix G coupait à SOC 21 % ce que la
branche « SOC bas » venait de rallumer à 20 % → hystérésis 20/30 réduite à
20/20,1, MPPT qui flappe à chaque bascule. → Fix G retiré, garde-fous
anti-yoyo ajoutés à `tests/golden-hysteresis/` (§11.8).
### 11.7 Conseiller IA — mode ombre et câblages réels
@@ -505,6 +503,93 @@ retesté. Remises à zéro : débranchement, session fraîche (P11), ou force_*.
---
### 11.8 Les deux oscillateurs identifiés le 24/08/2026
Deux boucles de rétroaction distinctes, de même nature : **une décision prise
sur un seuil que l'action elle-même vient détruire**, sans bande morte.
#### A. Yoyo EDF (workflow hystérésis) — Fix G retiré
Fix G coupait EDF dès que l'override EVCS disparaissait et que
`soc > grid_soc_on`. Or la branche « SOC bas » allume EDF à `grid_soc_on`
(20 %) et doit tenir jusqu'à `grid_soc_off` (30 %) : pendant TOUTE cette
remontée, `soc > grid_soc_on` est vrai. Fix G coupait donc à 21 % ce que la
branche basse rallumait à 20 %, les deux en bypass de leur amortisseur
(min_on 25 min d'un côté, cooldown 15 min de l'autre) → oscillateur libre de
2 à 4 minutes, MPPT qui flappe à chaque bascule. Retiré le 24/08.
**Le problème de fond n'est pas le seuil mais son absence de sens physique.**
Le 24/08, conso maison ≈ 0,75 kWh/h de 07:00 à 09:00 pour une production
solaire de 0,07 puis 0,63 kWh/h : rien ne peut relayer EDF avant ~09:00, où la
PV passe à 2,74 kWh/h et la batterie remonte seule de 21 à 31 %. Couper EDF à
06:43 à 21 % rend le yoyo **mécaniquement inévitable, quels que soient les
seuils**. Double peine : EDF ON bride le MPPT (ESS sans injection), donc la
seule ressource capable de sortir du yoyo est étouffée par le yoyo.
Constat structurel : le snapshot du workflow hystérésis lit 25 champs et
**aucune prévision solaire, aucune conso projetée, aucun helper advisor**. Il
décide sur un point de SOC instantané comparé à deux nombres fixes. `30 %`
n'est pas mal réglé, il est hors sujet : la bonne valeur dépend de l'heure, de
la saison et de la courbe du jour (plus haute en saison des pluies, plus basse
par forte prévision). Cible retenue : une **session réseau unique et
dimensionnée** (charger en une fois jusqu'au niveau qui permet de tenir
jusqu'au croisement PV/conso), arbitrée par le conseiller IA, les règles codées
restant un filet de sécurité borné.
#### B. Oscillation de la charge EVCS en plein soleil
Observée le 24/08 entre 12:25 et 12:28 : la borne charge à 18 A / 3502 W avec
PV 10,24 kW et batterie à **+127 A**, puis s'arrête (0 A, `Waiting for start`)
— et la PV retombe à 7638 W, soit **~2,6 kW écrêtés** faute de preneur (ESS
sans injection, batterie déjà à son courant de charge maximal).
**Ce n'est pas une limite 5 kVA** : aucun `binary_sensor.victron_ve_bus_overload`
depuis le 20/08, `ac_loads_max_safe_w` = 9000 W, AC-out L1 mesuré à 5335 W sans
overload (le parallèle maître/esclave tient bien au-delà de 5 kVA), plafond
calculé par `WINDOWS_SOURCEV2` ~20 A. **Ce n'est pas non plus une bande morte
batterie trop étroite** : le courant était à +127 A au moment de l'arrêt, très
loin de tout seuil de décharge.
**Cause réelle : inversion du plancher de démarrage et du plancher de maintien.**
À 12:24:00 le profil bascule `outside_window` → `midday_hc_or_solar`. Dans ce
profil, avec SOC 58 % :
| | Calcul | Valeur |
|---|---|---|
| Plancher de **démarrage** | `min(midday_soc_min 75, abondance P6 40)` | **40 %** |
| Plancher de **maintien** | `max(soc_stop_min 29, midday_soc_min 75 − 15)` | **60 %** |
`40 ≤ 58 < 60` : le système a le droit de **démarrer** ce qu'il n'a pas le
droit de **maintenir**. Cycle limite garanti à la période de `min_run_minutes`
(3 min) sur toute la bande [40 %, 60 %], traversée chaque jour vers midi.
L'origine est P6, qui abaissait le plancher de démarrage en cas d'abondance
sans toucher au plancher de maintien.
**Correctif déployé le 24/08 12:45** (`BATTERY_CLOUD_LOCKS.js`), deux volets :
1. **Invariant maintien ≤ démarrage** :
`effectiveSocHoldFloor = min(règle du profil, plancher de démarrage effectif)`.
Sans abondance, rien ne change (60 reste 60).
2. **L'abondance se prouve au démarrage, se maintient en charge.**
`solarAbundanceStart` exige toujours `pvAvg ≥ 4000 W` **et**
`batteryCurrentA ≥ +20 A` (preuve avant d'agir) ; `solarAbundanceHold`
n'exige que `pvAvg ≥ 4000 W` **et** `batteryCurrentA ≥ 0` (la batterie n'est
pas devenue fournisseuse). Sans cette distinction, la charge du véhicule —
qui détourne précisément le surplus — fait tomber le courant batterie sous
+20 A et **détruit sa propre condition d'autorisation** : système bistable.
Scénario golden `midday-inversion-start-hold` (relevé VRM du 24/08 12:25).
Preuve par mutation : sur le code d'avant → `stop` / « Conditions de maintien
perdues » ; après → `charging_step_up_battery` à 19 A. Golden 39 → 40.
Direction de fond (même logique qu'en A) : lire la conso au lieu de la
deviner, la filtrer numériquement (les pointes nocturnes sont improbables hors
charge EVCS), lisser le SOC pour les décisions de confort en gardant
l'instantané pour la sécurité, et exploiter les messages d'état réels du
Victron et de la borne plutôt que de s'auto-limiter à l'aveugle sur un proxy.
---
## 12. Configuration opencode.json
```json

View File

@@ -405,12 +405,25 @@ const profileSocStartFloor =
const PV_ABUNDANCE_AVG_MIN_W = 4000;
const ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;
const ABUNDANCE_SOC_FLOOR_PCT = 40;
const solarAbundance =
// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,
// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que
// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette
// distinction, la charge du véhicule — qui détourne précisément le surplus — fait
// tomber le courant batterie sous +20 A et détruit sa propre condition
// d'autorisation : système bistable, oscillation à la période de min_run.
const solarAbundanceStart =
parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&
parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;
const effectiveSocStartFloor = solarAbundance
? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT)
: profileSocStartFloor;
const solarAbundanceHold =
parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&
parsed.batteryCurrentA >= 0;
const solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug
const abundanceSocFloor = (abundant) =>
abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;
const effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);
const profileSocHoldFloor =
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
@@ -418,6 +431,19 @@ const profileSocHoldFloor =
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
params.socStopMin;
// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge
// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester
// <= au plancher de démarrage effectif.
// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec
// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien
// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de
// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à
// chaque creux faute de preneur (ESS sans injection).
const effectiveSocHoldFloor = Math.min(
profileSocHoldFloor,
abundanceSocFloor(solarAbundanceHold)
);
const profileSpecificDischargeFloorA =
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
@@ -553,7 +579,7 @@ const solarStartOk =
const rawSolarHoldOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyHoldOk &&
parsed.soc >= profileSocHoldFloor &&
parsed.soc >= effectiveSocHoldFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
@@ -862,7 +888,9 @@ return [{
profileSocStartFloor,
effectiveSocStartFloor,
solarAbundance,
solarAbundanceHold,
profileSocHoldFloor,
effectiveSocHoldFloor,
profileBatteryDischargeFloorA,
batteryCurrentHardExceeded,
mustStopHard,

View File

@@ -14,8 +14,8 @@
"notifications_enabled": "on",
"grid_latch": "off",
"grid_soc_on": "22",
"grid_soc_off": "35",
"grid_soc_on": "20",
"grid_soc_off": "30",
"grid_min_on_minutes": "25",
"grid_cooldown_minutes": "15",
"ac_loads_max_safe_w": "7800",

View File

@@ -1,14 +1,22 @@
// 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.
// Seuils du fixture = valeurs RÉELLES de production (vérifiées via MCP
// ha-get-state le 24/08/2026) : grid_soc_on = 20, grid_soc_off = 30,
// min_on = 25 min, cooldown = 15 min. Ne PAS utiliser les valeurs de repli
// codées dans le node (22/35) : elles ne correspondent à rien en production
// et masquent les bugs de bande.
//
// Historique : cette suite a été créée le 22/08/2026 pour valider "Fix G"
// (coupure immédiate dès que l'override EVCS disparaissait et que
// SOC > grid_soc_on). Fix G a été RETIRÉ le 24/08/2026 : son garde SOC ne
// protégeait que l'instant de l'engagement sur SOC bas, pas la phase de
// remontée grid_soc_on -> grid_soc_off, ce qui transformait l'hystérésis
// 20/30 en hystérésis 20/20,1 et produisait un yoyo EDF de 2 à 4 minutes.
// Les scénarios `h-recovery-band-*` ci-dessous sont les garde-fous qui
// auraient attrapé cette régression : ils vérifient que la remontée de la
// batterie sur réseau n'est JAMAIS interrompue tant que grid_soc_off n'est
// pas atteint.
function stamp(ms) {
const p = new Intl.DateTimeFormat('fr-FR', {
@@ -23,63 +31,31 @@ 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 = '2026-08-24T06:45:00+02:00'; // heure de l'incident de yoyo du 24/08
const T_MS = new Date(T).getTime();
// Base commune : EDF ON sous latch, aucun override EVCS (véhicule débranché),
// lecture HA saine. C'est la situation "remontée batterie sur réseau".
const recoveryBase = {
grid_latch: 'on',
grid_state: 'on',
night_grid_takeover_needed: 'off',
evcs_priority_active: 'off',
evcs_source_mode: 'Bloqué',
evcs_surplus_state: 'vehicle_not_connected',
user_strategy: 'auto_solar_plus',
};
export const scenarios = [
{
id: 'g-incident-gap-mid-session',
label: 'Fenêtre fermée mi-session (stop_source_lost) — min_on NON écoulé',
id: 'h-engage-low-soc',
label: 'SOC 18% <= seuil ON 20% : EDF maintenu (engagement batterie basse)',
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: '',
...recoveryBase,
evcs_surplus_state: '',
user_strategy: 'auto_solar_plus',
evcs_source_mode: '',
soc_percent: '18',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
@@ -89,17 +65,83 @@ export const scenarios = [
},
},
{
id: 'g-regression-fixc-unplugged',
label: 'Véhicule débranché — Fix C doit toujours gagner (pas absorbé par Fix G)',
id: 'h-recovery-band-no-cut-21',
label: 'ANTI-YOYO : SOC 21% (juste au-dessus du seuil ON), min_on non écoulé — aucune coupure',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
...recoveryBase,
// véhicule débranché mais SOC < seuil OFF : Fix C ne doit pas couper,
// et rien d'autre ne doit couper non plus. C'est LE point exact où
// Fix G coupait et provoquait le yoyo de 2-4 min du 24/08.
soc_percent: '21',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
state: 'hold_on_min_runtime',
gridAction: 'none',
latchAction: 'none',
evcsGridOverride: false,
},
},
{
id: 'h-recovery-band-no-cut-25',
label: 'ANTI-YOYO : SOC 25% en pleine bande, min_on écoulé — la remontée doit continuer',
time: T,
overrides: {
...recoveryBase,
soc_percent: '25',
last_on_at: minutesAgo(T_MS, 120), // min_on largement écoulé
},
expect: {
state: 'hold_on_until_soc_off',
gridAction: 'none',
latchAction: 'none',
evcsGridOverride: false,
},
},
{
id: 'h-recovery-complete',
label: 'SOC 30% = seuil OFF, min_on écoulé : coupure normale de fin de remontée',
time: T,
overrides: {
...recoveryBase,
// véhicule branché mais au repos : on teste la coupure GÉNÉRIQUE de fin
// de remontée, pas le raccourci Fix C (débranchement).
evcs_surplus_state: 'idle_wait_conditions',
soc_percent: '30',
last_on_at: minutesAgo(T_MS, 120),
},
expect: {
state: 'turn_off_recovered',
gridAction: 'turn_off',
latchAction: 'turn_off',
},
},
{
id: 'h-override-released-soc-ok',
label: 'Override EVCS relâché avec SOC 34% >= seuil OFF : la boucle générique coupe déjà (aucun fix dédié requis)',
time: T,
overrides: {
...recoveryBase,
// Cas de l'incident du 21-22/08 tel qu'il aurait dû se présenter :
// état transitoire non énuméré, mais SOC au-dessus du seuil OFF.
evcs_surplus_state: 'stop_source_lost',
soc_percent: '34',
last_on_at: minutesAgo(T_MS, 120),
},
expect: {
state: 'turn_off_recovered',
gridAction: 'turn_off',
latchAction: 'turn_off',
},
},
{
id: 'g-regression-fixc-unplugged',
label: 'Véhicule débranché + SOC 40% >= seuil OFF : Fix C coupe immédiatement (bypass min_on)',
time: T,
overrides: {
...recoveryBase,
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: {
@@ -109,16 +151,12 @@ export const scenarios = [
},
{
id: 'g-regression-fixe2-recovery-done',
label: 'Recovery done — Fix E2 doit toujours gagner (pas absorbé par Fix G)',
label: 'Recovery done + SOC 40% : Fix E2 coupe immédiatement (bypass min_on)',
time: T,
overrides: {
grid_latch: 'on',
grid_state: 'on',
soc_percent: '40',
night_grid_takeover_needed: 'off',
evcs_source_mode: 'Bloqué',
...recoveryBase,
evcs_surplus_state: 'night_recovery_done_idle',
user_strategy: 'auto_solar_plus',
soc_percent: '40',
last_on_at: minutesAgo(T_MS, 5),
},
expect: {
@@ -126,43 +164,4 @@ export const scenarios = [
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

@@ -777,6 +777,37 @@ export const scenarios = [
takeoverHelperService: 'turn_off',
},
},
{
id: 'midday-inversion-start-hold',
label: 'INVARIANT maintien<=demarrage : midi, SOC 58 dans la bande [40,60], charge 18 A en cours sous abondance → doit CONTINUER (incident 24/08 12:24-12:28)',
time: MIDDAY,
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on',
last_start_at: '2026-08-08 11:30:00', // min_run largement écoulé
// Relevé VRM du 24/08 12:25 : PV 10,24 kW, charges 3815 W (dont EVCS 3502 W),
// batterie +127 A. SOC 58 % : au-dessus du plancher de démarrage sous
// abondance (40 %) mais sous le plancher de maintien de la règle midi
// (max(29, 75-15) = 60 %). Avant correctif : arrêt, puis redémarrage au
// cycle suivant → cycle limite de 3 min et PV écrêtée.
soc_percent: '58', pv_power_w: '10240', pv_power_10m_avg_w: '9800',
pv_trend_w: '200', battery_current_a: '127',
evcs_current_a: '18', evcs_set_current_a: '18', evcs_command_current_a: '18',
evcs_ac_power_w: '3502',
house_ac_power_w: '3815', vebus_ac_out_l1_power_w: '3815',
},
expect: {
profile: 'midday_hc_or_solar',
sourceMode: 'solar_only',
state: 'charging_step_up_battery',
decision: 'step_up',
desiredCharging: true,
currentA: 19,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: 'turn_off',
},
},
{
id: 'midday-hc-garde-soc',
label: 'Midi sans PV, SOC 50 > 35, HC midi autorisé + EDF ON → grid proposé mais gardé (soc>35)',

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Patch 24/08/2026 — REVERT de FIX G (22/08/2026).
//
// Pourquoi : Fix G coupait EDF dès que l'override EVCS disparaissait ET que
// SOC > gridSocOn. Le garde `soc > gridSocOn` ne protégeait que l'INSTANT de
// l'engagement sur SOC bas (soc <= 20), pas la PHASE DE REMONTÉE 20 -> 30 qui
// est 100 % de la durée de vie de ce cas. Résultat en production la nuit du
// 23-24/08, véhicule débranché :
// SOC 20 -> branche low_soc : EDF ON (bypass cooldown)
// SOC 21 -> Fix G : EDF OFF (bypass min_on)
// ... cycle de 2 à 4 minutes, MPPT qui flappe à chaque bascule.
// Fix G avait de fait remplacé l'hystérésis 20/30 par une hystérésis 20/20,1.
//
// De plus, sa prémisse était fausse : l'incident du 21-22/08 qui l'avait
// motivé n'est pas un problème d'override EVCS mais un problème de bilan
// énergétique (couper EDF à 21 % à 06:43 alors que la PV ne couvre la conso
// maison qu'à partir de ~09:00 rend le yoyo mécaniquement inévitable, quels
// que soient les seuils). Ce sujet est traité séparément.
//
// Cible : workflows/victron-grid-hysteresis-v1.json (node "Compute Grid Hysteresis").
// Rejouable : détecte si déjà retiré.
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('Fix G déjà retiré — rien à faire.');
process.exit(0);
}
// --- 1. Bloc de décision -------------------------------------------------
const decisionStart = code.indexOf('\n// ======================================================\n// FIX G (22/08/2026)');
if (decisionStart === -1) throw new Error('En-tête du bloc de décision Fix G introuvable.');
const decisionEndMarker = " forcedCut = true;\n}\n";
const decisionEnd = code.indexOf(decisionEndMarker, decisionStart);
if (decisionEnd === -1) throw new Error('Fin du bloc de décision Fix G introuvable.');
code = code.slice(0, decisionStart) + code.slice(decisionEnd + decisionEndMarker.length);
// --- 2. Branche notification --------------------------------------------
const notifStart = code.indexOf("} else if (state === 'turn_off_evcs_override_released') {");
if (notifStart === -1) throw new Error('Branche notification Fix G introuvable.');
const notifEndMarker = " notifyWanted = true;\n\n";
const notifEnd = code.indexOf(notifEndMarker, notifStart);
if (notifEnd === -1) throw new Error('Fin de la branche notification Fix G introuvable.');
code = code.slice(0, notifStart) + code.slice(notifEnd + notifEndMarker.length);
if (code.includes('turn_off_evcs_override_released')) {
throw new Error('Résidus de Fix G après retrait — vérifier manuellement.');
}
node.parameters.jsCode = code;
fs.writeFileSync(FILE, JSON.stringify(wf, null, 2) + '\n', 'utf8');
console.log('Fix G retiré de', FILE, `(jsCode: ${code.length} caractères)`);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long